diff --git a/.tsc-out.txt b/.tsc-out.txt new file mode 100644 index 0000000..e02abfc --- /dev/null +++ b/.tsc-out.txt @@ -0,0 +1 @@ + diff --git a/.tsc-out2.txt b/.tsc-out2.txt new file mode 100644 index 0000000..1e980ff --- /dev/null +++ b/.tsc-out2.txt @@ -0,0 +1 @@ +EXIT=0 diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 0000000..620c70c --- /dev/null +++ b/src/api/user.ts @@ -0,0 +1,19 @@ +// 用户资料接口(真实后端绑定) +// 后端:org.dromara.system.controller.system.SysUserController#profile → /system/user/profile +// 鉴权:需登录态 +import { request } from '@/utils/request' +import type { UserProfileVo } from '@/types/business/user' + +/** + * 当前登录用户资料(需求提报页预填联系人姓名 / 电话)。 + * + *
⚠️ 后端返回的是 `R
+ */ +export async function getUserProfile(): Promise可接受:`http(s)://…`、协议相对 `//…`、站内绝对路径 `/…`。 + * OSS 文件标识(纯数字)、相对路径(`a/b.png`)一律判为不可直接使用。
+ */ +export function toAssetUrl(raw: string | null | undefined): string { + const value = (raw ?? '').trim() + if (!value) return '' + const isUrl = /^(https?:)?\/\//i.test(value) || value.startsWith('/') + return isUrl ? value : '' +} diff --git a/src/utils/bizTypeMeta.ts b/src/utils/bizTypeMeta.ts new file mode 100644 index 0000000..b639a0a --- /dev/null +++ b/src/utils/bizTypeMeta.ts @@ -0,0 +1,70 @@ +// 业务类型卡片展示元信息(图标 + 一句话描述) +// +// 值域来源:项目提报列表 GET /web/projectSubmission/list(见 api/demand.ts),字段对应关系: +// value ← businessType(类型名,也是提交时下发给后端的值) +// label ← title +// desc ← subtitle +// icon ← logoUrl(实测存的是 OSS 文件标识,换链后才是图片地址) +// +// ⚠️ 后端这两个展示字段目前是半成品:subtitle 实测常与类型名重复(title/subtitle 都是 +// 「硬件电路」),logoUrl 存的是 OSS 主键而非地址。所以本文件保留「按类型名映射 desc/icon」 +// 的前端兜底,只在后端字段不可用时生效。后端把 subtitle / logo 填好后,BIZ_META 可整表删掉。 +import type { BizIconKey, BizTypeOption, SubmissionBizTypeVo } from '@/types/business/demand' +import { toAssetUrl } from '@/utils/asset' + +interface BizMeta { + desc: string + icon: BizIconKey +} + +/** 按业务类型名索引的展示元信息(前端兜底,后端字段缺失时使用) */ +const BIZ_META: Record= `vm_project_requirement.budget_range` 的列宽 varchar(30)。注意后端 BO 上 + * `@Size(max = 100)` 更宽松,真正的瓶颈是数据库列宽,故以 30 为准。
+ */ +export const MANUAL_BUDGET_MAX = 30 + +/** 该字典项是否为「手写」项(决定它在页面上渲染成按钮还是输入框) */ +export function isManualBudget(label: string): boolean { + return MANUAL_PATTERN.test(label) +} + +/** + * 回填草稿时区分「预设档位」与「手填内容」,并把预设档位**归一成 dictValue**。 + * + *判据:`budgetRange` 命中字典的 **dictValue 或 dictLabel** 即为预设档位;否则(非空) + * 视为手填文本。后端只回传一个 budgetRange 字符串、没有模式标记(`budgetCustom` 恒为 null), + * 只能这样反推。
+ * + *为什么要连 dictLabel 一起比、并且归一:字典 `vm_budget_range` 的 dictValue 是 '0'~'4', + * 但库里的历史草稿存的是**文案**(如 `1万以下`、`10万以上`)。只比 dictValue 的话,这些历史档位 + * 会被误判成手填、串进输入框;而直接回填文案的话,页面按钮是按 dictValue 判选中的, + * 又会出现"值在、按钮不亮"的空档。故统一转换成 dictValue(下次保存草稿即把历史文案收敛成码值)。
+ */ +export function resolveDraftBudget( + budgetRange: string | null | undefined, + dict: DictOption[], +): { manual: boolean, value: string } { + const raw = (budgetRange ?? '').trim() + if (!raw) return { manual: false, value: '' } + const hit = dict.find((item) => item.dictValue === raw || item.dictLabel === raw) + if (hit) return { manual: false, value: hit.dictValue } + return { manual: true, value: raw } +} diff --git a/src/utils/caseVo.ts b/src/utils/caseVo.ts new file mode 100644 index 0000000..570847b --- /dev/null +++ b/src/utils/caseVo.ts @@ -0,0 +1,89 @@ +// 案例后端 VO → 页面实体映射(纯函数,无 Vue 依赖) +// +// 后端 ProjectCaseVo 与页面 Case 的差异(字段命名、业务类型取值、富文本、数值单位) +// 统一在此对齐,供首页 store(案例库精选)与案例库 store(列表 / 详情)共用。 + +import type { Case, CaseAttachment, CaseSection, ProjectCaseVo } from '@/types/business/cases' +import { toAssetUrl } from '@/utils/asset' +import { toBusinessType } from '@/utils/format' + +/** 规模展示值:后端下发「万」为单位的数值,页面按设计稿拼成「10万」 */ +function toScaleText(scale: number | null): string { + return scale == null ? '' : `${scale}万` +} + +/** + * 正文区块:后端 catalogs 按顺序映射为「标题 + 富文本」。 + * + *标题由后端下发(vm_project_case_catalog.title),前端不再写死区块名;顺序沿用后端 + * 的 sort_order、id 升序。去掉标签后为空的内容会被过滤掉,避免渲染出只有标题的空白卡。 + * 列表 / 精选接口不下发 catalogs,故那些场景返回空数组。
+ */ +function toSections(list: ProjectCaseVo['catalogs']): CaseSection[] { + return (list ?? []) + .map((item) => ({ + title: (item.title ?? '').trim(), + content: (item.content ?? '').trim(), + })) + .filter((section) => section.content.replace(/<[^>]*>/g, '').replace(/ /gi, ' ').trim().length > 0) +} + +/** 附件:过滤掉没有访问地址的脏数据 */ +function toAttachments(list: ProjectCaseVo['attachments']): CaseAttachment[] { + return (list ?? []) + .filter((item) => (item.url ?? '').trim().length > 0) + .map((item) => ({ + name: item.originalName ?? '附件', + size: item.fileSize ?? '', + url: (item.url ?? '').trim(), + })) +} + +/** 提报主键 id → 业务类型名称 的字典(来源 GET /categories 的 projectCaseCounts) */ +export type CaseTypeNameMap = Record优先级:后端 JOIN 出的 projectTypeName → 接口字典(id 反查名称)→ 原值。 + * 实测案例表 business_type 列存的是**提报主键 id**(如 "2099753433048526850")且 + * projectTypeName 为 null —— 若不查字典直接交给 toBusinessType,会全部归到「其他」。
+ */ +function resolveCategoryName(vo: ProjectCaseVo, typeNameById?: CaseTypeNameMap): string { + return ( + vo.projectTypeName?.trim() + || typeNameById?.[String(vo.businessType)] + || vo.businessType + || '' + ) +} + +/** + * 后端案例 VO → 页面案例实体。 + * + *列表接口不返回 catalogs / attachments,页面实体对应字段为空 + * (正文区块与附件仅详情接口下发)。
+ * + * @param typeNameById 业务类型字典(id → 名称),取自案例库 store 的 /categories 结果 + */ +export function toCase(vo: ProjectCaseVo, typeNameById?: CaseTypeNameMap): Case { + return { + id: String(vo.id), + title: vo.caseName ?? '', + // 案例表 business_type 存提报主键 id,展示名需经字典翻译(见 resolveCategoryName) + category: toBusinessType(resolveCategoryName(vo, typeNameById)), + cover: toAssetUrl(vo.coverUrl), + finishDate: vo.completedAt ?? '', + teamSize: vo.serviceTeam ?? 0, + scale: toScaleText(vo.scale), + industry: vo.industry || vo.businessIndustry || '', + duration: vo.projectCycle ?? '', + clientType: vo.customerType ?? '', + // 正文区块(标题 + 富文本)由后端 catalogs 下发,前端不再写死区块名 + sections: toSections(vo.catalogs), + attachments: toAttachments(vo.attachments), + video: '', + isFavorite: vo.isFavorite === 1, + favoriteCount: vo.favoriteCount ?? 0, + } +} diff --git a/src/utils/entryIcon.ts b/src/utils/entryIcon.ts new file mode 100644 index 0000000..d56567c --- /dev/null +++ b/src/utils/entryIcon.ts @@ -0,0 +1,94 @@ +import type { EntryTone, PortalEntryCard, PortalEntryGroup, PortalEntryVo } from '@/types/business/home' + +/** + * 门户入口的视图映射:后端 icon 字段 → 页面可直接渲染的图标 + 底色 + * + *后端 icon 是「图标名 / url」自由文本,可能为空、可能是图片地址、 + * 也可能是一个标识符(如 folder)。这里按优先级兜底,保证任何取值都能渲染出可视化图标。
+ */ + +/** 图标底色(设计稿为五色循环),按序号轮转分配 */ +const TONE_CYCLE: EntryTone[] = ['purple', 'green', 'blue', 'amber', 'red'] + +/** 显示名 → emoji 兜底表(后端 icon 为空或非 emoji 时使用) */ +const NAME_ICON_MAP: Record⚠️ 这里**只放后端当前没有下发的组**。业务类型的值域与件数必须走数据层 + * (`stores/modules/project.ts#typeCounts`,来自后端 `requirementStatistics.businessTypeCounts`), + * 不要在这个文件里再定义一份,否则接口一改就错位。
+ */ + +/** + * 项目状态筛选项。 + * + *`value` 是后端列表状态的**码值**(`COALESCE(rd_project.status, project_requirement.status)`), + * 空串表示「全部」(不出现在 querystring 里)。顺序与设计稿的 2 列网格一致。
+ * + *列表改成「需求表为主 + LEFT JOIN 项目表」后,未立项的需求状态回落为 `INITIATING` + * ⇒ 设计稿画的「待立项」成了真实码值,故补进来(8 项 = 4 行 × 2 列,正好铺满)。
+ */ +export const PROJECT_STATUS_OPTIONS: Array<{ label: string; value: RdProjectStatus | '' }> = [ + { label: '全部', value: '' }, + { label: '待立项', value: 'INITIATING' }, + { label: '进行中', value: 'IN_PROGRESS' }, + { label: '待验收', value: 'PENDING_ACCEPTANCE' }, + { label: '待内部验收', value: 'PENDING_INTERNAL_ACCEPTANCE' }, + { label: '已完成', value: 'COMPLETED' }, + { label: '售后中', value: 'AFTER_SALE' }, + { label: '已取消', value: 'CANCELLED' }, +] + +/** 码值 → 中文(表格状态列用;由上面那份选项派生,避免两处维护) */ +export const PROJECT_STATUS_LABEL: Record为什么不直接改组件:详情页 6 个子组件(InfoCard/Team/Milestones/Files/Finance/ + * AfterSales)都按 `Project` 这套字段写的,改数据来源不动组件契约,改动面最小。
+ * + *未立项是常态:需求在 `vm_rd_project` 里没有对应行时,后端返回的 + * `projectStage` / `projectMilestones` / `members` / `billPlans` / `invoices` / `contracts` + * 全为空 —— 这里一律兜底成空数组 / 0 / undefined,详情页显示为空态,不报错。
+ */ + +/** + * 状态码值 → 详情页中文。 + * + *码值域是两张表的并集(见 {@link ProjectStatus}):需求侧 `INITIATING`/`CANCELLED`, + * 项目侧 `IN_PROGRESS`/`PENDING_ACCEPTANCE`/… 未知码值兜底「待立项」(未立项是最常见态)。
+ */ +function toProjectStatus(raw: string | null | undefined): ProjectStatus { + const label = PROJECT_STATUS_LABEL[raw ?? ''] + return (label as ProjectStatus | undefined) ?? '待立项' +} + +/** + * 里程碑码值 → 三态。 + * + *详情页 `Milestone.status` 只有「待开始 / 进行中 / 已完成」三态(设计稿如此), + * 而后端 `status` 是自由文本 ⇒ 按关键词归类,认不出的一律「待开始」。
+ */ +function toMilestoneStatus(raw: string | null | undefined): Milestone['status'] { + const value = (raw ?? '').toUpperCase() + if (value.includes('COMPLET') || value.includes('DONE') || value.includes('FINISH')) return '已完成' + if (value.includes('PROGRESS') || value.includes('DOING')) return '进行中' + return '待开始' +} + +/** + * 账单/付款计划状态 → 三态。 + * + *⚠️ 后端 `BillPlan.status` 的码值语义**未确认**(`vm_bill_plan` 当前 0 行,无样本), + * 只认字面量,其余一律「未到期」——不臆造成「已支付」。
+ */ +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 '待支付' + return '未到期' +} + +/** 需求附件 → 详情页文件项(文件 tab 用) */ +function attachmentToFile(item: RequirementAttachment): FileInfo { + return { + name: item.originalName ?? '', + size: item.fileSize ?? 0, + url: item.url ?? '', + uploadTime: item.createTime ?? '', + } +} + +/** 发票 → 详情页文件项(财务 tab 的发票记录只是文件列表) */ +function invoiceToFile(item: RequirementInvoice): FileInfo { + return { + name: item.invoiceTitle ?? item.invoiceNo ?? '发票', + size: 0, + url: item.invoiceFile ?? '', + uploadTime: item.invoiceDate ?? item.createTime ?? '', + } +} + +/** 合同 → 详情页文件项(财务 tab 只展示一份合同文件,取第一条) */ +function contractToFile(item: RequirementContract): FileInfo { + return { + name: item.title ?? item.contractNo ?? '合同文件', + size: 0, + url: item.file ?? '', + uploadTime: item.signDate ?? item.createTime ?? '', + } +} + +/** + * 付款计划。 + * + *`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用 `periodRule`(付不出 + * 就用「第 N 期/共 M 期」拼),`billNo` 回落到 `projectNo`。
+ */ +function toProjectBill(item: RequirementBillPlan): ProjectBill { + const period = item.currentPeriod != null && item.periods != null + ? `第${item.currentPeriod}期/共${item.periods}期` + : '' + return { + id: String(item.id), + planName: item.periodRule || period || '付款计划', + billNo: item.projectNo ?? '', + amount: item.totalAmount ?? 0, + status: toBillStatus(item.status), + projectName: item.projectName ?? '', + } +} + +/** 项目成员 → 详情页团队成员卡 */ +function toTeamMember(item: RequirementDetailVo['members'][number]): TeamMember { + const name = item.userName ?? '' + return { + role: item.roleName ?? (item.isOwner ? '项目负责人' : '项目成员'), + name, + avatarChar: name.slice(0, 1), + } +} + +/** + * 售后工单。 + * + *详情页 `AfterSales` 只有「无 / 处理中」两态,且 `type`(问题类型)后端没有对应字段 + * ⇒ 留空,`description` 用售后原因、`remark` 用处理说明。
+ */ +function toAfterSales(item: RequirementAfterSale): AfterSales { + const value = (item.status ?? '').toUpperCase() + const closed = value.includes('CLOSE') || value.includes('CANCEL') || value.includes('WITHDRAW') + return { + status: closed ? '无' : '处理中', + type: '', + description: item.reason ?? '', + remark: item.processNote ?? '', + attachments: [], + createTime: item.requestTime ?? '', + } +} + +/** 详情接口返回体 → 详情页数据模型(未立项时项目侧字段自动为空) */ +export function toProjectDetail(vo: RequirementDetailVo): Project { + const stage = vo.projectStage + const contract = vo.contracts?.[0] + return { + id: String(vo.id), + projectNo: vo.projectNo ?? '', + projectName: vo.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), + contractFile: contract ? contractToFile(contract) : undefined, + afterSales: vo.afterSale ? toAfterSales(vo.afterSale) : undefined, + } +} diff --git a/src/views/cases/cpns/CaseHero.vue b/src/views/cases/cpns/CaseHero.vue new file mode 100644 index 0000000..61e800d --- /dev/null +++ b/src/views/cases/cpns/CaseHero.vue @@ -0,0 +1,86 @@ + + + + +暂无可选业务类型
+