diff --git a/src/api/project.ts b/src/api/project.ts index e0a516e..0fc7f2a 100644 --- a/src/api/project.ts +++ b/src/api/project.ts @@ -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 return request({ 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 { }) } -// ───────────────────────────────────────────────────────────────────────────── -// 详情:真实接口 -// 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 { - return request({ - url: `/web/requirementPool/get/${id}`, +/** + * 已完成项目(申请售后页「项目名称」下拉的候选)。 + * + *

直接复用列表接口做状态过滤:`status=COMPLETED` 在后端比的是 + * `COALESCE(vm_rd_project.status, vm_project_requirement.status)`,而 `COMPLETED` 只会 + * 出现在**项目表**(未立项的需求是 `INITIATING`)⇒ 结果天然就是「自己名下已立项且已完成的项目」, + * 与后端申请售后的准入校验(只有 COMPLETED 可申请)口径一致。

+ * + *

售后申请场景下候选量级不大,一次取回较大分页(后端无专用端点);后端默认排序 + * `update_time DESC`。返回行的 `id` = **需求 id**,正是申请接口要传的那个 id。

+ */ +export function getCompletedProjects(): Promise { + return request({ + 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 { - 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 个端点的后缀)。 + * + *

`views/project/detail.vue` 里的 `DetailTab` 是同一组值;那边不 import 本模块 + * (页面层不直接依赖 api),靠字面量对齐。

+ */ +export type ProjectDetailTab = 'progress' | 'files' | 'finance' | 'afterSales' + +/** 页签 → 端点(4 支都继承 ProjectRequirementBaseVo,基础信息任一支都有) */ +const DETAIL_TAB_URL: Record = { + progress: '/web/requirementPool/get/progress', + files: '/web/requirementPool/get/files', + finance: '/web/requirementPool/get/finance', + afterSales: '/web/requirementPool/get/afterSales', +} + +/** + * 单个页签取数(**点一次页签 = 一次请求**,拿该页签的最新数据)。 + * + *

返回类型按页签收窄:`getProjectDetailTab(id, 'finance')` → `RequirementFinanceVo | null`, + * 所以调用方可以直接把结果写回 bundle 的对应键。

+ * + *

`null` = 需求不存在 / 不属于当前用户(后端 `data: null`),调用方应保留现有数据并提示。

+ */ +export function getProjectDetailTab( + id: string, + tab: T, +): Promise { + return request({ + url: `${DETAIL_TAB_URL[tab]}/${id}`, + method: 'get', + }) +} + +/** + * 详情四支并行取回(**首屏用**);任一为 null ⇒ 整个 bundle 为 null。 + * + *

4 支同源(都查同一行需求),正常不会只缺一支;缺一支即视为「需求不存在 / 非本人」, + * 详情页走整页空态。

+ */ +export function getProjectDetailBundle(id: string): Promise { + 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 上传口)。 + * + *

只传 OSS、不落业务表;返回项带 `ossId`,提交时由后端转存成售后附件。 + * 后端响应没有文件大小,大小取本地 `File.size`。

+ */ +export function uploadAfterSalesFile(file: File): Promise { + const data = new FormData() + data.append('file', file) + return request({ + 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 { - if (index >= 0 && index < PROJECT_DETAIL.files.length) { - PROJECT_DETAIL.files.splice(index, 1) - } - return Promise.resolve() -} - -/** 提交售后工单(mock) */ +/** + * 申请售后(用户端)。 + * + *

`id` 是**需求 id**(= 详情页路由参数);`payload` 的 `reason` / `note` 分别对应 + * 页面的「问题描述」与「补充备注」。

+ */ export function submitAfterSales( - projectId: string, + id: string, payload: AfterSalesPayload, -): Promise { - const result: AfterSales = { - status: '处理中', - 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] }) +): Promise { + return request({ + url: '/web/requirementPool/afterSales/request', + method: 'post', + data: { + id, + type: payload.type, + reason: payload.reason, + note: payload.note, + // 空数组是安全的(后端 `appendAttachments` 对空集合直接 return,不 NPE) + attachmentOssIds: payload.attachmentOssIds, + }, + }) } -/** 取消售后工单(mock) */ -export function cancelAfterSales(projectId: string): Promise { - const result: AfterSales = { - status: '无', - type: '', - description: '', - remark: '', - attachments: [], - createTime: '', - } - if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result - return Promise.resolve({ ...result }) +/** + * 撤回售后申请(用户端)。 + * + *

`id` 传**需求 id**;`reason` 是撤回原因(选填,后端会存进 `withdraw_reason`)。

+ */ +export function cancelAfterSales(id: string, reason = ''): Promise { + return request({ + url: '/web/requirementPool/afterSales/withdraw', + method: 'post', + data: { id, reason }, + }) } diff --git a/src/api/web/home.ts b/src/api/web/home.ts deleted file mode 100644 index 8740904..0000000 --- a/src/api/web/home.ts +++ /dev/null @@ -1,62 +0,0 @@ -// 用户web端首页接口(真实后端绑定) -// 后端: vermis-maker com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController -// 前缀: /web/home-page 鉴权: 公开接口,无需 token -// -// 使用约定(与项目 SKILL.md 一致): -// 页面/组件禁止直接 import axios,一律经 store 调用本模块的 request。 -// 本文件只放「函数签名 + 请求路径」,不写业务逻辑。 -import { request } from '@/utils/request' -import type { - ProjectStatusCount, - ProjectSubmissionVo, - BannerVo, - AgreementFormVo, -} from '@/types/web/home' - -/** 项目状态统计 */ -export function getProjectStatusCount(): Promise { - return request({ - url: '/web/home-page/project-status-count', - method: 'get', - }) -} - -/** 业务服务类型列表 */ -export function getServiceTypeList(): Promise { - return request({ - url: '/web/home-page/service-type-list', - method: 'get', - }) -} - -/** 业务类型详情 */ -export function getServiceTypeDetail(id: number | string): Promise { - return request({ - url: `/web/home-page/service-type-detail/${id}`, - method: 'get', - }) -} - -/** 轮播图列表 */ -export function getCarouselList(): Promise { - return request({ - url: '/web/home-page/carousel-list', - method: 'get', - }) -} - -/** 平台介绍(富文本 HTML) */ -export function getPlatformIntro(): Promise { - return request({ - url: '/web/home-page/platform-intro', - method: 'get', - }) -} - -/** 联系信息(富文本 HTML) */ -export function getContactInfo(): Promise { - return request({ - url: '/web/home-page/contact-info', - method: 'get', - }) -} diff --git a/src/stores/modules/project.ts b/src/stores/modules/project.ts index cae8b2d..e648e2b 100644 --- a/src/stores/modules/project.ts +++ b/src/stores/modules/project.ts @@ -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 + /** + * 已完成项目(**申请售后页「项目名称」下拉的候选**)。 + * + *

口径见 `api/project.ts#getCompletedProjects`:自己名下已立项且状态为 COMPLETED 的项目, + * 与后端「只有已完成的项目可以申请售后」的准入校验一致。行的 `id` 就是申请接口要传的需求 id。

+ * + *

与业务类型的筛选条件不同,这个值域**会随项目状态变化**,所以不缓存、 + * 每次进申请页都重拉(见 {@link fetchCompletedProjects})。

+ */ + const completedProjects = ref([]) + + /** 详情原始 bundle(4 支接口的响应);切页签时按页签替换其中一支后重新映射 */ + const detailBundle = ref(null) + /** 详情请求序号:需求 id 一变就自增,用来丢弃在途的「上一个需求」的响应 */ + let detailSeq = 0 + /** 各页签自己的请求序号:同一页签连点只应用最后一次响应 */ + const tabSeq: Record = { + progress: 0, + files: 0, + finance: 0, + afterSales: 0, + } + /** 正在刷新的页签('' = 无);页签按钮据此显示加载态 */ + const loadingTab = ref('') + /** * 取业务类型筛选条件(左侧筛选栏「业务类型」的值域 + 件数)。 * @@ -97,21 +127,98 @@ export const useProjectStore = defineStore('project', () => { } /** - * 取详情(真实接口)。 + * 取「已完成项目」(申请售后页「项目名称」下拉的值域)。 * - *

后端在「需求不存在」或「不属于当前用户」时返回 `data: null` ⇒ 页面显示空态; - * 请求本身失败(如 500)也归为 null,不让异常冒到组件里。

+ *

失败只告警不清空:保留上一次的结果,页面下拉至少还有数据;首次即失败则为空数组, + * 申请页会退化成只显示当前项目的单项下拉(见 `after-sales.vue` 的兜底合并)。

*/ - async function fetchDetail(id: string): Promise { + async function fetchCompletedProjects(): Promise { try { - current.value = await getProjectDetail(id) + completedProjects.value = await getCompletedProjects() } catch (error) { + console.warn('[project] 已完成项目列表加载失败', error) + } + } + + /** + * 取详情(**首屏**:4 支并行,一次到位)。 + * + *

后端在「需求不存在」或「不属于当前用户」时 4 支都返回 `data: null` ⇒ 页面显示空态; + * 请求本身失败(如 500)也归为 null,不让异常冒到组件里。

+ * + *

原始响应同时缓存进 `detailBundle`,之后切页签只替换其中一支(见 {@link refreshTab}), + * 不必重拉另外三支。

+ */ + async function fetchDetail(id: string): Promise { + 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 再重新映射。 + * + *

后端 4 个页签各一个端点 ⇒ 「点一次页签 = 一次请求」,不需要重拉另外三支。 + * 页签内容(里程碑 / 付款计划 / 附件 / 售后…)可能被别的入口改动,所以每次点击都重新取。

+ * + *

并发保护两层:① 需求 id 变了(切项目)⇒ 丢弃在途响应;② 同一页签连点 ⇒ 只应用 + * 最后一次响应(先到的慢响应被后到的覆盖)。

+ * + *

后端返回 `null`(需求不存在 / 非本人)时**保留现有数据**,只告警、不清空页面。

+ */ + async function refreshTab(tab: ProjectDetailTab): Promise { + 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 { - const info = await uploadProjectFile(file) - current.value?.files.push(info) + /** + * 上传售后附件(提交前的暂存步骤)。 + * + *

只传 OSS、不落业务表;返回项带 `ossId`,由申请页收集后在提交时下发 + * `attachmentOssIds`,后端再转存成售后附件。

+ */ + async function uploadAfterSalesFile(file: File): Promise { + return uploadAfterSalesFileApi(file) } - /** 删除当前详情中的项目文件 */ - async function deleteFile(index: number): Promise { - await deleteProjectFile(index) - current.value?.files.splice(index, 1) + /** + * 申请售后(成功后就地重拉「售后信息」页签)。 + * + *

不拿返回值本地拼状态:售后状态与工单附件都由后端决定,本地拼容易和真实数据漂移。 + * 接口失败(如「只有已完成的项目可以申请售后」)会抛出,由统一 request 层弹提示, + * 这里不做兜底吞异常。

+ */ + async function submitAfterSales(id: string, payload: AfterSalesPayload): Promise { + await submitAfterSalesApi(id, payload) + ElMessage.success('售后申请已提交') + // 详情页在场时同步刷新该页签;从申请页提交时会立刻跳回详情,那边 onMounted 会重新取数 + if (current.value?.id === id) await refreshTab('afterSales') } - /** 提交售后工单 */ - async function submitAfterSales(payload: AfterSalesPayload): Promise { - if (!current.value) return - current.value.afterSales = await submitAfterSalesApi(current.value.id, payload) - ElMessage.success('已提交售后工单') - } - - /** 取消售后工单 */ - async function cancelAfterSales(): Promise { - if (!current.value) return - current.value.afterSales = await cancelAfterSalesApi(current.value.id) - ElMessage.success('已取消售后工单') + /** 撤回售后申请(成功后就地重拉「售后信息」页签) */ + async function cancelAfterSales(id: string): Promise { + 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, } }) diff --git a/src/styles/mixins.less b/src/styles/mixins.less index 3a17d47..4eff0c9 100644 --- a/src/styles/mixins.less +++ b/src/styles/mixins.less @@ -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%; diff --git a/src/styles/variables.less b/src/styles/variables.less index b8ad69e..b5dc365 100644 --- a/src/styles/variables.less +++ b/src/styles/variables.less @@ -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; // 团队头像底色(设计稿实取) diff --git a/src/types/business/project.d.ts b/src/types/business/project.d.ts index 1c9d8d7..77ab302 100644 --- a/src/types/business/project.d.ts +++ b/src/types/business/project.d.ts @@ -130,34 +130,92 @@ export interface ProjectBill { projectName: string } -/** 售后工单 */ +/** + * 售后工单(详情页组件契约)。 + * + *

两态:`处理中` = 售后工单存在且未撤回/未作废(含后端 REQUESTED / IN_PROGRESS); + * `无` = 没有工单,或最近一条已 WITHDRAWN(撤回后回到可再次申请的状态)。

+ */ 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 以便提交时由后端转存)。 + * + *

上传走需求提报的同一个 OSS 上传口(`POST /web/workOrderDraft/upload`), + * 提交时只传 `ossId` 列表,后端按 `phase = AFTER_SALE`、`target_id = 售后工单 id` + * 写进 `vm_project_requirement_attachment`,OSS 文件不重复上传。

+ */ +export interface AfterSalesAttachment extends FileInfo { + /** OSS 主键(提交时进 `attachmentOssIds`) */ + ossId: string | number } /** - * 项目详情(**详情页仍是前端 mock 形态**,尚未接后端)。 + * OSS 上传返回(后端 `SysOssVo` 的子集)—— 与需求提报共用同一个上传口。 * - *

列表已改走真实接口(见 {@link RdProjectVo}),本类型只服务详情页那几个组件。

+ *

⚠️ `fileSuffix` 带前导点(`.pdf`);响应里**没有**文件大小,大小要取本地 `File.size`。

+ */ +export interface AfterSalesUploadVo { + ossId: string | number + url: string + originalName: string + fileSuffix?: string +} + +/** + * 售后申请入参 —— **对齐后端 `ProjectRequirementActionBo`**。 + * + *

字段名与页面语义的对应:页面「问题描述」→ {@link reason}、「补充备注」→ {@link note}。

+ */ +export interface AfterSalesPayload { + /** 售后类型(下拉 8 项之一),最长 50 */ + type: string + /** 问题描述 → 后端 `reason`,最长 500 */ + reason: string + /** 补充备注 → 后端 `note`(页面限 500,后端 BO 上限 2000) */ + note: string + /** 附件 OSS 主键集合(空数组安全,后端 null 会走空判断) */ + attachmentOssIds: Array +} + +/** + * 项目详情页数据模型 —— **详情页组件契约**,不是后端原样字段。 + * + *

后端详情已拆成 4 个页签接口(见 {@link RequirementDetailBundle}),由 + * `utils/projectVo.ts#toProjectDetail` 合并映射成本类型;详情页 6 个子组件 + * (InfoCard/Team/Milestones/Files/Finance/AfterSales)只认这套字段,因此换数据来源 + * 不需要动组件。

*/ export interface Project { id: string projectNo: string projectName: string + /** 归一化后的枚举(未知类型会落到 'other',仅供图标/配色等视觉用途) */ businessType: BusinessType + /** + * 后端**原样下发**的业务类型名称(如「互联网软件」,来自提报表)。 + * + *

⚠️ 展示一律用它:`businessType` 只有 5 个固定枚举,后台新增业务类型时会全部落成 + * 「其他」——那是枚举的兜底,不是接口的值。

+ */ + businessTypeName: string progress: number status: ProjectStatus submitTime: string @@ -185,16 +243,16 @@ export interface ProjectTypeCount { /** * 项目列表查询条件(**已是后端口径**)。 * - *

与 `RdProjectBo` 的字段对应关系:`keyword → key`、`businessType → businessType`(提报表 id)、 + *

与 `RdProjectBo` 的字段对应关系:`keyword → key`、`businessTypes → businessTypes`(提报表 id,多选 IN)、 * `status → status`(码值)、`startDate/endDate 或 timeShortcut → beginTime/endTime`(两者都落在 * `create_time` 上)、`pageNum/pageSize → PageQuery`。

* - *

用空串表示「不限」而不是 'all'——空串直接不出现在 querystring 里,与后端 `eqIfText` 的语义一致。

+ *

用空串 / 空数组表示「不限」而不是 'all'——空值直接不出现在 querystring 里,与后端 `eqIfText` 的语义一致。

*/ 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`)。 * - *

注意两个「名字骗人」的字段:`attachments` / `afterSales`(复数)后端**从未赋值**, - * 真正有数据的是 `requirementAttachments` 与 `afterSale`(单数)。前端只取后者。

+ *

字段全部来自需求表 `vm_project_requirement`,**与是否立项无关**,任意页签都填充。

*/ -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[] +} + +/** + * 详情页四接口聚合结果。 + * + *

`api/project.ts#getProjectDetail` 同帧并行取回 4 个页签后交给 + * `utils/projectVo.ts#toProjectDetail` 合并成组件契约 {@link Project}; + * 任一接口返回 null(需求不存在 / 非本人)即整页空态。

+ */ +export interface RequirementDetailBundle { + progress: RequirementProgressVo + files: RequirementFilesVo + finance: RequirementFinanceVo + afterSales: RequirementAfterSalesVo } diff --git a/src/types/web/home.d.ts b/src/types/web/home.d.ts deleted file mode 100644 index 64364c4..0000000 --- a/src/types/web/home.d.ts +++ /dev/null @@ -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 -} diff --git a/src/utils/format.ts b/src/utils/format.ts index 547a190..293c8d1 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -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) +/** + * 金额千分位(默认两位小数)。 + * + *

⚠️ `value` 必须容错字符串:后端 `BigDecimal` 经全局 Jackson 配置序列化成 + * 字符串(如 `"120000.00"`),直接 `value.toFixed()` 会抛 + * `TypeError: value.toFixed is not a function`——在模板里调用会让整个组件渲染失败 + * (实测「合同财务」页签白屏)。这里统一 `Number()` 归一,非法值按 0 处理。

+ */ +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 diff --git a/src/utils/projectVo.ts b/src/utils/projectVo.ts index 2584d0f..c26d9be 100644 --- a/src/utils/projectVo.ts +++ b/src/utils/projectVo.ts @@ -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`。 * *

为什么不直接改组件:详情页 6 个子组件(InfoCard/Team/Milestones/Files/Finance/ * AfterSales)都按 `Project` 这套字段写的,改数据来源不动组件契约,改动面最小。

* + *

基础信息取哪一支都行:4 个页签 VO 都继承 `ProjectRequirementBaseVo` 且同源 + * (需求表同一行),这里统一取 `bundle.progress` 那一支当基础信息(UI 上它最先渲染)。

+ * *

未立项是常态:需求在 `vm_rd_project` 里没有对应行时,后端返回的 * `projectStage` / `projectMilestones` / `members` / `billPlans` / `invoices` / `contracts` * 全为空 —— 这里一律兜底成空数组 / 0 / undefined,详情页显示为空态,不报错。

*/ /** - * 状态码值 → 详情页中文。 + * 状态 → 详情页中文。 * - *

码值域是两张表的并集(见 {@link ProjectStatus}):需求侧 `INITIATING`/`CANCELLED`, - * 项目侧 `IN_PROGRESS`/`PENDING_ACCEPTANCE`/… 未知码值兜底「待立项」(未立项是最常见态)。

+ *

⚠️ 后端 `selectUserProjectPage` / 详情侧已用 `sys_dict_data`(`vm_requirement_status`) + * 把状态翻成**中文**下发(如「进行中」),但码值(`INITIATING` 等)也可能出现 + * (直接查子表、或字典缺项时)⇒ 两种口径都要认,认不出的一律兜底「待立项」(未立项最常见)。

*/ +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'] /** * 账单/付款计划状态 → 三态。 * - *

⚠️ 后端 `BillPlan.status` 的码值语义**未确认**(`vm_bill_plan` 当前 0 行,无样本), - * 只认字面量,其余一律「未到期」——不臆造成「已支付」。

+ *

⚠️ 数据源是 `vm_bill_plan`,实体注释的值域只有 **ACTIVE / DONE / CLOSED** + * (不是 `vm_bill` 的 UNSTARTED/PENDING/PAID)。两者语义对照: + * 计划执行中 = 还有期次要付 ⇒ 「待支付」;计划结束 = 已付清 ⇒ 「已支付」。

+ * + *

「未到期」在 `vm_bill_plan` 里没有对应码值(已知契约缺口)——设计稿的第三张卡 + * (第三期·尾款 → 未到期)目前靠 `UNSTARTED` 表达,所以这里显式认它, + * 而不是依赖「认不出就兜底未到期」的隐式行为;另兼容 `vm_bill` 的 PAID/PENDING 字面量。

*/ 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"`)。 + * + *

直接当 number 用会崩:`formatMoney('120000.00')` → `value.toFixed is not a function` + * ⇒ 组件渲染失败、整个页签白屏(实测「合同财务」页签)。

+ */ +function toNumber(value: number | string | null | undefined, fallback = 0): number { + const num = Number(value) + return Number.isFinite(num) ? num : fallback +} + +/** + * LocalDateTime 字符串 → 设计稿口径的「日」。 + * + *

后端下发 `2026-09-03 00:00:00`(或 ISO `…T00:00:00`),两种都能截前 10 位; + * 空值返回 undefined,交给组件显示「—」。

+ */ +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) ?? '', } } /** * 付款计划。 * - *

`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用 `periodRule`(付不出 - * 就用「第 N 期/共 M 期」拼),`billNo` 回落到 `projectNo`。

+ *

`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用「第 N 期/共 M 期」 + * (**期次优先**,`periodRule` 的 '30,30,40' 只作兜底——直接把比例当名字显示不可读), + * `billNo` 回落到 `projectNo`。

*/ 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 /** * 售后工单。 * - *

详情页 `AfterSales` 只有「无 / 处理中」两态,且 `type`(问题类型)后端没有对应字段 - * ⇒ 留空,`description` 用售后原因、`remark` 用处理说明。

+ *

详情页 `AfterSales` 只有「无 / 处理中」两态:撤回(WITHDRAWN)/ 作废 / 关闭 ⇒ 「无」 + * (页面回到可再次申请的状态),其余(REQUESTED / IN_PROGRESS / COMPLETED)都算「处理中」。

+ * + *

字段对应:`type` = 售后类型、`description` = 用户填的问题描述(后端 `reason`)、 + * `remark` = 用户补充备注(后端 `remark`,不是处理人写的 `processNote`)、 + * `attachments` = 售后工单附件(同表 `phase = AFTER_SALE`,由后端随页签下发)。

*/ -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) => ({ - name: item.milestoneName ?? '', - status: toMilestoneStatus(item.status), - time: item.startTime ?? undefined, - })), - team: (vo.members ?? []).map(toTeamMember), - files: (vo.requirementAttachments ?? []).map(attachmentToFile), - paymentPlans: (vo.billPlans ?? []).map(toProjectBill), - invoiceRecords: (vo.invoices ?? []).map(invoiceToFile), + 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), + // 后端是 LocalDateTime('2026-09-03 00:00:00'),设计稿只到「日」 + time: toDate(item.startTime), + })), + 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, } } diff --git a/src/views/demand/cpns/DemandStep1Business.vue b/src/views/demand/cpns/DemandStep1Business.vue deleted file mode 100644 index 5d11b3f..0000000 --- a/src/views/demand/cpns/DemandStep1Business.vue +++ /dev/null @@ -1,105 +0,0 @@ - - - - - diff --git a/src/views/demand/cpns/DemandStep2Info.vue b/src/views/demand/cpns/DemandStep2Info.vue deleted file mode 100644 index 1eb2b7f..0000000 --- a/src/views/demand/cpns/DemandStep2Info.vue +++ /dev/null @@ -1,229 +0,0 @@ - - -