项目中心

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

@@ -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) => ({
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,
}
}