项目中心

This commit is contained in:
wangzzpeng
2026-09-28 17:47:07 +08:00
parent 43c2dc0d8d
commit b014fffacf
23 changed files with 1630 additions and 1577 deletions

View File

@@ -1,16 +1,15 @@
import type { FileInfo } from '@/types/api'
import type {
AfterSales,
AfterSalesAttachment,
AfterSalesPayload,
Project,
AfterSalesUploadVo,
ProjectListResult,
ProjectQuery,
ProjectStatistics,
RequirementDetailVo,
RdProjectVo,
RequirementDetailBundle,
} from '@/types/business/project'
import { request } from '@/utils/request'
import { request, serializeQuery } from '@/utils/request'
import { formatDate } from '@/utils/format'
import { toProjectDetail } from '@/utils/projectVo'
// ─────────────────────────────────────────────────────────────────────────────
// 列表:真实接口
@@ -21,7 +20,7 @@ import { toProjectDetail } from '@/utils/projectVo'
// 鉴权:需登录态(匿名 401);每请求带 clientid 头(见 utils/request.ts 拦截器)。
// 筛选参数实测(tools/probe_project_api.py):
// key → like projectName OR projectNo OR requirementDesc
// businessType → eq business_type(值是提报表主键 id,不是类型名)
// businessTypes → IN business_type(**多选**;值是提报表主键 id,不是类型名)
// status → eq status(码值,见 RdProjectStatus)
// beginTime/endTime → between create_time(不是 submit_time!)
// pageNum/pageSize → PageQuery
@@ -83,12 +82,17 @@ export function getProjectList(query: ProjectQuery): Promise<ProjectListResult>
return request<ProjectListResult>({
url: '/web/requirementPool/list',
method: 'get',
// 多选数组必须走 serializeQuery,展开成 businessTypes[0]=…&businessTypes[1]=…
//(与 api/cases.ts 同一做法;不传则走 axios 默认的 businessTypes[]=,实测后端也能绑,
// 但这里取与文档/案例库一致的索引形式,避免依赖框架默认行为)
paramsSerializer: { serialize: serializeQuery },
params: {
pageNum: query.pageNum,
pageSize: query.pageSize,
// 空串不出现在 querystring 里,与后端 eqIfText 的「有值才等值匹配」语义一致
key: query.keyword || undefined,
businessType: query.businessType || undefined,
// 多选:空数组不发;非空由 serializeQuery 展开成 businessTypes[0]=…&businessTypes[1]=…
businessTypes: query.businessTypes.length > 0 ? [...query.businessTypes] : undefined,
status: query.status || undefined,
...toTimeRange(query),
},
@@ -108,240 +112,177 @@ export function getProjectFilterConditions(): Promise<ProjectStatistics> {
})
}
// ─────────────────────────────────────────────────────────────────────────────
// 详情:真实接口
// GET /web/requirementPool/get/{id} → ProjectRequirementUserController#get
// → ProjectRequirementServiceImpl#queryRequirementByUserId
// 出参 ProjectRequirementUserVo,经 utils/projectVo.ts 映射成详情页沿用的 Project 形态。
// ⚠️ id 传的是**需求 id**(vm_project_requirement.id)= 列表行 RdProjectVo.id。
// 未立项的需求也能查到(项目侧字段为空);不存在 / 非本人 → 后端 data 为 null。
//
// 文件 / 售后:**仍是前端 mock** —— 该 Controller 只有 create / list / get /
// serviceTypeList 四个接口,没有附件上传、售后提交/取消的后端实现。
// ─────────────────────────────────────────────────────────────────────────────
/** 构造 mock 文件信息 */
function mockFile(name: string, size: number, uploadTime: string): FileInfo {
return { name, size, uploadTime, url: '' }
}
/** 详情 mock 所属项目 id */
const DETAIL_ID = 'project-004'
/** 项目列表 mock(6 条,覆盖全部状态,internet/iot 混合)——仅详情页链路在用 */
const MOCK_PROJECTS: Project[] = [
{
id: 'project-001',
projectNo: 'P8K2M202408001',
projectName: '某大型银行数字化转型方案',
businessType: 'internet',
progress: 64,
status: '待立项',
submitTime: '2026-8-17 14:00',
milestones: [],
team: [],
files: [],
paymentPlans: [],
invoiceRecords: [],
},
{
id: 'project-002',
projectNo: 'P8K2M202408002',
projectName: '智慧城市大数据平台建设项目',
businessType: 'iot',
progress: 64,
status: '待验收',
submitTime: '2026-8-17 14:00',
milestones: [],
team: [],
files: [],
paymentPlans: [],
invoiceRecords: [],
},
{
id: 'project-003',
projectNo: 'P8K2M202408003',
projectName: '三甲医院指挥医疗信息化系统',
businessType: 'internet',
progress: 64,
status: '已完成',
submitTime: '2026-8-17 14:00',
milestones: [],
team: [],
files: [],
paymentPlans: [],
invoiceRecords: [],
},
{
id: DETAIL_ID,
projectNo: '20260813RJ251545',
projectName: '社区康养智能服务系统',
businessType: 'internet',
progress: 64,
status: '进行中',
submitTime: '2026-8-17 14:00',
milestones: [],
team: [],
files: [],
paymentPlans: [],
invoiceRecords: [],
},
{
id: 'project-005',
projectNo: 'P8K2M202408005',
projectName: '智能仓储物流分拣系统',
businessType: 'iot',
progress: 64,
status: '售后中',
submitTime: '2026-8-17 14:00',
milestones: [],
team: [],
files: [],
paymentPlans: [],
invoiceRecords: [],
afterSales: {
status: '处理中',
type: 'BUG问题',
description: '分拣识别偶发误判,需要排查',
remark: '',
attachments: [],
createTime: '2026-8-19 17:00',
},
},
{
id: 'project-006',
projectNo: 'P8K2M202408006',
projectName: '新能源电站运维监控平台',
businessType: 'internet',
progress: 64,
status: '已取消',
submitTime: '2026-8-17 14:00',
milestones: [],
team: [],
files: [],
paymentPlans: [],
invoiceRecords: [],
},
]
/** 项目详情 mock(社区康养智能服务系统) */
const PROJECT_DETAIL: Project = {
...MOCK_PROJECTS[3],
progress: 65,
startTime: '2026-8-13 11:04:12',
endTime: '2026-10-30 18:00:00',
milestones: [
{ name: '需求确认', status: '已完成', time: '2026-8-13 11:04:12' },
{ name: '原型设计', status: '已完成', time: '2026-8-13 11:04:12' },
{ name: '开发实现', status: '进行中' },
{ name: '测试验收', status: '待开始' },
{ name: '项目交付', status: '待开始' },
],
team: [
{ role: '项目经理', name: '张三', avatarChar: '张' },
{ role: '项目经理', name: '张三', avatarChar: '张' },
],
files: [
mockFile('项目需求规格说明书.pdf', 2516582, '2026-08-19 17:00'),
mockFile('项目需求说明书.word', 1887437, '2026-08-19 17:00'),
],
paymentPlans: [
{
id: 'bill-001',
planName: '第一期·预付款(比例33%)',
billNo: '20260813ZZ251245',
amount: 20100,
status: '已支付',
tradeNo: 'T20260819153035678K2M001',
projectName: '社区康养智能服务系统',
},
{
id: 'bill-002',
planName: '第二期·付款(比例33%)',
billNo: '20260813ZZ251245',
amount: 20100,
status: '待支付',
projectName: '社区康养智能服务系统',
},
{
id: 'bill-003',
planName: '第三期·尾款(比例33%)',
billNo: '20260813ZZ251245',
amount: 20100,
status: '未到期',
projectName: '社区康养智能服务系统',
},
],
invoiceRecords: [
mockFile('增值税专用发票.pdf', 325120, '2026-08-19 17:00'),
mockFile('电子回单.jpg', 186368, '2026-08-19 17:00'),
],
contractFile: mockFile('社区康养智能服务系统开发合同.pdf', 1039360, '2026-08-19 17:00'),
afterSales: {
status: '处理中',
type: 'BUG问题',
description: '具体请看清单',
remark: '',
attachments: [],
createTime: '2026-8-19 17:00',
},
}
/** 项目详情(真实接口;不存在 / 非本人需求返回 null) */
export function getProjectDetail(id: string): Promise<Project | null> {
return request<RequirementDetailVo | null>({
url: `/web/requirementPool/get/${id}`,
/**
* 已完成项目(申请售后页「项目名称」下拉的候选)。
*
* <p>直接复用列表接口做状态过滤:`status=COMPLETED` 在后端比的是
* `COALESCE(vm_rd_project.status, vm_project_requirement.status)`,而 `COMPLETED` 只会
* 出现在**项目表**(未立项的需求是 `INITIATING`)⇒ 结果天然就是「自己名下已立项且已完成的项目」,
* 与后端申请售后的准入校验(只有 COMPLETED 可申请)口径一致。</p>
*
* <p>售后申请场景下候选量级不大,一次取回较大分页(后端无专用端点);后端默认排序
* `update_time DESC`。返回行的 `id` = **需求 id**,正是申请接口要传的那个 id。</p>
*/
export function getCompletedProjects(): Promise<RdProjectVo[]> {
return request<ProjectListResult>({
url: '/web/requirementPool/list',
method: 'get',
}).then((vo) => (vo ? toProjectDetail(vo) : null))
params: {
pageNum: 1,
pageSize: 200,
status: 'COMPLETED',
},
}).then((result) => result?.requirementPage?.rows ?? [])
}
/** 上传项目文件(mock:追加进详情文件列表) */
export function uploadProjectFile(file: File): Promise<FileInfo> {
const info: FileInfo = {
name: file.name,
// ─────────────────────────────────────────────────────────────────────────────
// 详情:真实接口(**4 个页签各一个端点**,同帧并行取回后合并)
// GET /web/requirementPool/get/progress/{id} → ProjectRequirementProgressVo
// GET /web/requirementPool/get/files/{id} → ProjectRequirementFilesVo
// GET /web/requirementPool/get/finance/{id} → ProjectRequirementFinanceVo
// GET /web/requirementPool/get/afterSales/{id} → ProjectRequirementAfterSalesVo
// → ProjectRequirementUserController#getProgress / #getFiles / #getFinance / #getAfterSales
// → ProjectRequirementServiceImpl#queryProgress / queryFiles / queryFinance / queryAfterSales
//
// 4 个 VO 都继承 ProjectRequirementBaseVo(基础信息始终带)⇒ 任一响应里都有项目名 /
// 编号 / 业务类型 / 状态 / 提交时间,所以详情页顶部信息卡只依赖其中一支即可,切页签不缺数据。
// 首屏用 getProjectDetailBundle **同帧并行**发 4 个请求(一次到位,避免首屏缺数据);
// **之后每次点击页签**再用 getProjectDetailTab 只拉对应那一支(点一次 = 一次请求),
// 响应回填到 store 里的 bundle 后重新映射 ⇒ 页面始终是**最新数据**,不重拉另外三支。
// 出参由 stores/modules/project.ts 缓存成 bundle,经 utils/projectVo.ts#toProjectDetail
// 映射成详情页沿用的 Project 形态。
// ⚠️ id 传的是**需求 id**(vm_project_requirement.id)= 列表行 RdProjectVo.id;
// 未立项的需求也能查到(项目侧字段为空);不存在 / 非本人 → 后端 4 支 data 均为 null。
// ─────────────────────────────────────────────────────────────────────────────
/**
* 详情页签(= `RequirementDetailBundle` 的键名,也就是 4 个端点的后缀)。
*
* <p>`views/project/detail.vue` 里的 `DetailTab` 是同一组值;那边不 import 本模块
* (页面层不直接依赖 api),靠字面量对齐。</p>
*/
export type ProjectDetailTab = 'progress' | 'files' | 'finance' | 'afterSales'
/** 页签 → 端点(4 支都继承 ProjectRequirementBaseVo,基础信息任一支都有) */
const DETAIL_TAB_URL: Record<ProjectDetailTab, string> = {
progress: '/web/requirementPool/get/progress',
files: '/web/requirementPool/get/files',
finance: '/web/requirementPool/get/finance',
afterSales: '/web/requirementPool/get/afterSales',
}
/**
* 单个页签取数(**点一次页签 = 一次请求**,拿该页签的最新数据)。
*
* <p>返回类型按页签收窄:`getProjectDetailTab(id, 'finance')` → `RequirementFinanceVo | null`,
* 所以调用方可以直接把结果写回 bundle 的对应键。</p>
*
* <p>`null` = 需求不存在 / 不属于当前用户(后端 `data: null`),调用方应保留现有数据并提示。</p>
*/
export function getProjectDetailTab<T extends ProjectDetailTab>(
id: string,
tab: T,
): Promise<RequirementDetailBundle[T] | null> {
return request<RequirementDetailBundle[T] | null>({
url: `${DETAIL_TAB_URL[tab]}/${id}`,
method: 'get',
})
}
/**
* 详情四支并行取回(**首屏用**);任一为 null ⇒ 整个 bundle 为 null。
*
* <p>4 支同源(都查同一行需求),正常不会只缺一支;缺一支即视为「需求不存在 / 非本人」,
* 详情页走整页空态。</p>
*/
export function getProjectDetailBundle(id: string): Promise<RequirementDetailBundle | null> {
return Promise.all([
getProjectDetailTab(id, 'progress'),
getProjectDetailTab(id, 'files'),
getProjectDetailTab(id, 'finance'),
getProjectDetailTab(id, 'afterSales'),
]).then(([progress, files, finance, afterSales]) => {
if (!progress || !files || !finance || !afterSales) return null
return { progress, files, finance, afterSales }
})
}
// ─────────────────────────────────────────────────────────────────────────────
// 售后(**真实接口**)
// 申请 POST /web/requirementPool/afterSales/request body ProjectRequirementActionBo
// 撤回 POST /web/requirementPool/afterSales/withdraw body ProjectRequirementActionBo
// → ProjectRequirementUserController#requestAfterSale / #withdrawAfterSale
// → ProjectRequirementServiceImpl#requestAfterSale / #withdrawAfterSale
// 附件上传 POST /web/workOrderDraft/upload(multipart,字段名 `file`)—— 只传 OSS 不落业务表;
// 提交时下发 `attachmentOssIds`,后端转存进 `vm_project_requirement_attachment`
// (phase = AFTER_SALE、target_id = 售后工单 id),OSS 文件不重复上传。
//
// ⚠️ 后端业务校验(失败抛 ServiceException,由统一 request 层弹提示):
// · 只有「已完成(COMPLETED)」的项目能申请售后;
// · 已有 REQUESTED / IN_PROGRESS 的售后时拒绝重复申请;
// · 撤回只允许 REQUESTED 状态,撤回后售后置 WITHDRAWN、项目回到已完成。
// ⚠️ 撤回接口收的是**需求 id**(不是售后工单 id),与后端 `checkOwner` 的校验口径一致。
//
// ⚠️ 详情页「项目文件」页签的上传 / 删除**仍无用户端接口**(上传入口在需求提报页),
// 所以这里不再保留 mock —— 详情数据来自真实接口,塞本地假数据会让刷新前后不一致。
// ─────────────────────────────────────────────────────────────────────────────
/**
* 上传售后附件(复用需求提报的 OSS 上传口)。
*
* <p>只传 OSS、不落业务表;返回项带 `ossId`,提交时由后端转存成售后附件。
* 后端响应没有文件大小,大小取本地 `File.size`。</p>
*/
export function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
const data = new FormData()
data.append('file', file)
return request<AfterSalesUploadVo>({
url: '/web/workOrderDraft/upload',
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data' },
}).then((vo) => ({
ossId: vo.ossId,
name: vo.originalName || file.name,
size: file.size,
url: vo.url,
uploadTime: formatDate(new Date()),
url: '',
}
PROJECT_DETAIL.files.push(info)
return Promise.resolve({ ...info })
}))
}
/** 删除项目文件(mock:按索引从详情文件列表移除) */
export function deleteProjectFile(index: number): Promise<void> {
if (index >= 0 && index < PROJECT_DETAIL.files.length) {
PROJECT_DETAIL.files.splice(index, 1)
}
return Promise.resolve()
}
/** 提交售后工单(mock) */
/**
* 申请售后(用户端)。
*
* <p>`id` 是**需求 id**(= 详情页路由参数);`payload` 的 `reason` / `note` 分别对应
* 页面的「问题描述」与「补充备注」。</p>
*/
export function submitAfterSales(
projectId: string,
id: string,
payload: AfterSalesPayload,
): Promise<AfterSales> {
const result: AfterSales = {
status: '处理中',
): Promise<void> {
return request<void>({
url: '/web/requirementPool/afterSales/request',
method: 'post',
data: {
id,
type: payload.type,
description: payload.description,
remark: payload.remark,
attachments: [...payload.attachments],
createTime: formatDate(new Date()),
}
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
return Promise.resolve({ ...result, attachments: [...result.attachments] })
reason: payload.reason,
note: payload.note,
// 空数组是安全的(后端 `appendAttachments` 对空集合直接 return,不 NPE)
attachmentOssIds: payload.attachmentOssIds,
},
})
}
/** 取消售后工单(mock) */
export function cancelAfterSales(projectId: string): Promise<AfterSales> {
const result: AfterSales = {
status: '无',
type: '',
description: '',
remark: '',
attachments: [],
createTime: '',
}
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
return Promise.resolve({ ...result })
/**
* 撤回售后申请(用户端)。
*
* <p>`id` 传**需求 id**;`reason` 是撤回原因(选填,后端会存进 `withdraw_reason`)。</p>
*/
export function cancelAfterSales(id: string, reason = ''): Promise<void> {
return request<void>({
url: '/web/requirementPool/afterSales/withdraw',
method: 'post',
data: { id, reason },
})
}

View File

@@ -1,62 +0,0 @@
// 用户web端首页接口(真实后端绑定)
// 后端: vermis-maker com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
// 前缀: /web/home-page 鉴权: 公开接口,无需 token
//
// 使用约定(与项目 SKILL.md 一致):
// 页面/组件禁止直接 import axios,一律经 store 调用本模块的 request<T>。
// 本文件只放「函数签名 + 请求路径」,不写业务逻辑。
import { request } from '@/utils/request'
import type {
ProjectStatusCount,
ProjectSubmissionVo,
BannerVo,
AgreementFormVo,
} from '@/types/web/home'
/** 项目状态统计 */
export function getProjectStatusCount(): Promise<ProjectStatusCount> {
return request<ProjectStatusCount>({
url: '/web/home-page/project-status-count',
method: 'get',
})
}
/** 业务服务类型列表 */
export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
return request<ProjectSubmissionVo[]>({
url: '/web/home-page/service-type-list',
method: 'get',
})
}
/** 业务类型详情 */
export function getServiceTypeDetail(id: number | string): Promise<ProjectSubmissionVo> {
return request<ProjectSubmissionVo>({
url: `/web/home-page/service-type-detail/${id}`,
method: 'get',
})
}
/** 轮播图列表 */
export function getCarouselList(): Promise<BannerVo[]> {
return request<BannerVo[]>({
url: '/web/home-page/carousel-list',
method: 'get',
})
}
/** 平台介绍(富文本 HTML) */
export function getPlatformIntro(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({
url: '/web/home-page/platform-intro',
method: 'get',
})
}
/** 联系信息(富文本 HTML) */
export function getContactInfo(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({
url: '/web/home-page/contact-info',
method: 'get',
})
}

View File

@@ -2,27 +2,32 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import type {
AfterSalesAttachment,
AfterSalesPayload,
Project,
ProjectQuery,
ProjectStatistics,
ProjectTypeCount,
RdProjectVo,
RequirementDetailBundle,
} from '@/types/business/project'
import {
cancelAfterSales as cancelAfterSalesApi,
deleteProjectFile,
getProjectDetail,
getCompletedProjects,
getProjectDetailBundle,
getProjectDetailTab,
getProjectFilterConditions,
getProjectList,
submitAfterSales as submitAfterSalesApi,
uploadProjectFile,
uploadAfterSalesFile as uploadAfterSalesFileApi,
} from '@/api/project'
import type { ProjectDetailTab } from '@/api/project'
import { toProjectDetail } from '@/utils/projectVo'
function createDefaultQuery(): ProjectQuery {
return {
keyword: '',
businessType: '',
businessTypes: [],
status: '',
timeShortcut: 'all',
startDate: '',
@@ -65,6 +70,31 @@ export const useProjectStore = defineStore('project', () => {
/** 业务类型筛选条件是否已拉过(值域在运行期不变,进页面拉一次即可) */
let filterConditionsLoaded = false
/**
* 已完成项目(**申请售后页「项目名称」下拉的候选**)。
*
* <p>口径见 `api/project.ts#getCompletedProjects`:自己名下已立项且状态为 COMPLETED 的项目,
* 与后端「只有已完成的项目可以申请售后」的准入校验一致。行的 `id` 就是申请接口要传的需求 id。</p>
*
* <p>与业务类型的筛选条件不同,这个值域**会随项目状态变化**,所以不缓存、
* 每次进申请页都重拉(见 {@link fetchCompletedProjects})。</p>
*/
const completedProjects = ref<RdProjectVo[]>([])
/** 详情原始 bundle(4 支接口的响应);切页签时按页签替换其中一支后重新映射 */
const detailBundle = ref<RequirementDetailBundle | null>(null)
/** 详情请求序号:需求 id 一变就自增,用来丢弃在途的「上一个需求」的响应 */
let detailSeq = 0
/** 各页签自己的请求序号:同一页签连点只应用最后一次响应 */
const tabSeq: Record<ProjectDetailTab, number> = {
progress: 0,
files: 0,
finance: 0,
afterSales: 0,
}
/** 正在刷新的页签('' = 无);页签按钮据此显示加载态 */
const loadingTab = ref<ProjectDetailTab | ''>('')
/**
* 取业务类型筛选条件(左侧筛选栏「业务类型」的值域 + 件数)。
*
@@ -97,21 +127,98 @@ export const useProjectStore = defineStore('project', () => {
}
/**
* 取详情(真实接口)。
* 取「已完成项目」(申请售后页「项目名称」下拉的值域)。
*
* <p>后端在「需求不存在」或「不属于当前用户」时返回 `data: null` ⇒ 页面显示空态;
* 请求本身失败(如 500)也归为 null,不让异常冒到组件里。</p>
* <p>失败只告警不清空:保留上一次的结果,页面下拉至少还有数据;首次即失败则为空数组,
* 申请页会退化成只显示当前项目的单项下拉(见 `after-sales.vue` 的兜底合并)。</p>
*/
async function fetchDetail(id: string): Promise<void> {
async function fetchCompletedProjects(): Promise<void> {
try {
current.value = await getProjectDetail(id)
completedProjects.value = await getCompletedProjects()
}
catch (error) {
console.warn('[project] 已完成项目列表加载失败', error)
}
}
/**
* 取详情(**首屏**:4 支并行,一次到位)。
*
* <p>后端在「需求不存在」或「不属于当前用户」时 4 支都返回 `data: null` ⇒ 页面显示空态;
* 请求本身失败(如 500)也归为 null,不让异常冒到组件里。</p>
*
* <p>原始响应同时缓存进 `detailBundle`,之后切页签只替换其中一支(见 {@link refreshTab}),
* 不必重拉另外三支。</p>
*/
async function fetchDetail(id: string): Promise<void> {
const seq = ++detailSeq
loadingTab.value = ''
try {
const bundle = await getProjectDetailBundle(id)
// 期间已切到别的需求 ⇒ 丢弃这次响应,别用旧项目覆盖新项目
if (seq !== detailSeq) return
if (!bundle) {
detailBundle.value = null
current.value = null
return
}
detailBundle.value = bundle
current.value = toProjectDetail(bundle)
}
catch (error) {
if (seq !== detailSeq) return
detailBundle.value = null
current.value = null
console.warn('[project] 项目详情加载失败', error)
}
}
/**
* 切页签刷新:**只调该页签对应的那一个接口**,拿到最新数据后落回 bundle 再重新映射。
*
* <p>后端 4 个页签各一个端点 ⇒ 「点一次页签 = 一次请求」,不需要重拉另外三支。
* 页签内容(里程碑 / 付款计划 / 附件 / 售后…)可能被别的入口改动,所以每次点击都重新取。</p>
*
* <p>并发保护两层:① 需求 id 变了(切项目)⇒ 丢弃在途响应;② 同一页签连点 ⇒ 只应用
* 最后一次响应(先到的慢响应被后到的覆盖)。</p>
*
* <p>后端返回 `null`(需求不存在 / 非本人)时**保留现有数据**,只告警、不清空页面。</p>
*/
async function refreshTab(tab: ProjectDetailTab): Promise<void> {
const requestId = current.value?.id
if (!requestId) return
const seq = ++tabSeq[tab]
loadingTab.value = tab
try {
const vo = await getProjectDetailTab(requestId, tab)
if (seq !== tabSeq[tab] || current.value?.id !== requestId) return
if (!vo || !detailBundle.value) return
const next: RequirementDetailBundle = { ...detailBundle.value }
switch (tab) {
case 'progress':
next.progress = vo as RequirementDetailBundle['progress']
break
case 'files':
next.files = vo as RequirementDetailBundle['files']
break
case 'finance':
next.finance = vo as RequirementDetailBundle['finance']
break
case 'afterSales':
next.afterSales = vo as RequirementDetailBundle['afterSales']
break
}
detailBundle.value = next
current.value = toProjectDetail(next)
}
catch (error) {
console.warn(`[project] 页签「${tab}」数据刷新失败`, error)
}
finally {
if (seq === tabSeq[tab]) loadingTab.value = ''
}
}
function setQuery(next: ProjectQuery): void {
query.value = next
}
@@ -143,30 +250,35 @@ export const useProjectStore = defineStore('project', () => {
await load()
}
/** 上传项目文件(追加到当前详情) */
async function uploadFile(file: File): Promise<void> {
const info = await uploadProjectFile(file)
current.value?.files.push(info)
/**
* 上传售后附件(提交前的暂存步骤)。
*
* <p>只传 OSS、不落业务表;返回项带 `ossId`,由申请页收集后在提交时下发
* `attachmentOssIds`,后端再转存成售后附件。</p>
*/
async function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
return uploadAfterSalesFileApi(file)
}
/** 删除当前详情中的项目文件 */
async function deleteFile(index: number): Promise<void> {
await deleteProjectFile(index)
current.value?.files.splice(index, 1)
/**
* 申请售后(成功后就地重拉「售后信息」页签)。
*
* <p>不拿返回值本地拼状态:售后状态与工单附件都由后端决定,本地拼容易和真实数据漂移。
* 接口失败(如「只有已完成的项目可以申请售后」)会抛出,由统一 request 层弹提示,
* 这里不做兜底吞异常。</p>
*/
async function submitAfterSales(id: string, payload: AfterSalesPayload): Promise<void> {
await submitAfterSalesApi(id, payload)
ElMessage.success('售后申请已提交')
// 详情页在场时同步刷新该页签;从申请页提交时会立刻跳回详情,那边 onMounted 会重新取数
if (current.value?.id === id) await refreshTab('afterSales')
}
/** 提交售后工单 */
async function submitAfterSales(payload: AfterSalesPayload): Promise<void> {
if (!current.value) return
current.value.afterSales = await submitAfterSalesApi(current.value.id, payload)
ElMessage.success('已提交售后工单')
}
/** 取消售后工单 */
async function cancelAfterSales(): Promise<void> {
if (!current.value) return
current.value.afterSales = await cancelAfterSalesApi(current.value.id)
ElMessage.success('已取消售后工单')
/** 撤回售后申请(成功后就地重拉「售后信息」页签) */
async function cancelAfterSales(id: string): Promise<void> {
await cancelAfterSalesApi(id)
ElMessage.success('售后申请已撤回')
if (current.value?.id === id) await refreshTab('afterSales')
}
return {
@@ -177,15 +289,24 @@ export const useProjectStore = defineStore('project', () => {
typeCounts,
ensureFilterConditions,
fetchList,
/** 已完成项目(申请售后页「项目名称」下拉候选) */
completedProjects,
fetchCompletedProjects,
fetchDetail,
/** 切页签刷新(每次点击都调用,只拉该页签那一支) */
refreshTab,
/** 正在刷新的页签,供按钮显示加载态 */
loadingTab,
setQuery,
load,
search,
resetQuery,
changePage,
uploadFile,
deleteFile,
/** 上传售后附件(申请页用) */
uploadAfterSalesFile,
/** 申请售后(成功后自动刷新售后页签) */
submitAfterSales,
/** 撤回售后申请(成功后自动刷新售后页签) */
cancelAfterSales,
}
})

View File

@@ -36,6 +36,29 @@
}
}
// 卡片区块标题(左侧圆头竖条 + 16px 半粗)
// 项目详情设计稿规格:竖条 5×20 圆头 @project-accent,标题 16px/600,竖条→文字间距 10px
.section-title() {
position: relative;
padding-left: 15px;
font-size: @font-size-lg;
font-weight: 600;
line-height: 20px;
color: @heading-color;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 5px;
height: 20px;
border-radius: @radius-round;
background: @project-accent;
transform: translateY(-50%);
}
}
// 表单文本控件(输入框 / 文本域共用外观;需求提报页设计稿规格:白底 + 浅灰描边 + 4px 圆角)
.text-control() {
width: 100%;

View File

@@ -44,6 +44,7 @@
@font-size-base: 14px;
@font-size-lg: 16px;
@font-size-xl: 20px;
@font-size-title: 22px; // 详情页主标题(项目详情设计稿 28 设计px × 0.77)
@font-size-xxl: 28px;
// 布局(容器宽度按设计稿标注校准:设计稿为约 1400 宽的整幅版面)
@@ -74,3 +75,12 @@
// 项目中心(按项目中心设计稿标注校准)
@project-table-head-bg: #e8e8e8; // 表头底色(设计稿实取 (232,232,232))
@project-table-line: #bbbbbb; // 表格网格线(设计稿实取 (187,187,187))
// 项目详情(按 docs/prototype/项目中心-演示.html 设计稿校准)
// ⚠️ 换算系数:设计画布内容宽 1776px → 应用内容宽 1368px(.container 1400 − 2×16),
// 即 设计px × 0.77 ≈ CSS px。下列数值 = 设计稿实测值 × 0.77 后取整。
@project-accent: #2563eb; // 竖条 / 状态文字 / 进度条 / 激活页签 / 里程碑进行中(设计稿实取 rgba(37,99,235,1))
@project-track: #e8e8e8; // 进度条轨道(设计稿实取)
@milestone-done: #34c724; // 里程碑已完成圆点
@milestone-idle: #cecece; // 里程碑待开始圆环 + 连接线(设计稿实取)
@team-avatar-bg: #54bcbd; // 团队头像底色(设计稿实取)

View File

@@ -130,34 +130,92 @@ export interface ProjectBill {
projectName: string
}
/** 售后工单 */
/**
* 售后工单(详情页组件契约)。
*
* <p>两态:`处理中` = 售后工单存在且未撤回/未作废(含后端 REQUESTED / IN_PROGRESS);
* `无` = 没有工单,或最近一条已 WITHDRAWN(撤回后回到可再次申请的状态)。</p>
*/
export interface AfterSales {
status: '无' | '处理中'
/**
* 售后类型 —— 申请页下拉的固定 8 项:软件 BUG 修复 / 软件 / 固件功能迭代 /
* 硬件 PCB & 电路改版调整 / 硬件故障排查维修 / 成品不良品返修 / 元器件物料替换变更 /
* 物联网整机联调与版本升级 / 交付资料补齐与技术答疑。
* 库内暂无对应字典,值域先写在前端(`views/project/after-sales.vue#TYPE_OPTIONS`)。
*/
type: string
/** 问题描述(后端 `reason`) */
description: string
/** 用户补充备注(后端 `remark`) */
remark: string
/** 售后工单附件(后端 `afterSaleAttachments`,phase = AFTER_SALE) */
attachments: FileInfo[]
/** 申请时间(后端 `requestTime`) */
createTime: string
}
/** 售后提交入参 */
export interface AfterSalesPayload {
type: string
description: string
remark: string
attachments: FileInfo[]
/**
* 售后附件(已上传到 OSS,携带 ossId 以便提交时由后端转存)。
*
* <p>上传走需求提报的同一个 OSS 上传口(`POST /web/workOrderDraft/upload`),
* 提交时只传 `ossId` 列表,后端按 `phase = AFTER_SALE`、`target_id = 售后工单 id`
* 写进 `vm_project_requirement_attachment`,OSS 文件不重复上传。</p>
*/
export interface AfterSalesAttachment extends FileInfo {
/** OSS 主键(提交时进 `attachmentOssIds`) */
ossId: string | number
}
/**
* 项目详情(**详情页仍是前端 mock 形态**,尚未接后端)。
* OSS 上传返回(后端 `SysOssVo` 的子集)—— 与需求提报共用同一个上传口。
*
* <p>列表已改走真实接口(见 {@link RdProjectVo}),本类型只服务详情页那几个组件。</p>
* <p>⚠️ `fileSuffix` 带前导点(`.pdf`);响应里**没有**文件大小,大小要取本地 `File.size`。</p>
*/
export interface AfterSalesUploadVo {
ossId: string | number
url: string
originalName: string
fileSuffix?: string
}
/**
* 售后申请入参 —— **对齐后端 `ProjectRequirementActionBo`**。
*
* <p>字段名与页面语义的对应:页面「问题描述」→ {@link reason}、「补充备注」→ {@link note}。</p>
*/
export interface AfterSalesPayload {
/** 售后类型(下拉 8 项之一),最长 50 */
type: string
/** 问题描述 → 后端 `reason`,最长 500 */
reason: string
/** 补充备注 → 后端 `note`(页面限 500,后端 BO 上限 2000) */
note: string
/** 附件 OSS 主键集合(空数组安全,后端 null 会走空判断) */
attachmentOssIds: Array<string | number>
}
/**
* 项目详情页数据模型 —— **详情页组件契约**,不是后端原样字段。
*
* <p>后端详情已拆成 4 个页签接口(见 {@link RequirementDetailBundle}),由
* `utils/projectVo.ts#toProjectDetail` 合并映射成本类型;详情页 6 个子组件
* (InfoCard/Team/Milestones/Files/Finance/AfterSales)只认这套字段,因此换数据来源
* 不需要动组件。</p>
*/
export interface Project {
id: string
projectNo: string
projectName: string
/** 归一化后的枚举(未知类型会落到 'other',仅供图标/配色等视觉用途) */
businessType: BusinessType
/**
* 后端**原样下发**的业务类型名称(如「互联网软件」,来自提报表)。
*
* <p>⚠️ 展示一律用它:`businessType` 只有 5 个固定枚举,后台新增业务类型时会全部落成
* 「其他」——那是枚举的兜底,不是接口的值。</p>
*/
businessTypeName: string
progress: number
status: ProjectStatus
submitTime: string
@@ -185,16 +243,16 @@ export interface ProjectTypeCount {
/**
* 项目列表查询条件(**已是后端口径**)。
*
* <p>与 `RdProjectBo` 的字段对应关系:`keyword → key`、`businessType → businessType`(提报表 id)、
* <p>与 `RdProjectBo` 的字段对应关系:`keyword → key`、`businessTypes → businessTypes`(提报表 id,多选 IN)、
* `status → status`(码值)、`startDate/endDate 或 timeShortcut → beginTime/endTime`(两者都落在
* `create_time` 上)、`pageNum/pageSize → PageQuery`。</p>
*
* <p>用空串表示「不限」而不是 'all'——空串直接不出现在 querystring 里,与后端 `eqIfText` 的语义一致。</p>
* <p>用空串 / 空数组表示「不限」而不是 'all'——空值直接不出现在 querystring 里,与后端 `eqIfText` 的语义一致。</p>
*/
export interface ProjectQuery {
keyword: string
/** '' = 全部;否则为后端统计下发的 businessTypeId */
businessType: string
/** 空数组 = 全部;元素为后端统计下发的 businessTypeId(**多选**,后端走 IN) */
businessTypes: string[]
/** '' = 全部;否则为 {@link RdProjectStatus} 码值 */
status: string
/** 提交时间快捷区间;与 startDate/endDate 互斥(选其一会把另一组清空) */
@@ -208,12 +266,22 @@ export interface ProjectQuery {
}
// ─────────────────────────────────────────────────────────────────────────────
// 详情契约:后端 `ProjectRequirementUserVo`(GET /web/requirementPool/get/{id})
// 详情契约:后端 4 个页签接口(都继承 ProjectRequirementBaseVo)
// GET /web/requirementPool/get/progress/{id} → ProjectRequirementProgressVo
// GET /web/requirementPool/get/files/{id} → ProjectRequirementFilesVo
// GET /web/requirementPool/get/finance/{id} → ProjectRequirementFinanceVo
// GET /web/requirementPool/get/afterSales/{id} → ProjectRequirementAfterSalesVo
//
// 与列表不同,详情是「需求表为主 + 各子表按需填充」:需求未立项时 `vm_rd_project`
// 没有对应行,项目侧字段(projectStage / projectMilestones / members / billPlans /
// invoices / contracts)一律为空,需求侧字段(基本信息 / requirementAttachments /
// afterSale)照常返回。
// 为什么拆 4 个:详情页本就是 4 个页签,单接口要一次查全 8 张表;拆开后可「按页签取数」。
// 4 个 VO 都带基础信息(继承 `ProjectRequirementBaseVo`)⇒ 任一页签的响应里都有项目名 /
// 编号 / 业务类型 / 状态 / 提交时间,所以前端可以只用 `progress` 那一支渲染顶部信息卡,
// 并保证切页签后信息卡不缺数据(合并逻辑见 utils/projectVo.ts#toProjectDetail)。
//
// 与列表一致:需求未立项时 `vm_rd_project` 没有对应行,项目侧字段(projectStage /
// projectMilestones / members / billPlans / invoices / contracts)为空数组 / null,
// 需求侧字段(基础信息 / requirementAttachments / afterSale)照常返回。
// ⚠️ id 传的是**需求 id**(vm_project_requirement.id)= 列表行的 RdProjectVo.id;
// 不存在 / 非本人 → 后端 `data` 为 null(详情页整页空态)。
// ─────────────────────────────────────────────────────────────────────────────
/** 开发进度(后端 `ProjectStage`,表 `vm_project_stage`) */
@@ -275,7 +343,8 @@ export interface RequirementBillPlan {
projectId: string | null
projectNo: string | null
projectName: string | null
totalAmount: number | null
/** ⚠️ BigDecimal ⇒ 后端以**字符串**下发("120000.00"),用前必须 `Number()` 归一 */
totalAmount: string | number | null
periods: number | null
currentPeriod: number | null
periodRule: string | null
@@ -290,7 +359,8 @@ export interface RequirementInvoice {
invoiceTitle: string | null
taxNo: string | null
invoiceType: string | null
amount: number | null
/** ⚠️ BigDecimal ⇒ 字符串下发,用前 `Number()` 归一 */
amount: string | number | null
status: string | null
invoiceNo: string | null
/** 电子发票文件地址 */
@@ -307,7 +377,8 @@ export interface RequirementContract {
contractNo: string | null
projectId: string | null
title: string | null
amount: number | null
/** ⚠️ BigDecimal ⇒ 字符串下发,用前 `Number()` 归一 */
amount: string | number | null
signStatus: string | null
status: string | null
signDate: string | null
@@ -321,8 +392,21 @@ export interface RequirementContract {
export interface RequirementAfterSale {
id: string
requirementId: string | null
/** 售后原因 */
/**
* 售后类型 —— 申请页下拉的固定 8 项:软件 BUG 修复 / 软件 / 固件功能迭代 /
* 硬件 PCB & 电路改版调整 / 硬件故障排查维修 / 成品不良品返修 / 元器件物料替换变更 /
* 物联网整机联调与版本升级 / 交付资料补齐与技术答疑。
* 库内暂无对应字典,值域先写在前端(`views/project/after-sales.vue#TYPE_OPTIONS`)。
*/
type: string | null
/** 售后原因(用户在申请页填的「问题描述」) */
reason: string | null
/** 用户补充备注(申请页的「补充备注」) */
remark: string | null
/**
* REQUESTED(待受理)/ IN_PROGRESS(处理中)/ WITHDRAWN(已撤回)/ COMPLETED(已完成)
* —— 见 `ProjectRequirementConstants`。
*/
status: string | null
requestUserName: string | null
requestTime: string | null
@@ -333,40 +417,74 @@ export interface RequirementAfterSale {
}
/**
* 需求详情响应(后端 `ProjectRequirementUserVo`)。
* 4 个页签接口的公共基础信息(后端 `ProjectRequirementBaseVo`)。
*
* <p>注意两个「名字骗人」的字段:`attachments` / `afterSales`(复数)后端**从未赋值**,
* 真正有数据的是 `requirementAttachments` 与 `afterSale`(单数)。前端只取后者。</p>
* <p>字段全部来自需求表 `vm_project_requirement`,**与是否立项无关**,任意页签都填充。</p>
*/
export interface RequirementDetailVo {
export interface RequirementBaseVo {
/** 需求 id(= 详情接口路径参数 = 列表行 RdProjectVo.id) */
id: string
projectNo: string | null
submitUserId: string | null
submitUserName: string | null
submitPhone: string | null
projectName: string | null
/** 业务类型**中文名**(如「硬件电路」),非英文 code */
businessType: string | null
projectName: string | null
requirementDesc: string | null
priority: string | null
/** 状态码值:需求侧 INITIATING/CANCELLED,项目侧 IN_PROGRESS/… */
status: string | null
customerServiceName: string | null
customerServicePhone: string | null
technicalOwnerName: string | null
internalRemark: string | null
attachmentCount: number | null
/** 提交时间 */
createTime: string | null
updateTime: string | null
launchNote: string | null
cancelReason: string | null
/** 未立项时为 null */
/** 提交人名称 */
submitUserName: string | null
}
/** 「项目进度」页签(后端 `ProjectRequirementProgressVo`):基础信息 + 开发进度 + 里程碑 + 团队 */
export interface RequirementProgressVo extends RequirementBaseVo {
/** 开发进度;未立项为 null */
projectStage: RequirementProjectStage | null
/**
* 总体进度百分比(= 列表 `RdProjectVo.progress`,同源 `vm_rd_project.progress`)。
* 后端在项目行没有 progress 时回落到 `projectStage.developmentProgress`;未立项为 null。
*/
developmentProgress?: number | null
projectMilestones: RequirementMilestone[]
requirementAttachments: RequirementAttachment[]
/** 项目团队;未立项为空数组 */
members: RequirementMember[]
}
/** 「项目文件」页签(后端 `ProjectRequirementFilesVo`):基础信息 + 需求附件 */
export interface RequirementFilesVo extends RequirementBaseVo {
/** 需求附件;与是否立项无关,始终填充 */
requirementAttachments: RequirementAttachment[]
}
/** 「合同财务」页签(后端 `ProjectRequirementFinanceVo`):基础信息 + 付款计划 + 发票 + 合同 */
export interface RequirementFinanceVo extends RequirementBaseVo {
billPlans: RequirementBillPlan[]
invoices: RequirementInvoice[]
contracts: RequirementContract[]
afterSale: RequirementAfterSale | null
}
/** 「售后信息」页签(后端 `ProjectRequirementAfterSalesVo`):基础信息 + 售后工单 + 工单附件 */
export interface RequirementAfterSalesVo extends RequirementBaseVo {
/** 售后工单;无则 null */
afterSale: RequirementAfterSale | null
/**
* 售后工单附件 —— 与需求附件同表 `vm_project_requirement_attachment`,
* 靠 `phase = AFTER_SALE` + `target_id = 售后工单 id` 区分。
* 后端无条件下发(无工单时为空数组),前端直接渲染即可。
*/
afterSaleAttachments: RequirementAttachment[]
}
/**
* 详情页四接口聚合结果。
*
* <p>`api/project.ts#getProjectDetail` 同帧并行取回 4 个页签后交给
* `utils/projectVo.ts#toProjectDetail` 合并成组件契约 {@link Project};
* 任一接口返回 null(需求不存在 / 非本人)即整页空态。</p>
*/
export interface RequirementDetailBundle {
progress: RequirementProgressVo
files: RequirementFilesVo
finance: RequirementFinanceVo
afterSales: RequirementAfterSalesVo
}

View File

@@ -1,65 +0,0 @@
// 后端「用户web端首页」接口真实返回类型
// 对应 vermis-maker: com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
// 统一前缀: /web/home-page (公开接口,无需登录)
// 字段与后端 VO 严格对齐(驼峰、可空用 | null)。
/** GET /web/home-page/project-status-count —— 项目状态统计 */
export interface ProjectStatusCount {
/** 项目数量 */
projectCount: number
/** 进行中的项目数目 */
ongoingProjectCount: number
/** 待处理数目 */
pendingProjectCount: number
}
/** 服务类型子项(service-type-list / service-type-detail 内含) */
export interface ProjectServiceTypeVo {
id: number | null
projectId: number | null
serviceName: string | null
iconUrl: string | null
sortOrder: number | null
}
/** 项目背景(富文本) */
export interface ProjectBackgroundVo {
id: number | null
projectId: number | null
content: string | null
sortOrder: number | null
}
/** 项目提报视图对象:service-type-list / service-type-detail/{id} 的返回 */
export interface ProjectSubmissionVo {
id: number
businessType: string | null
title: string | null
subtitle: string | null
/** 是否启用:1 启用,0 停用 */
enabled: number | null
coverUrl: string | null
logoUrl: string | null
serviceTypes: ProjectServiceTypeVo[]
backgrounds: ProjectBackgroundVo[]
/** yyyy-MM-dd HH:mm:ss */
updateTime: string | null
}
/** GET /web/home-page/carousel-list —— 轮播图 */
export interface BannerVo {
id: number | null
imageUrl: string | null
sort: number | null
/** 是否启用(true 启用,false 停用) */
enabled: boolean | null
}
/** GET /web/home-page/platform-intro、contact-info —— 协议/静态文章富文本 */
export interface AgreementFormVo {
id: number | null
/** 协议类型(目前只有「关于我们」一条) */
type: string | null
/** 文章内容(富文本 HTML) */
content: string | null
}

View File

@@ -34,9 +34,17 @@ export function formatBusinessType(type: BusinessType): string {
return BUSINESS_TYPE_LABELS[type]
}
/** 金额千分位(默认两位小数) */
export function formatMoney(value: number, decimals = 2): string {
const fixed = value.toFixed(decimals)
/**
* 金额千分位(默认两位小数)。
*
* <p>⚠️ `value` 必须容错字符串:后端 `BigDecimal` 经全局 Jackson 配置序列化成
* <b>字符串</b>(如 `"120000.00"`),直接 `value.toFixed()` 会抛
* `TypeError: value.toFixed is not a function`——在模板里调用会让<b>整个组件渲染失败</b>
* (实测「合同财务」页签白屏)。这里统一 `Number()` 归一,非法值按 0 处理。</p>
*/
export function formatMoney(value: number | string | null | undefined, decimals = 2): string {
const num = Number(value)
const fixed = (Number.isFinite(num) ? num : 0).toFixed(decimals)
const [int = '0', fraction] = fixed.split('.')
const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return fraction ? `${withSep}.${fraction}` : withSep

View File

@@ -9,32 +9,43 @@ import type {
RequirementAttachment,
RequirementBillPlan,
RequirementContract,
RequirementDetailVo,
RequirementDetailBundle,
RequirementInvoice,
RequirementMember,
TeamMember,
} from '@/types/business/project'
import { toBusinessType } from '@/utils/format'
import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta'
/**
* 需求详情(后端 `ProjectRequirementUserVo`)→ 详情页 `Project`。
* 需求详情(后端 **4 个页签接口**的聚合结果 `RequirementDetailBundle`)→ 详情页 `Project`。
*
* <p>为什么不直接改组件:详情页 6 个子组件(InfoCard/Team/Milestones/Files/Finance/
* AfterSales)都按 `Project` 这套字段写的,改数据来源不动组件契约,改动面最小。</p>
*
* <p><b>基础信息取哪一支都行</b>:4 个页签 VO 都继承 `ProjectRequirementBaseVo` 且同源
* (需求表同一行),这里统一取 `bundle.progress` 那一支当基础信息(UI 上它最先渲染)。</p>
*
* <p><b>未立项是常态</b>:需求在 `vm_rd_project` 里没有对应行时,后端返回的
* `projectStage` / `projectMilestones` / `members` / `billPlans` / `invoices` / `contracts`
* 全为空 —— 这里一律兜底成空数组 / 0 / undefined,详情页显示为空态,不报错。</p>
*/
/**
* 状态码值 → 详情页中文。
* 状态 → 详情页中文。
*
* <p>码值域是两张表的并集(见 {@link ProjectStatus}):需求侧 `INITIATING`/`CANCELLED`,
* 项目侧 `IN_PROGRESS`/`PENDING_ACCEPTANCE`/… 未知码值兜底「待立项」(未立项是最常见态)。</p>
* <p>⚠️ 后端 `selectUserProjectPage` / 详情侧已用 `sys_dict_data`(`vm_requirement_status`)
* 把状态翻成**中文**下发(如「进行中」),但码值(`INITIATING` 等)也可能出现
* (直接查子表、或字典缺项时)⇒ 两种口径都要认,认不出的一律兜底「待立项」(未立项最常见)。</p>
*/
const PROJECT_STATUS_LABELS: string[] = Object.values(PROJECT_STATUS_LABEL)
function toProjectStatus(raw: string | null | undefined): ProjectStatus {
const label = PROJECT_STATUS_LABEL[raw ?? '']
const value = (raw ?? '').trim()
if (!value) return '待立项'
// 先按码值查表;后端已是中文名时直接采信
const label = PROJECT_STATUS_LABEL[value]
?? (PROJECT_STATUS_LABELS.includes(value) ? value : undefined)
return (label as ProjectStatus | undefined) ?? '待立项'
}
@@ -54,16 +65,48 @@ function toMilestoneStatus(raw: string | null | undefined): Milestone['status']
/**
* 账单/付款计划状态 → 三态。
*
* <p>⚠️ 后端 `BillPlan.status` 的码值语义**未确认**(`vm_bill_plan` 当前 0 行,无样本),
* 只认字面量,其余一律「未到期」——不臆造成「已支付」。</p>
* <p>⚠️ 数据源是 `vm_bill_plan`,实体注释的值域只有 **ACTIVE / DONE / CLOSED**
* (不是 `vm_bill` 的 UNSTARTED/PENDING/PAID)。两者语义对照:
* 计划执行中 = 还有期次要付 ⇒ 「待支付」;计划结束 = 已付清 ⇒ 「已支付」。</p>
*
* <p><b>「未到期」在 `vm_bill_plan` 里没有对应码值</b>(已知契约缺口)——设计稿的第三张卡
* (第三期·尾款 → 未到期)目前靠 `UNSTARTED` 表达,所以这里显式认它,
* 而不是依赖「认不出就兜底未到期」的隐式行为;另兼容 `vm_bill` 的 PAID/PENDING 字面量。</p>
*/
function toBillStatus(raw: string | null | undefined): ProjectBill['status'] {
const value = (raw ?? '').toUpperCase()
if (value.includes('PAID') || value.includes('支付')) return '已支付'
if (value.includes('WAIT') || value.includes('PENDING') || value.includes('待支付')) return '待支付'
// ⚠️ 关键词别用宽泛的「支付」:中文「**待**支付」也包含它,会先被这里截走误判成「已支付」。
if (value.includes('PAID') || value.includes('DONE') || value.includes('CLOSE')
|| value.includes('已支付')) return '已支付'
if (value.includes('ACTIVE') || value.includes('WAIT') || value.includes('PENDING')
|| value.includes('待支付')) return '待支付'
if (value.includes('UNSTART') || value.includes('NOT_DUE') || value.includes('未到期')
|| value.includes('未开始')) return '未到期'
// 认不出的一律「未到期」——不臆造成「已支付」(那是更坏的错)
return '未到期'
}
/**
* 后端数值字段归一(BigDecimal 会以**字符串**下发,如 `"120000.00"`)。
*
* <p>直接当 number 用会崩:`formatMoney('120000.00')` → `value.toFixed is not a function`
* ⇒ 组件渲染失败、整个页签白屏(实测「合同财务」页签)。</p>
*/
function toNumber(value: number | string | null | undefined, fallback = 0): number {
const num = Number(value)
return Number.isFinite(num) ? num : fallback
}
/**
* LocalDateTime 字符串 → 设计稿口径的「日」。
*
* <p>后端下发 `2026-09-03 00:00:00`(或 ISO `…T00:00:00`),两种都能截前 10 位;
* 空值返回 undefined,交给组件显示「—」。</p>
*/
function toDate(value: string | null | undefined): string | undefined {
return value ? value.slice(0, 10) : undefined
}
/** 需求附件 → 详情页文件项(文件 tab 用) */
function attachmentToFile(item: RequirementAttachment): FileInfo {
return {
@@ -90,15 +133,17 @@ function contractToFile(item: RequirementContract): FileInfo {
name: item.title ?? item.contractNo ?? '合同文件',
size: 0,
url: item.file ?? '',
uploadTime: item.signDate ?? item.createTime ?? '',
// signDate 是 DATE 列,后端会下发成 '2026-08-20 00:00:00' ⇒ 只保留到日
uploadTime: toDate(item.signDate ?? item.createTime) ?? '',
}
}
/**
* 付款计划。
*
* <p>`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用 `periodRule`(付不出
* 就用「第 N 期/共 M 期」拼),`billNo` 回落到 `projectNo`。</p>
* <p>`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用「第 N 期/共 M 期」
* (**期次优先**,`periodRule` 的 '30,30,40' 只作兜底——直接把比例当名字显示不可读),
* `billNo` 回落到 `projectNo`。</p>
*/
function toProjectBill(item: RequirementBillPlan): ProjectBill {
const period = item.currentPeriod != null && item.periods != null
@@ -106,16 +151,16 @@ function toProjectBill(item: RequirementBillPlan): ProjectBill {
: ''
return {
id: String(item.id),
planName: item.periodRule || period || '付款计划',
planName: period || item.periodRule || '付款计划',
billNo: item.projectNo ?? '',
amount: item.totalAmount ?? 0,
amount: toNumber(item.totalAmount),
status: toBillStatus(item.status),
projectName: item.projectName ?? '',
}
}
/** 项目成员 → 详情页团队成员卡 */
function toTeamMember(item: RequirementDetailVo['members'][number]): TeamMember {
function toTeamMember(item: RequirementMember): TeamMember {
const name = item.userName ?? ''
return {
role: item.roleName ?? (item.isOwner ? '项目负责人' : '项目成员'),
@@ -127,48 +172,71 @@ function toTeamMember(item: RequirementDetailVo['members'][number]): TeamMember
/**
* 售后工单。
*
* <p>详情页 `AfterSales` 只有「无 / 处理中」两态,且 `type`(问题类型)后端没有对应字段
* ⇒ 留空,`description` 用售后原因、`remark` 用处理说明。</p>
* <p>详情页 `AfterSales` 只有「无 / 处理中」两态:撤回(WITHDRAWN)/ 作废 / 关闭 ⇒ 「无」
* (页面回到可再次申请的状态),其余(REQUESTED / IN_PROGRESS / COMPLETED)都算「处理中」。</p>
*
* <p>字段对应:`type` = 售后类型、`description` = 用户填的问题描述(后端 `reason`)、
* `remark` = 用户补充备注(后端 `remark`,不是处理人写的 `processNote`)、
* `attachments` = 售后工单附件(同表 `phase = AFTER_SALE`,由后端随页签下发)。</p>
*/
function toAfterSales(item: RequirementAfterSale): AfterSales {
function toAfterSales(
item: RequirementAfterSale,
attachments: RequirementAttachment[] = [],
): AfterSales {
const value = (item.status ?? '').toUpperCase()
const closed = value.includes('CLOSE') || value.includes('CANCEL') || value.includes('WITHDRAW')
return {
status: closed ? '无' : '处理中',
type: '',
type: item.type ?? '',
description: item.reason ?? '',
remark: item.processNote ?? '',
attachments: [],
remark: item.remark ?? '',
attachments: attachments.map(attachmentToFile),
createTime: item.requestTime ?? '',
}
}
/** 详情接口返回体 → 详情页数据模型(未立项时项目侧字段自动为空) */
export function toProjectDetail(vo: RequirementDetailVo): Project {
const stage = vo.projectStage
const contract = vo.contracts?.[0]
/** 4 个页签接口的聚合结果 → 详情页数据模型(未立项时项目侧字段自动为空) */
export function toProjectDetail(bundle: RequirementDetailBundle): Project {
const { progress, files, finance, afterSales } = bundle
// 基础信息 4 支同源(需求表同一行),统一取 progress 那一支
const base = progress
const { projectStage: stage } = progress
const contract = finance.contracts?.[0]
return {
id: String(vo.id),
projectNo: vo.projectNo ?? '',
projectName: vo.projectName ?? '',
id: String(base.id),
projectNo: base.projectNo ?? '',
projectName: base.projectName ?? '',
// 后端下发的是中文类型名(如「硬件电路」),归一化成前端 code;'0' 之类脏值归 other
businessType: toBusinessType(vo.businessType),
// 列表页的 progress 就是详情侧 developmentProgress;未立项无进度 ⇒ 0
progress: stage?.developmentProgress ?? 0,
status: toProjectStatus(vo.status),
submitTime: vo.createTime ?? '',
startTime: stage?.startTime ?? undefined,
endTime: stage?.finishTime ?? stage?.planTime ?? undefined,
milestones: (vo.projectMilestones ?? []).map((item) => ({
businessType: toBusinessType(base.businessType),
// 同时保留原样名称:前端枚举只有 5 项,后台新增类型时会全落成「其他」,展示要用原名
businessTypeName: base.businessType ?? '',
// 总体进度百分比 —— 后端 `developmentProgress` 就是列表那个 progress(同源),
// 它缺失时才回落到阶段表的 development_progress;未立项两处都没有 ⇒ 0
progress: progress.developmentProgress ?? stage?.developmentProgress ?? 0,
status: toProjectStatus(base.status),
submitTime: base.createTime ?? '',
startTime: toDate(stage?.startTime),
endTime: toDate(stage?.finishTime ?? stage?.planTime),
// ⚠️ 后端里程碑/团队都是 `selectList(...eq(projectId))`,**没有 orderBy** ⇒ 返回顺序不保证
// (实测同一项目返回 id 5,8,6,7)。里程碑顺序会直接毁掉时间线语义,必须按 sortOrder 归位;
// 团队同理,项目负责人排最前。
milestones: [...(progress.projectMilestones ?? [])]
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((item) => ({
name: item.milestoneName ?? '',
status: toMilestoneStatus(item.status),
time: item.startTime ?? undefined,
// 后端是 LocalDateTime('2026-09-03 00:00:00'),设计稿只到「日」
time: toDate(item.startTime),
})),
team: (vo.members ?? []).map(toTeamMember),
files: (vo.requirementAttachments ?? []).map(attachmentToFile),
paymentPlans: (vo.billPlans ?? []).map(toProjectBill),
invoiceRecords: (vo.invoices ?? []).map(invoiceToFile),
team: [...(progress.members ?? [])]
.sort((a, b) => Number(b.isOwner ?? false) - Number(a.isOwner ?? false))
.map(toTeamMember),
files: (files.requirementAttachments ?? []).map(attachmentToFile),
paymentPlans: (finance.billPlans ?? []).map(toProjectBill),
invoiceRecords: (finance.invoices ?? []).map(invoiceToFile),
contractFile: contract ? contractToFile(contract) : undefined,
afterSales: vo.afterSale ? toAfterSales(vo.afterSale) : undefined,
afterSales: afterSales.afterSale
? toAfterSales(afterSales.afterSale, afterSales.afterSaleAttachments ?? [])
: undefined,
}
}

View File

@@ -1,105 +0,0 @@
<script setup lang="ts">
import type { BusinessType } from '@/types/api'
import type { Demand } from '@/types/business/demand'
interface Props {
demand: Demand
}
defineProps<Props>()
const emit = defineEmits<{ select: [businessType: BusinessType] }>()
interface BusinessOption {
value: BusinessType
name: string
desc: string
}
const options: BusinessOption[] = [
{ value: 'internet', name: '互联网软件', desc: 'Web/小程序/APP/二次开发/桌面工具等' },
{ value: 'iot', name: '物联网全案', desc: '软硬件开发+云端一体化/定制化开发等' },
{ value: 'manufacturing', name: '生产制造', desc: 'PCB制版/SMT/整机装配/定制生产等' },
{ value: 'hardware', name: '硬件电路', desc: '原理图设计/PCB设计/电气效验等' },
{ value: 'other', name: '其他', desc: '若没有对应的服务内容请选择该选项' },
]
</script>
<template>
<div class="demand-step1">
<p class="demand-step1__title">
请选择业务类型
</p>
<div class="demand-step1__grid">
<button
v-for="option in options"
:key="option.value"
class="demand-step1__card"
:class="{ 'is-active': demand.businessType === option.value }"
@click="emit('select', option.value)"
>
<span class="demand-step1__name">{{ option.name }}</span>
<span class="demand-step1__desc">{{ option.desc }}</span>
</button>
</div>
</div>
</template>
<style lang="less" scoped>
.demand-step1 {
.flex(column, flex-start);
gap: @spacing-base;
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__grid {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
}
&__card {
.flex(column, flex-start);
gap: @spacing-xs;
width: calc((100% - 32px) / 3);
min-width: 220px;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
text-align: left;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
box-shadow: @shadow-base;
}
}
&__name {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__desc {
font-size: @font-size-sm;
color: @text-color-secondary;
line-height: 1.5;
}
&__card.is-active &__name {
color: @primary-color;
}
}
</style>

View File

@@ -1,229 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { FileList, UploadArea } from '@/components'
import type { FileInfo } from '@/types/api'
import type { BudgetRange, Demand } from '@/types/business/demand'
interface Props {
demand: Demand
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:demand': [partial: Partial<Demand>]
upload: [file: File]
'remove-file': [file: FileInfo]
}>()
const budgetOptions: Array<{ label: string; value: Exclude<BudgetRange, ''> }> = [
{ label: '1万以下', value: 'lt1w' },
{ label: '1-5万', value: '1to5w' },
{ label: '5-10万', value: '5to10w' },
{ label: '10万+', value: 'gt10w' },
]
const descriptionLength = computed(() => props.demand.coreDescription.length)
function onProjectName(event: Event): void {
const target = event.target as HTMLInputElement
emit('update:demand', { projectName: target.value })
}
function onCoreDescription(event: Event): void {
const target = event.target as HTMLTextAreaElement
emit('update:demand', { coreDescription: target.value })
}
function onDuration(event: Event): void {
const target = event.target as HTMLInputElement
const value = target.value === '' ? '' : Number(target.value)
emit('update:demand', { expectedDuration: value })
}
function onUpload(file: File): void {
emit('upload', file)
}
function onRemoveFile(file: FileInfo): void {
emit('remove-file', file)
}
function onFileAction(action: 'preview' | 'download', file: FileInfo): void {
ElMessage.info(`「${file.name}」${action === 'preview' ? '预览' : '下载'}功能待接口接入`)
}
</script>
<template>
<div class="demand-step2">
<div class="demand-step2__field">
<label class="demand-step2__label">项目名称</label>
<input
class="demand-step2__input"
type="text"
:value="demand.projectName"
placeholder="请输入项目名称"
@input="onProjectName"
>
</div>
<div class="demand-step2__field">
<label class="demand-step2__label">相关附件</label>
<UploadArea @upload="onUpload" />
<FileList
:files="demand.attachments"
@preview="(file) => onFileAction('preview', file)"
@download="(file) => onFileAction('download', file)"
@delete="onRemoveFile"
/>
</div>
<div class="demand-step2__field">
<label class="demand-step2__label">核心需求描述</label>
<div class="demand-step2__textarea-wrap">
<textarea
class="demand-step2__textarea"
:value="demand.coreDescription"
maxlength="500"
placeholder="请描述您的核心需求(业务目标、功能范围、交付期望等)"
@input="onCoreDescription"
/>
<span class="demand-step2__counter">{{ descriptionLength }}/500</span>
</div>
</div>
<div class="demand-step2__field">
<label class="demand-step2__label">预计开发周期</label>
<input
class="demand-step2__input"
type="number"
:value="demand.expectedDuration"
min="1"
placeholder="请输入预计开发周期(工作日)"
@input="onDuration"
>
</div>
<div class="demand-step2__field">
<label class="demand-step2__label">预算范围</label>
<div class="demand-step2__budgets">
<button
v-for="option in budgetOptions"
:key="option.value"
class="demand-step2__budget"
:class="{ 'is-active': demand.budgetRange === option.value }"
@click="emit('update:demand', { budgetRange: option.value })"
>
{{ option.label }}
</button>
</div>
</div>
</div>
</template>
<style lang="less" scoped>
.demand-step2 {
.flex(column, flex-start);
gap: @spacing-lg;
&__field {
.flex(column, flex-start);
gap: @spacing-sm;
width: 100%;
}
&__label {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__input {
width: 100%;
height: 40px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__textarea-wrap {
position: relative;
width: 100%;
}
&__textarea {
width: 100%;
min-height: 120px;
padding: @spacing-sm @spacing-base 28px;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
line-height: 1.6;
outline: none;
resize: vertical;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__counter {
position: absolute;
right: @spacing-sm;
bottom: @spacing-sm;
font-size: @font-size-sm;
color: @text-color-secondary;
}
&__budgets {
.flex(row, flex-start);
gap: @spacing-sm;
flex-wrap: wrap;
}
&__budget {
height: 36px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
color: @primary-color;
font-weight: 600;
}
}
}
</style>

View File

@@ -1,172 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Demand } from '@/types/business/demand'
interface Props {
demand: Demand
}
const props = defineProps<Props>()
const emit = defineEmits<{ 'update:demand': [partial: Partial<Demand>] }>()
const remarkLength = computed(() => props.demand.remark.length)
function onUserName(event: Event): void {
const target = event.target as HTMLInputElement
emit('update:demand', { userName: target.value })
}
function onUserPhone(event: Event): void {
const target = event.target as HTMLInputElement
emit('update:demand', { userPhone: target.value })
}
function onRemark(event: Event): void {
const target = event.target as HTMLTextAreaElement
emit('update:demand', { remark: target.value })
}
</script>
<template>
<div class="demand-step3">
<div class="demand-step3__row">
<div class="demand-step3__field">
<label class="demand-step3__label">用户名称</label>
<input
class="demand-step3__input"
type="text"
:value="demand.userName"
placeholder="请输入用户名称"
@input="onUserName"
>
</div>
<div class="demand-step3__field">
<label class="demand-step3__label">用户手机号</label>
<input
class="demand-step3__input"
type="text"
:value="demand.userPhone"
placeholder="请输入用户手机号"
@input="onUserPhone"
>
</div>
</div>
<div class="demand-step3__field">
<label class="demand-step3__label">补充备注</label>
<div class="demand-step3__textarea-wrap">
<textarea
class="demand-step3__textarea"
:value="demand.remark"
maxlength="500"
placeholder="可补充其他说明(选填)"
@input="onRemark"
/>
<span class="demand-step3__counter">{{ remarkLength }}/500</span>
</div>
</div>
<p class="demand-step3__tip">
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
</p>
</div>
</template>
<style lang="less" scoped>
.demand-step3 {
.flex(column, flex-start);
gap: @spacing-lg;
&__row {
.flex(row, flex-start);
gap: @spacing-base;
width: 100%;
.demand-step3__field {
flex: 1;
min-width: 0;
}
}
&__field {
.flex(column, flex-start);
gap: @spacing-sm;
width: 100%;
}
&__label {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__input {
width: 100%;
height: 40px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__textarea-wrap {
position: relative;
width: 100%;
}
&__textarea {
width: 100%;
min-height: 120px;
padding: @spacing-sm @spacing-base 28px;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
font-size: @font-size-base;
color: @heading-color;
line-height: 1.6;
outline: none;
resize: vertical;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
border-color: @primary-color;
}
&::placeholder {
color: @text-color-secondary;
}
}
&__counter {
position: absolute;
right: @spacing-sm;
bottom: @spacing-sm;
font-size: @font-size-sm;
color: @text-color-secondary;
}
&__tip {
padding: @spacing-sm @spacing-base;
border-radius: @radius-base;
background: @primary-light;
font-size: @font-size-sm;
color: @primary-color;
line-height: 1.6;
}
}
</style>

View File

@@ -3,11 +3,11 @@ import { computed, onMounted, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { AppButton, StepForm, UploadArea } from '@/components'
import { AppButton, FileList } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useProjectStore } from '@/stores/modules/project'
import type { BusinessType, FileInfo } from '@/types/api'
import { formatDate } from '@/utils/format'
import type { FileInfo } from '@/types/api'
import type { AfterSalesAttachment } from '@/types/business/project'
const route = useRoute()
const router = useRouter()
@@ -16,58 +16,61 @@ const { current } = storeToRefs(projectStore)
const projectId = computed(() => String(route.params.id))
const STEP_TITLES = ['选择业务类型', '填写问题描述', '补充备注']
const step = ref(0)
const businessTypeOptions: Array<{ label: string; value: BusinessType }> = [
{ label: '互联网软件', value: 'internet' },
{ label: '硬件电路', value: 'hardware' },
{ label: '物联网全案', value: 'iot' },
{ label: '生产制造', value: 'manufacturing' },
/**
* 售后类型候选(**下拉框的值域**)。
*
* <p>⚠️ 库内**没有「售后类型」字典**(`aftersales_status` 是售后状态、`vm_requirement_business_type`
* 是项目业务类型,都不是它)⇒ 先在前端固定这 8 项。后端补上字典 / 选项接口后,
* 只需把这里换成接口驱动(`TYPE_OPTIONS` 改为 ref 接收接口数据),其余逻辑不用动。</p>
*/
const TYPE_OPTIONS = [
'软件 BUG 修复',
'软件 / 固件功能迭代',
'硬件 PCB & 电路改版调整',
'硬件故障排查维修',
'成品不良品返修',
'元器件物料替换变更',
'物联网整机联调与版本升级',
'交付资料补齐与技术答疑',
]
const typeChips = ['BUG问题', '功能新增', '其他']
const businessType = ref<BusinessType | ''>('')
const projectName = ref('')
const type = ref('')
/** 选中的项目(= 需求 id;提交时下发给后端,由它按此定位项目) */
const selectedProjectId = ref(projectId.value)
const description = ref('')
const remark = ref('')
const attachments = ref<FileInfo[]>([])
/** 已上传到 OSS 的附件(携带 ossId,提交时进 `attachmentOssIds`) */
const attachments = ref<AfterSalesAttachment[]>([])
const uploading = ref(false)
const submitting = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const projectNamePlaceholder = computed(() => current.value?.projectName || '默认当前项目')
const descriptionCount = computed(() => `${description.value.length}/500`)
const remarkCount = computed(() => `${remark.value.length}/500`)
function nextStep(): void {
if (step.value < STEP_TITLES.length - 1) step.value += 1
}
function prevStep(): void {
if (step.value > 0) step.value -= 1
}
function pickType(chip: string): void {
type.value = chip
}
function onUpload(file: File): void {
attachments.value.push({
name: file.name,
size: file.size,
uploadTime: formatDate(new Date()),
url: '',
/** 项目名称下拉的候选:先取**已完成项目**,再把当前项目兜底并入 */
const projectOptions = computed(() => {
const options = projectStore.completedProjects.map((item) => ({
id: String(item.id),
// 项目名称为空时退回编号,避免下拉出现空白项
label: item.projectName || item.projectNo || '未命名项目',
}))
// 当前项目理论上必然已完成(详情页只有已完成才给「申请售后」入口);
// 但「列表接口结果」与「详情状态」可能因刚变更而不同步 ⇒ 兜底并入,
// 否则 v-model 命不中任何 option,下拉会显示成空白。
if (projectId.value && !options.some((item) => item.id === projectId.value)) {
options.unshift({
id: projectId.value,
label: current.value?.projectName || '当前项目',
})
}
function removeAttachment(index: number): void {
attachments.value.splice(index, 1)
}
return options
})
function resetForm(): void {
step.value = 0
businessType.value = ''
projectName.value = ''
// 项目名称回到默认(当前项目),其余清空
selectedProjectId.value = projectId.value
type.value = ''
description.value = ''
remark.value = ''
@@ -78,102 +81,176 @@ function onCancel(): void {
void router.push(`/project/${projectId.value}`)
}
function pickFiles(): void {
fileInputRef.value?.click()
}
/**
* 选完文件后逐个上传到 OSS。
*
* <p>上传接口一次只收一个 `file` 字段,所以串行上传;全部成功后才进列表
* (失败的文件不会留下半条记录)。</p>
*/
async function onFilesPicked(list: FileList | null): Promise<void> {
if (!list || list.length === 0) return
uploading.value = true
try {
for (const file of Array.from(list)) {
attachments.value.push(await projectStore.uploadAfterSalesFile(file))
}
ElMessage.success('附件上传成功')
}
catch (error) {
console.warn('[after-sales] 附件上传失败', error)
}
finally {
uploading.value = false
// 清空 input,允许再次选择同一个文件
if (fileInputRef.value) fileInputRef.value.value = ''
}
}
function removeAttachment(file: FileInfo): void {
// FileList 回传的就是列表里的同一个对象引用;用引用定位,避免同名同大小文件删错一条
const index = attachments.value.indexOf(file as AfterSalesAttachment)
if (index >= 0) attachments.value.splice(index, 1)
}
function previewAttachment(file: FileInfo): void {
if (!file.url) {
ElMessage.info('该附件还没有可访问的地址')
return
}
window.open(file.url, '_blank', 'noopener')
}
function downloadAttachment(file: FileInfo): void {
if (!file.url) {
ElMessage.info('该附件还没有可访问的地址')
return
}
const link = document.createElement('a')
link.href = file.url
link.download = file.name
link.target = '_blank'
link.rel = 'noopener'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
/**
* 提交申请。
*
* <p>提交的 `id` 是「项目名称」下拉**选中的项目**(默认当前项目)——后端只按这个需求 id 定位
* 项目并校验「已完成」,所以切换项目后提交的就是那个项目的售后。附件下发 `ossId` 列表,由后端转存。</p>
*/
async function onSubmit(): Promise<void> {
const effectiveName = projectName.value || current.value?.projectName || ''
if (!effectiveName) {
ElMessage.warning('请输入项目名称')
if (!selectedProjectId.value) {
ElMessage.warning('请选择项目名称')
return
}
if (!type.value) {
ElMessage.warning('请选择或输入售后类型')
if (!type.value.trim()) {
ElMessage.warning('请选择售后类型')
return
}
if (!description.value) {
if (!description.value.trim()) {
ElMessage.warning('请填写问题描述')
return
}
await projectStore.submitAfterSales({
type: type.value,
description: description.value,
remark: remark.value,
attachments: attachments.value,
submitting.value = true
try {
await projectStore.submitAfterSales(selectedProjectId.value, {
type: type.value.trim(),
reason: description.value.trim(),
note: remark.value.trim(),
attachmentOssIds: attachments.value.map((item) => item.ossId),
})
await router.push(`/project/${projectId.value}`)
await router.push(`/project/${selectedProjectId.value}`)
}
catch (error) {
// 统一 request 层已弹错误提示(如「只有已完成的项目可以申请售后」),这里只恢复按钮
console.warn('[after-sales] 售后申请提交失败', error)
}
finally {
submitting.value = false
}
}
onMounted(() => {
if (!current.value || current.value.id !== projectId.value) {
void projectStore.fetchDetail(projectId.value)
}
// 「项目名称」下拉的值域(已完成项目);每次进页面都重拉,项目状态会变
void projectStore.fetchCompletedProjects()
})
</script>
<template>
<div class="container after-sales">
<nav class="after-sales__breadcrumb text-secondary">
<RouterLink to="/project" class="link">
项目中心
</RouterLink>
<span> &gt; </span>
<RouterLink :to="`/project/${projectId}`" class="link">
项目详情
</RouterLink>
<span> &gt; </span>
<span>申请售后</span>
</nav>
<header class="after-sales__header">
<h1 class="after-sales__title">
申请售后
</h1>
<p class="after-sales__subtitle text-secondary">
填写问题描述并上传相关资料,系统自动生成售后工单,全程线上跟踪处理进度。
</p>
</header>
<StepForm :titles="STEP_TITLES" :current="step">
<!-- 第一步:选择业务类型 -->
<div v-if="step === 0" class="after-sales__section">
<!-- ① 选择业务类型 -->
<section class="after-sales__card">
<h2 class="after-sales__card-title">
<span class="after-sales__step">1</span>
选择业务类型
</h2>
<div class="after-sales__field">
<label class="after-sales__label">业务类型</label>
<div class="after-sales__options">
<button
v-for="option in businessTypeOptions"
:key="option.value"
class="after-sales__option"
:class="{ 'is-active': businessType === option.value }"
@click="businessType = option.value"
>
{{ option.label }}
</button>
</div>
</div>
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-project">项目名称</label>
<input
id="after-sales-project"
v-model="projectName"
class="after-sales__input"
:placeholder="projectNamePlaceholder"
>
</div>
</div>
<!-- 第二步:填写问题描述 -->
<div v-if="step === 1" class="after-sales__section">
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-type">售后类型</label>
<input
<label class="after-sales__label" for="after-sales-type">
售后类型<span class="after-sales__required">*</span>
</label>
<select
id="after-sales-type"
v-model="type"
class="after-sales__input"
placeholder="选择下方推荐的售后类型或手动输入售后类型"
class="after-sales__select"
>
<div class="after-sales__options">
<button
v-for="chip in typeChips"
:key="chip"
class="after-sales__option"
:class="{ 'is-active': type === chip }"
@click="pickType(chip)"
>
{{ chip }}
</button>
</div>
<option value="">
请选择售后类型
</option>
<option v-for="item in TYPE_OPTIONS" :key="item" :value="item">
{{ item }}
</option>
</select>
</div>
</section>
<!-- ② 填写问题描述 -->
<section class="after-sales__card">
<h2 class="after-sales__card-title">
<span class="after-sales__step">2</span>
填写问题描述
</h2>
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-desc">问题描述</label>
<label class="after-sales__label" for="after-sales-project">
项目名称<span class="after-sales__required">*</span>
</label>
<select
id="after-sales-project"
v-model="selectedProjectId"
class="after-sales__select"
>
<option value="">
请选择项目
</option>
<option v-for="item in projectOptions" :key="item.id" :value="item.id">
{{ item.label }}
</option>
</select>
</div>
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-desc">
问题描述<span class="after-sales__required">*</span>
</label>
<textarea
id="after-sales-desc"
v-model="description"
@@ -182,31 +259,45 @@ onMounted(() => {
rows="5"
placeholder="请详细描述您的问题..."
/>
<span class="after-sales__count text-secondary">{{ descriptionCount }}</span>
</div>
<div class="after-sales__field">
<label class="after-sales__label">附件上传</label>
<UploadArea hint="支持图片/文档/压缩包,大小不超过1G" @upload="onUpload" />
<ul v-if="attachments.length > 0" class="after-sales__files">
<li
v-for="(file, index) in attachments"
:key="`${file.name}-${index}`"
class="after-sales__file"
>
<span class="after-sales__file-name" :title="file.name">{{ file.name }}</span>
<button
class="after-sales__file-remove"
@click="removeAttachment(index)"
>
移除
</button>
</li>
</ul>
</div>
<span class="after-sales__count text-secondary">{{ descriptionCount }}字</span>
</div>
<!-- 第三步:补充备注 -->
<div v-if="step === 2" class="after-sales__section">
<div class="after-sales__field">
<div class="after-sales__field-head">
<label class="after-sales__label">附件上传</label>
<span class="after-sales__field-hint text-secondary">支持图片/文档/压缩包,大小不超过1G</span>
<AppButton
class="after-sales__upload-btn"
size="sm"
:loading="uploading"
@click="pickFiles"
>
上传文件
</AppButton>
</div>
<input
ref="fileInputRef"
type="file"
multiple
class="after-sales__file-input"
@change="onFilesPicked(($event.target as HTMLInputElement).files)"
>
<FileList
v-if="attachments.length > 0"
:files="attachments"
@preview="previewAttachment"
@download="downloadAttachment"
@delete="removeAttachment"
/>
</div>
</section>
<!-- ③ 补充备注 -->
<section class="after-sales__card">
<h2 class="after-sales__card-title">
<span class="after-sales__step">3</span>
补充备注
</h2>
<div class="after-sales__field">
<label class="after-sales__label" for="after-sales-remark">补充备注</label>
<textarea
@@ -214,34 +305,31 @@ onMounted(() => {
v-model="remark"
class="after-sales__textarea"
maxlength="500"
rows="5"
rows="4"
placeholder="其他需要说明的事项..."
/>
<span class="after-sales__count text-secondary">{{ remarkCount }}</span>
<p class="after-sales__tip text-secondary">
备注为选填项,如有与问题描述相关的补充信息可在此说明,便于工程师更快定位问题。
</p>
<span class="after-sales__count text-secondary">{{ remarkCount }}字</span>
</div>
</div>
</StepForm>
</section>
<div class="after-sales__footer">
<AppButton v-if="step > 0" @click="prevStep">
上一步
</AppButton>
<AppButton v-if="step < STEP_TITLES.length - 1" @click="nextStep">
下一步
</AppButton>
<AppButton v-if="step === STEP_TITLES.length - 1" type="primary" @click="onSubmit">
申请售后
<!-- 底部操作条(设计稿:左侧提示 + 右侧 取消 / 重置 / 申请售后) -->
<footer class="after-sales__footer">
<p class="after-sales__footer-tip text-secondary">
<span class="after-sales__footer-icon" aria-hidden="true">i</span>
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
</p>
<div class="after-sales__footer-actions">
<AppButton @click="onCancel">
取消
</AppButton>
<AppButton @click="resetForm">
重置
</AppButton>
<AppButton @click="onCancel">
取消
<AppButton type="primary" :loading="submitting" @click="onSubmit">
申请售后
</AppButton>
</div>
</footer>
</div>
</template>
@@ -252,13 +340,49 @@ onMounted(() => {
padding-top: @spacing-lg;
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
&__header {
.flex(column, flex-start, stretch);
gap: @spacing-xs;
padding: @spacing-sm 0 @spacing-base;
}
&__section {
&__title {
font-size: @font-size-title;
font-weight: 600;
color: @heading-color;
}
&__subtitle {
font-size: @font-size-sm;
}
// ── 三段卡片(设计稿:白底圆角 + 序号圆标标题) ──
&__card {
.flex(column, flex-start, stretch);
gap: @spacing-lg;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
}
&__card-title {
.flex(row, flex-start, center);
gap: @spacing-sm;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__step {
.flex(row, center);
width: 26px;
height: 26px;
border-radius: @radius-round;
background: @primary-color;
color: @white;
font-size: @font-size-sm;
font-weight: 600;
}
&__field {
@@ -266,48 +390,49 @@ onMounted(() => {
gap: @spacing-sm;
}
&__field-head {
.flex(row, flex-start, center);
gap: @spacing-sm;
}
&__field-hint {
font-size: @font-size-sm;
}
// 「上传文件」推到行尾
&__upload-btn {
margin-left: auto;
}
&__label {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__options {
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-sm;
&__required {
margin-left: 2px;
color: @danger-color;
font-weight: 400;
}
&__option {
height: 30px;
padding: 0 @spacing-base;
border: 1px solid transparent;
border-radius: @radius-sm;
background: @bg-color;
color: @text-color;
font-size: @font-size-sm;
transition: all 0.2s;
&:hover {
color: @primary-color;
}
&.is-active {
border-color: @primary-color;
background: @primary-light;
color: @primary-color;
}
}
&__input {
width: 360px;
height: 36px;
padding: 0 @spacing-base;
&__select {
width: 100%;
height: 40px;
padding: 0 36px 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
background-color: @bg-color-light;
color: @text-color;
font-size: @font-size-base;
appearance: none;
outline: none;
cursor: pointer;
// 自定义下拉箭头(原生 select 不支持 ::after)
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2 4.5 6 8.5l4-4' fill='none' stroke='%238c8c8c' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 12px 12px;
transition: border-color 0.2s;
&:focus {
@@ -315,12 +440,14 @@ onMounted(() => {
}
}
// 「项目名称」与「售后类型」共用同一个下拉样式(见上方 &__select)
&__textarea {
width: 100%;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @white;
background: @bg-color-light;
color: @text-color;
font-size: @font-size-base;
line-height: 1.6;
resize: vertical;
@@ -337,41 +464,46 @@ onMounted(() => {
font-size: @font-size-sm;
}
&__tip {
font-size: @font-size-sm;
line-height: 1.6;
}
&__files {
.flex(column, flex-start, stretch);
gap: @spacing-xs;
}
&__file {
.flex(row, space-between);
gap: @spacing-base;
width: 360px;
padding: @spacing-xs @spacing-sm;
border: 1px solid @border-color;
border-radius: @radius-sm;
background: @bg-color-light;
}
&__file-name {
font-size: @font-size-sm;
color: @text-color;
.ellipsis();
}
&__file-remove {
flex-shrink: 0;
font-size: @font-size-sm;
color: @danger-color;
&__file-input {
display: none;
}
// ── 底部固定操作条 ──
&__footer {
.flex(row, flex-start);
position: sticky;
bottom: 0;
z-index: 10;
.flex(row, space-between, center);
gap: @spacing-base;
margin-top: @spacing-sm;
padding: @spacing-base @spacing-xl;
border-top: 1px solid @border-color;
background: @white;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
}
&__footer-tip {
.flex(row, flex-start, center);
gap: @spacing-xs;
font-size: @font-size-sm;
}
&__footer-icon {
.flex(row, center);
flex-shrink: 0;
width: 16px;
height: 16px;
border: 1px solid currentColor;
border-radius: @radius-round;
font-size: 11px;
font-style: normal;
line-height: 1;
}
&__footer-actions {
.flex(row, flex-end, center);
gap: @spacing-sm;
flex-shrink: 0;
}
}
</style>

View File

@@ -1,10 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { AfterSales } from '@/types/business/project'
import { ElMessage } from 'element-plus'
import { FileList } from '@/components'
import type { FileInfo } from '@/types/api'
import type { AfterSales, ProjectStatus } from '@/types/business/project'
interface Props {
/** 售后工单;`undefined` 或 `status === '无'` 表示没有进行中的工单 */
afterSales?: AfterSales
projectId: string
/** 项目状态 —— 决定售后区的三态展示(见下方模板注释) */
projectStatus: ProjectStatus
}
const props = defineProps<Props>()
@@ -14,47 +19,131 @@ const emit = defineEmits<{
cancel: []
}>()
const isProcessing = computed(() => props.afterSales?.status === '处理中')
/**
* 售后页签的三态(设计稿便签原文):
* 1. 项目**未完成** → 不展示售后信息;
* 2. 已完成 + **未提交**售后 → 内容为空,只给「申请售后」;
* 3. 已完成 + **已提交**售后 → 展示售后信息 + 「取消售后」。
*
* <p>「已完成」按详情页状态口径判定(后端 `COMPLETED` 经 projectVo 映射成「已完成」)。
* 后端 `requestAfterSale` 也会校验这一点,前端只是提前把入口藏掉。</p>
*/
const isCompleted = computed(() => props.projectStatus === '已完成')
/** 存在进行中的售后工单(撤回后的记录在映射层已归为「无」) */
const hasTicket = computed(() => props.afterSales?.status === '处理中')
/**
* 预览 / 下载直接走附件自身的 OSS 地址(后端下发的 `url` 就是可访问地址);
* 删除没有接口 —— 售后附件只在「申请 / 完成售后」时整体写入,不支持单独删。
*/
function previewFile(file: FileInfo): void {
if (!file.url) {
ElMessage.info('该附件没有可访问的地址')
return
}
window.open(file.url, '_blank', 'noopener')
}
function downloadFile(file: FileInfo): void {
if (!file.url) {
ElMessage.info('该附件没有可访问的地址')
return
}
const link = document.createElement('a')
link.href = file.url
link.download = file.name
link.target = '_blank'
link.rel = 'noopener'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
function onDeleteFile(): void {
// TODO(api): 售后工单附件无单独删除接口(后端只在申请 / 完成售后时整体写入)
ElMessage.info('售后附件暂不支持单独删除')
}
</script>
<template>
<section class="project-after-sales">
<!-- 处理中状态卡片 -->
<div
v-if="isProcessing && afterSales"
class="project-after-sales__card project-after-sales__card--active"
>
<div class="project-after-sales__card-head">
<span class="project-after-sales__badge">处理中</span>
<h3 class="project-after-sales__card-title">
<!-- 规则 1:项目未完成 → 不展示售后信息 -->
<div v-if="!isCompleted" class="project-after-sales__blank">
<p class="project-after-sales__blank-text text-secondary">
项目完成后可申请售后
</p>
</div>
<!-- 规则 3:已完成 + 已提交售后 → 工单横幅 + 售后信息 + 取消售后 -->
<template v-else-if="hasTicket && afterSales">
<div class="project-after-sales__banner">
<span class="project-after-sales__banner-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
<path d="M6 2h7l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm7 1.6V8h4.4L13 3.6ZM8 12.2h8v1.6H8v-1.6Zm0 3.6h8v1.6H8v-1.6Z" />
</svg>
</span>
<div class="project-after-sales__banner-text">
<h3 class="project-after-sales__banner-title">
售后工单处理中
</h3>
</div>
<p class="project-after-sales__desc">
<p class="project-after-sales__banner-desc">
工程师正在处理您的修改需求,请耐心等待
</p>
<div class="project-after-sales__meta text-secondary">
<span v-if="afterSales.type">类型:{{ afterSales.type }}</span>
<span v-if="afterSales.createTime">提交时间:{{ afterSales.createTime }}</span>
</div>
<p v-if="afterSales.description" class="project-after-sales__desc">
{{ afterSales.description }}
</div>
<h2 class="project-after-sales__title">
售后信息
</h2>
<div class="project-after-sales__field">
<span class="project-after-sales__label">售后类型</span>
<div class="project-after-sales__readonly">
{{ afterSales.type || '—' }}
</div>
</div>
<div class="project-after-sales__field">
<span class="project-after-sales__label">问题描述</span>
<div class="project-after-sales__readonly project-after-sales__readonly--block">
{{ afterSales.description || '—' }}
</div>
</div>
<div class="project-after-sales__field">
<div class="project-after-sales__field-head">
<span class="project-after-sales__label">附件上传</span>
<span class="project-after-sales__field-hint text-secondary">支持图片/文档/压缩包,大小不超过1G</span>
</div>
<FileList
v-if="afterSales.attachments.length > 0"
:files="afterSales.attachments"
@preview="previewFile"
@download="downloadFile"
@delete="onDeleteFile"
/>
<p v-else class="project-after-sales__none text-secondary">
暂无附件
</p>
</div>
<div class="project-after-sales__actions">
<button
class="project-after-sales__btn project-after-sales__btn--default"
class="project-after-sales__btn project-after-sales__btn--primary"
@click="emit('cancel')"
>
取消售后
</button>
</div>
</div>
</template>
<!-- 空状态 -->
<div v-else class="project-after-sales__card project-after-sales__card--empty">
<p class="project-after-sales__empty text-secondary">
<!-- 规则 2:已完成 + 未提交售后 → 内容为空 + 申请售后 -->
<template v-else>
<div class="project-after-sales__blank">
<p class="project-after-sales__blank-text text-secondary">
暂无售后工单
</p>
</div>
<div class="project-after-sales__actions">
<button
class="project-after-sales__btn project-after-sales__btn--primary"
@@ -63,79 +152,125 @@ const isProcessing = computed(() => props.afterSales?.status === '处理中')
申请售后
</button>
</div>
</div>
</template>
</section>
</template>
<style lang="less" scoped>
.project-after-sales {
.flex(column, flex-start, stretch);
gap: @spacing-base;
gap: @spacing-lg;
padding: @spacing-lg @spacing-xl;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__card {
// ── 顶部工单横幅:浅红底 + 红色图标块(设计稿) ──
&__banner {
.flex(row, flex-start, center);
gap: @spacing-base;
padding: @spacing-base @spacing-lg;
border-radius: @radius-base;
background: fade(@danger-color, 8%);
}
&__banner-icon {
.flex(row, center);
flex-shrink: 0;
width: 44px;
height: 44px;
border-radius: @radius-base;
background: @danger-color;
color: @white;
}
&__banner-text {
.flex(column, flex-start, stretch);
gap: 6px;
min-width: 0;
}
&__banner-title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
line-height: 1.2;
}
&__banner-desc {
font-size: @font-size-sm;
color: @text-color-secondary;
}
// ── 「售后信息」区块标题:竖条 + 15px 缩进(与其它页签一致) ──
&__title {
.section-title();
margin-top: @spacing-xs;
}
&__field {
.flex(column, flex-start, stretch);
gap: @spacing-sm;
padding: @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
&--active {
border-color: fade(@warning-color, 40%);
background: fade(@warning-color, 6%);
}
}
&__card-head {
.flex(row, center);
&__field-head {
.flex(row, flex-start, baseline);
gap: @spacing-sm;
}
&__badge {
padding: 2px @spacing-sm;
border-radius: @radius-sm;
background: fade(@warning-color, 12%);
color: @warning-color;
&__field-hint {
font-size: @font-size-sm;
}
&__card-title {
font-size: @font-size-lg;
&__label {
font-size: @font-size-base;
font-weight: 600;
color: @heading-color;
}
&__desc {
font-size: @font-size-base;
// 只读展示(设计稿里是灰底带边框的框,不是输入控件)
&__readonly {
min-height: 40px;
padding: 0 @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
color: @text-color;
line-height: 1.6;
font-size: @font-size-base;
line-height: 40px;
&--block {
min-height: 96px;
padding: @spacing-sm @spacing-base;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
}
&__meta {
.flex(row, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
&__none {
padding: @spacing-sm 0;
font-size: @font-size-sm;
}
&__empty {
padding: @spacing-lg 0;
text-align: center;
&__blank {
.flex(row, center);
padding: @spacing-xl 0;
}
&__blank-text {
font-size: @font-size-base;
}
&__actions {
.flex(row, flex-end);
.flex(row, flex-end, center);
gap: @spacing-sm;
}
&__btn {
height: 34px;
padding: 0 @spacing-base;
height: 36px;
padding: 0 @spacing-lg;
border-radius: @radius-base;
font-size: @font-size-base;
transition: all 0.2s;
@@ -149,18 +284,6 @@ const isProcessing = computed(() => props.afterSales?.status === '处理中')
background: @primary-hover;
}
}
&--default {
border: 1px solid @border-color-dark;
background: @white;
color: @text-color;
&:hover {
border-color: @primary-color;
color: @primary-color;
}
}
}
}
</style>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { UploadArea } from '@/components'
import type { FileInfo } from '@/types/api'
import { formatFileSize } from '@/utils/format'
@@ -9,8 +8,8 @@ interface Props {
defineProps<Props>()
// ⚠️ 详情页只「展示」文件,不提供上传入口(上传在需求提报页)。
const emit = defineEmits<{
upload: [file: File]
delete: [index: number]
preview: [index: number]
download: [index: number]
@@ -23,11 +22,6 @@ const emit = defineEmits<{
项目文件
</h2>
<UploadArea
hint="支持图片/文档/压缩包,大小不超过1G"
@upload="emit('upload', $event)"
/>
<ul class="project-files__list">
<li
v-for="(file, index) in files"
@@ -72,9 +66,7 @@ const emit = defineEmits<{
box-shadow: @shadow-base;
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
.section-title();
}
&__list {

View File

@@ -10,8 +10,8 @@ import type { ProjectQuery, ProjectTypeCount } from '@/types/business/project'
* 的 `businessTypeCounts`,全量口径),组件内不定义业务值域;
* 只有后端未下发的组(项目状态 / 提交时间)才用 `utils/projectMeta.ts` 里的前端常量。</p>
*
* <p>「不限」统一用**空串**表示(不是 'all'):空串不会出现在 querystring 里,
* 正好对上后端 `eqIfText` 的「有值才等值匹配」。</p>
* <p>「不限」统一用**空值**表示(不是 'all'):业务类型用空数组、其余用空串,
* 都不会出现在 querystring 里,正好对上后端「有值才匹配」的语义。</p>
*/
interface Props {
@@ -45,10 +45,11 @@ const rangeLabel = computed(() => {
const chips = computed<Chip[]>(() => {
const list: Chip[] = []
if (props.query.businessType) {
const found = props.typeCounts.find((item) => item.value === props.query.businessType)
list.push({ key: 'businessType', label: found?.label ?? props.query.businessType })
}
// 业务类型是**多选**:每个已选类型各出一个 chip,key 带 id 以便单独移除
props.query.businessTypes.forEach((value) => {
const found = props.typeCounts.find((item) => item.value === value)
list.push({ key: `businessType:${value}`, label: found?.label ?? value })
})
if (props.query.status) {
list.push({ key: 'status', label: PROJECT_STATUS_LABEL[props.query.status] ?? props.query.status })
}
@@ -66,6 +67,27 @@ function patch(partial: Partial<ProjectQuery>): void {
emit('update:query', { ...props.query, ...partial })
}
/** 业务类型行是否处于选中态(「全部」= 一个都没勾) */
function isTypeSelected(value: string): boolean {
return value === 'all'
? props.query.businessTypes.length === 0
: props.query.businessTypes.includes(value)
}
/** 业务类型多选切换:点「全部」清空,点具体行按勾选状态增删(后端口径是 IN,多选即并集) */
function onToggleType(value: string): void {
if (value === 'all') {
patch({ businessTypes: [] })
return
}
const selected = props.query.businessTypes
patch({
businessTypes: selected.includes(value)
? selected.filter((item) => item !== value)
: [...selected, value],
})
}
/** 快捷区间与自选区间互斥:点快捷区间会清掉自选日期 */
function onPickShortcut(value: ProjectQuery['timeShortcut']): void {
rangeOpen.value = false
@@ -78,7 +100,10 @@ function onPickDate(partial: { startDate?: string; endDate?: string }): void {
}
function onRemoveChip(key: string): void {
if (key === 'businessType') patch({ businessType: '' })
if (key.startsWith('businessType:')) {
const value = key.slice('businessType:'.length)
patch({ businessTypes: props.query.businessTypes.filter((item) => item !== value) })
}
else if (key === 'status') patch({ status: '' })
else if (key === 'time') patch({ timeShortcut: 'all' })
else if (key === 'range') patch({ startDate: '', endDate: '' })
@@ -87,7 +112,7 @@ function onRemoveChip(key: string): void {
function onClearChips(): void {
emit('update:query', {
...props.query,
businessType: '',
businessTypes: [],
status: '',
timeShortcut: 'all',
startDate: '',
@@ -102,7 +127,7 @@ function onClearChips(): void {
筛选条件
</h2>
<!-- 业务类型:复选外观的单选列表(值域 / 件数均来自后端统计) -->
<!-- 业务类型:**多选**(值域 / 件数均来自后端统计;「全部」= 清空选择,可单选或多选) -->
<section class="project-filter__group">
<h3 class="project-filter__group-title">
业务类型
@@ -112,8 +137,8 @@ function onClearChips(): void {
<button
type="button"
class="project-filter__row"
:class="{ 'is-active': query.businessType === item.value }"
@click="patch({ businessType: item.value === 'all' ? '' : item.value })"
:class="{ 'is-active': isTypeSelected(item.value) }"
@click="onToggleType(item.value)"
>
<span class="project-filter__box" aria-hidden="true" />
<span class="project-filter__row-label">{{ item.label }}</span>
@@ -289,6 +314,7 @@ function onClearChips(): void {
}
&__box {
position: relative;
flex-shrink: 0;
width: 14px;
height: 14px;
@@ -296,6 +322,31 @@ function onClearChips(): void {
border: 1px solid @border-color-dark;
border-radius: 2px;
background: @white;
transition: background-color 0.2s, border-color 0.2s;
// 勾:用两条边框旋转 45° 画出来,不引额外图标
&::after {
content: '';
position: absolute;
top: 1px;
left: 4px;
width: 3px;
height: 6px;
border: solid @white;
border-width: 0 1.5px 1.5px 0;
transform: rotate(45deg) scale(0);
transition: transform 0.15s;
}
}
// 选中行:方框实心主色 + 白色勾(多选下每行独立显隐)
&__row.is-active &__box {
border-color: @primary-color;
background: @primary-color;
&::after {
transform: rotate(45deg) scale(1);
}
}
&__row-label {

View File

@@ -85,7 +85,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
<div class="project-finance__file-info">
<span class="project-finance__file-name" :title="file.name">{{ file.name }}</span>
<span class="project-finance__file-meta text-secondary">
{{ formatFileSize(file.size) }} · {{ file.uploadTime }}
{{ file.size ? formatFileSize(file.size) + ' · ' : '' }}{{ file.uploadTime }}
</span>
</div>
<div class="project-finance__file-actions">
@@ -110,7 +110,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
<div class="project-finance__file-info">
<span class="project-finance__file-name" :title="contractFile.name">{{ contractFile.name }}</span>
<span class="project-finance__file-meta text-secondary">
{{ formatFileSize(contractFile.size) }} · {{ contractFile.uploadTime }}
{{ contractFile.size ? formatFileSize(contractFile.size) + ' · ' : '' }}{{ contractFile.uploadTime }}
</span>
</div>
<div class="project-finance__file-actions">

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { StatusTag } from '@/components'
import { formatBusinessType } from '@/utils/format'
import type { Project, ProjectStatus } from '@/types/business/project'
@@ -9,79 +8,148 @@ interface Props {
defineProps<Props>()
const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
'待立项': 'info',
'进行中': 'primary',
'待验收': 'warning',
'待内部验收': 'warning',
'已完成': 'success',
'售后中': 'warning',
'已取消': 'info',
/**
* 状态文字配色(项目详情设计稿:状态是**卡片右侧的纯文字**,不是胶囊标签)。
*
* <p>设计稿只画了「进行中」= @project-accent,其余状态按语义就近归组;
* 颜色一律走 `styles/variables.less`,组件内不写魔法色值。</p>
*/
const STATUS_CLASS: Record<ProjectStatus, string> = {
'待立项': 'is-idle',
'进行中': 'is-active',
'待验收': 'is-warning',
'待内部验收': 'is-warning',
'已完成': 'is-done',
'售后中': 'is-warning',
'已取消': 'is-idle',
}
/** 业务类型:优先用后端原样下发的名称(它来自提报表,接口能下发就照显),兜底才查前端枚举 */
function businessTypeText(project: Project): string {
return project.businessTypeName || formatBusinessType(project.businessType)
}
</script>
<template>
<div class="project-info-card">
<div class="project-info-card__head">
<StatusTag :label="project.status" :variant="STATUS_VARIANTS[project.status]" />
<section class="project-info-card">
<!--
项目标识:设计稿是「白底圆角方块 + 蓝色描边徽标(内含 </>)」。
⚠️ 接口没有 logo 字段(ProjectRequirementBaseVo 只下发名称/编号/类型/状态/时间),
所以这里按设计稿渲染占位徽标,不是真实项目图标。
-->
<span class="project-info-card__logo" aria-hidden="true">
<span class="project-info-card__logo-badge">&lt;/&gt;</span>
</span>
<div class="project-info-card__main">
<h1 class="project-info-card__name">
{{ project.projectName }}
{{ project.projectName || '—' }}
</h1>
<p class="project-info-card__meta">
<span class="project-info-card__meta-item">
<em class="project-info-card__label">项目编号:</em>{{ project.projectNo || '—' }}
</span>
<span class="project-info-card__meta-item">
<em class="project-info-card__label">业务类型:</em>{{ businessTypeText(project) }}
</span>
<span class="project-info-card__meta-item">
<em class="project-info-card__label">提交时间:</em>{{ project.submitTime || '—' }}
</span>
</p>
</div>
<div class="project-info-card__meta">
<div class="project-info-card__meta-item">
<span class="project-info-card__label text-secondary">项目编号</span>
<span class="project-info-card__value">{{ project.projectNo }}</span>
</div>
<div class="project-info-card__meta-item">
<span class="project-info-card__label text-secondary">业务类型</span>
<span class="project-info-card__value">{{ formatBusinessType(project.businessType) }}</span>
</div>
<div class="project-info-card__meta-item">
<span class="project-info-card__label text-secondary">提交时间</span>
<span class="project-info-card__value">{{ project.submitTime }}</span>
</div>
</div>
</div>
<span class="project-info-card__status" :class="STATUS_CLASS[project.status]">
{{ project.status }}
</span>
</section>
</template>
<style lang="less" scoped>
.project-info-card {
padding: @spacing-lg @spacing-xl;
.flex(row, flex-start, center);
gap: @spacing-base;
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, flex-start, center);
gap: @spacing-base;
&__logo {
.flex(row, center, center);
flex-shrink: 0;
width: 56px;
height: 56px;
border-radius: 14px;
background: @white;
box-shadow: @shadow-base;
}
&__logo-badge {
.flex(row, center, center);
width: 34px;
height: 34px;
border: 2px solid @project-accent;
border-radius: 10px;
color: @project-accent;
font-family: Consolas, Menlo, 'Courier New', monospace;
font-size: @font-size-base;
font-weight: 700;
letter-spacing: -1px;
}
&__main {
.flex(column, flex-start, stretch);
flex: 1;
min-width: 0;
gap: @spacing-sm;
}
&__name {
font-size: @font-size-xxl;
font-size: @font-size-title;
font-weight: 600;
line-height: 1.2;
color: @heading-color;
.ellipsis();
}
// 设计稿:三个字段是**单行**「标签:值」,等距排布
&__meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: @spacing-base;
margin-top: @spacing-base;
.flex(row, flex-start, center);
flex-wrap: wrap;
gap: @spacing-lg;
font-size: @font-size-sm;
color: @heading-color;
}
&__meta-item {
.flex(column, flex-start, stretch);
gap: @spacing-xs;
.flex(row, flex-start, center);
max-width: 100%;
}
&__label {
font-size: @font-size-sm;
color: @text-color-secondary;
font-style: normal;
}
&__value {
font-size: @font-size-base;
color: @heading-color;
&__status {
flex-shrink: 0;
font-size: @font-size-title;
font-weight: 600;
&.is-active {
color: @project-accent;
}
&.is-done {
color: @success-color;
}
&.is-warning {
color: @warning-color;
}
&.is-idle {
color: @text-color-secondary;
}
}
}
</style>

View File

@@ -2,185 +2,168 @@
import type { Milestone } from '@/types/business/project'
interface Props {
progress: number
milestones: Milestone[]
startTime?: string
endTime?: string
}
defineProps<Props>()
const props = defineProps<Props>()
/** 三态 → 修饰类(设计稿:已完成=绿实心+白勾 / 进行中=白底粗蓝环 / 待开始=白底灰环) */
function stateClass(status: Milestone['status']): string {
if (status === '已完成') return 'is-done'
if (status === '进行中') return 'is-active'
return 'is-pending'
}
/**
* 连接线是否走「已完成」的绿色。
*
* <p>设计稿实测:只有<b>下一个节点已完成</b>时该段才是绿色(第二个节点已完成 → 第 1 段绿;
* 第三个节点进行中 → 第 2 段仍是灰色,不随「进行中」变蓝)。</p>
*/
function linkDone(index: number): boolean {
return props.milestones[index + 1]?.status === '已完成'
}
</script>
<template>
<section class="project-milestones">
<div class="project-milestones__head">
<h2 class="project-milestones__title">
总体进度
项目进度(里程碑)
</h2>
<span class="project-milestones__percent">{{ progress }}%</span>
</div>
<div class="project-milestones__track">
<div
class="project-milestones__bar"
:style="{ width: `${progress}%` }"
/>
</div>
<ol class="project-milestones__timeline">
<ol v-if="props.milestones.length > 0" class="project-milestones__track">
<li
v-for="node in milestones"
:key="node.name"
v-for="(node, index) in props.milestones"
:key="`${node.name}-${index}`"
class="project-milestones__node"
:class="`is-${node.status === '已完成' ? 'done' : node.status === '进行中' ? 'active' : 'pending'}`"
:class="[stateClass(node.status), { 'is-link-done': linkDone(index) }]"
>
<span class="project-milestones__dot" />
<div class="project-milestones__body">
<div class="project-milestones__row">
<span class="project-milestones__dot">
<svg
v-if="node.status === '已完成'"
class="project-milestones__check"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path d="M3.5 8.4 6.6 11.5 12.5 4.8" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
<span class="project-milestones__name">{{ node.name }}</span>
<span class="project-milestones__status">{{ node.status }}</span>
</div>
<span v-if="node.time" class="project-milestones__time text-secondary">{{ node.time }}</span>
</div>
</li>
<li v-if="milestones.length === 0" class="project-milestones__empty text-secondary">
暂无里程碑数据
<!-- 设计稿:已完成节点显示完成时间,其余显示状态文案 -->
<span class="project-milestones__sub text-secondary">{{ node.time || node.status }}</span>
</li>
</ol>
<div class="project-milestones__range">
<span v-if="startTime" class="text-secondary">开始时间:{{ startTime }}</span>
<span v-if="endTime" class="text-secondary">结束时间:{{ endTime }}</span>
</div>
<p v-else class="project-milestones__empty text-secondary">
暂无里程碑数据
</p>
</section>
</template>
<style lang="less" scoped>
// 节点列宽 = 设计稿相邻节点中心距(237 设计px × 0.77 ≈ 182px)
@milestone-node-width: 182px;
@milestone-dot-size: 32px;
.project-milestones {
padding: @spacing-lg @spacing-xl;
.flex(column, flex-start, stretch);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__head {
.flex(row, space-between, center);
}
&__title {
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
}
&__percent {
font-size: @font-size-xl;
font-weight: 600;
color: @primary-color;
.section-title();
}
// 设计稿:轨迹从卡片左侧正文位起、**固定节距**左对齐(不铺满整宽)
&__track {
height: 8px;
margin-top: @spacing-base;
border-radius: @radius-round;
background: @bg-color;
overflow: hidden;
}
&__bar {
height: 100%;
border-radius: @radius-round;
background: @primary-color;
transition: width 0.3s;
}
&__timeline {
.flex(row, flex-start, flex-start);
margin-top: @spacing-lg;
padding-left: @spacing-xs;
padding-bottom: @spacing-xs;
overflow-x: auto;
.scrollbar();
}
&__node {
position: relative;
padding: 0 0 @spacing-base @spacing-lg;
border-left: 2px solid @border-color;
.flex(column, flex-start, center);
flex: 0 0 @milestone-node-width;
min-width: 0;
&:last-of-type {
border-left-color: transparent;
padding-bottom: 0;
// 圆点之间的连接线:从本节点圆点右缘连到下一节点圆点左缘
&:not(:last-child)::after {
content: '';
position: absolute;
top: (@milestone-dot-size / 2 - 1px);
left: calc(50% + @milestone-dot-size / 2);
width: calc(100% - @milestone-dot-size);
height: 2px;
background: @milestone-idle;
}
&.is-done {
border-left-color: @success-color;
}
&.is-active {
border-left-color: @primary-color;
&.is-link-done:not(:last-child)::after {
background: @milestone-done;
}
}
&__dot {
position: absolute;
top: 2px;
left: -7px;
width: 12px;
height: 12px;
border: 2px solid @border-color-dark;
.flex(row, center, center);
position: relative;
z-index: 1;
width: @milestone-dot-size;
height: @milestone-dot-size;
border: 2px solid @milestone-idle;
border-radius: @radius-round;
background: @white;
box-sizing: border-box;
.is-done & {
border-color: @success-color;
background: @success-color;
border-color: @milestone-done;
background: @milestone-done;
}
.is-active & {
border-color: @primary-color;
background: @primary-color;
border-width: 3px;
border-color: @project-accent;
}
}
&__body {
.flex(column, flex-start, stretch);
gap: 2px;
}
&__row {
.flex(row, center);
gap: @spacing-sm;
&__check {
width: 16px;
height: 16px;
}
&__name {
margin-top: @spacing-sm;
max-width: 100%;
font-size: @font-size-base;
color: @heading-color;
font-weight: 600;
}
&__status {
font-size: @font-size-sm;
text-align: center;
color: @text-color-secondary;
.multi-ellipsis(2);
.is-done & {
color: @success-color;
color: @milestone-done;
}
.is-active & {
color: @primary-color;
color: @project-accent;
}
}
&__time {
&__sub {
margin-top: @spacing-xs;
max-width: 100%;
font-size: @font-size-sm;
text-align: center;
.multi-ellipsis(2);
}
&__empty {
font-size: @font-size-base;
}
&__range {
.flex(row, flex-start, center);
gap: @spacing-lg;
margin-top: @spacing-base;
padding-top: @spacing-base;
border-top: 1px solid @border-color;
font-size: @font-size-sm;
padding-left: 21px;
font-size: @font-size-base;
}
}
</style>

View File

@@ -48,9 +48,20 @@ function typeLabel(value: string | null | undefined): string {
function statusLabel(value: string | null | undefined): string {
if (!value) return '—'
// 后端已用字典翻成中文(「进行中」…);码值也能认,认不出就原样显示
return PROJECT_STATUS_LABEL[value] ?? value
}
/**
* 是否已完成(决定是否给「申请售后」入口)。
*
* <p>⚠️ 后端状态自字典翻译后下发的是**中文**「已完成」,码值 `COMPLETED` 也可能出现
* (字典缺项 / 直连子表)⇒ 两种都认。只比码值会让售后按钮永远不出现。</p>
*/
function isCompleted(value: string | null | undefined): boolean {
return value === 'COMPLETED' || value === '已完成'
}
function progressLabel(value: number | null | undefined): string {
return value == null ? '—' : `${value}%`
}
@@ -82,7 +93,7 @@ function progressLabel(value: number | null | undefined): string {
<td>{{ statusLabel(item.status) }}</td>
<td class="project-table__ops">
<button
v-if="item.status === 'COMPLETED'"
v-if="isCompleted(item.status)"
type="button"
class="project-table__link"
@click="emit('afterSales', item.id)"

View File

@@ -13,80 +13,86 @@ defineProps<Props>()
<h2 class="project-team__title">
项目团队
</h2>
<div class="project-team__list">
<div
<ul v-if="team.length > 0" class="project-team__list">
<li
v-for="(member, index) in team"
:key="`${member.name}-${index}`"
class="project-team__card"
class="project-team__item"
>
<span class="project-team__avatar">{{ member.avatarChar }}</span>
<div class="project-team__info">
<span class="project-team__name">{{ member.name || '—' }}</span>
<span class="project-team__role text-secondary">{{ member.role }}</span>
<span class="project-team__name">{{ member.name }}</span>
</div>
</div>
<p v-if="team.length === 0" class="project-team__empty text-secondary">
</li>
</ul>
<p v-else class="project-team__empty text-secondary">
暂无团队成员
</p>
</div>
</section>
</template>
<style lang="less" scoped>
// 设计稿实测:头像直径 90 设计px、相邻头像中心距 183 设计px → ×0.77 得 69px / 141px
@team-avatar-size: 68px;
@team-avatar-gap: 72px;
.project-team {
padding: @spacing-lg @spacing-xl;
.flex(column, flex-start, stretch);
padding: @spacing-lg;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
&__title {
margin-bottom: @spacing-base;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
.section-title();
}
&__list {
.flex(row, flex-start, center);
.flex(row, flex-start, flex-start);
flex-wrap: wrap;
gap: @spacing-base;
gap: @spacing-base @team-avatar-gap;
margin-top: @spacing-lg;
padding-left: 18px;
}
&__card {
.flex(row, center);
gap: @spacing-sm;
padding: @spacing-sm @spacing-base;
border: 1px solid @border-color;
border-radius: @radius-base;
background: @bg-color-light;
&__item {
.flex(column, flex-start, center);
width: @team-avatar-size;
}
&__avatar {
.flex(row, center);
width: 40px;
height: 40px;
.flex(row, center, center);
width: @team-avatar-size;
height: @team-avatar-size;
border-radius: @radius-round;
background: @primary-light;
color: @primary-color;
font-size: @font-size-lg;
font-weight: 600;
}
&__info {
.flex(column, flex-start, stretch);
gap: 2px;
}
&__role {
font-size: @font-size-sm;
background: @team-avatar-bg;
color: @white;
font-size: @font-size-title;
font-weight: 500;
}
&__name {
font-size: @font-size-base;
margin-top: @spacing-sm;
max-width: 100%;
font-size: @font-size-lg;
font-weight: 600;
color: @heading-color;
text-align: center;
.ellipsis();
}
&__role {
margin-top: @spacing-xs;
max-width: 100%;
font-size: @font-size-sm;
text-align: center;
.ellipsis();
}
&__empty {
margin-top: @spacing-base;
padding-left: 18px;
font-size: @font-size-base;
}
}

View File

@@ -8,8 +8,9 @@ import type { FileInfo } from '@/types/api'
// 页面容器:数据只经 store,不直接 import api
import { useProjectStore } from '@/stores/modules/project'
import ProjectInfoCard from './cpns/ProjectInfoCard.vue'
import ProjectTeam from './cpns/ProjectTeam.vue'
import ProjectProgress from './cpns/ProjectProgress.vue'
import ProjectMilestones from './cpns/ProjectMilestones.vue'
import ProjectTeam from './cpns/ProjectTeam.vue'
import ProjectFiles from './cpns/ProjectFiles.vue'
import ProjectFinance from './cpns/ProjectFinance.vue'
import ProjectAfterSales from './cpns/ProjectAfterSales.vue'
@@ -19,12 +20,13 @@ type DetailTab = 'progress' | 'files' | 'finance' | 'afterSales'
const route = useRoute()
const router = useRouter()
const projectStore = useProjectStore()
const { current } = storeToRefs(projectStore)
const { current, loadingTab } = storeToRefs(projectStore)
const projectId = computed(() => String(route.params.id))
const activeTab = ref<DetailTab>('progress')
const cancelVisible = ref(false)
// 设计稿:页签是「文字 + 激活项蓝色下划线」(不是胶囊按钮组),顺序固定
const tabs: Array<{ label: string; value: DetailTab }> = [
{ label: '项目进度', value: 'progress' },
{ label: '项目文件', value: 'files' },
@@ -37,16 +39,26 @@ function load(): void {
}
/**
* ⚠️ 上传 / 删除附件、售后提交都还没有后端实现
* (`ProjectRequirementUserController` 只有 create / list / get / serviceTypeList)。
* 所以这里只提示「待接入」,**不往详情里塞本地假数据** —— 详情数据现在来自真实接口,
* 混入前端自造条目会让刷新前后不一致。
* 点击页签:**每次都调一次接口**,取该页签的最新数据。
*
* <p>后端详情本就是「4 个页签 4 个端点」,所以点一次 = 一次请求,且只请求被点的那一支;
* 重复点击当前页签也会重取(用于拿到别处改动后的最新值)。竞态由 store 的请求序号兜住
* ——切项目、连点同一页签都不会把旧响应盖回页面。</p>
*/
function onUpload(file: File): void {
void file
ElMessage.info('附件上传接口待接入')
function onTabClick(tab: DetailTab): void {
activeTab.value = tab
void projectStore.refreshTab(tab)
}
/**
* 写操作的现状(2026-09-28):
* · **售后已接真实接口** —— 申请跳 `/project/:id/after-sales`(`/afterSales/request`),
* 取消走 `/afterSales/withdraw`;两者成功后都会重拉售后页签;
* · **需求附件的删除 / 预览 / 下载仍无用户端接口**(上传入口在需求提报页),
* 所以这里只提示「待接入」,**不往详情里塞本地假数据** —— 详情数据来自真实接口,
* 混入前端自造条目会让刷新前后不一致。
* 另外详情页「只展示文件」:上传入口在需求提报页,这里不提供。
*/
function onDeleteFile(index: number): void {
void index
ElMessage.info('附件删除接口待接入')
@@ -82,7 +94,8 @@ function onApplyAfterSales(): void {
}
function onConfirmCancel(): void {
void projectStore.cancelAfterSales()
// 撤回接口收的是**需求 id**(后端按需求校验归属)
void projectStore.cancelAfterSales(projectId.value)
}
watch(projectId, load)
@@ -102,33 +115,37 @@ onMounted(load)
<template v-if="current">
<ProjectInfoCard :project="current" />
<ProjectTeam :team="current.team" />
<!-- 本地 Tab 切换 -->
<!-- 本地 Tab 切换(设计稿:文字 + 激活项下划线) -->
<div class="project-detail__tabs">
<button
v-for="tab in tabs"
:key="tab.value"
class="project-detail__tab"
:class="{ 'is-active': activeTab === tab.value }"
@click="activeTab = tab.value"
:class="{
'is-active': activeTab === tab.value,
'is-loading': loadingTab === tab.value,
}"
@click="onTabClick(tab.value)"
>
{{ tab.label }}
</button>
</div>
<ProjectMilestones
v-if="activeTab === 'progress'"
<!-- 「项目进度」页签 = 开发进度 → 里程碑 → 项目团队(顺序按设计稿) -->
<template v-if="activeTab === 'progress'">
<ProjectProgress
:progress="current.progress"
:milestones="current.milestones"
:start-time="current.startTime"
:end-time="current.endTime"
/>
<ProjectMilestones :milestones="current.milestones" />
<ProjectTeam :team="current.team" />
</template>
<ProjectFiles
v-if="activeTab === 'files'"
:files="current.files"
@upload="onUpload"
@delete="onDeleteFile"
@preview="onPreviewFile"
@download="onDownloadFile"
@@ -144,10 +161,11 @@ onMounted(load)
@download="onFinanceDownload"
/>
<!-- 售后页签三形态:项目未完成 / 已完成未提交 / 已完成已提交(详见组件内注释) -->
<ProjectAfterSales
v-if="activeTab === 'afterSales'"
:after-sales="current.afterSales"
:project-id="current.id"
:project-status="current.status"
@apply="onApplyAfterSales"
@cancel="cancelVisible = true"
/>
@@ -178,36 +196,49 @@ onMounted(load)
padding-bottom: @spacing-xxl;
&__breadcrumb {
font-size: @font-size-base;
font-size: @font-size-sm;
}
// 设计稿:页签无白底容器,仅一行文字 + 激活项 2px 下划线;相邻间距实测 ≈50px
&__tabs {
.flex(row, flex-start);
gap: @spacing-xs;
padding: @spacing-xs;
border-radius: @radius-lg;
background: @white;
box-shadow: @shadow-base;
.flex(row, flex-start, center);
gap: 50px;
padding: 0 @spacing-base;
}
&__tab {
height: 34px;
padding: 0 @spacing-base;
position: relative;
padding: 0 0 @spacing-sm;
border: none;
border-radius: @radius-base;
background: transparent;
color: @text-color;
font-size: @font-size-base;
transition: all 0.2s;
line-height: 20px;
transition: color 0.2s;
&:hover {
color: @primary-color;
color: @project-accent;
}
// 刷新中:轻微变淡 + 进度光标(本地接口很快,做太重的 loading 反而闪屏)
&.is-loading {
opacity: 0.6;
cursor: progress;
}
&.is-active {
background: @primary-light;
color: @primary-color;
font-weight: 600;
color: @project-accent;
&::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 2px;
border-radius: 1px;
background: @project-accent;
}
}
}

View File

@@ -17,7 +17,7 @@ const { query, list, total, typeCounts } = storeToRefs(projectStore)
/** 是否处于筛选态(决定空态文案:没筛过 = 本来就没项目,筛过 = 没有符合条件的) */
const filtered = computed(() => Boolean(
query.value.keyword
|| query.value.businessType
|| query.value.businessTypes.length > 0
|| query.value.status
|| query.value.timeShortcut !== 'all'
|| query.value.startDate