From 6eb2bfcbfd476892abdade1dc9a0f106b5a8c545 Mon Sep 17 00:00:00 2001 From: wangzzpeng <1032675359@qq.com> Date: Mon, 28 Sep 2026 09:27:51 +0800 Subject: [PATCH] first1 --- .tsc-out.txt | 1 + .tsc-out2.txt | 1 + src/api/user.ts | 19 ++ src/types/business/auth.d.ts | 23 ++ src/utils/asset.ts | 18 ++ src/utils/bizTypeMeta.ts | 70 ++++ src/utils/budgetMeta.ts | 52 +++ src/utils/caseVo.ts | 89 ++++++ src/utils/entryIcon.ts | 94 ++++++ src/utils/projectMeta.ts | 48 +++ src/utils/projectVo.ts | 174 ++++++++++ src/views/cases/cpns/CaseHero.vue | 86 +++++ src/views/cases/cpns/CasesSearchBar.vue | 159 +++++++++ src/views/demand/cpns/BizTypeIcon.vue | 96 ++++++ src/views/demand/cpns/DemandActionBar.vue | 129 ++++++++ src/views/demand/cpns/DemandAttachments.vue | 336 ++++++++++++++++++++ src/views/demand/cpns/DemandBizTypes.vue | 132 ++++++++ src/views/demand/cpns/DemandConfirmForm.vue | 126 ++++++++ src/views/demand/cpns/DemandInfoForm.vue | 297 +++++++++++++++++ src/views/home/cpns/HomeAppDialog.vue | 317 ++++++++++++++++++ src/views/home/cpns/HomeBanner.vue | 128 ++++++++ src/views/home/cpns/HomeServiceContents.vue | 120 +++++++ src/views/home/cpns/HomeServiceTypes.vue | 110 +++++++ src/views/project/cpns/ProjectPager.vue | 117 +++++++ src/views/project/cpns/ProjectToolbar.vue | 145 +++++++++ 25 files changed, 2887 insertions(+) create mode 100644 .tsc-out.txt create mode 100644 .tsc-out2.txt create mode 100644 src/api/user.ts create mode 100644 src/types/business/auth.d.ts create mode 100644 src/utils/asset.ts create mode 100644 src/utils/bizTypeMeta.ts create mode 100644 src/utils/budgetMeta.ts create mode 100644 src/utils/caseVo.ts create mode 100644 src/utils/entryIcon.ts create mode 100644 src/utils/projectMeta.ts create mode 100644 src/utils/projectVo.ts create mode 100644 src/views/cases/cpns/CaseHero.vue create mode 100644 src/views/cases/cpns/CasesSearchBar.vue create mode 100644 src/views/demand/cpns/BizTypeIcon.vue create mode 100644 src/views/demand/cpns/DemandActionBar.vue create mode 100644 src/views/demand/cpns/DemandAttachments.vue create mode 100644 src/views/demand/cpns/DemandBizTypes.vue create mode 100644 src/views/demand/cpns/DemandConfirmForm.vue create mode 100644 src/views/demand/cpns/DemandInfoForm.vue create mode 100644 src/views/home/cpns/HomeAppDialog.vue create mode 100644 src/views/home/cpns/HomeBanner.vue create mode 100644 src/views/home/cpns/HomeServiceContents.vue create mode 100644 src/views/home/cpns/HomeServiceTypes.vue create mode 100644 src/views/project/cpns/ProjectPager.vue create mode 100644 src/views/project/cpns/ProjectToolbar.vue 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`,用户对象嵌在 **`data.user`** 里(同级还有 roles / posts), + * 不是平铺在 data 上——这里在 api 层解包,调用方拿到的是用户对象本身。

+ */ +export async function getUserProfile(): Promise { + const data = await request<{ user: UserProfileVo }>({ + url: '/system/user/profile', + method: 'get', + }) + return data.user +} diff --git a/src/types/business/auth.d.ts b/src/types/business/auth.d.ts new file mode 100644 index 0000000..6d41280 --- /dev/null +++ b/src/types/business/auth.d.ts @@ -0,0 +1,23 @@ +// 账号密码登录/注册真实接口类型(对接 vermis-maker /auth/*) +// 归位于 types/business/auth.d.ts,与 api/auth.ts 对应 + +/** 登录成功返回,对应后端 LoginVo(字段名以 @JsonProperty 为准) */ +export interface LoginResult { + /** 授权令牌(后端字段 access_token) */ + access_token: string + /** 刷新令牌 */ + refresh_token?: string + /** access_token 有效期(秒) */ + expire_in?: number + /** refresh_token 有效期(秒) */ + refresh_expire_in?: number + /** 应用 id */ + client_id?: string + /** 令牌权限 */ + scope?: string + /** 用户 openid */ + openid?: string +} + +/** 注册返回:后端 R,无 data */ +export type RegisterResult = void diff --git a/src/utils/asset.ts b/src/utils/asset.ts new file mode 100644 index 0000000..659138f --- /dev/null +++ b/src/utils/asset.ts @@ -0,0 +1,18 @@ +// 资源地址工具(后端多个接口会下发「OSS 文件标识」而不是完整地址) +// +// 实测:案例 ProjectCaseVo.coverUrl、项目提报 ProjectSubmissionVo.coverUrl/logoUrl +// 都可能是 OSS 主键(如 2099753406859292674)。直接当 用会请求到当前站点的 +// 错误路径上(表现为 404 / 空白),故统一在这里收口判断。 + +/** + * 仅当值本身是可直接访问的地址时才返回它,否则返回空串。 + * + *

可接受:`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 = { + 互联网软件: { desc: 'Web/小程序/APP/二次开发/桌面工具等', icon: 'code' }, + 硬件电路: { desc: '原理图设计/PCB设计/电气效验等', icon: 'chip' }, + 物联网全案: { desc: '软硬件开发+云端一体化/定制化开发等', icon: 'iot' }, + 生产制造: { desc: 'PCB制版/SMT/整机装配/定制生产等', icon: 'factory' }, + // 「其他」只在此处作为**展示元信息**(描述/图标)备着:只有当接口真的下发一条 + // businessType = 「其他」时才用得上,本文件不会据此凭空生成选项。 + 其他: { desc: '若没有对应的服务内容请选择该选项', icon: 'other' }, +} + +/** + * 提报项 → 业务类型卡片选项。 + * + * @param ossUrlById OSS 文件标识 → 可访问地址 的映射(换链结果;取不到时传空对象) + * @returns 类型名为空的脏数据返回 null,由调用方过滤 + */ +export function toBizTypeOption( + vo: SubmissionBizTypeVo, + ossUrlById: Record = {}, +): BizTypeOption | null { + const value = (vo.businessType ?? '').trim() || (vo.title ?? '').trim() + if (!value) return null + const label = (vo.title ?? '').trim() || value + const meta = BIZ_META[value] + const subtitle = (vo.subtitle ?? '').trim() + // 后端 subtitle 常常直接填类型名本身,这种「描述」没有信息量 → 视为缺失,回退前端映射 + const desc = subtitle && subtitle !== value && subtitle !== label + ? subtitle + : (meta?.desc ?? '') + const logoRaw = (vo.logoUrl ?? '').trim() + return { + value, + label, + desc, + icon: meta?.icon ?? 'other', + // logoUrl 有时本就填的是地址,有时是 OSS 标识(需换链) + iconUrl: toAssetUrl(logoRaw) || ossUrlById[logoRaw] || '', + } +} + +/** 字典项 → 业务类型卡片选项(仅当提报表无可用数据、回退字典兜底时使用) */ +export function toBizTypeOptionFromDict(value: string, label: string): BizTypeOption { + const meta = BIZ_META[value] + return { + value, + label, + desc: meta?.desc ?? '', + icon: meta?.icon ?? 'other', + } +} diff --git a/src/utils/budgetMeta.ts b/src/utils/budgetMeta.ts new file mode 100644 index 0000000..672e826 --- /dev/null +++ b/src/utils/budgetMeta.ts @@ -0,0 +1,52 @@ +// 预算范围(字典 vm_budget_range)的展示/校验元信息 +// +// 后端字典 `vm_budget_range` 共 5 项,最后一项(dictValue='4',label「手填」)语义为 +// 「不在预设档位里、由用户自己写一个金额」。设计上: +// 前 4 项 = 普通胶囊按钮;最后一项 = **同排的第 5 个控件,本身就是一个输入框** +// (占位文案直接用字典 label「手填」),不再是"点一下再额外弹一个输入框"。 +// +// ⚠️ 手写文本没有独立的落库位置:`WorkOrderDraftBo.budgetCustom` 被注释掉、 +// `vm_work_order_draft` 表也没有 `budget_custom` 列(VO 上那个字段恒为 null), +// 所以手写内容直接写进 `budgetRange` 本身。而提交落库的 +// `vm_project_requirement.budget_range` 是 **varchar(30)**,故输入框必须限长 30, +// 否则 MySQL 严格模式下会报 1406(Data too long)导致提交失败。 +import type { DictOption } from '@/types/business/demand' + +/** 字典里表示「自己填写」的那一项(后端 label 现为「手填」) */ +const MANUAL_PATTERN = /手填|手写|自定义/ + +/** + * 手写内容最大长度。 + * + *

= `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 = { + 项目中心: '🗂️', + 需求中心: '🧾', + 需求池: '🧾', + 工单需求池: '📋', + 快捷提报: '➕', + 在线客服: '🎧', + 客服工作台: '💬', + 合同管理: '📑', + 合同台账: '📄', + 合同列表: '📁', + 合同档案: '📁', + 财务管理: '💰', + 财务台账: '💰', + 财务档案: '💰', + 案例库管理: '📁', + 案例库: '📁', + 个人中心: '👤', + 消息中心: '💬', + 账号安全: '🔒', + 意见反馈: '✉️', + 交易列表: '📊', + 交易明细: '📊', + 部署管理: '🚀', + 设备工作台: '🖥️', + 设备管理: '🖥️', + 资质列表: '📋', + 帮助中心: '❓', + 关于我们: 'ℹ️', +} + +/** 兜底图标 */ +const DEFAULT_ICON = '📦' + +/** 是否图片地址 */ +function isImageUrl(value: string): boolean { + return /^(https?:)?\/\//i.test(value) || value.startsWith('/') +} + +/** 是否可直接当作 emoji / 字符图标使用(非纯字母数字标识符) */ +function isGlyph(value: string): boolean { + return !/^[a-zA-Z0-9_-]+$/.test(value) +} + +/** 解析单项图标与底色 */ +function resolveIcon(icon: string | null, name: string): { icon: string; isImage: boolean } { + const raw = (icon ?? '').trim() + if (raw) { + if (isImageUrl(raw)) return { icon: raw, isImage: true } + if (isGlyph(raw)) return { icon: raw, isImage: false } + } + return { icon: NAME_ICON_MAP[name] ?? DEFAULT_ICON, isImage: false } +} + +/** + * 后端入口 → 页面卡片 + * + * @param vo 后端入口 + * @param index 组内序号(决定底色轮转) + */ +export function toPortalEntryCard(vo: PortalEntryVo, index: number): PortalEntryCard | null { + if (vo.id == null) return null + const label = (vo.name ?? '').trim() || '未命名入口' + const { icon, isImage } = resolveIcon(vo.icon, label) + return { + id: vo.id, + label, + route: (vo.route ?? '').trim(), + groupType: (vo.groupType === '1' ? '1' : '0') as PortalEntryGroup, + icon, + isImage, + tone: TONE_CYCLE[index % TONE_CYCLE.length], + } +} + +/** 后端入口列表 → 页面卡片列表(过滤无效项,保持后端下发顺序) */ +export function toPortalEntryCards(list: PortalEntryVo[]): PortalEntryCard[] { + return list + .map((vo, index) => toPortalEntryCard(vo, index)) + .filter((item): item is PortalEntryCard => item !== null) +} diff --git a/src/utils/projectMeta.ts b/src/utils/projectMeta.ts new file mode 100644 index 0000000..d6933e9 --- /dev/null +++ b/src/utils/projectMeta.ts @@ -0,0 +1,48 @@ +import type { RdProjectStatus } from '@/types/business/project' + +/** + * 项目中心筛选栏的静态选项。 + * + *

⚠️ 这里**只放后端当前没有下发的组**。业务类型的值域与件数必须走数据层 + * (`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 = PROJECT_STATUS_OPTIONS + .filter((option) => option.value !== '') + .reduce>((acc, option) => { + acc[option.value] = option.label + return acc + }, {}) + +/** 提交时间快捷区间(后端未下发该组,前端换算成 beginTime/endTime 后下发) */ +export const PROJECT_TIME_OPTIONS: Array<{ + label: string + value: 'all' | '3m' | '6m' | '1y' +}> = [ + { label: '全部', value: 'all' }, + { label: '近3个月', value: '3m' }, + { label: '近半年', value: '6m' }, + { label: '近一年', value: '1y' }, +] diff --git a/src/utils/projectVo.ts b/src/utils/projectVo.ts new file mode 100644 index 0000000..2584d0f --- /dev/null +++ b/src/utils/projectVo.ts @@ -0,0 +1,174 @@ +import type { FileInfo } from '@/types/api' +import type { + AfterSales, + Milestone, + Project, + ProjectBill, + ProjectStatus, + RequirementAfterSale, + RequirementAttachment, + RequirementBillPlan, + RequirementContract, + RequirementDetailVo, + RequirementInvoice, + TeamMember, +} from '@/types/business/project' +import { toBusinessType } from '@/utils/format' +import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta' + +/** + * 需求详情(后端 `ProjectRequirementUserVo`)→ 详情页 `Project`。 + * + *

为什么不直接改组件:详情页 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 @@ + + + + + diff --git a/src/views/cases/cpns/CasesSearchBar.vue b/src/views/cases/cpns/CasesSearchBar.vue new file mode 100644 index 0000000..86d72dd --- /dev/null +++ b/src/views/cases/cpns/CasesSearchBar.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/src/views/demand/cpns/BizTypeIcon.vue b/src/views/demand/cpns/BizTypeIcon.vue new file mode 100644 index 0000000..2db4eec --- /dev/null +++ b/src/views/demand/cpns/BizTypeIcon.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/src/views/demand/cpns/DemandActionBar.vue b/src/views/demand/cpns/DemandActionBar.vue new file mode 100644 index 0000000..9d04f51 --- /dev/null +++ b/src/views/demand/cpns/DemandActionBar.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/src/views/demand/cpns/DemandAttachments.vue b/src/views/demand/cpns/DemandAttachments.vue new file mode 100644 index 0000000..5c87926 --- /dev/null +++ b/src/views/demand/cpns/DemandAttachments.vue @@ -0,0 +1,336 @@ + + + + + diff --git a/src/views/demand/cpns/DemandBizTypes.vue b/src/views/demand/cpns/DemandBizTypes.vue new file mode 100644 index 0000000..2f3e004 --- /dev/null +++ b/src/views/demand/cpns/DemandBizTypes.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/src/views/demand/cpns/DemandConfirmForm.vue b/src/views/demand/cpns/DemandConfirmForm.vue new file mode 100644 index 0000000..e9f0147 --- /dev/null +++ b/src/views/demand/cpns/DemandConfirmForm.vue @@ -0,0 +1,126 @@ + + +