feat 完善注释

This commit is contained in:
Ironsp
2026-10-01 17:24:57 +08:00
parent 2b59f68fd7
commit 9f4a5c7cda
183 changed files with 485 additions and 3615 deletions

View File

@@ -8,16 +8,8 @@ import type {
} from '@/types/business/user' } from '@/types/business/user'
import { isPhone, isValidPassword } from '@/utils/validate' import { isPhone, isValidPassword } from '@/utils/validate'
/**
* PC 端 clientId(sys_client 种子数据,grantType 含 password)。
* 与请求头 clientid 同源(见 @/utils/request),改一处即可整体生效。
*/
export const PC_CLIENT_ID = CLIENT_ID export const PC_CLIENT_ID = CLIENT_ID
/**
* 账号密码登录:POST /auth/login
* 后端要求请求体含 clientId + grantType + username + password(api-decrypt 未开启,明文即可)
*/
export function loginByAccountApi(data: { username: string; password: string }): Promise<AuthLoginResult> { export function loginByAccountApi(data: { username: string; password: string }): Promise<AuthLoginResult> {
return request<AuthLoginResult>({ return request<AuthLoginResult>({
url: '/auth/login', url: '/auth/login',
@@ -31,10 +23,6 @@ export function loginByAccountApi(data: { username: string; password: string }):
}) })
} }
/**
* 账号密码注册:POST /auth/register
* userType=sys_user;需后端开启注册开关(sys.account.registerUser=true),否则返回「当前系统没有开启注册功能!」
*/
export function registerByAccountApi(data: { username: string; password: string }): Promise<RegisterResult> { export function registerByAccountApi(data: { username: string; password: string }): Promise<RegisterResult> {
return request<RegisterResult>({ return request<RegisterResult>({
url: '/auth/register', url: '/auth/register',
@@ -53,13 +41,11 @@ export function registerByAccountApi(data: { username: string; password: string
const SIX_DIGIT_CODE = /^\d{6}$/ const SIX_DIGIT_CODE = /^\d{6}$/
/** 发送短信验证码 */
export function sendSmsCode(phone: string): Promise<void> { export function sendSmsCode(phone: string): Promise<void> {
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确')) if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
return Promise.resolve() return Promise.resolve()
} }
/** 手机验证码登录(mock:验证码须为 6 位数字) */
export function loginBySms(params: LoginBySmsParams): Promise<LoginResult> { export function loginBySms(params: LoginBySmsParams): Promise<LoginResult> {
const { phone, code } = params const { phone, code } = params
if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确')) if (!isPhone(phone)) return Promise.reject(new Error('手机号格式不正确'))
@@ -90,7 +76,6 @@ function validateAuthForm(
return null return null
} }
/** 重置密码 */
export function resetPassword(params: ResetPasswordParams): Promise<void> { export function resetPassword(params: ResetPasswordParams): Promise<void> {
const message = validateAuthForm( const message = validateAuthForm(
params.phone, params.phone,

View File

@@ -1,10 +1,4 @@
// 案例库接口(真实后端绑定)
// 后端: vermis-maker com.ruoyi.vermins.controller.pc.web.ProjectCaseUserController
// 前缀: /web/projectCase
// //
// 使用约定(与项目 SKILL.md 一致):
// 页面/组件禁止直接 import axios,一律经 store 调用本模块的 request<T>。
// 本文件只放「函数签名 + 请求路径」,不做数据加工、不维护状态。
import { request, serializeQuery } from '@/utils/request' import { request, serializeQuery } from '@/utils/request'
import type { import type {
CaseSelectionModel, CaseSelectionModel,
@@ -13,16 +7,6 @@ import type {
ProjectCaseVo, ProjectCaseVo,
} from '@/types/business/cases' } from '@/types/business/cases'
/**
* 案例库分页列表(返回列表 + 业务数据统计)。
*
* <p>规模区间是嵌套对象(`scales[0].lowerLimit`),必须用 serializeQuery 序列化——
* axios 默认的 `scales[0][lowerLimit]` 后端绑不上,实测 HTTP 500。</p>
*
* @param bo 筛选条件(businessTypes 按案例表 business_type 原值匹配)
* @param pageNum 页码,从 1 开始(后端 PageQuery)
* @param pageSize 每页条数
*/
export function getCaseList( export function getCaseList(
bo: ProjectCaseUserBo, bo: ProjectCaseUserBo,
pageNum: number, pageNum: number,
@@ -36,11 +20,6 @@ export function getCaseList(
}) })
} }
/**
* 案例详情(含项目背景富文本与附件,并回填当前用户是否已收藏)。
*
* <p>注意:该接口每次调用都会把案例预览量 +1,勿在轮询 / 重试场景滥用。</p>
*/
export function getCaseDetail(id: string): Promise<ProjectCaseVo | null> { export function getCaseDetail(id: string): Promise<ProjectCaseVo | null> {
return request<ProjectCaseVo | null>({ return request<ProjectCaseVo | null>({
url: `/web/projectCase/${id}`, url: `/web/projectCase/${id}`,
@@ -48,11 +27,6 @@ export function getCaseDetail(id: string): Promise<ProjectCaseVo | null> {
}) })
} }
/**
* 案例收藏 / 取消收藏。
*
* <p>后端为 toggle 语义:已收藏则取消收藏。需登录态,匿名调用会被后端拦截。</p>
*/
export function collectCase(id: string): Promise<void> { export function collectCase(id: string): Promise<void> {
return request<void>({ return request<void>({
url: '/web/projectCase/collect', url: '/web/projectCase/collect',
@@ -61,13 +35,6 @@ export function collectCase(id: string): Promise<void> {
}) })
} }
/**
* 筛选面板选项(业务类型统计 + 行业 + 规模档位)。
*
* <p>左侧筛选除完成时间外的选项都由本接口一次下发:业务类型与案例总数在
* {@code projectCaseStatisticsVo},行业在 {@code businessIndustry},规模在
* {@code upperAndLowerLimits}(label 已由后端拼好)。</p>
*/
export function getCaseCategories(): Promise<CaseSelectionModel> { export function getCaseCategories(): Promise<CaseSelectionModel> {
return request<CaseSelectionModel>({ return request<CaseSelectionModel>({
url: '/web/projectCase/categories', url: '/web/projectCase/categories',
@@ -75,12 +42,6 @@ export function getCaseCategories(): Promise<CaseSelectionModel> {
}) })
} }
/**
* 案例浏览王(首页「案例库精选」)。
*
* <p>不传 type 时返回「各业务类型的浏览王」各一条;传 type 时按案例表
* business_type 的原值精确匹配,返回该类型全部案例。</p>
*/
export function getFeaturedCases(type?: string): Promise<ProjectCaseVo[]> { export function getFeaturedCases(type?: string): Promise<ProjectCaseVo[]> {
return request<ProjectCaseVo[]>({ return request<ProjectCaseVo[]>({
url: '/web/projectCase/mostViewed', url: '/web/projectCase/mostViewed',

View File

@@ -3,15 +3,12 @@ import type { Contract, ContractQuery } from '@/types/business/contract'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用 // TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
/** 构造 mock 文件信息 */
function mockFile(name: string, size: number, uploadTime: string): FileInfo { function mockFile(name: string, size: number, uploadTime: string): FileInfo {
return { name, size, uploadTime, url: '' } return { name, size, uploadTime, url: '' }
} }
/** 详情 mock 所属合同 id */
const DETAIL_ID = 'contract-001' const DETAIL_ID = 'contract-001'
/** 合同列表 mock(3 条,覆盖全部签署状态) */
const MOCK_CONTRACTS: Contract[] = [ const MOCK_CONTRACTS: Contract[] = [
{ {
id: 'contract-001', id: 'contract-001',
@@ -56,7 +53,6 @@ const MOCK_CONTRACTS: Contract[] = [
}, },
] ]
/** 合同详情 mock */
const CONTRACT_DETAIL: Contract = { const CONTRACT_DETAIL: Contract = {
id: DETAIL_ID, id: DETAIL_ID,
contractNo: 'C8K2M202408001', contractNo: 'C8K2M202408001',
@@ -72,7 +68,6 @@ const CONTRACT_DETAIL: Contract = {
files: [mockFile('项目需求规格说明书.pdf', 2516582, '2024-10-15')], files: [mockFile('项目需求规格说明书.pdf', 2516582, '2024-10-15')],
} }
/** 合同列表(keyword 筛选 + 分页) */
export function getContractList(query: ContractQuery): Promise<PageResult<Contract>> { export function getContractList(query: ContractQuery): Promise<PageResult<Contract>> {
const filtered = MOCK_CONTRACTS.filter((item) => { const filtered = MOCK_CONTRACTS.filter((item) => {
if ( if (
@@ -92,19 +87,16 @@ export function getContractList(query: ContractQuery): Promise<PageResult<Contra
}) })
} }
/** 合同详情(不存在返回 null) */
export function getContractDetail(id: string): Promise<Contract | null> { export function getContractDetail(id: string): Promise<Contract | null> {
if (id === DETAIL_ID) return Promise.resolve({ ...CONTRACT_DETAIL, files: [...CONTRACT_DETAIL.files] }) if (id === DETAIL_ID) return Promise.resolve({ ...CONTRACT_DETAIL, files: [...CONTRACT_DETAIL.files] })
const found = MOCK_CONTRACTS.find((item) => item.id === id) ?? null const found = MOCK_CONTRACTS.find((item) => item.id === id) ?? null
return Promise.resolve(found ? { ...found, files: [...found.files] } : null) return Promise.resolve(found ? { ...found, files: [...found.files] } : null)
} }
/** 获取签署二维码(返回有效期) */
export function getSignQr(_id: string): Promise<{ expiresInSeconds: number }> { export function getSignQr(_id: string): Promise<{ expiresInSeconds: number }> {
return Promise.resolve({ expiresInSeconds: 272 }) return Promise.resolve({ expiresInSeconds: 272 })
} }
/** 刷新签署二维码(返回有效期) */
export function refreshSignQr(_id: string): Promise<{ expiresInSeconds: number }> { export function refreshSignQr(_id: string): Promise<{ expiresInSeconds: number }> {
return Promise.resolve({ expiresInSeconds: 272 }) return Promise.resolve({ expiresInSeconds: 272 })
} }

View File

@@ -1,14 +1,4 @@
// 需求提报接口(真实后端绑定)
// 草稿 com.ruoyi.vermins.controller.pc.web.WorkUserOrderDraftController → /web/workOrderDraft
// 提交 com.ruoyi.vermins.controller.pc.web.ProjectRequirementUserController → /web/requirementPool/create(WorkOrderDraftBo)
// 同控制器的 /submit(RdProjectBo)是直接提报入口(额外落 vm_rd_project),前端当前未使用
// 业务类型 com.ruoyi.vermins.controller.pc.ProjectSubmissionWebController → /web/projectSubmission(@SaIgnore 免登录)
// 预算范围 org.dromara.system.controller.system.SysDictDataController → /system/dict/data/type/{dictType}
// 图片换链 org.dromara.resource.controller.SysOssController → /resource/oss/listByIds/{ids}
// 鉴权:业务类型列表免登录;草稿 / 提交 / 字典 / OSS 需登录态(匿名实测 401,故 /demand 路由带 requiresAuth)
// //
// 使用约定(与项目 SKILL.md 一致):
// 页面/组件禁止直接 import axios,一律经 store 调用本模块;本文件只放「函数签名 + 请求路径」。
import service, { request } from '@/utils/request' import service, { request } from '@/utils/request'
import type { ApiResponse } from '@/types/api' import type { ApiResponse } from '@/types/api'
import type { import type {
@@ -20,21 +10,9 @@ import type {
SubmissionBizTypeVo, SubmissionBizTypeVo,
} from '@/types/business/demand' } from '@/types/business/demand'
/** 业务类型字典类型(sys_dict_type.dict_type)——仅作提报列表为空时的兜底值域 */
export const BIZ_TYPE_DICT = 'vm_biz_type' export const BIZ_TYPE_DICT = 'vm_biz_type'
/** 预算范围字典类型 */
export const BUDGET_RANGE_DICT = 'vm_budget_range' export const BUDGET_RANGE_DICT = 'vm_budget_range'
/**
* 业务类型列表(项目提报表,**免登录**)。
*
* <p>后端 `ProjectSubmissionWebController` 带 `@SaIgnore`,是需求页唯一匿名可用的接口;
* 它与首页 `/web/home-page/service-type-list` 共用同一个 `queryEnabledList()`,只返回
* `enabled = 1` 且未被逻辑删除的记录。</p>
*
* <p>返回项里 `subtitle` 是卡片描述来源、`logoUrl` 是卡片图标来源(实测是 OSS 文件标识,
* 需经 {@link getOssUrls} 换链)。</p>
*/
export function getSubmissionBizTypes(): Promise<SubmissionBizTypeVo[]> { export function getSubmissionBizTypes(): Promise<SubmissionBizTypeVo[]> {
return request<SubmissionBizTypeVo[]>({ return request<SubmissionBizTypeVo[]>({
url: '/web/projectSubmission/list', url: '/web/projectSubmission/list',
@@ -42,14 +20,6 @@ export function getSubmissionBizTypes(): Promise<SubmissionBizTypeVo[]> {
}) })
} }
/**
* OSS 文件标识 → 可直接访问的地址。
*
* <p>提报列表的 `coverUrl` / `logoUrl` 下发的是 OSS 主键(如 2099753406859292674)而非地址,
* 直接当 `<img src>` 用会请求到本站的错误路径上,必须换链。</p>
*
* <p>⚠️ 本接口需要登录态;失败时调用方回退前端内置图标,且不阻塞页面。</p>
*/
export function getOssUrls(ids: (string | number)[]): Promise<OssUrlVo[]> { export function getOssUrls(ids: (string | number)[]): Promise<OssUrlVo[]> {
return request<OssUrlVo[]>({ return request<OssUrlVo[]>({
url: `/resource/oss/listByIds/${ids.join(',')}`, url: `/resource/oss/listByIds/${ids.join(',')}`,
@@ -57,12 +27,6 @@ export function getOssUrls(ids: (string | number)[]): Promise<OssUrlVo[]> {
}) })
} }
/**
* 业务类型字典(`vm_biz_type`)。
*
* <p>⚠️ 仅作**兜底**用:业务类型值域已改为提报列表 {@link getSubmissionBizTypes};
* 只有当提报表一条可用记录都没有时,store 才回退到本字典,避免页面无可选项。</p>
*/
export function getBizTypeDict(): Promise<DictOption[]> { export function getBizTypeDict(): Promise<DictOption[]> {
return request<DictOption[]>({ return request<DictOption[]>({
url: `/system/dict/data/type/${BIZ_TYPE_DICT}`, url: `/system/dict/data/type/${BIZ_TYPE_DICT}`,
@@ -70,7 +34,6 @@ export function getBizTypeDict(): Promise<DictOption[]> {
}) })
} }
/** 预算范围选项(字典 vm_budget_range) */
export function getBudgetRangeDict(): Promise<DictOption[]> { export function getBudgetRangeDict(): Promise<DictOption[]> {
return request<DictOption[]>({ return request<DictOption[]>({
url: `/system/dict/data/type/${BUDGET_RANGE_DICT}`, url: `/system/dict/data/type/${BUDGET_RANGE_DICT}`,
@@ -78,14 +41,6 @@ export function getBudgetRangeDict(): Promise<DictOption[]> {
}) })
} }
/**
* 读取当前登录用户的需求提报草稿(无草稿返回 null)。
*
* <p>⚠️ 后端 `WorkUserOrderDraftController#getDraft` 在"该用户还没有草稿"时会 NPE:
* `VmWorkOrderDraftServiceImpl#queryById` 里 `draft` 为 null 仍继续执行 `draft.getId()`,
* 实测返回 code=500「发生未知异常」。这里刻意绕过统一 `request`(它会对非 200 弹错误提示),
* 直接用原始实例静默兜底:非 200 一律视作"无草稿"。后端补上判空后本段可简化。</p>
*/
export async function getDraftDemand(): Promise<DemandDraftVo | null> { export async function getDraftDemand(): Promise<DemandDraftVo | null> {
const resp = await service.request<ApiResponse<DemandDraftVo>>({ const resp = await service.request<ApiResponse<DemandDraftVo>>({
url: '/web/workOrderDraft/getDraft', url: '/web/workOrderDraft/getDraft',
@@ -95,18 +50,6 @@ export async function getDraftDemand(): Promise<DemandDraftVo | null> {
return resp.data.data ?? null return resp.data.data ?? null
} }
/**
* Demand → 后端 `WorkOrderDraftBo` 请求体。
*
* <p>显式列出 BO 的字段,**剔掉纯前端字段 `budgetManual`**(后端 BO/VO 与两张表都没有
* 「是否手写」的概念,留着虽然会被 Jackson 忽略,但容易被误读成后端支持该标记)。</p>
*
* <p>预算范围只有一个字段可下发:预设档位是字典 dictValue,手写时是用户填的金额文本
* ——后端没有可用的 `budgetCustom`(`WorkOrderDraftBo` 里被注释掉、表也无该列)。</p>
*
* <p>`developmentCycle` 是 Integer,表单未填时是空串,必须归一成 null:空串虽然能靠
* Jackson 的宽松转换塞进去,但依赖默认行为、且语义上是"没填"。</p>
*/
function toBoPayload(demand: Demand): Record<string, unknown> { function toBoPayload(demand: Demand): Record<string, unknown> {
return { return {
id: demand.id, id: demand.id,
@@ -122,7 +65,6 @@ function toBoPayload(demand: Demand): Record<string, unknown> {
} }
} }
/** 新增/覆盖草稿(后端 insertByBo 按登录用户 upsert) */
export function saveDraft(demand: Demand): Promise<void> { export function saveDraft(demand: Demand): Promise<void> {
return request<void>({ return request<void>({
url: '/web/workOrderDraft/add', url: '/web/workOrderDraft/add',
@@ -131,7 +73,6 @@ export function saveDraft(demand: Demand): Promise<void> {
}) })
} }
/** 清空当前用户的草稿(后端 cleanHistoryDrafts) */
export function resetDraft(): Promise<void> { export function resetDraft(): Promise<void> {
return request<void>({ return request<void>({
url: '/web/workOrderDraft/reset', url: '/web/workOrderDraft/reset',
@@ -139,7 +80,6 @@ export function resetDraft(): Promise<void> {
}) })
} }
/** 上传附件(multipart,后端字段名 file,返回 SysOssVo) */
export function uploadDemandFile(file: File): Promise<OssUploadVo> { export function uploadDemandFile(file: File): Promise<OssUploadVo> {
const data = new FormData() const data = new FormData()
data.append('file', file) data.append('file', file)
@@ -151,29 +91,6 @@ export function uploadDemandFile(file: File): Promise<OssUploadVo> {
}) })
} }
/**
* 提交需求(`POST /web/requirementPool/create`)。
*
* <p>入参与草稿共用同一个 BO(`WorkOrderDraftBo`),故请求体直接复用 {@link toBoPayload},
* 不需要字段名转换。</p>
*
* <p>后端 `createRequirement` 一个事务里做三件事:</p>
* <ul>
* <li>落 `vm_project_requirement`(需求主记录,生成项目编号、优先级=未评估、状态=待接洽);</li>
* <li>把**请求体里的 `attachmentInfo`** 逐条复制成需求附件(只复制 `ossId` 引用到
* `vm_project_requirement_attachment`,OSS 文件不重复上传)——注意这里是直接吃请求体,
* 不像 `/submit` 那样还得先落一次草稿,所以附件随本次请求一起走;</li>
* <li>清掉该用户的草稿(`cleanHistoryDrafts`)。</li>
* </ul>
*
* <p>后端必填校验:`bizType` / `projectName` / `requirementDesc` 任一为空返回
* 「请完整填写业务类型、项目名称和需求描述」;联系人姓名与电话缺失时先回退登录用户资料,
* 仍为空则报「请完善联系人姓名和联系电话」。</p>
*
* <p>⚠️ 本条链路**只落 `vm_project_requirement` 单表**。而「我的项目/需求」列表
* (`/web/requirementPool/list`)查的是 `vm_rd_project`,因此 create 提交的记录不会出现在
* 该列表里——要同时落两张表得走后端 `/web/requirementPool/submit`(`RdProjectBo`)。</p>
*/
export function submitDemand(demand: Demand): Promise<void> { export function submitDemand(demand: Demand): Promise<void> {
return request<void>({ return request<void>({
url: '/web/requirementPool/create', url: '/web/requirementPool/create',

View File

@@ -17,10 +17,6 @@ import { toBills, toPlanBills, type PlanLike } from '@/utils/billVo'
import { toPayMethod } from '@/utils/payMeta' import { toPayMethod } from '@/utils/payMeta'
import { request } from '@/utils/request' import { request } from '@/utils/request'
// 说明:账单 / 支付相关接口已接后端真数据;仅「发票」与「统计」仍是 mock。
// 另有已下线的遗留演示扫码页(pay.vue)用的一组 mock,见文件中部注释。
/** 遗留演示页用的假账单(真账单走 `GET /web/bill/list`,不读这里) */
const MOCK_BILLS: Bill[] = [ const MOCK_BILLS: Bill[] = [
{ {
id: 'bill-001', id: 'bill-001',
@@ -80,7 +76,6 @@ const MOCK_BILLS: Bill[] = [
}, },
] ]
/** 发票 mock(可变:申请开票后状态在前端内存中翻转演示) */
const MOCK_INVOICES: Invoice[] = [ const MOCK_INVOICES: Invoice[] = [
{ {
id: 'invoice-001', id: 'invoice-001',
@@ -113,7 +108,6 @@ const MOCK_INVOICES: Invoice[] = [
}, },
] ]
/** 发票抬头 mock(可变,CRUD 在前端内存中演示) */
const MOCK_TITLES: InvoiceTitle[] = [ const MOCK_TITLES: InvoiceTitle[] = [
{ {
id: 'title-001', id: 'title-001',
@@ -131,10 +125,8 @@ const MOCK_TITLES: InvoiceTitle[] = [
}, },
] ]
/** 支付进行单(billId → 当前二维码) */
const MOCK_PAYMENTS = new Map<string, Payment>() const MOCK_PAYMENTS = new Map<string, Payment>()
/** 已产生支付结果的账单(billId → 结果),预置已支付账单 */
const MOCK_RESULTS = new Map<string, PaymentResult>() const MOCK_RESULTS = new Map<string, PaymentResult>()
MOCK_RESULTS.set('bill-001', { MOCK_RESULTS.set('bill-001', {
method: 'wechat', method: 'wechat',
@@ -163,18 +155,15 @@ const DEFAULT_STATS: FinanceStats = {
], ],
} }
/** 财务统计(period 为展示周期参数,mock 恒返回原型数据) */
export function getFinanceStats(period?: string): Promise<FinanceStats> { export function getFinanceStats(period?: string): Promise<FinanceStats> {
void period void period
return Promise.resolve(structuredClone(DEFAULT_STATS)) return Promise.resolve(structuredClone(DEFAULT_STATS))
} }
/** 真账单 id 一定是纯数字(`vm_bill.id` 是 bigint);非数字只可能来自已下线的演示mock */
function isRealBillId(id: string): boolean { function isRealBillId(id: string): boolean {
return /^\d+$/.test(id) return /^\d+$/.test(id)
} }
/** 拉取「当前登录用户」的全部账单(`GET /web/bill/list`,不带 ids) */
async function fetchOwnBills(): Promise<Bill[]> { async function fetchOwnBills(): Promise<Bill[]> {
const rows = await request<BillVo[]>({ url: '/web/bill/list', method: 'get' }) const rows = await request<BillVo[]>({ url: '/web/bill/list', method: 'get' })
return toBills(rows) return toBills(rows)
@@ -190,13 +179,6 @@ function matchBillKeyword(item: Bill, keyword: string): boolean {
) )
} }
/**
* 账单列表(筛选 + 分页)。
*
* <p>⚠️ `GET /web/bill/list` 只按登录用户返回**全量**账单,后端没有 pageNum / keyword /
* status 查询参数;单个客户的账单量级是「几条 ~ 几十条」,故筛选与分页放在前端做。
* 若日后账单量变大,应给该端点补后端分页参数,这里再切成一次请求。</p>
*/
export async function getBillList(query: BillQuery): Promise<PageResult<Bill>> { export async function getBillList(query: BillQuery): Promise<PageResult<Bill>> {
const all = await fetchOwnBills() const all = await fetchOwnBills()
const filtered = all.filter((item) => { const filtered = all.filter((item) => {
@@ -207,22 +189,15 @@ export async function getBillList(query: BillQuery): Promise<PageResult<Bill>> {
return { list: filtered.slice(start, start + query.pageSize), total: filtered.length } return { list: filtered.slice(start, start + query.pageSize), total: filtered.length }
} }
/** 账单详情(不存在返回 null) */
export async function getBillDetail(id: string): Promise<Bill | null> { export async function getBillDetail(id: string): Promise<Bill | null> {
const rows = await request<BillVo[]>({ url: '/web/bill/list', method: 'get', params: { ids: id } }) const rows = await request<BillVo[]>({ url: '/web/bill/list', method: 'get', params: { ids: id } })
return toBills(rows)[0] ?? null return toBills(rows)[0] ?? null
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// 遗留演示扫码页(`/finance/bills/:id/pay`,页面 pay.vue)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// 该页已无任何入口(收银台 checkout.vue 取代了它),但按既定决定保留。
// 它用的是下面这几个 mock 函数,与真账单互不干扰:真账单 id 一定是数字,
// 假账单 id 形如 `bill-001`,两条路径靠 `isRealBillId` 分流。
// 若要下线该页,这一整段(含 MOCK_PAYMENTS / MOCK_RESULTS)可一并删除。
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
/** 创建支付单(遗留页演示用:生成二维码支付信息) */
export function createPayment(billId: string, method: PayMethod): Promise<Payment> { export function createPayment(billId: string, method: PayMethod): Promise<Payment> {
const bill = MOCK_BILLS.find((item) => item.id === billId) const bill = MOCK_BILLS.find((item) => item.id === billId)
if (!bill) return Promise.reject(new Error('账单不存在')) if (!bill) return Promise.reject(new Error('账单不存在'))
@@ -236,21 +211,10 @@ export function createPayment(billId: string, method: PayMethod): Promise<Paymen
return Promise.resolve({ ...payment }) return Promise.resolve({ ...payment })
} }
/** 切换支付方式(遗留页演示用:刷新二维码) */
export function switchPaymentMethod(billId: string, method: PayMethod): Promise<Payment> { export function switchPaymentMethod(billId: string, method: PayMethod): Promise<Payment> {
return createPayment(billId, method) return createPayment(billId, method)
} }
/**
* 查询支付结果。
*
* <p><b>真账单</b>:直接读 `vm_bill` —— 账单被回调置为 PAID 时,`pay_time` /
* `transaction_no` / `pay_channel` 已由后端一并回写,这里拼出结果页所需结构;
* 未支付则返回 null。</p>
*
* <p><b>假账单</b>(id 形如 `bill-001`):走下面的遗留演示逻辑。真账单 id 一定是数字,
* 故只有已下线的 pay.vue 会命中这条分支。</p>
*/
export async function getPayResult(billId: string): Promise<PaymentResult | null> { export async function getPayResult(billId: string): Promise<PaymentResult | null> {
if (isRealBillId(billId)) { if (isRealBillId(billId)) {
const bill = await getBillDetail(billId) const bill = await getBillDetail(billId)
@@ -266,7 +230,6 @@ export async function getPayResult(billId: string): Promise<PaymentResult | null
return getLegacyPayResult(billId) return getLegacyPayResult(billId)
} }
/** 遗留演示:假账单的支付结果(详见上方说明,随 pay.vue 一起可删) */
function getLegacyPayResult(billId: string): Promise<PaymentResult | null> { function getLegacyPayResult(billId: string): Promise<PaymentResult | null> {
const existing = MOCK_RESULTS.get(billId) const existing = MOCK_RESULTS.get(billId)
if (existing) return Promise.resolve(structuredClone(existing)) if (existing) return Promise.resolve(structuredClone(existing))
@@ -275,7 +238,6 @@ function getLegacyPayResult(billId: string): Promise<PaymentResult | null> {
const bill = MOCK_BILLS.find((item) => item.id === billId) const bill = MOCK_BILLS.find((item) => item.id === billId)
if (!payment || !bill) return Promise.resolve(null) if (!payment || !bill) return Promise.resolve(null)
// 演示:存在支付单即视为完成支付(createPayment 由「完成支付」触发)
const tradeNo = bill.tradeNo ?? 'T20260819153035678K2M001' const tradeNo = bill.tradeNo ?? 'T20260819153035678K2M001'
if (bill.status !== '已支付') { if (bill.status !== '已支付') {
bill.status = '已支付' bill.status = '已支付'
@@ -302,7 +264,6 @@ function matchInvoiceKeyword(item: Invoice, keyword: string): boolean {
) )
} }
/** 发票列表(筛选 + 分页) */
export function getInvoiceList(query: InvoiceQuery): Promise<PageResult<Invoice>> { export function getInvoiceList(query: InvoiceQuery): Promise<PageResult<Invoice>> {
const filtered = MOCK_INVOICES.filter((item) => { const filtered = MOCK_INVOICES.filter((item) => {
if (query.status !== 'all' && item.status !== query.status) return false if (query.status !== 'all' && item.status !== query.status) return false
@@ -315,7 +276,6 @@ export function getInvoiceList(query: InvoiceQuery): Promise<PageResult<Invoice>
}) })
} }
/** 申请开票(演示:状态置为开票中),返回最新发票 */
export function applyInvoice(id: string): Promise<Invoice> { export function applyInvoice(id: string): Promise<Invoice> {
const found = MOCK_INVOICES.find((item) => item.id === id) const found = MOCK_INVOICES.find((item) => item.id === id)
if (!found) return Promise.reject(new Error('发票记录不存在')) if (!found) return Promise.reject(new Error('发票记录不存在'))
@@ -323,12 +283,10 @@ export function applyInvoice(id: string): Promise<Invoice> {
return Promise.resolve({ ...found }) return Promise.resolve({ ...found })
} }
/** 发票抬头列表 */
export function getInvoiceTitles(): Promise<InvoiceTitle[]> { export function getInvoiceTitles(): Promise<InvoiceTitle[]> {
return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item }))) return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item })))
} }
/** 新增发票抬头 */
export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise<InvoiceTitle> { export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise<InvoiceTitle> {
const created: InvoiceTitle = { const created: InvoiceTitle = {
id: `title-${Date.now()}`, id: `title-${Date.now()}`,
@@ -339,7 +297,6 @@ export function createInvoiceTitle(payload: InvoiceTitlePayload): Promise<Invoic
return Promise.resolve({ ...created }) return Promise.resolve({ ...created })
} }
/** 编辑发票抬头 */
export function updateInvoiceTitle(id: string, payload: InvoiceTitlePayload): Promise<InvoiceTitle> { export function updateInvoiceTitle(id: string, payload: InvoiceTitlePayload): Promise<InvoiceTitle> {
const found = MOCK_TITLES.find((item) => item.id === id) const found = MOCK_TITLES.find((item) => item.id === id)
if (!found) return Promise.reject(new Error('抬头不存在')) if (!found) return Promise.reject(new Error('抬头不存在'))
@@ -349,7 +306,6 @@ export function updateInvoiceTitle(id: string, payload: InvoiceTitlePayload): Pr
return Promise.resolve({ ...found }) return Promise.resolve({ ...found })
} }
/** 删除发票抬头 */
export function deleteInvoiceTitle(id: string): Promise<void> { export function deleteInvoiceTitle(id: string): Promise<void> {
const index = MOCK_TITLES.findIndex((item) => item.id === id) const index = MOCK_TITLES.findIndex((item) => item.id === id)
if (index < 0) return Promise.reject(new Error('抬头不存在')) if (index < 0) return Promise.reject(new Error('抬头不存在'))
@@ -357,7 +313,6 @@ export function deleteInvoiceTitle(id: string): Promise<void> {
return Promise.resolve() return Promise.resolve()
} }
/** 设为默认抬头(唯一默认) */
export function setDefaultTitle(id: string): Promise<InvoiceTitle[]> { export function setDefaultTitle(id: string): Promise<InvoiceTitle[]> {
for (const item of MOCK_TITLES) { for (const item of MOCK_TITLES) {
item.isDefault = item.id === id item.isDefault = item.id === id
@@ -365,30 +320,13 @@ export function setDefaultTitle(id: string): Promise<InvoiceTitle[]> {
return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item }))) return Promise.resolve(MOCK_TITLES.map((item) => ({ ...item })))
} }
/** 导出报表(演示:稍后由页面提示成功) */
export function exportReport(): Promise<void> { export function exportReport(): Promise<void> {
return Promise.resolve() return Promise.resolve()
} }
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
// 收银台(确认支付)——以下为**真实后端接口**,不是 mock
// 后端类:vermis-modules/…/controller/pc/pay/WebScanPayController,类级 @SaIgnore(匿名可调)
// ═══════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════
/**
* 取收银台「订单明细」——**复用项目详情「合同财务」那个现成接口**,不另开端点。
*
* <p>`GET /web/requirementPool/get/finance/{requirementId}`(`ProjectRequirementUserController`)
* 的 `{id}` 是**需求 id**;后端把「需求 + 项目」拼成一条联合结构(`loadProject` 用
* `vm_rd_project.requirement_id` 反查项目),再把该项目的付款计划随 `billPlans` 一并下发。</p>
*
* <p>本栈支付口径是 `vm_bill_plan.id`(`vm_finance_payment.bill_id` 实测存的就是计划 id),
* 故订单明细直接取这份 `billPlans`,只保留**本次要付的期**:
* URL 带 `ids`(期次 id)时按期次过滤,不带则取状态为「待支付」的期。</p>
*
* @param requirementId 需求 id(项目详情路由 `:id`)
* @param ids 期次 id(`vm_bill_plan.id`)列表,可选,用于精确指定付哪一期
*/
export async function getCheckoutBills(requirementId: string, ids: string[] = []): Promise<Bill[]> { export async function getCheckoutBills(requirementId: string, ids: string[] = []): Promise<Bill[]> {
if (!/^\d+$/.test(requirementId)) return [] if (!/^\d+$/.test(requirementId)) return []
const vo = await request<{ billPlans?: PlanLike[] }>({ const vo = await request<{ billPlans?: PlanLike[] }>({
@@ -401,12 +339,6 @@ export async function getCheckoutBills(requirementId: string, ids: string[] = []
: all.filter((bill) => bill.status === '待支付') : all.filter((bill) => bill.status === '待支付')
} }
/**
* 支付结果(收银台「我已完成支付」→ 结果页)。
*
* <p>口径同 {@link getCheckoutBills}:按**需求 id + 期次 id** 从 `billPlans` 里取出该期,
* 支付单状态为 `SUCCESS` 才算已支付——计划本身的 status 可能还没被回调推到 DONE。</p>
*/
export async function getPlanPayResult(requirementId: string, planId: string): Promise<PaymentResult | null> { export async function getPlanPayResult(requirementId: string, planId: string): Promise<PaymentResult | null> {
const bills = await getCheckoutBills(requirementId, [planId]) const bills = await getCheckoutBills(requirementId, [planId])
const bill = bills[0] const bill = bills[0]
@@ -420,17 +352,6 @@ export async function getPlanPayResult(requirementId: string, planId: string): P
} }
} }
/**
* 微信 Native 下单(PC 网站扫码):`POST /pay/web/wechat/native`,`@SaIgnore`。
*
* @param orderNo 商户订单号(前端生成,见 `utils/payMeta.ts#createMerchantOrderNo`)
* @param amount 支付金额,单位 **元**(后端内部 `×100` 转「分」提交微信)
* @param billIds 本笔支付覆盖的账单 id(合并支付传多张)。
* 后端落 `vm_finance_payment`(status=CREATED)时把它序列化进
* `request_payload`,支付成功回调据此为**每张账单**落一行
* `vm_finance_payment_bill`,缺了它回调分摊落库会跳过。
* @returns `codeUrl`(形如 `weixin://wxpay/bizpayurl?pr=xxx`),前端渲染成二维码供扫
*/
export function createWechatNativeOrder( export function createWechatNativeOrder(
orderNo: string, orderNo: string,
amount: number, amount: number,
@@ -443,16 +364,6 @@ export function createWechatNativeOrder(
}) })
} }
/**
* 支付宝电脑网站支付下单:`POST /pay/web/alipay/page`,`@SaIgnore`。
*
* <p>入参对应后端 `IFinancePaymentBo`。其 `outTradeNo` / `status` 上的 `@NotBlank`
* 只声明在 AddGroup / EditGroup 分组上,而控制器用的是默认分组 ⇒ 只传
* `outTradeNo` + `amount` 即可(`payChannel` / `status` 由后端自己补),不会校验失败。</p>
*
* @returns `formBody` —— 后端走 `pageExecute(request, "GET")`,实测内容是
* **支付宝收银台跳转地址**(`https://…/gateway.do?…`)而不是 HTML 表单;前端两种形态都兜住。
*/
export function createAlipayPageOrder( export function createAlipayPageOrder(
outTradeNo: string, outTradeNo: string,
amount: number, amount: number,

View File

@@ -1,11 +1,4 @@
// 首页接口(真实后端绑定)
// 后端: vermis-maker com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
// 前缀: /web/home-page 鉴权: 公开接口,无需 token
// 门户入口: com.ruoyi.vermins.controller.pc.web.IPortalEntryUserController(首页快捷入口功能,并入本文件)
// //
// 使用约定(与项目 SKILL.md 一致):
// 页面/组件禁止直接 import axios,一律经 store 调用本模块的 request<T>。
// 本文件只放「函数签名 + 请求路径」,不写业务逻辑。
import { request } from '@/utils/request' import { request } from '@/utils/request'
import type { import type {
AgreementFormVo, AgreementFormVo,
@@ -17,7 +10,6 @@ import type {
ProjectSubmissionVo, ProjectSubmissionVo,
} from '@/types/business/home' } from '@/types/business/home'
/** 项目状态统计 */
export function getProjectStatusCount(): Promise<ProjectStatusCount> { export function getProjectStatusCount(): Promise<ProjectStatusCount> {
return request<ProjectStatusCount>({ return request<ProjectStatusCount>({
url: '/web/home-page/project-status-count', url: '/web/home-page/project-status-count',
@@ -25,7 +17,6 @@ export function getProjectStatusCount(): Promise<ProjectStatusCount> {
}) })
} }
/** 业务服务类型列表 */
export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> { export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
return request<ProjectSubmissionVo[]>({ return request<ProjectSubmissionVo[]>({
url: '/web/home-page/service-type-list', url: '/web/home-page/service-type-list',
@@ -33,7 +24,6 @@ export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
}) })
} }
/** 业务类型详情(聚合 VO:projectSubmission / projectServiceType / projectSubmissionAttachment / projectSubmissionCatalog) */
export function getServiceTypeDetail(id: number | string): Promise<ProjectSubmissionInfo> { export function getServiceTypeDetail(id: number | string): Promise<ProjectSubmissionInfo> {
return request<ProjectSubmissionInfo>({ return request<ProjectSubmissionInfo>({
url: `/web/home-page/service-type-detail/${id}`, url: `/web/home-page/service-type-detail/${id}`,
@@ -41,7 +31,6 @@ export function getServiceTypeDetail(id: number | string): Promise<ProjectSubmis
}) })
} }
/** 轮播图列表 */
export function getCarouselList(): Promise<BannerVo[]> { export function getCarouselList(): Promise<BannerVo[]> {
return request<BannerVo[]>({ return request<BannerVo[]>({
url: '/web/home-page/carousel-list', url: '/web/home-page/carousel-list',
@@ -49,7 +38,6 @@ export function getCarouselList(): Promise<BannerVo[]> {
}) })
} }
/** 平台介绍(富文本 HTML) */
export function getPlatformIntro(): Promise<AgreementFormVo> { export function getPlatformIntro(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({ return request<AgreementFormVo>({
url: '/web/home-page/platform-intro', url: '/web/home-page/platform-intro',
@@ -57,7 +45,6 @@ export function getPlatformIntro(): Promise<AgreementFormVo> {
}) })
} }
/** 联系信息(富文本 HTML) */
export function getContactInfo(): Promise<AgreementFormVo> { export function getContactInfo(): Promise<AgreementFormVo> {
return request<AgreementFormVo>({ return request<AgreementFormVo>({
url: '/web/home-page/contact-info', url: '/web/home-page/contact-info',
@@ -65,14 +52,6 @@ export function getContactInfo(): Promise<AgreementFormVo> {
}) })
} }
// 首页「案例库精选」接口(/web/projectCase/mostViewed)按资源归位到 `@/api/cases` 的 getFeaturedCases。
/**
* 查询用户门户入口列表
*
* <p>登录态返回该用户个性化后的分组结果;匿名返回固定目录。
* 两组数据由 groupType 区分(1 = 快捷、0 = 其他)。</p>
*/
export function getPortalEntryList(): Promise<PortalEntryVo[]> { export function getPortalEntryList(): Promise<PortalEntryVo[]> {
return request<PortalEntryVo[]>({ return request<PortalEntryVo[]>({
url: '/vermins/portalEntryUser/list', url: '/vermins/portalEntryUser/list',
@@ -80,14 +59,6 @@ export function getPortalEntryList(): Promise<PortalEntryVo[]> {
}) })
} }
/**
* 快捷 / 其他入口互转
*
* <p>后端 add 的入参是 @RequestParam,必须走 query 而非 body。</p>
*
* @param id 入口 id
* @param type 目标分组:1 = 快捷、0 = 其他
*/
export function switchPortalEntryGroup(id: number, type: PortalEntryGroup): Promise<void> { export function switchPortalEntryGroup(id: number, type: PortalEntryGroup): Promise<void> {
return request<void>({ return request<void>({
url: '/vermins/portalEntryUser/add', url: '/vermins/portalEntryUser/add',

View File

@@ -3,7 +3,6 @@ import type { Message, MessageQuery } from '@/types/business/message'
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用 // TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用
/** 消息 mock(可变,已读状态在前端内存中翻转演示) */
const MOCK_MESSAGES: Message[] = [ const MOCK_MESSAGES: Message[] = [
{ {
id: 'msg-001', id: 'msg-001',
@@ -107,7 +106,6 @@ function applyFilters(query: MessageQuery): Message[] {
}) })
} }
/** 消息列表(筛选 + 分页) */
export function getMessageList(query: MessageQuery): Promise<PageResult<Message>> { export function getMessageList(query: MessageQuery): Promise<PageResult<Message>> {
const filtered = applyFilters(query) const filtered = applyFilters(query)
const start = (query.page - 1) * query.pageSize const start = (query.page - 1) * query.pageSize
@@ -117,13 +115,11 @@ export function getMessageList(query: MessageQuery): Promise<PageResult<Message>
}) })
} }
/** 消息详情(不存在返回 null) */
export function getMessageDetail(id: string): Promise<Message | null> { export function getMessageDetail(id: string): Promise<Message | null> {
const found = MOCK_MESSAGES.find((item) => item.id === id) ?? null const found = MOCK_MESSAGES.find((item) => item.id === id) ?? null
return Promise.resolve(found ? { ...found } : null) return Promise.resolve(found ? { ...found } : null)
} }
/** 全部标记已读 */
export function markAllRead(): Promise<void> { export function markAllRead(): Promise<void> {
for (const item of MOCK_MESSAGES) { for (const item of MOCK_MESSAGES) {
item.isRead = true item.isRead = true
@@ -131,14 +127,12 @@ export function markAllRead(): Promise<void> {
return Promise.resolve() return Promise.resolve()
} }
/** 单条标记已读 */
export function markRead(id: string): Promise<void> { export function markRead(id: string): Promise<void> {
const found = MOCK_MESSAGES.find((item) => item.id === id) const found = MOCK_MESSAGES.find((item) => item.id === id)
if (found) found.isRead = true if (found) found.isRead = true
return Promise.resolve() return Promise.resolve()
} }
/** 各分类消息总数(tab 角标) */
export function getTabCounts(): Promise<{ all: number; 在线客服: number; 状态通知: number; 系统通知: number }> { export function getTabCounts(): Promise<{ all: number; 在线客服: number; 状态通知: number; 系统通知: number }> {
const counts = { all: MOCK_MESSAGES.length, 在线客服: 0, 状态通知: 0, 系统通知: 0 } const counts = { all: MOCK_MESSAGES.length, 在线客服: 0, 状态通知: 0, 系统通知: 0 }
for (const item of MOCK_MESSAGES) { for (const item of MOCK_MESSAGES) {
@@ -147,7 +141,6 @@ export function getTabCounts(): Promise<{ all: number; 在线客服: number; 状
return Promise.resolve(counts) return Promise.resolve(counts)
} }
/** 未读消息总数(头部角标) */
export function getUnreadCount(): Promise<number> { export function getUnreadCount(): Promise<number> {
return Promise.resolve(MOCK_MESSAGES.filter((item) => !item.isRead).length) return Promise.resolve(MOCK_MESSAGES.filter((item) => !item.isRead).length)
} }

View File

@@ -1,15 +1,5 @@
import service from '@/utils/request' import service from '@/utils/request'
/**
* 同源预览:后端从 OSS 读取字节后以 `inline` 方式返回,绕开阿里云 OSS 桶「强制下载」配置。
*
* <p>⚠️ 该端点返回的是<b>裸字节</b>(非 `R.ok()` 包装的 `ApiResponse<T>`),故不走统一的
* `request<T>` 解包逻辑,直接用底层 axios 实例拿 `blob`:请求拦截器已自动带上
* `Authorization` 与 `clientid` 头,后端据此放行(端点需登录)。</p>
*
* <p>用 blob 而非 `window.open(url)` 的关键:blob URL 自身不带 `Content-Disposition`,
* 天然绕开 OSS 的强制下载;且全程同源,不受 OSS 跨域 / 强制下载限制。</p>
*/
export async function fetchOssPreviewBlob(ossId: string | number): Promise<Blob> { export async function fetchOssPreviewBlob(ossId: string | number): Promise<Blob> {
const resp = await service.get(`/web/workOrderDraft/preview/${ossId}`, { const resp = await service.get(`/web/workOrderDraft/preview/${ossId}`, {
responseType: 'blob', responseType: 'blob',

View File

@@ -14,7 +14,6 @@ import type {
// TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变) // TODO(api): 待接口文档到位后,将以下 mock 替换为 request<T> 调用(页面 → store → api 数据流不变)
/** 账号资料 mock(可变,昵称/绑定等操作在前端内存中演示) */
const MOCK_PROFILE: Profile = { const MOCK_PROFILE: Profile = {
nickname: '王永志', nickname: '王永志',
phone: '186****618', phone: '186****618',
@@ -22,57 +21,48 @@ const MOCK_PROFILE: Profile = {
passwordModifiedAt: '15天前', passwordModifiedAt: '15天前',
} }
/** 获取账号资料 */
export function getProfile(): Promise<Profile> { export function getProfile(): Promise<Profile> {
return Promise.resolve({ ...MOCK_PROFILE }) return Promise.resolve({ ...MOCK_PROFILE })
} }
/** 修改昵称 */
export function updateNickname(name: string): Promise<Profile> { export function updateNickname(name: string): Promise<Profile> {
MOCK_PROFILE.nickname = name MOCK_PROFILE.nickname = name
return Promise.resolve({ ...MOCK_PROFILE }) return Promise.resolve({ ...MOCK_PROFILE })
} }
/** 修改密码(验证码 + 新密码) */
export function changePassword(params: ChangePasswordParams): Promise<void> { export function changePassword(params: ChangePasswordParams): Promise<void> {
MOCK_PROFILE.passwordModifiedAt = '刚刚' MOCK_PROFILE.passwordModifiedAt = '刚刚'
void params void params
return Promise.resolve() return Promise.resolve()
} }
/** 绑定/换绑手机号 */
export function bindPhone(phone: string, code: string): Promise<Profile> { export function bindPhone(phone: string, code: string): Promise<Profile> {
void code void code
MOCK_PROFILE.phone = phone MOCK_PROFILE.phone = phone
return Promise.resolve({ ...MOCK_PROFILE }) return Promise.resolve({ ...MOCK_PROFILE })
} }
/** 绑定/换绑邮箱 */
export function bindEmail(email: string, code: string): Promise<Profile> { export function bindEmail(email: string, code: string): Promise<Profile> {
void code void code
MOCK_PROFILE.email = email MOCK_PROFILE.email = email
return Promise.resolve({ ...MOCK_PROFILE }) return Promise.resolve({ ...MOCK_PROFILE })
} }
/** 取消绑定邮箱 */
export function unbindEmail(): Promise<Profile> { export function unbindEmail(): Promise<Profile> {
MOCK_PROFILE.email = '' MOCK_PROFILE.email = ''
return Promise.resolve({ ...MOCK_PROFILE }) return Promise.resolve({ ...MOCK_PROFILE })
} }
/** 注销前安全身份验证 */
export function verifyIdentity(phone: string, code: string): Promise<void> { export function verifyIdentity(phone: string, code: string): Promise<void> {
void phone void phone
void code void code
return Promise.resolve() return Promise.resolve()
} }
/** 注销账号 */
export function deleteAccount(): Promise<void> { export function deleteAccount(): Promise<void> {
return Promise.resolve() return Promise.resolve()
} }
/** 收货地址 mock(isDefault 混合) */
const MOCK_ADDRESSES: Address[] = [ const MOCK_ADDRESSES: Address[] = [
{ {
id: 'addr-1', id: 'addr-1',
@@ -116,12 +106,10 @@ function paginate<T>(list: T[], query: ListQuery): PageResult<T> {
} }
} }
/** 收货地址列表(分页) */
export function getAddresses(query: ListQuery): Promise<PageResult<Address>> { export function getAddresses(query: ListQuery): Promise<PageResult<Address>> {
return Promise.resolve(paginate(MOCK_ADDRESSES, query)) return Promise.resolve(paginate(MOCK_ADDRESSES, query))
} }
/** 新增收货地址 */
export function createAddress(input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> { export function createAddress(input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> {
const created: Address = { const created: Address = {
...input, ...input,
@@ -132,7 +120,6 @@ export function createAddress(input: Omit<Address, 'id' | 'isDefault'>): Promise
return Promise.resolve({ ...created }) return Promise.resolve({ ...created })
} }
/** 编辑收货地址 */
export function updateAddress(id: string, input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> { export function updateAddress(id: string, input: Omit<Address, 'id' | 'isDefault'>): Promise<Address> {
const found = MOCK_ADDRESSES.find((item) => item.id === id) const found = MOCK_ADDRESSES.find((item) => item.id === id)
if (!found) return Promise.reject(new Error('地址不存在')) if (!found) return Promise.reject(new Error('地址不存在'))
@@ -140,14 +127,12 @@ export function updateAddress(id: string, input: Omit<Address, 'id' | 'isDefault
return Promise.resolve({ ...found }) return Promise.resolve({ ...found })
} }
/** 删除收货地址 */
export function deleteAddress(id: string): Promise<void> { export function deleteAddress(id: string): Promise<void> {
const index = MOCK_ADDRESSES.findIndex((item) => item.id === id) const index = MOCK_ADDRESSES.findIndex((item) => item.id === id)
if (index >= 0) MOCK_ADDRESSES.splice(index, 1) if (index >= 0) MOCK_ADDRESSES.splice(index, 1)
return Promise.resolve() return Promise.resolve()
} }
/** 设为默认地址 */
export function setDefaultAddress(id: string): Promise<Address[]> { export function setDefaultAddress(id: string): Promise<Address[]> {
for (const item of MOCK_ADDRESSES) { for (const item of MOCK_ADDRESSES) {
item.isDefault = item.id === id item.isDefault = item.id === id
@@ -155,7 +140,6 @@ export function setDefaultAddress(id: string): Promise<Address[]> {
return Promise.resolve(MOCK_ADDRESSES.map((item) => ({ ...item }))) return Promise.resolve(MOCK_ADDRESSES.map((item) => ({ ...item })))
} }
/** 我的收藏 mock(4 条已收藏案例,字段与案例库原型一致) */
const MOCK_FAVORITES: Case[] = [ const MOCK_FAVORITES: Case[] = [
{ {
id: 'fav-1', id: 'fav-1',
@@ -243,7 +227,6 @@ const MOCK_FAVORITES: Case[] = [
}, },
] ]
/** 我的收藏(关键字/分类筛选 + 分页) */
export function getFavorites(query: FavoritesQuery): Promise<PageResult<Case>> { export function getFavorites(query: FavoritesQuery): Promise<PageResult<Case>> {
const filtered = MOCK_FAVORITES.filter((item) => { const filtered = MOCK_FAVORITES.filter((item) => {
if (query.category !== 'all' && item.category !== query.category) return false if (query.category !== 'all' && item.category !== query.category) return false
@@ -257,7 +240,6 @@ export function getFavorites(query: FavoritesQuery): Promise<PageResult<Case>> {
}) })
} }
/** 收藏分类计数(分类快捷按钮角标) */
export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>> { export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>> {
const counts: Partial<Record<BusinessType, number>> = { const counts: Partial<Record<BusinessType, number>> = {
internet: 1, internet: 1,
@@ -268,7 +250,6 @@ export function getCaseCounts(): Promise<Partial<Record<BusinessType, number>>>
return Promise.resolve(counts) return Promise.resolve(counts)
} }
/** 资质 mock(状态混合:已认证/审核中/未通过) */
const MOCK_QUALIFICATIONS: Qualification[] = [ const MOCK_QUALIFICATIONS: Qualification[] = [
{ {
id: 'qual-1', id: 'qual-1',
@@ -324,12 +305,10 @@ const MOCK_QUALIFICATIONS: Qualification[] = [
}, },
] ]
/** 资质列表(分页) */
export function getQualifications(query: ListQuery): Promise<PageResult<Qualification>> { export function getQualifications(query: ListQuery): Promise<PageResult<Qualification>> {
return Promise.resolve(paginate(MOCK_QUALIFICATIONS, query)) return Promise.resolve(paginate(MOCK_QUALIFICATIONS, query))
} }
/** 新增资质(提交后进入审核中) */
export function createQualification(input: QualificationInput): Promise<Qualification> { export function createQualification(input: QualificationInput): Promise<Qualification> {
const created: Qualification = { const created: Qualification = {
...input, ...input,
@@ -340,7 +319,6 @@ export function createQualification(input: QualificationInput): Promise<Qualific
return Promise.resolve({ ...created }) return Promise.resolve({ ...created })
} }
/** 编辑资质(未通过重新提交后回到审核中) */
export function updateQualification(id: string, input: QualificationInput): Promise<Qualification> { export function updateQualification(id: string, input: QualificationInput): Promise<Qualification> {
const found = MOCK_QUALIFICATIONS.find((item) => item.id === id) const found = MOCK_QUALIFICATIONS.find((item) => item.id === id)
if (!found) return Promise.reject(new Error('资质不存在')) if (!found) return Promise.reject(new Error('资质不存在'))
@@ -355,20 +333,17 @@ export function updateQualification(id: string, input: QualificationInput): Prom
return Promise.resolve({ ...found }) return Promise.resolve({ ...found })
} }
/** 删除资质 */
export function deleteQualification(id: string): Promise<void> { export function deleteQualification(id: string): Promise<void> {
const index = MOCK_QUALIFICATIONS.findIndex((item) => item.id === id) const index = MOCK_QUALIFICATIONS.findIndex((item) => item.id === id)
if (index >= 0) MOCK_QUALIFICATIONS.splice(index, 1) if (index >= 0) MOCK_QUALIFICATIONS.splice(index, 1)
return Promise.resolve() return Promise.resolve()
} }
/** 提交意见反馈 */
export function submitFeedback(feedback: Feedback): Promise<void> { export function submitFeedback(feedback: Feedback): Promise<void> {
void feedback void feedback
return Promise.resolve() return Promise.resolve()
} }
/** 帮助中心 FAQ */
export function getHelpFaqs(): Promise<HelpFaqGroup[]> { export function getHelpFaqs(): Promise<HelpFaqGroup[]> {
return Promise.resolve([ return Promise.resolve([
{ {

View File

@@ -12,55 +12,29 @@ import { request, serializeQuery } from '@/utils/request'
import { formatDate } from '@/utils/format' import { formatDate } from '@/utils/format'
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 列表:真实接口
// GET /web/requirementPool/list → ProjectRequirementUserController#list // GET /web/requirementPool/list → ProjectRequirementUserController#list
// → ProjectRequirementServiceImpl#queryUserPageList // → ProjectRequirementServiceImpl#queryUserPageList
// 入参 RdProjectBo + PageQuery;出参 ProjectRequirementAndCountVo
// { requirementPage: { total, rows: RdProjectVo[] }, requirementStatistics: null } // { requirementPage: { total, rows: RdProjectVo[] }, requirementStatistics: null }
// 鉴权:需登录态(匿名 401);每请求带 clientid 头(见 utils/request.ts 拦截器)。
// 筛选参数实测(tools/probe_project_api.py):
// key → like projectName OR projectNo OR requirementDesc // key → like projectName OR projectNo OR requirementDesc
// businessTypes → IN business_type(**多选**;值是提报表主键 id,不是类型名)
// status → eq status(码值,见 RdProjectStatus)
// beginTime/endTime → between create_time(不是 submit_time!)
// pageNum/pageSize → PageQuery // pageNum/pageSize → PageQuery
// ⚠️ 主表已改为**需求表** vm_project_requirement(LEFT JOIN 项目表),未立项的需求也会出现;
// 未立项行 progress 为 null(显示「—」)、status 回落需求表中的 INITIATING。
// ⚠️ 列表行的 businessType 是 SQL 里 JOIN 提报表**翻译后的中文名称**(如「互联网软件」),
// 与筛选参数 businessType(提报表 id)**不是同一口径**,渲染时不能用 typeCounts 反查。
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 业务类型筛选条件:真实接口
// GET /web/requirementPool/getFilterConditions // GET /web/requirementPool/getFilterConditions
// → ProjectRequirementUserController#getFilterConditions // → ProjectRequirementUserController#getFilterConditions
// → ProjectRequirementServiceImpl#getFilterConditions // → ProjectRequirementServiceImpl#getFilterConditions
// 出参 ProjectRequirementStatisticsVo:
// { total: 该用户需求总数,
// businessTypeCounts: [{ businessTypeId, businessTypeLabel, count }] } // businessTypeCounts: [{ businessTypeId, businessTypeLabel, count }] }
// 口径:**全量、不分页**,所以件数不会随 pageNum/pageSize 变化,适合当筛选栏的固定值域;
// businessTypeId = vm_project_submission 主键 id(直接回传当 businessType 筛选参数);
// businessTypeLabel = 该表的 business_type 名称;提报表里没命中的脏数据不计入。
// ⚠️ /list 响应里的 requirementStatistics 后端仍是注释状态(实测恒 null),统计一律走本端点。
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/** 两位补零 */
function pad(value: number): string { function pad(value: number): string {
return String(value).padStart(2, '0') return String(value).padStart(2, '0')
} }
/** Date → 'yyyy-MM-dd HH:mm:ss'(后端 RdProjectBo.beginTime/endTime 是 Date,实测该格式可绑定) */
function toDateTime(date: Date, endOfDay: boolean): string { function toDateTime(date: Date, endOfDay: boolean): string {
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
return `${ymd} ${endOfDay ? '23:59:59' : '00:00:00'}` return `${ymd} ${endOfDay ? '23:59:59' : '00:00:00'}`
} }
/**
* 提交时间条件 → 后端 `beginTime` / `endTime`(都落在 `create_time` 上)。
*
* <p>自选区间优先(UI 上两组互斥,这里再兜一层);结束日一律补到 23:59:59,
* 否则 `endTime=2026-09-30` 会被绑成当天 00:00:00 而漏掉当天数据。</p>
*/
function toTimeRange(query: ProjectQuery): { beginTime?: string; endTime?: string } { function toTimeRange(query: ProjectQuery): { beginTime?: string; endTime?: string } {
if (query.startDate || query.endDate) { if (query.startDate || query.endDate) {
return { return {
@@ -77,21 +51,15 @@ function toTimeRange(query: ProjectQuery): { beginTime?: string; endTime?: strin
return { beginTime: toDateTime(start, false), endTime: toDateTime(new Date(), true) } return { beginTime: toDateTime(start, false), endTime: toDateTime(new Date(), true) }
} }
/** 项目列表(真实接口:服务端筛选 + 分页;业务类型统计走 getProjectFilterConditions) */
export function getProjectList(query: ProjectQuery): Promise<ProjectListResult> { export function getProjectList(query: ProjectQuery): Promise<ProjectListResult> {
return request<ProjectListResult>({ return request<ProjectListResult>({
url: '/web/requirementPool/list', url: '/web/requirementPool/list',
method: 'get', method: 'get',
// 多选数组必须走 serializeQuery,展开成 businessTypes[0]=…&businessTypes[1]=…
//(与 api/cases.ts 同一做法;不传则走 axios 默认的 businessTypes[]=,实测后端也能绑,
// 但这里取与文档/案例库一致的索引形式,避免依赖框架默认行为)
paramsSerializer: { serialize: serializeQuery }, paramsSerializer: { serialize: serializeQuery },
params: { params: {
pageNum: query.pageNum, pageNum: query.pageNum,
pageSize: query.pageSize, pageSize: query.pageSize,
// 空串不出现在 querystring 里,与后端 eqIfText 的「有值才等值匹配」语义一致
key: query.keyword || undefined, key: query.keyword || undefined,
// 多选:空数组不发;非空由 serializeQuery 展开成 businessTypes[0]=…&businessTypes[1]=…
businessTypes: query.businessTypes.length > 0 ? [...query.businessTypes] : undefined, businessTypes: query.businessTypes.length > 0 ? [...query.businessTypes] : undefined,
status: query.status || undefined, status: query.status || undefined,
...toTimeRange(query), ...toTimeRange(query),
@@ -99,12 +67,6 @@ export function getProjectList(query: ProjectQuery): Promise<ProjectListResult>
}) })
} }
/**
* 业务类型筛选条件(左侧筛选栏「业务类型」的值域 + 件数)。
*
* <p>与列表分开取:`/list` 的 `requirementStatistics` 后端恒为 null,统计由本端点单独下发,
* 且是**全量口径**(不随筛选、分页变化),所以进页面拉一次即可。</p>
*/
export function getProjectFilterConditions(): Promise<ProjectStatistics> { export function getProjectFilterConditions(): Promise<ProjectStatistics> {
return request<ProjectStatistics>({ return request<ProjectStatistics>({
url: '/web/requirementPool/getFilterConditions', url: '/web/requirementPool/getFilterConditions',
@@ -112,17 +74,6 @@ export function getProjectFilterConditions(): Promise<ProjectStatistics> {
}) })
} }
/**
* 已完成项目(申请售后页「项目名称」下拉的候选)。
*
* <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[]> { export function getCompletedProjects(): Promise<RdProjectVo[]> {
return request<ProjectListResult>({ return request<ProjectListResult>({
url: '/web/requirementPool/list', url: '/web/requirementPool/list',
@@ -136,7 +87,6 @@ export function getCompletedProjects(): Promise<RdProjectVo[]> {
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 详情:真实接口(**4 个页签各一个端点**,同帧并行取回后合并)
// GET /web/requirementPool/get/progress/{id} → ProjectRequirementProgressVo // GET /web/requirementPool/get/progress/{id} → ProjectRequirementProgressVo
// GET /web/requirementPool/get/files/{id} → ProjectRequirementFilesVo // GET /web/requirementPool/get/files/{id} → ProjectRequirementFilesVo
// GET /web/requirementPool/get/finance/{id} → ProjectRequirementFinanceVo // GET /web/requirementPool/get/finance/{id} → ProjectRequirementFinanceVo
@@ -144,26 +94,10 @@ export function getCompletedProjects(): Promise<RdProjectVo[]> {
// → ProjectRequirementUserController#getProgress / #getFiles / #getFinance / #getAfterSales // → ProjectRequirementUserController#getProgress / #getFiles / #getFinance / #getAfterSales
// → ProjectRequirementServiceImpl#queryProgress / queryFiles / queryFinance / queryAfterSales // → 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' export type ProjectDetailTab = 'progress' | 'files' | 'finance' | 'afterSales'
/** 页签 → 端点(4 支都继承 ProjectRequirementBaseVo,基础信息任一支都有) */
const DETAIL_TAB_URL: Record<ProjectDetailTab, string> = { const DETAIL_TAB_URL: Record<ProjectDetailTab, string> = {
progress: '/web/requirementPool/get/progress', progress: '/web/requirementPool/get/progress',
files: '/web/requirementPool/get/files', files: '/web/requirementPool/get/files',
@@ -171,14 +105,6 @@ const DETAIL_TAB_URL: Record<ProjectDetailTab, string> = {
afterSales: '/web/requirementPool/get/afterSales', afterSales: '/web/requirementPool/get/afterSales',
} }
/**
* 单个页签取数(**点一次页签 = 一次请求**,拿该页签的最新数据)。
*
* <p>返回类型按页签收窄:`getProjectDetailTab(id, 'finance')` → `RequirementFinanceVo | null`,
* 所以调用方可以直接把结果写回 bundle 的对应键。</p>
*
* <p>`null` = 需求不存在 / 不属于当前用户(后端 `data: null`),调用方应保留现有数据并提示。</p>
*/
export function getProjectDetailTab<T extends ProjectDetailTab>( export function getProjectDetailTab<T extends ProjectDetailTab>(
id: string, id: string,
tab: T, tab: T,
@@ -189,12 +115,6 @@ export function getProjectDetailTab<T extends ProjectDetailTab>(
}) })
} }
/**
* 详情四支并行取回(**首屏用**);任一为 null ⇒ 整个 bundle 为 null。
*
* <p>4 支同源(都查同一行需求),正常不会只缺一支;缺一支即视为「需求不存在 / 非本人」,
* 详情页走整页空态。</p>
*/
export function getProjectDetailBundle(id: string): Promise<RequirementDetailBundle | null> { export function getProjectDetailBundle(id: string): Promise<RequirementDetailBundle | null> {
return Promise.all([ return Promise.all([
getProjectDetailTab(id, 'progress'), getProjectDetailTab(id, 'progress'),
@@ -208,31 +128,12 @@ export function getProjectDetailBundle(id: string): Promise<RequirementDetailBun
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 售后(**真实接口**)
// 申请 POST /web/requirementPool/afterSales/request body ProjectRequirementActionBo
// 撤回 POST /web/requirementPool/afterSales/withdraw body ProjectRequirementActionBo
// → ProjectRequirementUserController#requestAfterSale / #withdrawAfterSale // → ProjectRequirementUserController#requestAfterSale / #withdrawAfterSale
// → ProjectRequirementServiceImpl#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> { export function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
const data = new FormData() const data = new FormData()
data.append('file', file) data.append('file', file)
@@ -250,12 +151,6 @@ export function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment>
})) }))
} }
/**
* 申请售后(用户端)。
*
* <p>`id` 是**需求 id**(= 详情页路由参数);`payload` 的 `reason` / `note` 分别对应
* 页面的「问题描述」与「补充备注」。</p>
*/
export function submitAfterSales( export function submitAfterSales(
id: string, id: string,
payload: AfterSalesPayload, payload: AfterSalesPayload,
@@ -268,17 +163,11 @@ export function submitAfterSales(
type: payload.type, type: payload.type,
reason: payload.reason, reason: payload.reason,
note: payload.note, note: payload.note,
// 空数组是安全的(后端 `appendAttachments` 对空集合直接 return,不 NPE)
attachmentOssIds: payload.attachmentOssIds, attachmentOssIds: payload.attachmentOssIds,
}, },
}) })
} }
/**
* 撤回售后申请(用户端)。
*
* <p>`id` 传**需求 id**;`reason` 是撤回原因(选填,后端会存进 `withdraw_reason`)。</p>
*/
export function cancelAfterSales(id: string, reason = ''): Promise<void> { export function cancelAfterSales(id: string, reason = ''): Promise<void> {
return request<void>({ return request<void>({
url: '/web/requirementPool/afterSales/withdraw', url: '/web/requirementPool/afterSales/withdraw',

View File

@@ -15,7 +15,6 @@ function nowTime(): string {
return `今天 ${pad(date.getHours())}:${pad(date.getMinutes())}` return `今天 ${pad(date.getHours())}:${pad(date.getMinutes())}`
} }
/** 历史会话(欢迎语 + 用户提问 + 文件资料) */
export function getChatHistory(): Promise<ChatMessage[]> { export function getChatHistory(): Promise<ChatMessage[]> {
return Promise.resolve([ return Promise.resolve([
{ {
@@ -40,7 +39,6 @@ export function getChatHistory(): Promise<ChatMessage[]> {
]) ])
} }
/** 发送文本消息(echo 用户消息,携带当前时间) */
export function sendChatMessage(content: string): Promise<ChatMessage> { export function sendChatMessage(content: string): Promise<ChatMessage> {
return Promise.resolve({ return Promise.resolve({
id: nextId('chat'), id: nextId('chat'),
@@ -50,7 +48,6 @@ export function sendChatMessage(content: string): Promise<ChatMessage> {
}) })
} }
/** 上传聊天文件(生成文件卡片消息) */
export function uploadChatFile(file: File): Promise<ChatMessage> { export function uploadChatFile(file: File): Promise<ChatMessage> {
return Promise.resolve({ return Promise.resolve({
id: nextId('chat'), id: nextId('chat'),
@@ -61,7 +58,6 @@ export function uploadChatFile(file: File): Promise<ChatMessage> {
}) })
} }
/** 客服自动回复 */
export function mockAutoReply(): Promise<ChatMessage> { export function mockAutoReply(): Promise<ChatMessage> {
return Promise.resolve({ return Promise.resolve({
id: nextId('chat'), id: nextId('chat'),

View File

@@ -1,15 +1,6 @@
// 用户资料接口(真实后端绑定)
// 后端:org.dromara.system.controller.system.SysUserController#profile → /system/user/profile
// 鉴权:需登录态
import { request } from '@/utils/request' import { request } from '@/utils/request'
import type { UserProfileVo } from '@/types/business/user' import type { UserProfileVo } from '@/types/business/user'
/**
* 当前登录用户资料(需求提报页预填联系人姓名 / 电话)。
*
* <p>⚠️ 后端返回的是 `R<Map>`,用户对象嵌在 **`data.user`** 里(同级还有 roles / posts),
* 不是平铺在 data 上——这里在 api 层解包,调用方拿到的是用户对象本身。</p>
*/
export async function getUserProfile(): Promise<UserProfileVo> { export async function getUserProfile(): Promise<UserProfileVo> {
const data = await request<{ user: UserProfileVo }>({ const data = await request<{ user: UserProfileVo }>({
url: '/system/user/profile', url: '/system/user/profile',

View File

@@ -3,7 +3,6 @@ import { useSiteStore } from '@/stores/modules/site'
const siteStore = useSiteStore() const siteStore = useSiteStore()
// 设计稿快速导航列原文:首页 / 案例中心 / 需求提报 / 下载 APP / 帮助中心
const quickLinks = [ const quickLinks = [
{ label: '首页', path: '/' }, { label: '首页', path: '/' },
{ label: '案例中心', path: '/cases' }, { label: '案例中心', path: '/cases' },
@@ -85,10 +84,6 @@ const coreBusiness = [
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
// 设计稿页脚 1920×481(首页稿 y1942→2423):
// 列区 y1996 起(页脚顶 +54),四列 x155/922/1257/1511,列宽 625/193/80/253
// 品牌名「虫子智造」@x200(相对列缩进 45),描述 @x155
// 版权区:©行 @x526(867 盒在 1920 画布居中)、ICP 行 @x841(相对 867 盒缩进 315),均左对齐
.app-footer { .app-footer {
background: @footer-bg; background: @footer-bg;
color: @white; color: @white;
@@ -97,16 +92,16 @@ const coreBusiness = [
.flex(row, flex-start, flex-start); .flex(row, flex-start, flex-start);
width: 100%; width: 100%;
max-width: 1920px; max-width: 1920px;
margin: 0 auto; // 视口宽于 1920 时整块居中;缺这行内容会贴左、右侧留白远大于左侧 margin: 0 auto;
height: 250px; // 页脚顶 → © 行(y1942→2274) height: 250px;
box-sizing: border-box; box-sizing: border-box;
padding-top: 30px; padding-top: 30px;
padding-left: clamp( padding-left: clamp(
@spacing-base, @spacing-base,
calc((100% - 1609px) / 2), calc((100% - 1609px) / 2),
155px 155px
); // 设计内容 x155→1764 = 1609 宽 );
overflow: hidden; // 防列内容撑出横向滚动 overflow: hidden;
} }
&__about { &__about {
@@ -116,8 +111,8 @@ const coreBusiness = [
&__brand { &__brand {
.flex(row, flex-start); .flex(row, flex-start);
margin-bottom: 20px; // 品牌名盒底 2037 → 描述 2057 margin-bottom: 20px;
margin-left: 45px; // 设计:品牌名 @x200,列起点 x155 margin-left: 45px;
} }
&__name { &__name {
@@ -131,16 +126,14 @@ const coreBusiness = [
margin-top: 0; margin-top: 0;
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
line-height: 23px; // 设计 625×115 五行 line-height: 23px;
} }
&__col { &__col {
.flex(column, flex-start, flex-start); // 第三参必须显式:mixin 默认 center 会把列项水平居中 .flex(column, flex-start, flex-start);
gap: 0; gap: 0;
min-width: 0; min-width: 0;
// 设计列位:核心业务 @922(列宽 193)、快速导航 @1257(80)、联系我们 @1511(253)
// 用 modifier 类定位;:nth-of-type 按标签序号计数(about 也算 div),会整体错位一档
&--core { &--core {
flex: 0 0 193px; flex: 0 0 193px;
margin-left: 142px; margin-left: 142px;
@@ -156,7 +149,7 @@ const coreBusiness = [
} }
&__title { &__title {
margin-bottom: 20px; // 标题盒底 2037 → 列表 2057 margin-bottom: 20px;
font-size: 28px; font-size: 28px;
font-weight: 500; font-weight: 500;
line-height: 41px; line-height: 41px;
@@ -167,7 +160,7 @@ const coreBusiness = [
&__link { &__link {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
line-height: 23px; // 设计 5 项 115 高 line-height: 23px;
white-space: nowrap; white-space: nowrap;
cursor: pointer; cursor: pointer;
transition: color 0.2s; transition: color 0.2s;
@@ -183,12 +176,11 @@ const coreBusiness = [
line-height: 23px; line-height: 23px;
} }
// 版权区:867 盒在画布居中(1920 视口 → x526),盒内文字左对齐;ICP 缩进 315(→ x841)
&__bottom { &__bottom {
width: 867px; width: 867px;
max-width: calc(100% - 32px); max-width: calc(100% - 32px);
margin: 0 auto; margin: 0 auto;
padding-bottom: 20px; // ICP 盒底 2336 → 页脚底 2423 padding-bottom: 20px;
text-align: left; text-align: left;
} }
@@ -197,11 +189,11 @@ const coreBusiness = [
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
line-height: 23px; line-height: 23px;
white-space: nowrap; // 设计稿单行(867×23),回退字体偏宽防折行 white-space: nowrap;
color: @white; color: @white;
&--icp { &--icp {
margin-top: 16px; // © 盒底 2297 → ICP 2313 margin-top: 16px;
margin-left: 315px; margin-left: 315px;
} }
} }

View File

@@ -8,8 +8,6 @@ interface NavItem {
path: string path: string
} }
// 设计稿页头(43 张稿一致):无 logo 文字,导航首项 x275(间隙 94),
// 右侧两枚 106×36 胶囊(快捷提报 x1559 / 在线客服 x1694,右缘 1800)
const navItems: NavItem[] = [ const navItems: NavItem[] = [
{ label: '首页', path: '/' }, { label: '首页', path: '/' },
{ label: '案例库', path: '/cases' }, { label: '案例库', path: '/cases' },
@@ -20,7 +18,6 @@ const navItems: NavItem[] = [
{ label: '个人中心', path: '/profile' }, { label: '个人中心', path: '/profile' },
] ]
// 🔔 消息提示:顶栏铃铛展示未读数量,点击进入消息中心
const messageStore = useMessageStore() const messageStore = useMessageStore()
const { unreadCount } = storeToRefs(messageStore) const { unreadCount } = storeToRefs(messageStore)
onMounted(() => void messageStore.fetchUnreadCount()) onMounted(() => void messageStore.fetchUnreadCount())
@@ -84,8 +81,6 @@ onMounted(() => void messageStore.fetchUnreadCount())
background: @header-bg; background: @header-bg;
border-bottom: 1px solid @header-border; border-bottom: 1px solid @header-border;
// 设计稿:导航首项 x275、胶囊右缘 1800(= 1920 − 120)
// 左留白按 155 基准、右留白按 120 基准,各自 clamp 收窄,窄视口不溢出
&__inner { &__inner {
.flex(row, flex-start); .flex(row, flex-start);
height: 100%; height: 100%;
@@ -95,10 +90,9 @@ onMounted(() => void messageStore.fetchUnreadCount())
padding-right: 15%; padding-right: 15%;
} }
// 导航首项 x275 = 左留白 155 + 120
&__nav { &__nav {
.flex(row, center); .flex(row, center);
gap: @header-nav-gap; // 设计实测 94(首页 x275→311,案例库 x405) gap: @header-nav-gap;
height: 100%; height: 100%;
margin-left: 0px; margin-left: 0px;
@@ -114,7 +108,6 @@ onMounted(() => void messageStore.fetchUnreadCount())
position: relative; position: relative;
.flex(row, center); .flex(row, center);
height: 100%; height: 100%;
// 设计稿:导航项 18/500,未激活 #000000、激活 #085DFF
font-size: 18px; font-size: 18px;
font-weight: 500; font-weight: 500;
color: @black; color: @black;
@@ -125,7 +118,6 @@ onMounted(() => void messageStore.fetchUnreadCount())
color: @home-tab-active; color: @home-tab-active;
} }
// 激活项底部短横线(设计实测 29×2px @y59,距底 11)
&.router-link-active::after { &.router-link-active::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -142,12 +134,11 @@ onMounted(() => void messageStore.fetchUnreadCount())
&__actions { &__actions {
.flex(row, flex-end, center); .flex(row, flex-end, center);
gap: 29px; // 设计:胶囊1 右缘 1665 → 胶囊2 左缘 1694 gap: 29px;
margin-left: auto; margin-left: auto;
flex-shrink: 0; flex-shrink: 0;
} }
// 设计稿:胶囊 106×36,图标 18 + 文字 16/500 白字
&__action { &__action {
.flex(row, center); .flex(row, center);
gap: 5px; gap: 5px;
@@ -170,7 +161,6 @@ onMounted(() => void messageStore.fetchUnreadCount())
} }
} }
// 🔔 消息铃铛:展示未读数量红点,点击进入消息中心
&__bell { &__bell {
position: relative; position: relative;
.flex(row, center, center); .flex(row, center, center);
@@ -200,7 +190,6 @@ onMounted(() => void messageStore.fetchUnreadCount())
box-sizing: border-box; box-sizing: border-box;
} }
// 登录:文字链接,点击直达登录页
&__login { &__login {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;

View File

@@ -14,14 +14,12 @@ const emit = defineEmits<{
favorite: [id: string] favorite: [id: string]
}>() }>()
/** 卡片元信息:设计稿为「标签:值」三列两行 */
const meta = computed<Array<{ label: string; value: string }>>(() => [ const meta = computed<Array<{ label: string; value: string }>>(() => [
{ label: '客户类型', value: props.item.clientType || '--' }, { label: '客户类型', value: props.item.clientType || '--' },
{ label: '所属行业', value: props.item.industry || '--' }, { label: '所属行业', value: props.item.industry || '--' },
{ label: '规模', value: props.item.scale || '--' }, { label: '规模', value: props.item.scale || '--' },
{ label: '项目周期', value: props.item.duration || '--' }, { label: '项目周期', value: props.item.duration || '--' },
{ label: '服务团队', value: props.item.teamSize ? `${props.item.teamSize}人` : '--' }, { label: '服务团队', value: props.item.teamSize ? `${props.item.teamSize}人` : '--' },
// 设计稿完成时间为 2026/8/10 形式,后端 completedAt 下发 2026-08-10
{ label: '完成时间', value: props.item.finishDate ? props.item.finishDate.replace(/-/g, '/') : '--' }, { label: '完成时间', value: props.item.finishDate ? props.item.finishDate.replace(/-/g, '/') : '--' },
]) ])
</script> </script>
@@ -67,7 +65,6 @@ const meta = computed<Array<{ label: string; value: string }>>(() => [
<style lang="less" scoped> <style lang="less" scoped>
.case-card { .case-card {
// 设计稿卡片 396×267 白底 bd #E1DFDF/1
overflow: hidden; overflow: hidden;
height: 267px; height: 267px;
box-sizing: border-box; box-sizing: border-box;
@@ -84,7 +81,6 @@ const meta = computed<Array<{ label: string; value: string }>>(() => [
&__cover { &__cover {
position: relative; position: relative;
// 设计封面 396×137.81(通栏无留白)
height: 137px; height: 137px;
overflow: hidden; overflow: hidden;
} }
@@ -101,7 +97,6 @@ const meta = computed<Array<{ label: string; value: string }>>(() => [
&__tag { &__tag {
position: absolute; position: absolute;
// 设计标签文字 @(628.6,540.35),卡 @(606,423) → 卡内 (22.6,117) 高 44
top: 87px; top: 87px;
left: 22px; left: 22px;
height: 44px; height: 44px;
@@ -109,7 +104,6 @@ const meta = computed<Array<{ label: string; value: string }>>(() => [
border-radius: @radius-sm; border-radius: @radius-sm;
background: @primary-strong; background: @primary-strong;
color: @white; color: @white;
// 设计 28/500
font-size: 28px; font-size: 28px;
font-weight: 500; font-weight: 500;
line-height: 44px; line-height: 44px;
@@ -167,14 +161,12 @@ const meta = computed<Array<{ label: string; value: string }>>(() => [
&__body { &__body {
position: relative; position: relative;
// 设计标题 @(626.45,578.03),卡 @(606,423) → 卡内 (20,155)
padding: 18px 20px 0; padding: 18px 20px 0;
height: 129px; height: 129px;
box-sizing: border-box; box-sizing: border-box;
} }
&__title { &__title {
// 设计标题 16/500 #333333
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
@@ -186,10 +178,8 @@ const meta = computed<Array<{ label: string; value: string }>>(() => [
&__meta { &__meta {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
// 设计两行:y610.33 / y631.86 → 行距 21.5
gap: 21.5px 12px; gap: 21.5px 12px;
margin-top: 10px; margin-top: 10px;
// 设计元信息 10/400 #333333
font-size: 10px; font-size: 10px;
line-height: 14px; line-height: 14px;
color: @cases-text; color: @cases-text;
@@ -205,11 +195,9 @@ const meta = computed<Array<{ label: string; value: string }>>(() => [
&__more { &__more {
position: absolute; position: absolute;
// 设计 @(926.67,659.85),卡 @(606,423) → 卡内 (320.67,236.85)
right: 20px; right: 20px;
bottom: 14px; bottom: 14px;
color: @primary-strong; color: @primary-strong;
// 设计 12/400 #024FFF
font-size: 12px; font-size: 12px;
font-weight: 400; font-weight: 400;
} }

View File

@@ -58,8 +58,6 @@ function onCancel(): void {
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
// 设计 24(取消绑定确认)/ 29(注销风险提示):白卡 ~840 设计px、无蒙层压暗、
// 底部两枚等宽整行按钮(次级 #EEF0F3 / 主色或危险色)
.confirm-dialog { .confirm-dialog {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -68,7 +66,7 @@ function onCancel(): void {
background: transparent; background: transparent;
&__panel { &__panel {
width: 660px; // 设计 840 × 0.77 width: 660px;
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
padding: @spacing-base; padding: @spacing-base;
border-radius: @radius-lg; border-radius: @radius-lg;
@@ -76,7 +74,6 @@ function onCancel(): void {
box-shadow: @shadow-lg; box-shadow: @shadow-lg;
} }
// 蓝条 + 标题 15 设计px w700(与个人中心各弹窗一致)
&__title { &__title {
position: relative; position: relative;
margin-bottom: @spacing-base; margin-bottom: @spacing-base;
@@ -100,12 +97,11 @@ function onCancel(): void {
&__body { &__body {
margin-bottom: @spacing-lg; margin-bottom: @spacing-lg;
font-size: 11px; // 设计 29 条目 13 → 11 font-size: 11px;
color: @text-color; color: @text-color;
line-height: 1.7; line-height: 1.7;
} }
// 设计 24:两枚等宽整行按钮铺满卡宽
&__footer { &__footer {
.flex(row, flex-start); .flex(row, flex-start);
gap: @spacing-sm; gap: @spacing-sm;
@@ -129,7 +125,6 @@ function onCancel(): void {
color: @text-color; color: @text-color;
} }
// 设计 24 主按钮 #024FFF / 设计 29 危险按钮 #EF4444
&--confirm { &--confirm {
background: @primary-strong; background: @primary-strong;
color: @white; color: @white;

View File

@@ -45,7 +45,6 @@ const emit = defineEmits<{
.flex(column, flex-start); .flex(column, flex-start);
gap: @spacing-sm; gap: @spacing-sm;
// 设计稿(06/07 附件条):实底 #EDEDED 灰条、无描边
&__item { &__item {
.flex(row, space-between); .flex(row, space-between);
gap: @spacing-base; gap: @spacing-base;

View File

@@ -37,12 +37,10 @@ function onDownload(): void {
</header> </header>
<div class="preview-dialog__body"> <div class="preview-dialog__body">
<!-- 加载中 -->
<div v-if="loading" class="preview-dialog__center"> <div v-if="loading" class="preview-dialog__center">
预览加载中… 预览加载中…
</div> </div>
<!-- 加载失败 / 无 ossId 等错误 -->
<div v-else-if="error" class="preview-dialog__center"> <div v-else-if="error" class="preview-dialog__center">
<p class="preview-dialog__msg">{{ error }}</p> <p class="preview-dialog__msg">{{ error }}</p>
<AppButton v-if="file?.url" type="primary" @click="onDownload"> <AppButton v-if="file?.url" type="primary" @click="onDownload">
@@ -50,7 +48,6 @@ function onDownload(): void {
</AppButton> </AppButton>
</div> </div>
<!-- 不支持内联预览(office 等) -->
<div v-else-if="kind === 'unsupported'" class="preview-dialog__center"> <div v-else-if="kind === 'unsupported'" class="preview-dialog__center">
<p class="preview-dialog__msg"> <p class="preview-dialog__msg">
当前格式({{ (file?.name ?? '').split('.').pop() }})不支持在线预览,请下载后查看。 当前格式({{ (file?.name ?? '').split('.').pop() }})不支持在线预览,请下载后查看。
@@ -60,7 +57,6 @@ function onDownload(): void {
</AppButton> </AppButton>
</div> </div>
<!-- 图片 -->
<img <img
v-else-if="kind === 'image' && objectUrl" v-else-if="kind === 'image' && objectUrl"
:src="objectUrl" :src="objectUrl"
@@ -68,7 +64,6 @@ function onDownload(): void {
alt="预览" alt="预览"
> >
<!-- PDF(blob 已带 application/pdf,iframe 内联渲染) -->
<iframe <iframe
v-else-if="kind === 'pdf' && objectUrl" v-else-if="kind === 'pdf' && objectUrl"
:src="objectUrl" :src="objectUrl"
@@ -76,7 +71,6 @@ function onDownload(): void {
title="预览" title="预览"
/> />
<!-- 视频 -->
<video <video
v-else-if="kind === 'video' && objectUrl" v-else-if="kind === 'video' && objectUrl"
:src="objectUrl" :src="objectUrl"
@@ -84,7 +78,6 @@ function onDownload(): void {
controls controls
/> />
<!-- 音频 -->
<audio <audio
v-else-if="kind === 'audio' && objectUrl" v-else-if="kind === 'audio' && objectUrl"
:src="objectUrl" :src="objectUrl"
@@ -92,7 +85,6 @@ function onDownload(): void {
controls controls
/> />
<!-- 文本(大文件截断预览,避免卡死浏览器) -->
<div v-else-if="kind === 'text'" class="preview-dialog__text-wrap"> <div v-else-if="kind === 'text'" class="preview-dialog__text-wrap">
<p v-if="truncated" class="preview-dialog__hint"> <p v-if="truncated" class="preview-dialog__hint">
<span>文件较大({{ formatBytes(totalBytes ?? 0) }}),仅预览前 {{ formatBytes(PREVIEW_TEXT_MAX_BYTES) }},完整内容请下载。</span> <span>文件较大({{ formatBytes(totalBytes ?? 0) }}),仅预览前 {{ formatBytes(PREVIEW_TEXT_MAX_BYTES) }},完整内容请下载。</span>

View File

@@ -71,26 +71,26 @@ const emit = defineEmits<{
<style lang="less" scoped> <style lang="less" scoped>
.filter-panel { .filter-panel {
padding: 24px 36px; // 设计04 搜索卡左右内缩 47 × 0.77 padding: 24px 36px;
border-radius: @radius-base; border-radius: @radius-base;
background: @white; background: @white;
&__bar { &__bar {
.flex(row, flex-start); .flex(row, flex-start);
gap: 18px; // 按钮间距 24 × 0.77 gap: 18px;
} }
&__input { &__input {
flex: 1; flex: 1;
max-width: 456px; // 设计输入框 592 × 0.77 max-width: 456px;
margin-right: 28px; // 与按钮总距 60 × 0.77(叠加 bar gap 18) margin-right: 28px;
height: 38px; // 设计 49 × 0.77 height: 38px;
padding: 0 @spacing-base; padding: 0 @spacing-base;
border: 1px solid @cases-input-border; border: 1px solid @cases-input-border;
border-radius: @radius-sm; border-radius: @radius-sm;
background: @white; background: @white;
color: @cases-text; color: @cases-text;
font-size: 15px; // 设计 20 × 0.77 font-size: 15px;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
@@ -104,13 +104,13 @@ const emit = defineEmits<{
} }
&__btn { &__btn {
min-width: 77px; // 设计 100 × 0.77 min-width: 77px;
height: 38px; // 设计 49 × 0.77 height: 38px;
padding: 0 @spacing-base; padding: 0 @spacing-base;
border-radius: @radius-sm; border-radius: @radius-sm;
background: @cases-accent; // 设计 重置/搜索 均为 rgba(7,92,255) background: @cases-accent;
color: @white; color: @white;
font-size: 15px; // 设计 20 × 0.77 font-size: 15px;
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
@@ -131,26 +131,26 @@ const emit = defineEmits<{
&__chips { &__chips {
.flex(row, flex-start, center); .flex(row, flex-start, center);
flex-wrap: wrap; flex-wrap: wrap;
gap: 11px 10px; // 行距 14 / 列距 13 × 0.77 gap: 11px 10px;
margin-top: @spacing-base; margin-top: @spacing-base;
padding: 12px 18px 16px; // 「已选条件」面板内边距 16/24/21 × 0.77 padding: 12px 18px 16px;
border-radius: @radius-base; border-radius: @radius-base;
background: @chips-panel-bg; background: @chips-panel-bg;
font-size: 15px; // 设计 20 × 0.77 font-size: 15px;
} }
&__chips-label { &__chips-label {
color: @cases-text; // 设计04 标题 rgba(51,51,51) color: @cases-text;
font-weight: 500; font-weight: 500;
} }
&__chip { &__chip {
height: 25px; // 设计 32 × 0.77 height: 25px;
padding: 0 @spacing-sm; padding: 0 @spacing-sm;
border-radius: @radius-round; border-radius: @radius-round;
background: @white; background: @white;
color: @cases-accent; // 设计 chips 文字 rgba(7,92,255) color: @cases-accent;
font-size: 11px; // 设计 14 → 10.8,取可读下限 11 font-size: 11px;
font-weight: 500; font-weight: 500;
transition: all 0.2s; transition: all 0.2s;
@@ -160,7 +160,7 @@ const emit = defineEmits<{
&--clear { &--clear {
background: transparent; background: transparent;
color: @primary-strong; // 设计15「清空」为纯文字蓝链 color: @primary-strong;
} }
} }

View File

@@ -1,10 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
/**
* 页面头部(白底通栏):主标题 22/700 #1F2937 + 副标语 13/400 #6B7280。
*
* <p>43 张设计稿实测:标题顶 y125(顶栏 72 → 内容区 padding-top 53 含 1px 描边)、
* 行盒 29 → 副标语 ink y162(标题盒底 154 + 8)。整块白底高 179(72 → 251)。</p>
*/
interface Props { interface Props {
title: string title: string
slogan?: string slogan?: string
@@ -29,10 +23,10 @@ defineProps<Props>()
<style lang="less" scoped> <style lang="less" scoped>
.page-head { .page-head {
background: @white; background: @white;
padding-bottom: 72px; // 副标语盒底 179 → 页头块底 251(块高 179) padding-bottom: 72px;
&__title { &__title {
padding-top: 52px; // 顶栏 72 → 标题 ink 124(design 125) padding-top: 52px;
font-size: 22px; font-size: 22px;
font-weight: 700; font-weight: 700;
line-height: 29px; line-height: 29px;
@@ -41,7 +35,7 @@ defineProps<Props>()
&__slogan { &__slogan {
padding-bottom: 0; padding-bottom: 0;
margin-top: 9px; // 标题盒底 153 → 副标语盒顶 162 margin-top: 9px;
font-size: 13px; font-size: 13px;
font-weight: 400; font-weight: 400;
line-height: 17px; line-height: 17px;

View File

@@ -13,7 +13,6 @@ const emit = defineEmits<{ pageChange: [page: number] }>()
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize))) const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
/** 页码序列:≤7 全部展示,否则首尾 + 当前页窗口(… 省略) */
const pages = computed<Array<number | '...'>>(() => { const pages = computed<Array<number | '...'>>(() => {
const n = pageCount.value const n = pageCount.value
const { page } = props const { page } = props
@@ -67,19 +66,19 @@ function go(page: number): void {
<style lang="less" scoped> <style lang="less" scoped>
.pagination { .pagination {
.flex(row, center); .flex(row, center);
gap: @spacing-xs; // 盒间距 6 × 0.77 ≈ 5 gap: @spacing-xs;
padding: 12px 0; // 网格 → 分页 36 × 0.77(叠加主栏 gap 18) padding: 12px 0;
&__btn { &__btn {
.flex(row, center); .flex(row, center);
min-width: 20px; // 设计15 页码盒 26 × 0.77 min-width: 20px;
height: 25px; // 设计 32 × 0.77 height: 25px;
padding: 0 3px; padding: 0 3px;
border: 1px solid @border-color; border: 1px solid @border-color;
border-radius: @radius-sm; border-radius: @radius-sm;
background: @white; background: @white;
color: @text-color-secondary; // 设计15 实取 rgba(107,114,128) color: @text-color-secondary;
font-size: 11px; // 设计 12 → 9.2,取可读下限 11 font-size: 11px;
transition: all 0.2s; transition: all 0.2s;
&:hover:not(:disabled):not(.is-ellipsis) { &:hover:not(:disabled):not(.is-ellipsis) {
@@ -90,7 +89,7 @@ function go(page: number): void {
&.is-active { &.is-active {
border-color: @cases-page-active-bg; border-color: @cases-page-active-bg;
background: @cases-page-active-bg; background: @cases-page-active-bg;
color: @primary-strong; // 设计15 当前页文字 rgba(2,79,255) color: @primary-strong;
} }
&.is-ellipsis { &.is-ellipsis {

View File

@@ -19,8 +19,6 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
</script> </script>
<template> <template>
<!-- 设计稿:强度条常驻显示(未输入时整条为空轨道 + 空强度文字),
占位高度固定 15(条行)+ 18(提示行),保证表单纵向节奏与设计稿一致 -->
<div class="pwd-strength"> <div class="pwd-strength">
<div class="pwd-strength__bars"> <div class="pwd-strength__bars">
<span <span
@@ -38,7 +36,6 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
// 设计稿(54/55):满宽强度条(弱=首段红,余段浅灰轨道)+ 右端灰色强度文字 + 下方红色提示
.pwd-strength { .pwd-strength {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
@@ -46,7 +43,6 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
column-gap: @spacing-sm; column-gap: @spacing-sm;
row-gap: 3px; row-gap: 3px;
align-items: center; align-items: center;
// 设计稿:密码框底 → 条行 → 提示行 → 下一标签,节奏 15/15/18
min-height: 33px; min-height: 33px;
box-sizing: border-box; box-sizing: border-box;
@@ -55,7 +51,6 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
gap: 8px; gap: 8px;
} }
// 设计稿轨道实取 #EDEFF2,条高 5
&__bar { &__bar {
flex: 1; flex: 1;
height: 5px; height: 5px;
@@ -75,7 +70,6 @@ const levelIndex = computed(() => (level.value === 'strong' ? 3 : level.value ==
} }
} }
// 设计稿强度文字为 11px 中性灰(「弱」实取 rgba(156,163,175))
&__label { &__label {
justify-self: end; justify-self: end;
font-size: 11px; font-size: 11px;

View File

@@ -3,7 +3,6 @@ import { computed, onMounted, ref } from 'vue'
import { useCountDown } from '@/composables/useCountDown' import { useCountDown } from '@/composables/useCountDown'
interface Props { interface Props {
/** 二维码有效期(秒),默认 4:32 */
expiresIn?: number expiresIn?: number
size?: number size?: number
} }
@@ -67,10 +66,9 @@ onMounted(() => {
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
// 扫码面板(设计 39:灰色二维码占位块 + 红色倒计时胶囊 + 灰色小字提示)
.qr-panel { .qr-panel {
.flex(column, center); .flex(column, center);
gap: 17px; // 二维码 → 倒计时(设计 22 × 0.77) gap: 17px;
&__box { &__box {
.flex(column, center); .flex(column, center);
@@ -99,23 +97,22 @@ onMounted(() => {
text-align: center; text-align: center;
} }
// 状态行:胶囊(倒计时)+ 下方灰色提示,整体居中
&__status { &__status {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: @spacing-sm; gap: @spacing-sm;
font-size: 11px; // 设计 13/11 → 可读下限 font-size: 11px;
color: @text-color-disabled; color: @text-color-disabled;
} }
&__countdown { &__countdown {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 8px 16px; // 胶囊高 42 设计 × 0.77 padding: 8px 16px;
border-radius: @radius-round; border-radius: @radius-round;
background: fade(@danger-color, 8%); background: fade(@danger-color, 8%);
color: @danger-color; // 设计 39 实取 @danger-color color: @danger-color;
line-height: 1.4; line-height: 1.4;
} }

View File

@@ -33,8 +33,6 @@ function onGetCode(): void {
.flex(row, stretch); .flex(row, stretch);
gap: @spacing-sm; gap: @spacing-sm;
// 设计稿(02/54/55):验证码输入框 590×39(整行 680,右侧留 90 给「获取验证码」)
// ⚠️ 盒高/字号随登录页 @auth-font-scale 放大,与各表单页输入框保持同高
&__input { &__input {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -57,7 +55,6 @@ function onGetCode(): void {
} }
} }
// 设计稿:「获取验证码」为蓝字纯文本(无边框),12/400 #024FFF
&__btn { &__btn {
flex-shrink: 0; flex-shrink: 0;
min-width: 74px * @auth-font-scale; min-width: 74px * @auth-font-scale;

View File

@@ -14,7 +14,6 @@ withDefaults(defineProps<Props>(), { variant: 'info' })
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
// 设计稿(项目中心 09 付款计划徽标):约 57×22 设计px、圆角 4 设计px、11px 字、浅底彩色字
.status-tag { .status-tag {
display: inline-block; display: inline-block;
padding: 3px 10px; padding: 3px 10px;

View File

@@ -28,7 +28,6 @@ defineProps<Props>()
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
// 设计稿(06/07 三步表单):白卡上竖排「大号蓝色序号 + 区块标题」,无连接线/分隔线/阴影
.step-form { .step-form {
padding: @spacing-lg @spacing-xl; padding: @spacing-lg @spacing-xl;
border-radius: @radius-base; border-radius: @radius-base;
@@ -48,7 +47,6 @@ defineProps<Props>()
gap: @spacing-xs; gap: @spacing-xs;
} }
// 设计稿序号:48 设计px ≈ 37px、#024FFF、与内容列对齐(列宽 46px)
&__dot { &__dot {
flex-shrink: 0; flex-shrink: 0;
width: 46px; width: 46px;
@@ -62,20 +60,17 @@ defineProps<Props>()
text-align: left; text-align: left;
} }
// 设计稿区块标题:28 设计px ≈ 22px、500、rgba(51,51,51)
&__label { &__label {
font-size: @font-size-title; font-size: @font-size-title;
font-weight: 500; font-weight: 500;
color: @demand-heading; color: @demand-heading;
} }
// 设计稿无步骤连接线
&__line { &__line {
display: none; display: none;
} }
&__body { &__body {
// 内容列与序号后的标题对齐(序号列 46px)
margin-left: 46px; margin-left: 46px;
} }
} }

View File

@@ -63,7 +63,6 @@ function onDrop(event: DragEvent): void {
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
// 设计稿(06/07 附件条):实底 #EDEDED 灰条、无描边、90 设计px ≈ 69px 高
.upload-area { .upload-area {
.flex(column, center); .flex(column, center);
gap: @spacing-xs; gap: @spacing-xs;

View File

@@ -1,6 +1,5 @@
import { computed, onScopeDispose, ref } from 'vue' import { computed, onScopeDispose, ref } from 'vue'
/** 倒计时(验证码 60s、二维码失效 4:32 等通用) */
export function useCountDown(defaultSeconds = 60) { export function useCountDown(defaultSeconds = 60) {
const seconds = ref(0) const seconds = ref(0)
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setInterval> | null = null

View File

@@ -1,6 +1,5 @@
import { onMounted, onUnmounted, ref, type Ref } from 'vue' import { onMounted, onUnmounted, ref, type Ref } from 'vue'
/** 元素可见性(配合图片懒加载/曝光统计) */
export function useIntersection( export function useIntersection(
target: Ref<Element | null | undefined>, target: Ref<Element | null | undefined>,
options?: IntersectionObserverInit, options?: IntersectionObserverInit,

View File

@@ -1,6 +1,5 @@
import { ref, type Ref } from 'vue' import { ref, type Ref } from 'vue'
/** 请求 loading 状态管理 */
export function useLoading(initial = false): { export function useLoading(initial = false): {
loading: Ref<boolean> loading: Ref<boolean>
withLoading: <T>(fn: () => Promise<T>) => Promise<T> withLoading: <T>(fn: () => Promise<T>) => Promise<T>

View File

@@ -1,6 +1,5 @@
import { computed, onMounted, onUnmounted, ref, type ComputedRef, type Ref } from 'vue' import { computed, onMounted, onUnmounted, ref, type ComputedRef, type Ref } from 'vue'
/** 滚动监听/吸顶/回到顶部 */
export function useScroll(threshold = 200): { export function useScroll(threshold = 200): {
scrollY: Ref<number> scrollY: Ref<number>
isAtTop: ComputedRef<boolean> isAtTop: ComputedRef<boolean>

View File

@@ -13,7 +13,6 @@ function normalize(value: DebounceValue): DebounceBinding {
return typeof value === 'function' ? { handler: value } : value return typeof value === 'function' ? { handler: value } : value
} }
/** v-debounce 防抖点击:v-debounce="fn" 或 v-debounce="{ handler: fn, delay: 500 }" */
const debounce: Directive<Element, DebounceValue> = { const debounce: Directive<Element, DebounceValue> = {
mounted(el, binding) { mounted(el, binding) {
const { handler, delay = 300 } = normalize(binding.value) const { handler, delay = 300 } = normalize(binding.value)

View File

@@ -3,7 +3,6 @@ import lazy from './lazy'
import debounce from './debounce' import debounce from './debounce'
import throttle from './throttle' import throttle from './throttle'
/** main.ts 调用:统一注册自定义指令 */
export function setupDirectives(app: App): void { export function setupDirectives(app: App): void {
app app
.directive('lazy', lazy) .directive('lazy', lazy)

View File

@@ -2,7 +2,6 @@ import type { Directive } from 'vue'
const observerMap = new WeakMap<Element, IntersectionObserver>() const observerMap = new WeakMap<Element, IntersectionObserver>()
/** v-lazy 图片懒加载:v-lazy="图片地址" */
const lazy: Directive<Element, string> = { const lazy: Directive<Element, string> = {
mounted(el, binding) { mounted(el, binding) {
if (!(el instanceof HTMLImageElement)) return if (!(el instanceof HTMLImageElement)) return

View File

@@ -13,7 +13,6 @@ function normalize(value: ThrottleValue): ThrottleBinding {
return typeof value === 'function' ? { handler: value } : value return typeof value === 'function' ? { handler: value } : value
} }
/** v-throttle 节流点击:v-throttle="fn" 或 v-throttle="{ handler: fn, interval: 500 }" */
const throttle: Directive<Element, ThrottleValue> = { const throttle: Directive<Element, ThrottleValue> = {
mounted(el, binding) { mounted(el, binding) {
const { handler, interval = 500 } = normalize(binding.value) const { handler, interval = 500 } = normalize(binding.value)

1
src/env.d.ts vendored
View File

@@ -1,7 +1,6 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
/** API 基础地址 */
readonly VITE_API_BASE_URL: string readonly VITE_API_BASE_URL: string
} }

View File

@@ -4,7 +4,6 @@ import { computed } from 'vue'
import { AppFooter, AppHeader, FilePreviewDialog } from '@/components' import { AppFooter, AppHeader, FilePreviewDialog } from '@/components'
const route = useRoute() const route = useRoute()
// 需求提报 / 合同中心等特殊页不展示页脚(路由 meta.noFooter 控制)
const showFooter = computed(() => !route.meta.noFooter) const showFooter = computed(() => !route.meta.noFooter)
</script> </script>
@@ -15,16 +14,11 @@ const showFooter = computed(() => !route.meta.noFooter)
<router-view /> <router-view />
</main> </main>
<AppFooter v-if="showFooter" /> <AppFooter v-if="showFooter" />
<!-- 全局文件预览弹窗(同源 OSS 预览,绕开桶强制下载) -->
<FilePreviewDialog /> <FilePreviewDialog />
</div> </div>
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
// 公共底栏全站常驻(设计稿 43 张中除登录类外均带 481 高页脚)
// sticky footer:.layout 至少占满视口 → 内容少时 footer 贴底不留白;
// 内容多时 .layout 随内容长高,footer 自然落在内容下方(滚动可见)。
// ⚠️ html 全局 zoom 会把内部 100vh 再缩一次,必须除以 @page-zoom(同 .auth-page)。
.layout { .layout {
.flex(column, flex-start, stretch); .flex(column, flex-start, stretch);
min-height: calc(100vh / @page-zoom); min-height: calc(100vh / @page-zoom);

View File

@@ -1,10 +1,8 @@
import type { Router } from 'vue-router' import type { Router } from 'vue-router'
import { storage } from '@/utils/storage' import { storage } from '@/utils/storage'
/** 路由守卫:登录态校验 + document.title */
export function setupGuard(router: Router): void { export function setupGuard(router: Router): void {
router.beforeEach((to) => { router.beforeEach((to) => {
// 视觉走查旁路:仅本地开发且带 ?visual=1 时放行,便于逐页比对设计稿
if ( if (
import.meta.env.DEV import.meta.env.DEV
&& new URLSearchParams(window.location.search).has('visual') && new URLSearchParams(window.location.search).has('visual')

View File

@@ -1,23 +1,15 @@
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
/**
* 公共布局(AppHeader + router-view + AppFooter)下的路由
* 完整规划见 docs/page-architecture.md 第 2 节
* M3–M10 视图由子代理并行实现中,交付后本表即为终态
*/
const layoutRoutes: RouteRecordRaw = { const layoutRoutes: RouteRecordRaw = {
path: '/', path: '/',
component: () => import('@/layout/index.vue'), component: () => import('@/layout/index.vue'),
children: [ children: [
// ── 公开 ──
{ path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页', footer: true } }, { path: '', name: 'Home', component: () => import('@/views/home'), meta: { title: '首页', footer: true } },
{ path: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), meta: { title: '业务详情' } }, { path: 'business/:type', name: 'BusinessDetail', component: () => import('@/views/home/detail.vue'), meta: { title: '业务详情' } },
{ path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } }, { path: 'cases', name: 'Cases', component: () => import('@/views/cases'), meta: { title: '成功案例库' } },
{ path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } }, { path: 'cases/:id', name: 'CaseDetail', component: () => import('@/views/cases/detail.vue'), meta: { title: '案例详情' } },
// 需求提报依赖登录态:字典 / 业务类型 / 草稿 / 上传 / 提交接口匿名实测均 401
{ path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报', requiresAuth: true, noFooter: true } }, { path: 'demand', name: 'Demand', component: () => import('@/views/demand'), meta: { title: '需求提报', requiresAuth: true, noFooter: true } },
// ── 登录区 ──
{ path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } }, { path: 'project', name: 'Project', component: () => import('@/views/project'), meta: { title: '项目中心', requiresAuth: true } },
{ path: 'project/:id', name: 'ProjectDetail', component: () => import('@/views/project/detail.vue'), meta: { title: '项目详情', requiresAuth: true } }, { path: 'project/:id', name: 'ProjectDetail', component: () => import('@/views/project/detail.vue'), meta: { title: '项目详情', requiresAuth: true } },
{ path: 'project/:id/after-sales', name: 'AfterSales', component: () => import('@/views/project/after-sales.vue'), meta: { title: '申请售后', requiresAuth: true } }, { path: 'project/:id/after-sales', name: 'AfterSales', component: () => import('@/views/project/after-sales.vue'), meta: { title: '申请售后', requiresAuth: true } },
@@ -29,7 +21,6 @@ const layoutRoutes: RouteRecordRaw = {
{ path: 'finance/bills/:id', name: 'BillDetail', component: () => import('@/views/finance/bill-detail.vue'), meta: { title: '账单详情', requiresAuth: true } }, { path: 'finance/bills/:id', name: 'BillDetail', component: () => import('@/views/finance/bill-detail.vue'), meta: { title: '账单详情', requiresAuth: true } },
{ path: 'finance/bills/:id/pay', name: 'BillPay', component: () => import('@/views/finance/pay.vue'), meta: { title: '扫码支付', requiresAuth: true } }, { path: 'finance/bills/:id/pay', name: 'BillPay', component: () => import('@/views/finance/pay.vue'), meta: { title: '扫码支付', requiresAuth: true } },
{ path: 'finance/bills/:id/pay/result', name: 'PayResult', component: () => import('@/views/finance/pay-result.vue'), meta: { title: '支付成功', requiresAuth: true } }, { path: 'finance/bills/:id/pay/result', name: 'PayResult', component: () => import('@/views/finance/pay-result.vue'), meta: { title: '支付成功', requiresAuth: true } },
// 收银台(确认支付):由账单明细勾选/单笔「去支付」带入 `?ids=bill-001,bill-002`
{ path: 'finance/checkout', name: 'Checkout', component: () => import('@/views/finance/checkout.vue'), meta: { title: '确认支付', requiresAuth: true } }, { path: 'finance/checkout', name: 'Checkout', component: () => import('@/views/finance/checkout.vue'), meta: { title: '确认支付', requiresAuth: true } },
{ path: 'finance/invoices', name: 'Invoices', component: () => import('@/views/finance/invoices.vue'), meta: { title: '发票申请', requiresAuth: true } }, { path: 'finance/invoices', name: 'Invoices', component: () => import('@/views/finance/invoices.vue'), meta: { title: '发票申请', requiresAuth: true } },
{ path: 'finance/invoice-titles', name: 'InvoiceTitles', component: () => import('@/views/finance/invoice-titles.vue'), meta: { title: '发票抬头', requiresAuth: true } }, { path: 'finance/invoice-titles', name: 'InvoiceTitles', component: () => import('@/views/finance/invoice-titles.vue'), meta: { title: '发票抬头', requiresAuth: true } },
@@ -37,7 +28,6 @@ const layoutRoutes: RouteRecordRaw = {
{ path: 'message/:id', name: 'MessageDetail', component: () => import('@/views/message/detail.vue'), meta: { title: '通知详情', requiresAuth: true } }, { path: 'message/:id', name: 'MessageDetail', component: () => import('@/views/message/detail.vue'), meta: { title: '通知详情', requiresAuth: true } },
{ path: 'service', name: 'Service', component: () => import('@/views/service'), meta: { title: '在线客服', requiresAuth: true } }, { path: 'service', name: 'Service', component: () => import('@/views/service'), meta: { title: '在线客服', requiresAuth: true } },
// ── 个人中心(左菜单嵌套)──
{ {
path: 'profile', path: 'profile',
component: () => import('@/views/profile'), component: () => import('@/views/profile'),
@@ -57,7 +47,6 @@ const layoutRoutes: RouteRecordRaw = {
], ],
} }
/** 无全局布局的路由(登录注册独立品牌页 + 404) */
const plainRoutes: RouteRecordRaw[] = [ const plainRoutes: RouteRecordRaw[] = [
{ path: '/login', name: 'Login', component: () => import('@/views/login'), meta: { title: '登录' } }, { path: '/login', name: 'Login', component: () => import('@/views/login'), meta: { title: '登录' } },
{ path: '/register', name: 'Register', component: () => import('@/views/login/register.vue'), meta: { title: '注册' } }, { path: '/register', name: 'Register', component: () => import('@/views/login/register.vue'), meta: { title: '注册' } },

View File

@@ -13,33 +13,8 @@ import { collectCase, getCaseCategories, getCaseDetail, getCaseList } from '@/ap
import { toCase } from '@/utils/caseVo' import { toCase } from '@/utils/caseVo'
import type { CaseTypeNameMap } from '@/utils/caseVo' import type { CaseTypeNameMap } from '@/utils/caseVo'
/**
* 案例库数据(列表 / 筛选 / 分页 / 详情 / 收藏)
*
* 后端接口(ProjectCaseUserController,前缀 /web/projectCase):
* - GET /list 列表(分页 + 跟随筛选条件的统计)
* - GET /categories 筛选面板选项(业务类型统计 + 所属行业 + 项目规模档位)
* - GET /{id} 详情(含项目背景富文本与附件,并回填 isFavorite)
* - POST /collect 收藏 / 取消收藏(toggle)
*
* ⚠️ 筛选面板**除完成时间外**的选项全部由后端下发,且**都来自 GET /categories**(一次请求):
* - 业务类型:projectCaseStatisticsVo.projectCaseCounts(按项目提报表补全类型全集)
* - 案例总数:projectCaseStatisticsVo.total
* - 所属行业:businessIndustry
* - 项目规模:upperAndLowerLimits(label 已由后端拼好)
* 完成时间后端无对应选项接口,由前端按快捷区间 + 日期选择器生成。
*
* ⚠️ 业务类型**下发的是提报主键 id**(不是类型名):案例表 business_type 列实测存的就是
* 该 id,传类型名后端按原值匹配不到(实测 total=0)。同一份 id→名称 字典还用于把列表 /
* 详情里的 id 翻译成展示名(见 typeNameById),否则标签会全部退化成「其他」。
*
* 后端 VO 与页面字段的差异(命名、业务类型取值、富文本、数值单位)由 utils/caseVo 统一对齐。
*/
/** 每页条数(案例库为三列卡片栅格,9 = 3 行) */
const PAGE_SIZE = 9 const PAGE_SIZE = 9
/** 结果数预探测的防抖时长(筛选面板改动后即时回显「N 个结果」) */
const PROBE_DELAY = 300 const PROBE_DELAY = 300
function createDefaultQuery(): CaseQuery { function createDefaultQuery(): CaseQuery {
@@ -55,7 +30,6 @@ function createDefaultQuery(): CaseQuery {
} }
} }
/** 筛选面板给的是 yyyy-MM-dd,后端 Date 入参按 yyyy-MM-dd HH:mm:ss 补全 */
function withTime(date: string, time: string): string { function withTime(date: string, time: string): string {
return date.length === 10 ? `${date} ${time}` : date return date.length === 10 ? `${date} ${time}` : date
} }
@@ -64,12 +38,6 @@ function trimmed(raw: string | null | undefined): string {
return (raw ?? '').trim() return (raw ?? '').trim()
} }
/**
* 业务类型统计 → 筛选面板选项。
*
* <p>value 取**提报主键 id**(下发 businessTypes 用),label 取业务类型名称(展示用)。
* 不按前端 BusinessType 枚举归一化,选项一律用后端下发的原值。</p>
*/
function toTypeOptions(stats: ProjectCaseStatisticsVo | null): CaseTypeOption[] { function toTypeOptions(stats: ProjectCaseStatisticsVo | null): CaseTypeOption[] {
return (stats?.projectCaseCounts ?? []) return (stats?.projectCaseCounts ?? [])
.map((item) => ({ .map((item) => ({
@@ -80,7 +48,6 @@ function toTypeOptions(stats: ProjectCaseStatisticsVo | null): CaseTypeOption[]
.filter((item) => item.value.length > 0 && item.label.length > 0) .filter((item) => item.value.length > 0 && item.label.length > 0)
} }
/** 业务类型统计 → 「提报 id → 类型名」字典(供 utils/caseVo 翻译列表/详情的 business_type) */
function toTypeNameMap(stats: ProjectCaseStatisticsVo | null): CaseTypeNameMap { function toTypeNameMap(stats: ProjectCaseStatisticsVo | null): CaseTypeNameMap {
const map: CaseTypeNameMap = {} const map: CaseTypeNameMap = {}
for (const item of stats?.projectCaseCounts ?? []) { for (const item of stats?.projectCaseCounts ?? []) {
@@ -95,25 +62,18 @@ export const useCasesStore = defineStore('cases', () => {
const query = ref<CaseQuery>(createDefaultQuery()) const query = ref<CaseQuery>(createDefaultQuery())
const list = ref<Case[]>([]) const list = ref<Case[]>([])
const total = ref(0) const total = ref(0)
/** 待提交条件的结果数(筛选面板改动后即时探测,仅用于「确定筛选(N 个结果)」) */
const previewTotal = ref(0) const previewTotal = ref(0)
/** 业务类型筛选项(接口下发:GET /categories 的统计;value=提报 id) */
const typeOptions = ref<CaseTypeOption[]>([]) const typeOptions = ref<CaseTypeOption[]>([])
/** 业务类型字典:提报 id → 类型名(列表/详情标签翻译 + 首页精选共用) */
const typeNameById = ref<CaseTypeNameMap>({}) const typeNameById = ref<CaseTypeNameMap>({})
/** 所属行业筛选项(接口下发:GET /categories) */
const industryOptions = ref<BusinessIndustryOption[]>([]) const industryOptions = ref<BusinessIndustryOption[]>([])
/** 项目规模筛选项(接口下发:GET /categories,label 由后端拼好) */
const scaleOptions = ref<ScaleLimit[]>([]) const scaleOptions = ref<ScaleLimit[]>([])
/** 案例总数(全量口径,用于业务类型「全部」行角标) */
const caseTotal = ref(0) const caseTotal = ref(0)
const currentCase = ref<Case | null>(null) const currentCase = ref<Case | null>(null)
let probeTimer: ReturnType<typeof setTimeout> | null = null let probeTimer: ReturnType<typeof setTimeout> | null = null
/** 页面筛选条件 → 后端查询条件(值均由接口下发,原样回传) */
function buildQuery(input: CaseQuery): { function buildQuery(input: CaseQuery): {
bo: ProjectCaseUserBo bo: ProjectCaseUserBo
pageNum: number pageNum: number
@@ -121,14 +81,12 @@ export const useCasesStore = defineStore('cases', () => {
} { } {
const bo: ProjectCaseUserBo = {} const bo: ProjectCaseUserBo = {}
if (input.categories.length > 0) { if (input.categories.length > 0) {
// categories 存的已是提报主键 id(CaseTypeOption.value),直接下发
bo.businessTypes = [...input.categories] bo.businessTypes = [...input.categories]
} }
if (input.industries.length > 0) { if (input.industries.length > 0) {
bo.businessIndustries = [...input.industries] bo.businessIndustries = [...input.industries]
} }
if (input.scales.length > 0) { if (input.scales.length > 0) {
// 整档对象下发(后端接收 List<UpperAndLowerLimitsModel>),浅拷贝避免把响应式对象传出去
bo.scales = input.scales.map((item) => ({ ...item })) bo.scales = input.scales.map((item) => ({ ...item }))
} }
if (input.dateRange) { if (input.dateRange) {
@@ -141,12 +99,6 @@ export const useCasesStore = defineStore('cases', () => {
return { bo, pageNum: input.page, pageSize: input.pageSize } return { bo, pageNum: input.page, pageSize: input.pageSize }
} }
/**
* 筛选面板选项:一次 GET /categories 取回三组 + 业务类型字典。
*
* <p>后端 CaseSelectionModel 里同时带 projectCaseStatisticsVo,所以业务类型选项与案例总数
* 无需再单独打一次 /list —— 这里的所有选项都是「全量口径」,不随当前筛选条件收缩。</p>
*/
async function fetchCategories(): Promise<void> { async function fetchCategories(): Promise<void> {
const result = await getCaseCategories() const result = await getCaseCategories()
const stats = result.projectCaseStatisticsVo const stats = result.projectCaseStatisticsVo
@@ -157,17 +109,10 @@ export const useCasesStore = defineStore('cases', () => {
scaleOptions.value = result.upperAndLowerLimits ?? [] scaleOptions.value = result.upperAndLowerLimits ?? []
} }
/**
* 确保业务类型字典已就绪(幂等)。
*
* <p>首页「案例库精选」的标签翻译同样依赖该字典 —— 从首页直接进入时,
* 案例库页自身的 fetchCategories 还没跑过,故由调用方(首页 store)显式确保。</p>
*/
async function ensureCategories(): Promise<void> { async function ensureCategories(): Promise<void> {
if (typeOptions.value.length === 0) await fetchCategories() if (typeOptions.value.length === 0) await fetchCategories()
} }
/** 列表取数 */
async function fetchCases(): Promise<void> { async function fetchCases(): Promise<void> {
const { bo, pageNum, pageSize } = buildQuery(query.value) const { bo, pageNum, pageSize } = buildQuery(query.value)
const result = await getCaseList(bo, pageNum, pageSize) const result = await getCaseList(bo, pageNum, pageSize)
@@ -176,7 +121,6 @@ export const useCasesStore = defineStore('cases', () => {
previewTotal.value = total.value previewTotal.value = total.value
} }
/** 待提交条件的结果数(防抖,避免每次点击都打接口) */
async function probeTotal(): Promise<void> { async function probeTotal(): Promise<void> {
const { bo } = buildQuery(query.value) const { bo } = buildQuery(query.value)
const result = await getCaseList(bo, 1, 1) const result = await getCaseList(bo, 1, 1)
@@ -188,19 +132,16 @@ export const useCasesStore = defineStore('cases', () => {
probeTimer = setTimeout(() => void probeTotal(), PROBE_DELAY) probeTimer = setTimeout(() => void probeTotal(), PROBE_DELAY)
} }
/** 面板改条件(只改待提交条件,不取数;取数由「确定筛选」触发) */
function setQuery(next: CaseQuery): void { function setQuery(next: CaseQuery): void {
query.value = next query.value = next
scheduleProbe() scheduleProbe()
} }
/** 确定筛选(重置页码后取数) */
async function search(): Promise<void> { async function search(): Promise<void> {
query.value = { ...query.value, page: 1 } query.value = { ...query.value, page: 1 }
await fetchCases() await fetchCases()
} }
/** 重置全部条件 */
async function resetQuery(): Promise<void> { async function resetQuery(): Promise<void> {
query.value = createDefaultQuery() query.value = createDefaultQuery()
await fetchCases() await fetchCases()
@@ -211,24 +152,16 @@ export const useCasesStore = defineStore('cases', () => {
await fetchCases() await fetchCases()
} }
/** 「收藏列表」开关:只筛已收藏案例 */
async function toggleFavoriteOnly(): Promise<void> { async function toggleFavoriteOnly(): Promise<void> {
query.value = { ...query.value, favoriteOnly: !query.value.favoriteOnly, page: 1 } query.value = { ...query.value, favoriteOnly: !query.value.favoriteOnly, page: 1 }
await fetchCases() await fetchCases()
} }
/** 案例详情(后端查不到时返回 null,页面据此展示空态) */
async function fetchCaseDetail(id: string): Promise<void> { async function fetchCaseDetail(id: string): Promise<void> {
const vo = await getCaseDetail(id) const vo = await getCaseDetail(id)
currentCase.value = vo ? toCase(vo, typeNameById.value) : null currentCase.value = vo ? toCase(vo, typeNameById.value) : null
} }
/**
* 收藏 / 取消收藏。
*
* <p>后端为 toggle 语义且不返回最新状态,这里按调用成功的结果本地翻转,
* 避免再调一次详情接口(该接口会自增预览量)。</p>
*/
async function toggleFavorite(id: string): Promise<void> { async function toggleFavorite(id: string): Promise<void> {
await collectCase(id) await collectCase(id)
const index = list.value.findIndex((item) => item.id === id) const index = list.value.findIndex((item) => item.id === id)

View File

@@ -17,7 +17,6 @@ function createDefaultQuery(): ContractQuery {
} }
} }
/** 合同中心数据(列表 / 筛选 / 分页 / 详情 / 签署二维码) */
export const useContractStore = defineStore('contract', () => { export const useContractStore = defineStore('contract', () => {
const query = ref<ContractQuery>(createDefaultQuery()) const query = ref<ContractQuery>(createDefaultQuery())
const list = ref<Contract[]>([]) const list = ref<Contract[]>([])
@@ -39,13 +38,11 @@ export const useContractStore = defineStore('contract', () => {
query.value = next query.value = next
} }
/** 确定筛选(重置页码后取数) */
async function search(): Promise<void> { async function search(): Promise<void> {
query.value = { ...query.value, page: 1 } query.value = { ...query.value, page: 1 }
await fetchList() await fetchList()
} }
/** 重置全部条件 */
async function resetQuery(): Promise<void> { async function resetQuery(): Promise<void> {
query.value = createDefaultQuery() query.value = createDefaultQuery()
await fetchList() await fetchList()
@@ -56,7 +53,6 @@ export const useContractStore = defineStore('contract', () => {
await fetchList() await fetchList()
} }
/** 获取/刷新签署二维码(refresh=true 时走刷新接口) */
async function fetchSignQr(id: string, refresh = false): Promise<void> { async function fetchSignQr(id: string, refresh = false): Promise<void> {
const result = refresh ? await refreshSignQr(id) : await getSignQr(id) const result = refresh ? await refreshSignQr(id) : await getSignQr(id)
signExpires.value = result.expiresInSeconds signExpires.value = result.expiresInSeconds

View File

@@ -41,18 +41,10 @@ function createDefaultForm(): Demand {
} }
} }
/** 字典按 dictSort 升序(后端已排序,前端再保一次序,避免脏序) */
function sortDict(list: DictOption[] | null): DictOption[] { function sortDict(list: DictOption[] | null): DictOption[] {
return [...(list ?? [])].sort((a, b) => (a.dictSort ?? 0) - (b.dictSort ?? 0)) return [...(list ?? [])].sort((a, b) => (a.dictSort ?? 0) - (b.dictSort ?? 0))
} }
/**
* 草稿 VO → 表单。空值回退到 base(base 里已放入登录用户资料预填),
* 否则草稿里的空 userName / userPhone 会把预填的联系人信息覆盖掉。
*
* <p>预算范围要单独反推:后端只回传一个 `budgetRange` 字符串、没有「是否手写」的标记
* (`budgetCustom` 恒为 null),故命中字典码值或文案即预设档位,否则视为手填文本。</p>
*/
function mergeDraft(base: Demand, draft: DemandDraftVo, budgetDict: DictOption[]): Demand { function mergeDraft(base: Demand, draft: DemandDraftVo, budgetDict: DictOption[]): Demand {
const budget = resolveDraftBudget(draft.budgetRange, budgetDict) const budget = resolveDraftBudget(draft.budgetRange, budgetDict)
return { return {
@@ -70,12 +62,6 @@ function mergeDraft(base: Demand, draft: DemandDraftVo, budgetDict: DictOption[]
} }
} }
/**
* 批量把 OSS 文件标识换成可访问地址。
*
* <p>提报列表的 `logoUrl` 实测是 OSS 主键(如 2099753406859292674)。其中本身已是地址的
* 直接跳过;接口需要登录态,失败时返回空映射,由调用方回退前端内置图标(不阻塞页面)。</p>
*/
async function loadOssUrlMap(raws: (string | null)[]): Promise<Record<string, string>> { async function loadOssUrlMap(raws: (string | null)[]): Promise<Record<string, string>> {
const ids = [...new Set( const ids = [...new Set(
raws.map((item) => (item ?? '').trim()).filter((item) => item && !toAssetUrl(item)), raws.map((item) => (item ?? '').trim()).filter((item) => item && !toAssetUrl(item)),
@@ -90,24 +76,15 @@ async function loadOssUrlMap(raws: (string | null)[]): Promise<Record<string, st
} }
} }
/** 需求提报数据(单页三段表单 + 真实接口:提报列表 / 字典 / 草稿 / 上传 / 提交) */
export const useDemandStore = defineStore('demand', () => { export const useDemandStore = defineStore('demand', () => {
const form = ref<Demand>(createDefaultForm()) const form = ref<Demand>(createDefaultForm())
/** 业务类型卡片选项(**完全由提报列表接口决定**:接口几条就几条,前端不追加任何兜底项) */
const bizTypeOptions = ref<BizTypeOption[]>([]) const bizTypeOptions = ref<BizTypeOption[]>([])
/** 预算范围字典原始项(值域来源:vm_budget_range) */
const budgetDict = ref<DictOption[]>([]) const budgetDict = ref<DictOption[]>([])
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const submitting = ref(false) const submitting = ref(false)
const uploading = ref(false) const uploading = ref(false)
/**
* 预算范围选项:全部来自字典,只把「手填」项**固定排到最后**(设计稿要求末尾那一项是可输入的手填位)。
*
* <p>不动字典的值域,只调顺序。渲染方式由 `DemandInfoForm` 决定:非手填项渲染成胶囊按钮,
* 手填项渲染成**同排第 5 个输入框**(占位文案即字典 label)。</p>
*/
const budgetOptions = computed<DictOption[]>(() => { const budgetOptions = computed<DictOption[]>(() => {
const list = budgetDict.value const list = budgetDict.value
return [ return [
@@ -116,16 +93,6 @@ export const useDemandStore = defineStore('demand', () => {
] ]
}) })
/**
* 业务类型选项:项目提报列表(免登录)→ logo 换链 → 映射卡片选项。
*
* <p>值域**完全由接口决定**:接口返回几条就渲染几张卡,前端不追加任何兜底项
* (包括「其他」——接口有才显示,没有就不显示)。</p>
*
* <p>仅当提报接口**整体不可用**(请求异常)或一条可用记录都没有(全停用/全删除)时,
* 才回退字典 `vm_biz_type`。这仍是接口驱动的降级路径(不是前端凭空造值),
* 目的是避免出现"一个业务类型都选不了、表单无法提交"的死局。</p>
*/
async function loadBizTypeOptions(): Promise<BizTypeOption[]> { async function loadBizTypeOptions(): Promise<BizTypeOption[]> {
try { try {
const submissions = await getSubmissionBizTypes() const submissions = await getSubmissionBizTypes()
@@ -138,7 +105,6 @@ export const useDemandStore = defineStore('demand', () => {
} }
} }
catch { catch {
// 提报接口不可用时落到字典兜底,避免整页初始化失败
} }
const dict = sortDict(await getBizTypeDict()) const dict = sortDict(await getBizTypeDict())
return dict.map((item) => toBizTypeOptionFromDict(item.dictValue, item.dictLabel)) return dict.map((item) => toBizTypeOptionFromDict(item.dictValue, item.dictLabel))
@@ -148,14 +114,12 @@ export const useDemandStore = defineStore('demand', () => {
form.value = { ...form.value, ...partial } form.value = { ...form.value, ...partial }
} }
/** 页面初始化:业务类型 + 预算字典 + 登录用户资料 + 已有草稿(并行) */
async function init(): Promise<void> { async function init(): Promise<void> {
loading.value = true loading.value = true
try { try {
const [bizTypes, budgetDictRes, profile, draft] = await Promise.all([ const [bizTypes, budgetDictRes, profile, draft] = await Promise.all([
loadBizTypeOptions(), loadBizTypeOptions(),
getBudgetRangeDict(), getBudgetRangeDict(),
// 用户资料失败不阻塞页面(联系人留空时后端会回退取登录用户资料)
getUserProfile().catch(() => null), getUserProfile().catch(() => null),
getDraftDemand(), getDraftDemand(),
]) ])
@@ -177,7 +141,6 @@ export const useDemandStore = defineStore('demand', () => {
} }
} }
/** 上传附件:先传 OSS,再把附件记录挂到表单(提交时后端按 ossId 转存) */
async function addAttachment(file: File): Promise<void> { async function addAttachment(file: File): Promise<void> {
if (uploading.value) return if (uploading.value) return
uploading.value = true uploading.value = true
@@ -203,14 +166,12 @@ export const useDemandStore = defineStore('demand', () => {
} }
} }
/** 移除附件(仅从表单摘除,OSS 文件保留) */
function removeAttachment(target: DemandAttachment): void { function removeAttachment(target: DemandAttachment): void {
form.value.attachmentInfo = form.value.attachmentInfo.filter( form.value.attachmentInfo = form.value.attachmentInfo.filter(
(item) => !(item.ossId === target.ossId && item.id === target.id), (item) => !(item.ossId === target.ossId && item.id === target.id),
) )
} }
/** 保存草稿(后端按登录用户 upsert) */
async function saveDraft(): Promise<void> { async function saveDraft(): Promise<void> {
saving.value = true saving.value = true
try { try {
@@ -225,7 +186,6 @@ export const useDemandStore = defineStore('demand', () => {
} }
} }
/** 重置:清空后端草稿并复位表单(保留登录账号的联系人预填) */
async function reset(): Promise<void> { async function reset(): Promise<void> {
saving.value = true saving.value = true
try { try {
@@ -244,21 +204,6 @@ export const useDemandStore = defineStore('demand', () => {
} }
} }
/**
* 提交需求:前端必填校验 → 提报接口(`/web/requirementPool/create`)→ 复位表单。
*
* <p>接口收的就是草稿那套 BO(`WorkOrderDraftBo`),附件**随本次请求体一起下发**:后端
* `createRequirement` 会逐条把 `attachmentInfo` 的 `ossId` 引用复制成需求附件,所以这里
* 不需要先落一次草稿(这点与后端 `/requirementPool/submit` 不同——那条链路才是从已落库
* 草稿里搬附件)。</p>
*
* <p>后端在同一事务里还会清掉该用户的草稿(`cleanHistoryDrafts`),故提交成功后草稿箱
* 一并清空,用户不会在下次进入页面时看到已经提报过的内容。</p>
*
* <p>⚠️ 该链路只落 `vm_project_requirement` 单表;「我的项目/需求」列表
* (`/web/requirementPool/list`)查的是 `vm_rd_project`,所以本页提交的记录**不会**出现在
* 那个列表里(详见 `api/demand.ts#submitDemand`)。</p>
*/
async function submit(): Promise<boolean> { async function submit(): Promise<boolean> {
if (!form.value.bizType) { if (!form.value.bizType) {
ElMessage.warning('请选择业务类型') ElMessage.warning('请选择业务类型')
@@ -272,7 +217,6 @@ export const useDemandStore = defineStore('demand', () => {
ElMessage.warning('请填写核心需求描述') ElMessage.warning('请填写核心需求描述')
return false return false
} }
// 手写模式下金额会直接作为 budgetRange 提交,故空值必须拦住
if (form.value.budgetManual && !form.value.budgetRange.trim()) { if (form.value.budgetManual && !form.value.budgetRange.trim()) {
ElMessage.warning('请填写预算金额,或改选一个预设档位') ElMessage.warning('请填写预算金额,或改选一个预设档位')
return false return false

View File

@@ -43,42 +43,31 @@ function createDefaultInvoiceQuery(): InvoiceQuery {
return { keyword: '', status: 'all', page: 1, pageSize: 6 } return { keyword: '', status: 'all', page: 1, pageSize: 6 }
} }
/** 财务中心数据(统计/账单/支付/发票/抬头) */
export const useFinanceStore = defineStore('finance', () => { export const useFinanceStore = defineStore('finance', () => {
// —— 统计
const stats = ref<FinanceStats | null>(null) const stats = ref<FinanceStats | null>(null)
// —— 账单列表
const list = ref<Bill[]>([]) const list = ref<Bill[]>([])
const total = ref(0) const total = ref(0)
const query = ref<BillQuery>(createDefaultBillQuery()) const query = ref<BillQuery>(createDefaultBillQuery())
const currentBill = ref<Bill | null>(null) const currentBill = ref<Bill | null>(null)
// —— 支付
const payment = ref<Payment | null>(null) const payment = ref<Payment | null>(null)
const result = ref<PaymentResult | null>(null) const result = ref<PaymentResult | null>(null)
// —— 收银台(确认支付页)
/** 收银台待支付账单(由路由 `?ids=` 拉取) */
const checkoutBills = ref<Bill[]>([]) const checkoutBills = ref<Bill[]>([])
const checkoutLoading = ref(false) const checkoutLoading = ref(false)
/** 最近一次下单响应(微信 codeUrl / 支付宝跳转地址) */
const payOrder = ref<PayOrderResponse | null>(null) const payOrder = ref<PayOrderResponse | null>(null)
// —— 发票
const invoices = ref<Invoice[]>([]) const invoices = ref<Invoice[]>([])
const invoiceTotal = ref(0) const invoiceTotal = ref(0)
const invoiceQuery = ref<InvoiceQuery>(createDefaultInvoiceQuery()) const invoiceQuery = ref<InvoiceQuery>(createDefaultInvoiceQuery())
// —— 发票抬头
const titles = ref<InvoiceTitle[]>([]) const titles = ref<InvoiceTitle[]>([])
// —— 统计
async function fetchStats(period?: string): Promise<void> { async function fetchStats(period?: string): Promise<void> {
stats.value = await getFinanceStats(period) stats.value = await getFinanceStats(period)
} }
// —— 账单
async function fetchList(): Promise<void> { async function fetchList(): Promise<void> {
const pageData: PageResult<Bill> = await getBillList(query.value) const pageData: PageResult<Bill> = await getBillList(query.value)
list.value = pageData.list list.value = pageData.list
@@ -89,13 +78,11 @@ export const useFinanceStore = defineStore('finance', () => {
query.value = next query.value = next
} }
/** 确定筛选(重置页码后取数) */
async function search(): Promise<void> { async function search(): Promise<void> {
query.value = { ...query.value, page: 1 } query.value = { ...query.value, page: 1 }
await fetchList() await fetchList()
} }
/** 重置全部条件 */
async function resetQuery(): Promise<void> { async function resetQuery(): Promise<void> {
query.value = createDefaultBillQuery() query.value = createDefaultBillQuery()
await fetchList() await fetchList()
@@ -110,12 +97,10 @@ export const useFinanceStore = defineStore('finance', () => {
currentBill.value = await getBillDetail(id) currentBill.value = await getBillDetail(id)
} }
// —— 支付
async function createPayment(billId: string, method: PayMethod): Promise<void> { async function createPayment(billId: string, method: PayMethod): Promise<void> {
payment.value = await createPaymentApi(billId, method) payment.value = await createPaymentApi(billId, method)
} }
/** 切换支付方式(刷新二维码) */
async function switchPaymentMethod(billId: string, method: PayMethod): Promise<void> { async function switchPaymentMethod(billId: string, method: PayMethod): Promise<void> {
payment.value = await switchPaymentMethodApi(billId, method) payment.value = await switchPaymentMethodApi(billId, method)
} }
@@ -126,15 +111,6 @@ export const useFinanceStore = defineStore('finance', () => {
: await getPayResult(billId) : await getPayResult(billId)
} }
// —— 收银台(确认支付)
/**
* 拉取收银台订单明细。
*
* <p>入参是**需求 id**(项目详情路由 `:id`):数据走项目详情那个现成接口
* `GET /web/requirementPool/get/finance/{需求id}` 的 `billPlans`,不另开端点。
* `ids` 为可选的期次 id(`vm_bill_plan.id`),用于精确指定付哪一期;
* 不传则取该需求下状态为「待支付」的期。</p>
*/
async function loadCheckout(requirementId: string, ids: string[] = []): Promise<void> { async function loadCheckout(requirementId: string, ids: string[] = []): Promise<void> {
checkoutLoading.value = true checkoutLoading.value = true
try { try {
@@ -144,27 +120,18 @@ export const useFinanceStore = defineStore('finance', () => {
} }
} }
/**
* 微信 Native 下单(拿到 codeUrl 后由页面渲染二维码)。
*
* @param billIds 本笔支付覆盖的账单 id。后端据此落 `vm_finance_payment` 并把它写进
* `request_payload`,支付成功回调再为每张账单落 `vm_finance_payment_bill`。
*/
async function payByWechatNative(orderNo: string, amount: number, billIds: string[]): Promise<void> { async function payByWechatNative(orderNo: string, amount: number, billIds: string[]): Promise<void> {
payOrder.value = await createWechatNativeOrderApi(orderNo, amount, billIds) payOrder.value = await createWechatNativeOrderApi(orderNo, amount, billIds)
} }
/** 支付宝电脑网站支付下单(拿到跳转地址后由页面跳转) */
async function payByAlipayPage(orderNo: string, amount: number, billIds: string[]): Promise<void> { async function payByAlipayPage(orderNo: string, amount: number, billIds: string[]): Promise<void> {
payOrder.value = await createAlipayPageOrderApi(orderNo, amount, billIds) payOrder.value = await createAlipayPageOrderApi(orderNo, amount, billIds)
} }
/** 清空下单响应(关闭二维码弹窗时调用) */
function clearPayOrder(): void { function clearPayOrder(): void {
payOrder.value = null payOrder.value = null
} }
// —— 发票
async function fetchInvoices(): Promise<void> { async function fetchInvoices(): Promise<void> {
const pageData: PageResult<Invoice> = await getInvoiceList(invoiceQuery.value) const pageData: PageResult<Invoice> = await getInvoiceList(invoiceQuery.value)
invoices.value = pageData.list invoices.value = pageData.list
@@ -175,7 +142,6 @@ export const useFinanceStore = defineStore('finance', () => {
invoiceQuery.value = next invoiceQuery.value = next
} }
/** 发票确定筛选(重置页码后取数) */
async function searchInvoices(): Promise<void> { async function searchInvoices(): Promise<void> {
invoiceQuery.value = { ...invoiceQuery.value, page: 1 } invoiceQuery.value = { ...invoiceQuery.value, page: 1 }
await fetchInvoices() await fetchInvoices()
@@ -186,14 +152,12 @@ export const useFinanceStore = defineStore('finance', () => {
await fetchInvoices() await fetchInvoices()
} }
/** 申请开票(状态 → 开票中) */
async function applyInvoice(id: string): Promise<void> { async function applyInvoice(id: string): Promise<void> {
const updated = await applyInvoiceApi(id) const updated = await applyInvoiceApi(id)
const index = invoices.value.findIndex((item) => item.id === id) const index = invoices.value.findIndex((item) => item.id === id)
if (index >= 0) invoices.value[index] = updated if (index >= 0) invoices.value[index] = updated
} }
// —— 发票抬头
async function fetchTitles(): Promise<void> { async function fetchTitles(): Promise<void> {
titles.value = await getInvoiceTitles() titles.value = await getInvoiceTitles()
} }
@@ -217,7 +181,6 @@ export const useFinanceStore = defineStore('finance', () => {
titles.value = await setDefaultTitleApi(id) titles.value = await setDefaultTitleApi(id)
} }
// —— 报表导出
async function exportReport(): Promise<void> { async function exportReport(): Promise<void> {
await exportReportApi() await exportReportApi()
} }

View File

@@ -28,20 +28,6 @@ import { toCase } from '@/utils/caseVo'
import { formatBusinessType, toBusinessType } from '@/utils/format' import { formatBusinessType, toBusinessType } from '@/utils/format'
import { useCasesStore } from '@/stores/modules/cases' import { useCasesStore } from '@/stores/modules/cases'
/**
* 首页数据(数据层:取数 + 状态 + 后端 VO → 页面类型映射,页面只调本 store)
*
* 后端接口:
* - UserSideWebHomePageController(前缀 /web/home-page):看板、业务服务线、业务详情
* - ProjectCaseUserController 的 /web/projectCase/mostViewed:案例库精选
*
* 页面/组件消费的类型(BusinessLine / Case / HomeStats)保持不变,
* 后端 VO 与页面字段的差异(字段名、业务类型取值、富文本、数值单位)全部在本层对齐。
*/
// 业务类型归一化(toBusinessType)已抽到 `@/utils/format`,首页与案例库共用。
/** 富文本转纯文本(服务内容区以 <pre> 展示,需去掉 HTML 标签,避免渲染出标签本身) */
function stripHtml(html: string): string { function stripHtml(html: string): string {
return html return html
.replace(/<br\s*\/?>/gi, '\n') .replace(/<br\s*\/?>/gi, '\n')
@@ -55,7 +41,6 @@ function stripHtml(html: string): string {
.trim() .trim()
} }
/** 后端轮播 VO → 页面 Banner(仅启用项,按 sort 升序) */
function toBanners(list: BannerVo[]): Banner[] { function toBanners(list: BannerVo[]): Banner[] {
return list return list
.filter((item) => item.enabled !== false && Boolean(item.imageUrl)) .filter((item) => item.enabled !== false && Boolean(item.imageUrl))
@@ -66,7 +51,6 @@ function toBanners(list: BannerVo[]): Banner[] {
})) }))
} }
/** 后端「项目提报」→ 业务线卡片 / 业务详情 */
function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine { function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
const serviceTypes: ServiceType[] = (vo.serviceTypes ?? []).map((item) => ({ const serviceTypes: ServiceType[] = (vo.serviceTypes ?? []).map((item) => ({
label: item.serviceName ?? '', label: item.serviceName ?? '',
@@ -77,7 +61,6 @@ function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
.map((item) => stripHtml(item.content ?? '')) .map((item) => stripHtml(item.content ?? ''))
.filter((text) => text.length > 0) .filter((text) => text.length > 0)
.join('\n\n') .join('\n\n')
// 列表接口的「项目背景」富文本按条映射为服务内容区块(保留 HTML 原文,详情页逐块渲染)
const contents: ServiceContentBlock[] = (vo.backgrounds ?? []) const contents: ServiceContentBlock[] = (vo.backgrounds ?? [])
.map((item) => ({ title: '', content: item.content ?? '' })) .map((item) => ({ title: '', content: item.content ?? '' }))
.filter((block) => block.content.trim().length > 0) .filter((block) => block.content.trim().length > 0)
@@ -95,7 +78,6 @@ function toBusinessLine(vo: ProjectSubmissionVo): BusinessLine {
} }
} }
/** 后端「业务详情聚合 VO」→ 业务线(主表单对象;服务类型 / 附件 / 富文本目录均为一对多 List,需按数组下钻读取) */
function toBusinessLineFromInfo(info: ProjectSubmissionInfo): BusinessLine { function toBusinessLineFromInfo(info: ProjectSubmissionInfo): BusinessLine {
const ps = info.projectSubmission ?? ({} as ProjectSubmissionFlat) const ps = info.projectSubmission ?? ({} as ProjectSubmissionFlat)
const serviceTypes: ServiceType[] = (info.projectServiceType ?? []).map((item) => ({ const serviceTypes: ServiceType[] = (info.projectServiceType ?? []).map((item) => ({
@@ -107,7 +89,6 @@ function toBusinessLineFromInfo(info: ProjectSubmissionInfo): BusinessLine {
.map((item) => stripHtml(item.content ?? '')) .map((item) => stripHtml(item.content ?? ''))
.filter((text) => text.length > 0) .filter((text) => text.length > 0)
.join('\n\n') .join('\n\n')
// 一、二、三…每条富文本目录 → 一个「服务内容」区块(保留 HTML,含内嵌图片/视频)
const contents: ServiceContentBlock[] = (info.projectSubmissionCatalog ?? []) const contents: ServiceContentBlock[] = (info.projectSubmissionCatalog ?? [])
.map((item) => ({ title: item.title ?? '', content: item.content ?? '' })) .map((item) => ({ title: item.title ?? '', content: item.content ?? '' }))
.filter((block) => block.content.trim().length > 0) .filter((block) => block.content.trim().length > 0)
@@ -125,9 +106,6 @@ function toBusinessLineFromInfo(info: ProjectSubmissionInfo): BusinessLine {
} }
} }
// 后端案例 VO → 页面 Case 的映射已抽到 `@/utils/caseVo`(首页与案例库共用)。
/** 未登录时后端看板返回 null,这里兜底为零值 */
const EMPTY_STATUS: ProjectStatusCount = { const EMPTY_STATUS: ProjectStatusCount = {
projectCount: 0, projectCount: 0,
ongoingProjectCount: 0, ongoingProjectCount: 0,
@@ -135,12 +113,6 @@ const EMPTY_STATUS: ProjectStatusCount = {
} }
export const useHomeStore = defineStore('home', () => { export const useHomeStore = defineStore('home', () => {
/**
* 案例类型字典(提报 id → 类型名)由案例库 store 提供。
*
* <p>案例表 business_type 列存的是提报主键 id,精选案例标签要翻译成名称就得查这份字典,
* 故首页与案例库共用同一份(见 casesStore.ensureCategories)。</p>
*/
const casesStore = useCasesStore() const casesStore = useCasesStore()
const stats = ref<HomeStats>({ pending: 0, inProgress: 0, total: 0 }) const stats = ref<HomeStats>({ pending: 0, inProgress: 0, total: 0 })
@@ -149,31 +121,20 @@ export const useHomeStore = defineStore('home', () => {
const featuredCases = ref<Case[]>([]) const featuredCases = ref<Case[]>([])
const currentBusiness = ref<BusinessLine | null>(null) const currentBusiness = ref<BusinessLine | null>(null)
/** 提报原始列表(内部缓存):业务线卡片与详情共用,详情需按提报主键 id 取数 */
const submissions = ref<ProjectSubmissionVo[]>([]) const submissions = ref<ProjectSubmissionVo[]>([])
/** 门户入口原始列表(含快捷 / 其他两组,由 groupType 区分) */
const portalEntries = ref<PortalEntryVo[]>([]) const portalEntries = ref<PortalEntryVo[]>([])
/** 门户入口是否已成功取到(失败时组件回退到内置默认入口) */
const portalEntriesLoaded = ref(false) const portalEntriesLoaded = ref(false)
/** 快捷入口组(groupType = 1) */
const quickEntries = computed(() => const quickEntries = computed(() =>
portalEntries.value.filter((item) => item.groupType === '1'), portalEntries.value.filter((item) => item.groupType === '1'),
) )
/** 其他入口组(groupType ≠ 1) */
const otherEntries = computed(() => const otherEntries = computed(() =>
portalEntries.value.filter((item) => item.groupType !== '1'), portalEntries.value.filter((item) => item.groupType !== '1'),
) )
/**
* 拉取当前用户的门户入口。
*
* <p>该接口对游客(无登录态)与无权限用户不可用,失败时保持入口为空,
* 由调用方回退到默认入口,不影响首页其余数据。</p>
*/
async function fetchPortalEntries(): Promise<void> { async function fetchPortalEntries(): Promise<void> {
try { try {
const list = await getPortalEntryList() const list = await getPortalEntryList()
@@ -185,27 +146,23 @@ export const useHomeStore = defineStore('home', () => {
} }
} }
/** 快捷 / 其他入口互转,成功后重新拉取以同步后端排序 */
async function switchEntryGroup(id: number, type: PortalEntryGroup): Promise<void> { async function switchEntryGroup(id: number, type: PortalEntryGroup): Promise<void> {
await switchPortalEntryGroupApi(id, type) await switchPortalEntryGroupApi(id, type)
await fetchPortalEntries() await fetchPortalEntries()
} }
/** 确保提报列表已加载(业务详情页可能被直接打开,此时列表尚未取过) */
async function ensureSubmissions(): Promise<void> { async function ensureSubmissions(): Promise<void> {
if (submissions.value.length === 0) { if (submissions.value.length === 0) {
submissions.value = await getServiceTypeList() submissions.value = await getServiceTypeList()
} }
} }
/** 首页初始化:看板 + 轮播图 + 业务服务线 + 案例精选 */
async function fetchHomeData(): Promise<void> { async function fetchHomeData(): Promise<void> {
const [statusCount, submissionList, cases, carouselList] = await Promise.all([ const [statusCount, submissionList, cases, carouselList] = await Promise.all([
getProjectStatusCount(), getProjectStatusCount(),
getServiceTypeList(), getServiceTypeList(),
getFeaturedCasesApi(), getFeaturedCasesApi(),
getCarouselList().catch(() => [] as BannerVo[]), getCarouselList().catch(() => [] as BannerVo[]),
// 案例标签需要「提报 id → 类型名」字典,与首页其余数据并行取(幂等,已加载则不发请求)
casesStore.ensureCategories(), casesStore.ensureCategories(),
]) ])
const status = statusCount ?? EMPTY_STATUS const status = statusCount ?? EMPTY_STATUS
@@ -218,17 +175,9 @@ export const useHomeStore = defineStore('home', () => {
businessLines.value = submissionList.map(toBusinessLine) businessLines.value = submissionList.map(toBusinessLine)
featuredCases.value = cases.map((vo) => toCase(vo, casesStore.typeNameById)) featuredCases.value = cases.map((vo) => toCase(vo, casesStore.typeNameById))
banners.value = toBanners(carouselList ?? []) banners.value = toBanners(carouselList ?? [])
// 入口个性化接口失败已被内部吞掉,不会影响上面的首页数据
await fetchPortalEntries() await fetchPortalEntries()
} }
/**
* 按业务类型切换案例精选。
*
* <p>后端 selectByType 按案例表 business_type 列的**原值**精确匹配,而该列实测存的是
* **提报主键 id**,故这里用 BusinessType 名称经 /categories 字典反查提报 id 再下发;
* 字典里查不到(提报表未登记该类型)时直接置空,不发无效请求。</p>
*/
async function fetchFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<void> { async function fetchFeaturedCases(category: BusinessType | 'all' = 'all'): Promise<void> {
if (category === 'all') { if (category === 'all') {
featuredCases.value = (await getFeaturedCasesApi()).map((vo) => toCase(vo, casesStore.typeNameById)) featuredCases.value = (await getFeaturedCasesApi()).map((vo) => toCase(vo, casesStore.typeNameById))
@@ -244,7 +193,6 @@ export const useHomeStore = defineStore('home', () => {
featuredCases.value = (await getFeaturedCasesApi(typeId)).map((vo) => toCase(vo, casesStore.typeNameById)) featuredCases.value = (await getFeaturedCasesApi(typeId)).map((vo) => toCase(vo, casesStore.typeNameById))
} }
/** 业务详情:前端 BusinessType 反查提报主键后取详情 */
async function fetchBusinessDetail(type: BusinessType): Promise<void> { async function fetchBusinessDetail(type: BusinessType): Promise<void> {
await ensureSubmissions() await ensureSubmissions()
const target = submissions.value.find((item) => toBusinessType(item.businessType) === type) const target = submissions.value.find((item) => toBusinessType(item.businessType) === type)

View File

@@ -12,7 +12,6 @@ import {
markRead as markReadApi, markRead as markReadApi,
} from '@/api/message' } from '@/api/message'
/** tab 计数结构 */
export interface MessageTabCounts { export interface MessageTabCounts {
all: number all: number
在线客服: number 在线客服: number
@@ -29,7 +28,6 @@ function createDefaultQuery(): MessageQuery {
} }
} }
/** 消息中心数据(列表 / 筛选 / 分页 / 已读) */
export const useMessageStore = defineStore('message', () => { export const useMessageStore = defineStore('message', () => {
const query = ref<MessageQuery>(createDefaultQuery()) const query = ref<MessageQuery>(createDefaultQuery())
const list = ref<Message[]>([]) const list = ref<Message[]>([])
@@ -59,13 +57,11 @@ export const useMessageStore = defineStore('message', () => {
query.value = next query.value = next
} }
/** 确定搜索(重置页码后取数) */
async function search(): Promise<void> { async function search(): Promise<void> {
query.value = { ...query.value, page: 1 } query.value = { ...query.value, page: 1 }
await fetchList() await fetchList()
} }
/** 重置全部条件 */
async function resetQuery(): Promise<void> { async function resetQuery(): Promise<void> {
query.value = createDefaultQuery() query.value = createDefaultQuery()
await fetchList() await fetchList()
@@ -76,7 +72,6 @@ export const useMessageStore = defineStore('message', () => {
await fetchList() await fetchList()
} }
/** 全部标记已读 */
async function markAllRead(): Promise<void> { async function markAllRead(): Promise<void> {
await markAllReadApi() await markAllReadApi()
ElMessage.success('已全部标记为已读') ElMessage.success('已全部标记为已读')

View File

@@ -4,13 +4,6 @@ import { fetchOssPreviewBlob } from '@/api/oss'
import { resolvePreviewKind, PREVIEW_TEXT_MAX_BYTES, type PreviewKind } from '@/utils/filePreview' import { resolvePreviewKind, PREVIEW_TEXT_MAX_BYTES, type PreviewKind } from '@/utils/filePreview'
import type { FileInfo } from '@/types/api' import type { FileInfo } from '@/types/api'
/**
* OSS 文件预览全局状态。
*
* <p>所有 `FileList` 的「预览」统一走这里,弹一个全局 `FilePreviewDialog`:
* 有 `ossId` 的文件经同源后端接口取 blob 内联渲染(绕开桶强制下载);
* 无 `ossId` 的图片可退化为公网直链(子资源渲染不受强制下载影响),其余提示「不支持在线预览」。</p>
*/
export const useOssStore = defineStore('oss', () => { export const useOssStore = defineStore('oss', () => {
const visible = ref(false) const visible = ref(false)
const file = ref<FileInfo | null>(null) const file = ref<FileInfo | null>(null)
@@ -19,9 +12,7 @@ export const useOssStore = defineStore('oss', () => {
const textContent = ref<string | null>(null) const textContent = ref<string | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
/** 文本类文件超过预览上限被截断渲染 */
const truncated = ref(false) const truncated = ref(false)
/** 被截断文件的实际大小(字节) */
const totalBytes = ref(0) const totalBytes = ref(0)
function revokeObjectUrl(): void { function revokeObjectUrl(): void {
@@ -44,13 +35,11 @@ export const useOssStore = defineStore('oss', () => {
const previewKind = resolvePreviewKind(target.name) const previewKind = resolvePreviewKind(target.name)
kind.value = previewKind kind.value = previewKind
// 不支持内联渲染(office / 未知类型):直接提示,等用户下载
if (previewKind === 'unsupported') { if (previewKind === 'unsupported') {
loading.value = false loading.value = false
return return
} }
// 无 ossId:图片可走公网直链(<img> 子资源渲染不受强制下载影响),其余无法预览
if (target.ossId === undefined || target.ossId === null) { if (target.ossId === undefined || target.ossId === null) {
if (previewKind === 'image' && target.url) { if (previewKind === 'image' && target.url) {
objectUrl.value = target.url objectUrl.value = target.url
@@ -65,8 +54,6 @@ export const useOssStore = defineStore('oss', () => {
try { try {
const blob = await fetchOssPreviewBlob(target.ossId) const blob = await fetchOssPreviewBlob(target.ossId)
if (previewKind === 'text') { if (previewKind === 'text') {
// 整文件塞进 <pre> 会卡死浏览器(如 5MB+ 的 .sql/.log),
// 仅读取前 PREVIEW_TEXT_MAX_BYTES 渲染,超出截断并提示下载
const total = blob.size const total = blob.size
if (total > PREVIEW_TEXT_MAX_BYTES) { if (total > PREVIEW_TEXT_MAX_BYTES) {
textContent.value = await blob.slice(0, PREVIEW_TEXT_MAX_BYTES).text() textContent.value = await blob.slice(0, PREVIEW_TEXT_MAX_BYTES).text()
@@ -76,7 +63,6 @@ export const useOssStore = defineStore('oss', () => {
textContent.value = await blob.text() textContent.value = await blob.text()
} }
} else { } else {
// 图片 / PDF / 音视频由浏览器原生处理,5MB 量级毫无压力
objectUrl.value = URL.createObjectURL(blob) objectUrl.value = URL.createObjectURL(blob)
} }
loading.value = false loading.value = false

View File

@@ -51,9 +51,7 @@ function createDefaultFavoritesQuery(): FavoritesQuery {
return { page: 1, pageSize: 8, keyword: '', category: 'all' } return { page: 1, pageSize: 8, keyword: '', category: 'all' }
} }
/** 个人中心数据(资料/安全/地址/收藏/资质/反馈/帮助) */
export const useProfileStore = defineStore('profile', () => { export const useProfileStore = defineStore('profile', () => {
// ── 账号资料 ──
const profile = ref<Profile | null>(null) const profile = ref<Profile | null>(null)
async function fetchProfile(): Promise<void> { async function fetchProfile(): Promise<void> {
@@ -65,7 +63,6 @@ export const useProfileStore = defineStore('profile', () => {
ElMessage.success('昵称修改成功') ElMessage.success('昵称修改成功')
} }
// ── 账号安全 ──
async function changePasswordAction(params: ChangePasswordParams): Promise<void> { async function changePasswordAction(params: ChangePasswordParams): Promise<void> {
await changePassword(params) await changePassword(params)
profile.value = await getProfile() profile.value = await getProfile()
@@ -97,7 +94,6 @@ export const useProfileStore = defineStore('profile', () => {
ElMessage.success('注销成功') ElMessage.success('注销成功')
} }
// ── 收货地址 ──
const addresses = ref<Address[]>([]) const addresses = ref<Address[]>([])
const addressTotal = ref(0) const addressTotal = ref(0)
const addressQuery = ref<ListQuery>(createDefaultAddressQuery()) const addressQuery = ref<ListQuery>(createDefaultAddressQuery())
@@ -138,7 +134,6 @@ export const useProfileStore = defineStore('profile', () => {
await fetchAddresses() await fetchAddresses()
} }
// ── 我的收藏 ──
const favorites = ref<Case[]>([]) const favorites = ref<Case[]>([])
const favoritesTotal = ref(0) const favoritesTotal = ref(0)
const favoritesCounts = ref<Partial<Record<BusinessType, number>>>({}) const favoritesCounts = ref<Partial<Record<BusinessType, number>>>({})
@@ -169,7 +164,6 @@ export const useProfileStore = defineStore('profile', () => {
await fetchFavorites() await fetchFavorites()
} }
// ── 资质管理 ──
const qualifications = ref<Qualification[]>([]) const qualifications = ref<Qualification[]>([])
const qualificationTotal = ref(0) const qualificationTotal = ref(0)
const qualificationQuery = ref<ListQuery>(createDefaultQualificationQuery()) const qualificationQuery = ref<ListQuery>(createDefaultQualificationQuery())
@@ -204,7 +198,6 @@ export const useProfileStore = defineStore('profile', () => {
await fetchQualifications() await fetchQualifications()
} }
// ── 意见反馈 / 帮助中心 ──
async function submitFeedbackAction(feedback: Feedback): Promise<void> { async function submitFeedbackAction(feedback: Feedback): Promise<void> {
await submitFeedbackApi(feedback) await submitFeedbackApi(feedback)
} }

View File

@@ -37,12 +37,6 @@ function createDefaultQuery(): ProjectQuery {
} }
} }
/**
* 业务类型件数 —— 由 `getFilterConditions` 的统计构建(**全量口径**,不随筛选 / 分页变化)。
*
* <p>「全部」的件数取统计里的 `total`(该用户需求总数);统计拿不到时退回列表 `total`,
* 这种降级下只会渲染出「全部」一行。</p>
*/
function buildTypeCounts( function buildTypeCounts(
statistics: ProjectStatistics | null | undefined, statistics: ProjectStatistics | null | undefined,
total: number, total: number,
@@ -58,49 +52,26 @@ function buildTypeCounts(
] ]
} }
/** 项目中心数据(列表 + 详情走真实接口;文件 / 售后仍是 mock,后端无对应接口) */
export const useProjectStore = defineStore('project', () => { export const useProjectStore = defineStore('project', () => {
const query = ref<ProjectQuery>(createDefaultQuery()) const query = ref<ProjectQuery>(createDefaultQuery())
/** 列表行(后端 RdProjectVo;**不是**详情那套 mock 的 Project) */
const list = ref<RdProjectVo[]>([]) const list = ref<RdProjectVo[]>([])
const total = ref(0) const total = ref(0)
const current = ref<Project | null>(null) const current = ref<Project | null>(null)
/** 业务类型件数(左侧筛选栏用;由 `ensureFilterConditions` 单独拉取,全量口径) */
const typeCounts = ref<ProjectTypeCount[]>([{ value: 'all', label: '全部', count: 0 }]) const typeCounts = ref<ProjectTypeCount[]>([{ value: 'all', label: '全部', count: 0 }])
/** 业务类型筛选条件是否已拉过(值域在运行期不变,进页面拉一次即可) */
let filterConditionsLoaded = false let filterConditionsLoaded = false
/**
* 已完成项目(**申请售后页「项目名称」下拉的候选**)。
*
* <p>口径见 `api/project.ts#getCompletedProjects`:自己名下已立项且状态为 COMPLETED 的项目,
* 与后端「只有已完成的项目可以申请售后」的准入校验一致。行的 `id` 就是申请接口要传的需求 id。</p>
*
* <p>与业务类型的筛选条件不同,这个值域**会随项目状态变化**,所以不缓存、
* 每次进申请页都重拉(见 {@link fetchCompletedProjects})。</p>
*/
const completedProjects = ref<RdProjectVo[]>([]) const completedProjects = ref<RdProjectVo[]>([])
/** 详情原始 bundle(4 支接口的响应);切页签时按页签替换其中一支后重新映射 */
const detailBundle = ref<RequirementDetailBundle | null>(null) const detailBundle = ref<RequirementDetailBundle | null>(null)
/** 详情请求序号:需求 id 一变就自增,用来丢弃在途的「上一个需求」的响应 */
let detailSeq = 0 let detailSeq = 0
/** 各页签自己的请求序号:同一页签连点只应用最后一次响应 */
const tabSeq: Record<ProjectDetailTab, number> = { const tabSeq: Record<ProjectDetailTab, number> = {
progress: 0, progress: 0,
files: 0, files: 0,
finance: 0, finance: 0,
afterSales: 0, afterSales: 0,
} }
/** 正在刷新的页签('' = 无);页签按钮据此显示加载态 */
const loadingTab = ref<ProjectDetailTab | ''>('') const loadingTab = ref<ProjectDetailTab | ''>('')
/**
* 取业务类型筛选条件(左侧筛选栏「业务类型」的值域 + 件数)。
*
* <p>幂等:成功拉到一次后不再重复请求(值域是全量口径,与当前筛选条件无关)。
* 失败时不标记已加载,下次进页面仍会重试;期间保留上一次结果,首屏即失败则只有「全部」一行。</p>
*/
async function ensureFilterConditions(): Promise<void> { async function ensureFilterConditions(): Promise<void> {
if (filterConditionsLoaded) return if (filterConditionsLoaded) return
try { try {
@@ -112,13 +83,6 @@ export const useProjectStore = defineStore('project', () => {
} }
} }
/**
* 取列表(服务端筛选 + 分页;失败向上抛给调用方处理)。
*
* <p>⚠️ 这里**不**碰 `typeCounts`:`/list` 的 `requirementStatistics` 后端恒为 null,
* 拿它重建会把业务类型整列抹成只剩「全部」,且「全部」件数会变成筛选后的 total(偏小)。
* 业务类型的值域统一由 {@link ensureFilterConditions} 负责。</p>
*/
async function fetchList(): Promise<void> { async function fetchList(): Promise<void> {
const result = await getProjectList(query.value) const result = await getProjectList(query.value)
const page = result?.requirementPage const page = result?.requirementPage
@@ -126,12 +90,6 @@ export const useProjectStore = defineStore('project', () => {
total.value = page?.total ?? 0 total.value = page?.total ?? 0
} }
/**
* 取「已完成项目」(申请售后页「项目名称」下拉的值域)。
*
* <p>失败只告警不清空:保留上一次的结果,页面下拉至少还有数据;首次即失败则为空数组,
* 申请页会退化成只显示当前项目的单项下拉(见 `after-sales.vue` 的兜底合并)。</p>
*/
async function fetchCompletedProjects(): Promise<void> { async function fetchCompletedProjects(): Promise<void> {
try { try {
completedProjects.value = await getCompletedProjects() completedProjects.value = await getCompletedProjects()
@@ -141,21 +99,11 @@ export const useProjectStore = defineStore('project', () => {
} }
} }
/**
* 取详情(**首屏**:4 支并行,一次到位)。
*
* <p>后端在「需求不存在」或「不属于当前用户」时 4 支都返回 `data: null` ⇒ 页面显示空态;
* 请求本身失败(如 500)也归为 null,不让异常冒到组件里。</p>
*
* <p>原始响应同时缓存进 `detailBundle`,之后切页签只替换其中一支(见 {@link refreshTab}),
* 不必重拉另外三支。</p>
*/
async function fetchDetail(id: string): Promise<void> { async function fetchDetail(id: string): Promise<void> {
const seq = ++detailSeq const seq = ++detailSeq
loadingTab.value = '' loadingTab.value = ''
try { try {
const bundle = await getProjectDetailBundle(id) const bundle = await getProjectDetailBundle(id)
// 期间已切到别的需求 ⇒ 丢弃这次响应,别用旧项目覆盖新项目
if (seq !== detailSeq) return if (seq !== detailSeq) return
if (!bundle) { if (!bundle) {
detailBundle.value = null detailBundle.value = null
@@ -173,17 +121,6 @@ export const useProjectStore = defineStore('project', () => {
} }
} }
/**
* 切页签刷新:**只调该页签对应的那一个接口**,拿到最新数据后落回 bundle 再重新映射。
*
* <p>后端 4 个页签各一个端点 ⇒ 「点一次页签 = 一次请求」,不需要重拉另外三支。
* 页签内容(里程碑 / 付款计划 / 附件 / 售后…)可能被别的入口改动,所以每次点击都重新取。</p>
*
* <p>并发保护两层:① 需求 id 变了(切项目)⇒ 丢弃在途响应;② 同一页签连点 ⇒ 只应用
* 最后一次响应(先到的慢响应被后到的覆盖)。</p>
*
* <p>后端返回 `null`(需求不存在 / 非本人)时**保留现有数据**,只告警、不清空页面。</p>
*/
async function refreshTab(tab: ProjectDetailTab): Promise<void> { async function refreshTab(tab: ProjectDetailTab): Promise<void> {
const requestId = current.value?.id const requestId = current.value?.id
if (!requestId) return if (!requestId) return
@@ -223,7 +160,6 @@ export const useProjectStore = defineStore('project', () => {
query.value = next query.value = next
} }
/** 确定筛选 / 换页后的取数统一入口(失败只提示,不清空已有数据) */
async function load(): Promise<void> { async function load(): Promise<void> {
try { try {
await fetchList() await fetchList()
@@ -233,13 +169,11 @@ export const useProjectStore = defineStore('project', () => {
} }
} }
/** 确定筛选(重置页码后取数) */
async function search(): Promise<void> { async function search(): Promise<void> {
query.value = { ...query.value, pageNum: 1 } query.value = { ...query.value, pageNum: 1 }
await load() await load()
} }
/** 重置全部条件 */
async function resetQuery(): Promise<void> { async function resetQuery(): Promise<void> {
query.value = createDefaultQuery() query.value = createDefaultQuery()
await load() await load()
@@ -250,31 +184,16 @@ export const useProjectStore = defineStore('project', () => {
await load() await load()
} }
/**
* 上传售后附件(提交前的暂存步骤)。
*
* <p>只传 OSS、不落业务表;返回项带 `ossId`,由申请页收集后在提交时下发
* `attachmentOssIds`,后端再转存成售后附件。</p>
*/
async function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> { async function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
return uploadAfterSalesFileApi(file) return uploadAfterSalesFileApi(file)
} }
/**
* 申请售后(成功后就地重拉「售后信息」页签)。
*
* <p>不拿返回值本地拼状态:售后状态与工单附件都由后端决定,本地拼容易和真实数据漂移。
* 接口失败(如「只有已完成的项目可以申请售后」)会抛出,由统一 request 层弹提示,
* 这里不做兜底吞异常。</p>
*/
async function submitAfterSales(id: string, payload: AfterSalesPayload): Promise<void> { async function submitAfterSales(id: string, payload: AfterSalesPayload): Promise<void> {
await submitAfterSalesApi(id, payload) await submitAfterSalesApi(id, payload)
ElMessage.success('售后申请已提交') ElMessage.success('售后申请已提交')
// 详情页在场时同步刷新该页签;从申请页提交时会立刻跳回详情,那边 onMounted 会重新取数
if (current.value?.id === id) await refreshTab('afterSales') if (current.value?.id === id) await refreshTab('afterSales')
} }
/** 撤回售后申请(成功后就地重拉「售后信息」页签) */
async function cancelAfterSales(id: string): Promise<void> { async function cancelAfterSales(id: string): Promise<void> {
await cancelAfterSalesApi(id) await cancelAfterSalesApi(id)
ElMessage.success('售后申请已撤回') ElMessage.success('售后申请已撤回')
@@ -289,24 +208,18 @@ export const useProjectStore = defineStore('project', () => {
typeCounts, typeCounts,
ensureFilterConditions, ensureFilterConditions,
fetchList, fetchList,
/** 已完成项目(申请售后页「项目名称」下拉候选) */
completedProjects, completedProjects,
fetchCompletedProjects, fetchCompletedProjects,
fetchDetail, fetchDetail,
/** 切页签刷新(每次点击都调用,只拉该页签那一支) */
refreshTab, refreshTab,
/** 正在刷新的页签,供按钮显示加载态 */
loadingTab, loadingTab,
setQuery, setQuery,
load, load,
search, search,
resetQuery, resetQuery,
changePage, changePage,
/** 上传售后附件(申请页用) */
uploadAfterSalesFile, uploadAfterSalesFile,
/** 申请售后(成功后自动刷新售后页签) */
submitAfterSales, submitAfterSales,
/** 撤回售后申请(成功后自动刷新售后页签) */
cancelAfterSales, cancelAfterSales,
} }
}) })

View File

@@ -8,7 +8,6 @@ import {
uploadChatFile, uploadChatFile,
} from '@/api/service' } from '@/api/service'
/** 在线客服会话数据(消息列表 / 发送状态) */
export const useServiceStore = defineStore('service', () => { export const useServiceStore = defineStore('service', () => {
const messages = ref<ChatMessage[]>([]) const messages = ref<ChatMessage[]>([])
const sending = ref(false) const sending = ref(false)
@@ -17,7 +16,6 @@ export const useServiceStore = defineStore('service', () => {
messages.value = await getChatHistory() messages.value = await getChatHistory()
} }
/** 发送文本:先推入用户消息,600ms 后推入客服自动回复 */
async function send(content: string): Promise<void> { async function send(content: string): Promise<void> {
const trimmed = content.trim() const trimmed = content.trim()
if (!trimmed || sending.value) return if (!trimmed || sending.value) return
@@ -37,7 +35,6 @@ export const useServiceStore = defineStore('service', () => {
} }
} }
/** 发送文件卡片消息 */
async function sendFile(file: File): Promise<void> { async function sendFile(file: File): Promise<void> {
if (sending.value) return if (sending.value) return
sending.value = true sending.value = true

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
/** 站点公共数据(站点配置、全局 loading),非页面级 */
export const useSiteStore = defineStore('site', () => { export const useSiteStore = defineStore('site', () => {
const globalLoading = ref(false) const globalLoading = ref(false)

View File

@@ -18,7 +18,6 @@ import {
} from '@/api/auth' } from '@/api/auth'
import { storage } from '@/utils/storage' import { storage } from '@/utils/storage'
/** 用户登录态(M4:token 持久化 key 'token',路由守卫读取同一 key) */
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const token = ref<string | null>(storage.get<string>('token')) const token = ref<string | null>(storage.get<string>('token'))
const user = ref<User | null>(null) const user = ref<User | null>(null)
@@ -26,7 +25,6 @@ export const useUserStore = defineStore('user', () => {
const isLoggedIn = computed(() => Boolean(token.value)) const isLoggedIn = computed(() => Boolean(token.value))
/** 发送短信验证码(保留原 mock 逻辑) */
async function sendSmsCode(phone: string): Promise<void> { async function sendSmsCode(phone: string): Promise<void> {
try { try {
await sendSmsCodeApi(phone) await sendSmsCodeApi(phone)
@@ -38,14 +36,12 @@ export const useUserStore = defineStore('user', () => {
} }
} }
/** 登录成功统一落库(token 持久化) */
function applyLogin(payload: { token: string; user: User | null }): void { function applyLogin(payload: { token: string; user: User | null }): void {
token.value = payload.token token.value = payload.token
user.value = payload.user user.value = payload.user
storage.set('token', payload.token) storage.set('token', payload.token)
} }
/** 账号密码登录(对接真实后端 /auth/login,返回 access_token) */
async function loginByAccount(params: LoginByAccountParams): Promise<boolean> { async function loginByAccount(params: LoginByAccountParams): Promise<boolean> {
loading.value = true loading.value = true
try { try {
@@ -63,7 +59,6 @@ export const useUserStore = defineStore('user', () => {
} }
} }
/** 手机验证码登录(保留原 mock 逻辑) */
async function loginBySms(params: LoginBySmsParams): Promise<boolean> { async function loginBySms(params: LoginBySmsParams): Promise<boolean> {
loading.value = true loading.value = true
try { try {
@@ -81,7 +76,6 @@ export const useUserStore = defineStore('user', () => {
} }
} }
/** 注册(对接真实后端 /auth/register,账号+密码,无验证码) */
async function register(params: { username: string; password: string; confirmPassword: string }): Promise<boolean> { async function register(params: { username: string; password: string; confirmPassword: string }): Promise<boolean> {
loading.value = true loading.value = true
try { try {
@@ -98,7 +92,6 @@ export const useUserStore = defineStore('user', () => {
} }
} }
/** 重置密码(保留原 mock 逻辑) */
async function resetPassword(params: ResetPasswordParams): Promise<boolean> { async function resetPassword(params: ResetPasswordParams): Promise<boolean> {
loading.value = true loading.value = true
try { try {
@@ -115,7 +108,6 @@ export const useUserStore = defineStore('user', () => {
} }
} }
/** 退出登录(清除持久化 token) */
function logout(): void { function logout(): void {
token.value = null token.value = null
user.value = null user.value = null

View File

@@ -1,18 +1,12 @@
// 通用工具类
// 内容容器:设计稿内容宽 1780(x70→1850,specs2 全量复核)
// 1920 视口下 padding 70 → 内容 1780;窄视口只收窄留白,不产生横向滚动。
// ⚠️ 不能用固定 70px 留白:窄视口下 1780+140 > 视口宽 → 横向滚动条。
.container { .container {
width: 100%; width: 100%;
max-width: @container-width; max-width: @container-width;
margin: 0 auto; margin: 0 auto;
// 留白夹在 16 与 70 之间:(1920 − 1780) / 2 = 70
padding: 0 padding: 0
clamp(@spacing-base, calc((100% - @page-content) / 2), @page-gutter); clamp(@spacing-base, calc((100% - @page-content) / 2), @page-gutter);
} }
// 首页容器:首页设计稿单独基准(内容 1680,留白 120)
.container--home { .container--home {
padding: 0 @home-gutter; padding: 0 @home-gutter;
@@ -21,14 +15,9 @@
} }
} }
// ============ 登录 / 注册 / 找回密码(蓝湖设计稿 02·51·54·55) ============
// 三页共用外壳:左侧 784 品牌渐变面板 + 右侧白底表单区(表单块在右区内居中)。
// 设计稿锚点:面板 (0,0) 784×1080;主标 (257,304) 40/700;标语 (257,715) 22/600。
.auth-page { .auth-page {
position: relative; position: relative;
display: flex; display: flex;
// ⚠️ html 上有全局 zoom(@page-zoom):内部 100vh 会被再缩一次,
// 渲染高 = 100vh × zoom,故必须除以 zoom 才能铺满真实视口(否则底部留灰带)。
min-height: calc(100vh / @page-zoom); min-height: calc(100vh / @page-zoom);
background: @white; background: @white;
@@ -38,7 +27,7 @@
&__brand { &__brand {
.flex(column, flex-start, center); .flex(column, flex-start, center);
flex: 0 0 @login-brand-width; // 35%,右栏 flex:1 自动占 65% flex: 0 0 @login-brand-width;
padding: 304px 24px 0; padding: 304px 24px 0;
background: linear-gradient( background: linear-gradient(
160deg, 160deg,
@@ -74,7 +63,6 @@
color: @white; color: @white;
} }
// 游客模式固定 24px(不再乘 @auth-font-scale)
&__guest { &__guest {
position: absolute; position: absolute;
top: 28px; top: 28px;
@@ -90,7 +78,6 @@
} }
} }
// 表单项在右侧区域内水平 + 垂直居中(不再按设计稿固定锚点定位)
&__main { &__main {
.flex(column, center, center); .flex(column, center, center);
flex: 1; flex: 1;
@@ -101,8 +88,6 @@
&__card { &__card {
.flex(column, flex-start, flex-start); .flex(column, flex-start, flex-start);
width: 693px; width: 693px;
// 标题 / 副标题 / 表单标签一律左对齐(设计稿 x1039)。
// 显式声明是为了不继承任何父级 text-align:center —— 三页(登录/注册/找回)共用。
text-align: left; text-align: left;
} }
@@ -114,9 +99,6 @@
color: #1f2937; color: #1f2937;
} }
// 老大要求:主标 / 副标在**表单列内**居中(设计稿原锚点 x1039 是左对齐)。
// 表单列 = margin-left:17px + 宽 680;这里用 padding-left:17px 把居中盒同步右移,
// 使两者的视觉中轴与表单列中轴重合(而不是与 693 宽的卡片中轴重合,否则会偏左 7px)。
&__title, &__title,
&__subtitle { &__subtitle {
align-self: center; align-self: center;

View File

@@ -1,10 +1,6 @@
// 样式入口(main.ts 引入;variables/mixins 由 vite additionalData 自动注入)
@import './reset.less'; @import './reset.less';
@import './common.less'; @import './common.less';
// 全局视觉缩放:设计 1920 画布整体 ×0.67(变量见 variables.less @page-zoom)。
// zoom 由布局引擎重排,等价于屏幕逻辑分辨率放大 1/zoom,通栏/居中/滚动行为全部保持。
// ≤1286px 物理视口(1280 屏等)降到 0.66,保证 1920 逻辑内容仍无横向滚动。
html { html {
zoom: @page-zoom; zoom: @page-zoom;

View File

@@ -1,4 +1,3 @@
// 混合宏(flex、文本省略、滚动条)
.flex(@direction: row; @justify: flex-start; @align: center) { .flex(@direction: row; @justify: flex-start; @align: center) {
display: flex; display: flex;
@@ -36,8 +35,6 @@
} }
} }
// 卡片区块标题(左侧圆头竖条 + 16px 半粗)
// 项目详情设计稿规格:竖条 5×20 圆头 @project-accent,标题 16px/600,竖条→文字间距 10px
.section-title() { .section-title() {
position: relative; position: relative;
padding-left: 15px; padding-left: 15px;
@@ -59,7 +56,6 @@
} }
} }
// 表单文本控件(输入框 / 文本域共用外观;需求提报页设计稿规格:白底 + 浅灰描边 + 4px 圆角)
.text-control() { .text-control() {
width: 100%; width: 100%;
padding: 0 @spacing-base; padding: 0 @spacing-base;

View File

@@ -1,4 +1,3 @@
// 样式重置
*, *,
*::before, *::before,

View File

@@ -1,48 +1,35 @@
// ★ less 变量唯一来源(颜色/间距/字号/圆角)
// 数据来源:蓝湖 MCP 实测(lanhu pid=b0052b6c,43 张设计稿已全量提取到本地,规格见 specs2/)。
// 换算约定:**1:1 像素级还原**——设计画布 1920px。直接使用设计标注 px,不做任何缩放。
// ⚠️ 内容区宽度按页面分两档(43 张设计稿实测结论):
// · 常规页(案例库/需求提报/项目/合同/财务/消息/客服/个人中心):内容宽 **1780**,左右留白各 **70**
// · 首页:内容宽 **1680**,左右留白各 **120**(首页设计稿单独一套基准)
// · 登录页:左品牌栏 784 + 右表单区 1136(无顶栏/页脚)
// 品牌色(设计稿实测)
@primary-color: #1f67ef; @primary-color: #1f67ef;
@primary-strong: #024fff; // 内容强调 / 选中态(设计实测 #024FFF) @primary-strong: #024fff;
@primary-hover: #598bff; @primary-hover: #598bff;
@primary-light: #eaf1ff; @primary-light: #eaf1ff;
// 功能色(设计稿实测:危险 #EF4444、成功 #16A34A)
@success-color: #16a34a; @success-color: #16a34a;
@warning-color: #ff8f1f; @warning-color: #ff8f1f;
@danger-color: #ef4444; @danger-color: #ef4444;
@info-color: #8a94a6; @info-color: #8a94a6;
// 文字色(设计稿灰阶:#121212 / #333333 / #666666 / #999999)
@heading-color: #121212; @heading-color: #121212;
@text-color: #333333; @text-color: #333333;
@text-color-secondary: #666666; @text-color-secondary: #666666;
@text-color-disabled: #999999; @text-color-disabled: #999999;
// 背景 / 边框(设计稿实测)
@bg-color: #f5f6f8; @bg-color: #f5f6f8;
@bg-color-light: #fafbfc; @bg-color-light: #fafbfc;
@border-color: #e4e7ec; @border-color: #e4e7ec;
@border-color-dark: #cdd3dd; @border-color-dark: #cdd3dd;
@border-color-btn: #d8dbdf; // 注销确认弹窗「暂不注销」底/描边(设计实取) @border-color-btn: #d8dbdf;
@white: #ffffff; @white: #ffffff;
@black: #000000; @black: #000000;
@header-bg: #f4f5f9; @header-bg: #f4f5f9;
@header-border: #d8d8d8; @header-border: #d8d8d8;
@footer-bg: #0c1321; @footer-bg: #0c1321;
// 圆角
@radius-sm: 4px; @radius-sm: 4px;
@radius-base: 8px; @radius-base: 8px;
@radius-lg: 12px; @radius-lg: 12px;
@radius-round: 999px; @radius-round: 999px;
// 间距
@spacing-xs: 4px; @spacing-xs: 4px;
@spacing-sm: 8px; @spacing-sm: 8px;
@spacing-base: 16px; @spacing-base: 16px;
@@ -50,306 +37,234 @@
@spacing-xl: 32px; @spacing-xl: 32px;
@spacing-xxl: 48px; @spacing-xxl: 48px;
// 字号(1:1,设计实测体系 —— 43 张设计稿全量统计:16px 为出现最多的正文级字号)
@font-size-xs: 10px; @font-size-xs: 10px;
@font-size-sm: 12px; @font-size-sm: 12px;
@font-size-base: 14px; @font-size-base: 14px;
@font-size-body: 16px; // 正文基准(设计稿最高频字号;body 默认值) @font-size-body: 16px;
@font-size-md: 16px; @font-size-md: 16px;
@font-size-lg: 18px; // 顶栏导航 / 小标题(设计实测 18/500) @font-size-lg: 18px;
@font-size-xl: 20px; @font-size-xl: 20px;
@font-size-title: 28px; // 栏目标题(设计实测 28/500) @font-size-title: 28px;
@font-size-xxl: 36px; // 大标题(设计实测 36/700) @font-size-xxl: 36px;
@font-size-hero: 48px; // 头图主标题(设计实测 48/500、52/700、60/700) @font-size-hero: 48px;
@font-size-hero-lg: 60px; // 首页头图主标题(设计实测 60/700) @font-size-hero-lg: 60px;
// 首页头图(设计画板 y73 起,1920×661) @banner-title-color: #12171a;
@banner-title-color: #12171a; // 头图标题/副标题色(设计实取) @btn-gradient-from: #a061fe;
@btn-gradient-from: #a061fe; // 主按钮渐变起(设计实取) @btn-gradient-to: #045dff;
@btn-gradient-to: #045dff; // 主按钮渐变止(设计实取) @btn-ghost-color: #2b5ff0;
@btn-ghost-color: #2b5ff0; // 描边按钮文字色(设计像素实测)
// 布局(1:1,设计实测) @container-width: 1920px;
@container-width: 1920px; // 设计画布 / 通栏块总宽
// 全局视觉缩放:设计稿按 1920 画布 1:1 实现,整体观感偏大,
// 按用户要求整体缩放 67%(html zoom,等比缩放、比例关系与通栏布局不破坏)。
// 1920 物理屏逻辑视口 ≈ 2866 ≥ 1920 → 内容居中、无横向滚动。
// 小屏适配(自行判断):物理视口 <1287px(如 1280 屏)时 1920×0.67≈1286 放不下,
// 降到 0.66 → 1280/0.66 ≈ 1939 ≥ 1920,仍无横向滚动;1366 屏 1366/0.67≈2039 ✓ 不需降档。
@page-zoom: 0.67; @page-zoom: 0.67;
@page-zoom--sm: 0.66; @page-zoom--sm: 0.66;
// 常规页内容区(specs2 全量复核 2026-09-30:案例库/需求提报/项目/合同/财务/消息详情/客服/ @page-content: 1780px;
// 收藏/发票抬头/业务详情 共 17 张设计稿内容块均为 x70→1850 = **1780 宽**;此前误标 155/1600 @page-gutter: 70px;
// 导致整站内容右移 85px、窄 170px,已修正回归设计稿)
@page-content: 1780px; // 常规页内容实宽(设计实测 x70→1850)
@page-gutter: 70px; // 常规页左右留白(设计实测)
// 个人中心系(个人中心/账号安全/地址管理等 13 张)视觉核对定版基准,独立不联动:
// 左列 x155 宽 200 + 右卡,视觉比对设计稿裁片确认(specs 嵌套组坐标为组内相对值,不可直接用)
@profile-content: 1600px; @profile-content: 1600px;
@profile-gutter: 155px; @profile-gutter: 155px;
// 首页专用(首页设计稿单独基准:x120→1800) @home-content: 1680px;
@home-content: 1680px; // 首页内容实宽 @home-gutter: 120px;
@home-gutter: 120px; // 首页左右留白
// 兼容旧引用(= 常规页基准,即绝大多数页面)
@content-width: @page-content; @content-width: @page-content;
@container-width-inner: @page-content; @container-width-inner: @page-content;
@header-height: 72px; // 顶栏高(设计实测 72px) @header-height: 72px;
@header-nav-gap: 94px; // 顶栏导航项文字间距(设计实测:首页 x275→311,案例库 x405) @header-nav-gap: 94px;
@footer-height: 481px; // 页脚高(设计实测 481px) @footer-height: 481px;
@footer-inset: 70px; // 页脚内容左右内缩(与常规页内容区对齐) @footer-inset: 70px;
// 登录页(无顶栏/页脚) @login-brand-width: 35%;
@login-brand-width: 35%; // 左侧品牌渐变栏宽度占比(右栏 flex:1 → 自动占 65%) @login-form-offset: 1022px;
@login-form-offset: 1022px; // 右侧表单区起点 @auth-font-scale: 1.5;
@auth-font-scale: 1.5; // 登录页字体放大倍数(老大要求整体 ×2)
// 阴影
@shadow-base: 0 2px 8px rgba(31, 39, 51, 0.08); @shadow-base: 0 2px 8px rgba(31, 39, 51, 0.08);
@shadow-lg: 0 4px 16px rgba(31, 39, 51, 0.12); @shadow-lg: 0 4px 16px rgba(31, 39, 51, 0.12);
// 案例库(蓝湖设计稿 04/08/15;筛选面板 / 案例卡片 / 分页专用) @filter-selected-bg: #e7eeff;
// ⚠️ 换算约定:设计画布内容宽 1780px → 应用内容宽 1368px,即 设计px × 0.77 ≈ CSS px; @pill-bg: #ededed;
// 小字号(设计 ≤12px)按可读下限 11px 不跟缩。下列色值均为设计稿取色实测。 @pill-selected-bg: #075cff;
@filter-selected-bg: #e7eeff; // 业务类型选中行底色(设计15 实取 rgba(231,238,255)) @chips-panel-bg: #eaf1ff;
@pill-bg: #ededed; // 胶囊选项默认底色(设计04 实取 rgba(237,237,237)) @input-bg: #ededed;
@pill-selected-bg: #075cff; // 胶囊选项选中底色(设计04 实取 rgba(7,92,255)) @tag-hardware: #4caf50;
@chips-panel-bg: #eaf1ff; // 「已选条件」区底色(设计04 实取 rgba(234,241,255)) @tag-iot: #5720ba;
@input-bg: #ededed; // 日期区间胶囊底色(设计04 实取 rgba(237,237,237)) @tag-manufacturing: #ff8f1f;
@tag-hardware: #4caf50; // 业务标签:硬件电路(设计15 实取 #4CAF50) @tag-other: #8a94a6;
@tag-iot: #5720ba; // 业务标签:物联网全案
@tag-manufacturing: #ff8f1f; // 业务标签:生产制造
@tag-other: #8a94a6; // 业务标签:其他
@cases-accent: #075cff; // 搜索/重置按钮、已选 chip 文字(设计04/08 实取 rgba(7,92,255)) @cases-accent: #075cff;
@cases-confirm: #2563eb; // 底部「确定筛选」按钮底色(设计04 实取) @cases-confirm: #2563eb;
@cases-hero-title: #121212; // 「成功案例库」主标题(设计04 实取 18,18,18) @cases-hero-title: #121212;
@cases-filter-title: #222222; // 「筛选条件」标题(设计04 实取 34,34,34) @cases-filter-title: #222222;
@cases-text: #333333; // 筛选项 / 卡片正文(设计04 实取 51,51,51) @cases-text: #333333;
@cases-muted: #666666; // 次级文字:计数 / 页面标语(设计04 实取 102,102,102) @cases-muted: #666666;
@cases-input-border: #999999; // 搜索框描边·占位文字 / 复选框描边(设计04 实取 153,153,153) @cases-input-border: #999999;
@cases-icon: #797979; // 搜索图标(设计04 实取 121,121,121) @cases-icon: #797979;
@cases-reset-bg: #f0f0f0; // 底部「重置」按钮底色(设计04 实取 239.82→240) @cases-reset-bg: #f0f0f0;
@cases-card-border: #e1dfdf; // 案例卡片描边(设计04 实取 224.6,222.7,222.7) @cases-card-border: #e1dfdf;
@cases-card-shadow: 0 4px 10px rgba(0, 0, 0, 0.08); // 案例卡片常驻阴影(设计04 实取) @cases-card-shadow: 0 4px 10px rgba(0, 0, 0, 0.08);
@cases-page-active-bg: #e0e9fa; // 分页当前页底色(设计15 实取 ≈ rgba(2,79,255,.12) 叠白) @cases-page-active-bg: #e0e9fa;
// 需求提报(按需求提报设计稿 06/07 标注实测校准) @demand-selected-bg: #024fff;
@demand-selected-bg: #024fff; // 业务类型卡片 / 预算胶囊选中底色(设计稿实取 rgba(2,79,255)) @demand-accent: #024fff;
@demand-accent: #024fff; // 业务类型选中描边·右上圆点 / 步骤序号(设计稿强调色 #024FFF) @demand-secondary-bg: #075cff;
@demand-secondary-bg: #075cff; // 底部「保存草稿 / 重置 / 立即提报」按钮底色(设计稿实取 rgba(7,92,255))
// 项目中心(按项目中心设计稿标注校准) @project-table-head-bg: #e8e8e8;
@project-table-head-bg: #e8e8e8; // 表头底色(设计稿实取 (232,232,232)) @project-table-line: #bbbbbb;
@project-table-line: #bbbbbb; // 表格网格线(设计稿实取 (187,187,187))
// 项目详情(按 docs/prototype/项目中心-演示.html 设计稿校准) @project-accent: #024fff;
// ⚠️ 换算系数:设计画布内容宽 1776px → 应用内容宽 1368px(.container 1400 − 2×16), @project-track: #edeff2;
// 即 设计px × 0.77 ≈ CSS px。下列数值 = 设计稿实测值 × 0.77 后取整。 @milestone-done: #22c55e;
@project-accent: #024fff; // 竖条 / 状态文字 / 进度条 / 激活页签 / 里程碑进行中(蓝湖设计稿实取 #024FFF) @milestone-idle: #d6d9de;
@project-track: #edeff2; // 进度条轨道(蓝湖设计 13 实取 #EDEFF2) @team-avatar-bg: #1f67ef;
@milestone-done: #22c55e; // 里程碑已完成圆点 + 已完成段连接线(蓝湖设计 13 实取 #22C55E)
@milestone-idle: #d6d9de; // 里程碑待开始圆环描边(蓝湖设计 13 实取)
@team-avatar-bg: #1f67ef; // 团队头像底色(蓝湖设计 13 实取 #1F67EF)
// 收银台 / 确认支付(按「支付」设计稿标注校准) @checkout-card: #ffffff;
// ⚠️ 换算系数:设计稿 250px 宽 ≈ 375 视口(缩放 1.5×),下列数值 = 设计稿实测值 × 1.5 后取整。 @checkout-line: #e1e1e1;
// 页面底色沿用全站 @bg-color(#f5f6f8),与设计稿实取的 #f5f5f5 仅差 1/255,不再单独开变量。 @checkout-amount: #ff5500;
@checkout-card: #ffffff; // 卡片底色 @checkout-method-active-bg: #d3f3df;
@checkout-line: #e1e1e1; // 订单明细卡描边(设计稿实取 (225,225,225)) @checkout-wechat: #28c445;
@checkout-amount: #ff5500; // 合计金额(设计稿实取) @checkout-alipay: #1677ff;
@checkout-method-active-bg: #d3f3df; // 选中支付行底色(设计稿实取) @checkout-cta: #f59e0b;
@checkout-wechat: #28c445; // 微信绿(图标 / 选中 radio) @checkout-cta-hover: #e08d05;
@checkout-alipay: #1677ff; // 支付宝蓝(图标) @checkout-muted: #9b9b9b;
@checkout-cta: #f59e0b; // 确认支付按钮(设计稿实取) @checkout-radio-idle: #c8c8c8;
@checkout-cta-hover: #e08d05; // 确认支付 hover
@checkout-muted: #9b9b9b; // 次级说明文字(设计稿实取)
@checkout-radio-idle: #c8c8c8; // 未选 radio 圆圈描边
// 项目详情 → 合同财务(付款计划卡 / 发票·合同文件行) @finance-card-width: 285px;
// 来源 docs/prototype/项目中心-演示.html 第 1 帧(与 @project-accent 同源,换算同为 设计px × 0.77)。 @finance-card-radius: 10px;
// ⚠️ 小字号例外:设计 12px/10px 折算后是 9/8px(不可读),按可读下限取 12/11px,不跟缩。 @finance-meta-color: #6b7280;
@finance-card-width: 285px; // 计划卡定宽(蓝湖设计 09 实测 x70..440 = 370 设计px × 0.77),竖排左对齐 @finance-row-bg: #f7f8fa;
@finance-card-radius: 10px; // 计划卡圆角(蓝湖设计 09 实测 ≈13 设计px × 0.77) @finance-paid: #16a34a;
@finance-meta-color: #6b7280; // 字段行「账单号:/ 关联项目:」(蓝湖设计 09 (107,114,128)) @finance-pending: #f59e0b;
@finance-row-bg: #f7f8fa; // 项目文件行底色(蓝湖设计 09/10/11 实取 #F7F8FA) @finance-pending-amount: #f59e0b;
@finance-paid: #16a34a; // 已支付:徽标文字 + 金额(蓝湖设计 09 (22,163,74)) @finance-idle: #b3bfc4;
@finance-pending: #f59e0b; // 待支付:「立即支付」按钮底色 + 金额(蓝湖设计 09 实取) @finance-cta-hover: #e08d05;
@finance-pending-amount: #f59e0b; // 待支付:金额文字(蓝湖设计 09 (245,158,11))
@finance-idle: #b3bfc4; // 未到期:金额文字(蓝湖设计 09 (179,185,196))
@finance-cta-hover: #e08d05; // 「立即支付」hover(与收银台 CTA 同色系)
// 首页 / 业务详情(蓝湖设计稿:03·05 首页,53 业务详情) @home-ink: #121212;
// ⚠️ 换算约定同上:设计px × 0.77 ≈ CSS px;设计 ≤12px 小字号按可读下限取 12/11px。 @home-welcome: #2c2c2c;
@home-ink: #121212; // 栏目标题 / 卡片标题 / 分类标签文字(设计实取 #121212) @home-body: #333333;
@home-welcome: #2c2c2c; // 首页欢迎语与副文案(设计实取 #2C2C2C) @home-paragraph: #222222;
@home-body: #333333; // 详情页标题/标签、案例元信息(设计实取 #333333) @home-breadcrumb: #666666;
@home-paragraph: #222222; // 详情页富文本正文(设计实取 #222222) @home-card-border: #ebebeb;
@home-breadcrumb: #666666; // 详情页面包屑(设计实取 #666666) @home-section-border: #e2e2e2;
@home-card-border: #ebebeb; // 首页仪表盘(欢迎卡/快捷入口卡)描边(设计实取) @home-case-border: #e0dede;
@home-section-border: #e2e2e2; // 首页大区块卡(业务服务/案例库精选)描边(设计实取) @home-business-border: #999999;
@home-case-border: #e0dede; // 案例卡片描边(设计实取 rgba(224,222,222)) @home-ring: #c6c2c2;
@home-business-border: #999999; // 业务卡片未选中描边(设计实取 #999999) @home-tab-active: #085dff;
@home-ring: #c6c2c2; // 业务卡片右上圆环·未选中 / 快捷入口「更多」箭头(设计实取) @home-banner-dot: #8d96ae;
@home-tab-active: #085dff; // 案例分类激活文字(设计实取 #085DFF,下划线仍用 @primary-color) @home-banner-bg: #f3f4f8;
@home-banner-dot: #8d96ae; // 首页头图轮播指示条/点(设计实取 rgba(141,150,174)) @home-quick-blue: #a5d0ff;
@home-banner-bg: #f3f4f8; // 首页头图底色(设计实取,轮播无图时兜底) @home-quick-purple: #dbc9fe;
@home-quick-blue: #a5d0ff; // 快捷入口·工单需求池(cyan,设计实取) @home-quick-green: #c4f0be;
@home-quick-purple: #dbc9fe; // 快捷入口·案例库管理(purple,设计实取) @home-quick-peach: #ffcbbe;
@home-quick-green: #c4f0be; // 快捷入口·客服工作台(green,设计实取) @home-quick-pink: #ffcadb;
@home-quick-peach: #ffcbbe; // 快捷入口·合同台账(blue tone,设计稿实取浅橙)
@home-quick-pink: #ffcadb; // 快捷入口·财务台账(amber tone,设计稿实取浅粉)
@home-detail-hero-bg: linear-gradient( @home-detail-hero-bg: linear-gradient(
115deg, 115deg,
#e2ebf7 0%, #e2ebf7 0%,
#cfe7fd 55%, #cfe7fd 55%,
#b6dfff 100% #b6dfff 100%
); // 业务详情头图底(设计实取采样渐变) );
@home-detail-logo-bg: #d9e5ff; // 业务详情头像块底色(设计实取 rgba(217.8,229.4,255)) @home-detail-logo-bg: #d9e5ff;
@home-service-web: #ae7eff; // 服务类型·Web应用 圆形底(设计实取) @home-service-web: #ae7eff;
@home-service-mp: #00b240; // 服务类型·小程序 圆形底(设计实取) @home-service-mp: #00b240;
@home-service-app: #fd9a83; // 服务类型·APP 圆形底(设计实取) @home-service-app: #fd9a83;
@home-service-dev: #80e561; // 服务类型·二次开发 圆形底(设计实取) @home-service-dev: #80e561;
@home-service-pc: #0081e3; // 服务类型·桌面工具 圆形底(设计实取) @home-service-pc: #0081e3;
// 需求提报 / 登录注册(蓝湖设计稿) @demand-ink: #121212;
// 设计稿索引:06·07 需求提报,02·51 登录,54 注册,55 找回密码 @demand-heading: #333333;
// ⚠️ 换算约定同上:设计px × 0.77 ≈ CSS px;设计 ≤12px 小字号按可读下限取 11/12px。 @demand-subtitle: #666666;
@demand-ink: #121212; // 需求提报页主标题 / 卡片标题 / 输入值(设计实取 rgba(18,18,18)) @demand-line: #999999;
@demand-heading: #333333; // 区块标题(01/02/03 旁)/ 表单标签 / 底部提示(设计实取 rgba(51,51,51)) @demand-upload-bg: #ededed;
@demand-subtitle: #666666; // 页面副标题「填写您的需求信息…」(设计实取 rgba(102,102,102)) @demand-radio-idle: #c7c2c2;
@demand-line: #999999; // 输入框·胶囊·卡片描边 + 占位文案(设计实取 rgba(153,153,153)) @auth-gradient-from: #505be8;
@demand-upload-bg: #ededed; // 附件上传区 / 文件行底色(设计实取 rgba(236.8,236.8,236.8)≈#EDEDED) @auth-gradient-to: #8b3fe8;
@demand-radio-idle: #c7c2c2; // 业务类型未选中单选圈描边(设计实取 rgba(198.8,194.4,194.4)) @auth-track: #edeff2;
@auth-gradient-from: #505be8; // 登录/注册左侧品牌渐变起点(设计实取采样)
@auth-gradient-to: #8b3fe8; // 登录/注册左侧品牌渐变终点(设计实取采样)
@auth-track: #edeff2; // 密码强度条轨道底色(设计实取 #EDEFF2)
// 项目中心(蓝湖设计稿) @project-divider: #f0f1f3;
// 蓝湖设计 09(合同财务)/10(售后信息)/11(项目文件)/12(申请售后)/13(项目进度)像素实取。 @project-breadcrumb: #666666;
// 换算约定同上:设计px × 0.77 ≈ CSS px;设计 ≤12px 小字号按可读下限取 11/12px。 @project-logo-bg: #f2f4f6;
@project-divider: #f0f1f3; // 页签下分割线 / 计划卡描边 / 表单底栏分隔线(设计 (240,241,243)) @project-banner-bg: #fdeeee;
@project-breadcrumb: #666666; // 详情页·申请售后页面包屑文字(设计 (102,102,102)) @project-banner-title: #d93025;
@project-logo-bg: #f2f4f6; // 详情头像方块「</>」底色(设计 09 实取 ≈#F1F3F5) @project-apply: #e3464b;
@project-banner-bg: #fdeeee; // 售后工单提示横幅底色(设计 10 实取 #FDEEEE) @project-quiet-btn: #eef0f3;
@project-banner-title: #d93025; // 横幅标题红(设计 10 (217,48,37)) @tag-success-bg: #e7f7ec;
@project-apply: #e3464b; // 「申请售后」按钮底色(设计 10 实取 #E3464B) @tag-warning-bg: #fef3e2;
@project-quiet-btn: #eef0f3; // 浅灰按钮底:上传文档 / 取消 / 重置(设计 10·12 实取) @tag-warning-text: #d97706;
@tag-success-bg: #e7f7ec; // 徽标底:已支付 / 成功(设计 09 实取) @tag-info-bg: #f3f4f6;
@tag-warning-bg: #fef3e2; // 徽标底:待支付(设计 09 实取) @milestone-line: #eeeff2;
@tag-warning-text: #d97706; // 徽标字:待支付(设计 09 (217,119,6))
@tag-info-bg: #f3f4f6; // 徽标底:未支付 / 中性(设计 09 实取)
@milestone-line: #eeeff2; // 里程碑未完成连接线(设计 13 实取)
// 项目详情(蓝湖设计稿 5 张:项目进度 / 项目文件 / 合同财务 / 售后信息 / 申请售后) @project-ink: #1f2937;
// ⚠️ 换算约定:**1:1 像素级还原**,设计画布 1920px,直接使用设计标注 px,不做缩放。 @project-label: #6b7280;
// 实测锚点(1920 视口):面包屑 (70,116) 24/500 #666666;主标题 (142,212) 19/700; @project-value: #1f2937;
// 页签行 (70,278) 1780×40,文字 13/600 激活 / 13/400 常规,节距 86; @project-muted: #9ca3af;
// 分隔线 (70,317) 1780×1 #F0F1F3;区块标题 15/700 @(82,y)。 @project-cell: #4b5563;
@project-ink: #1f2937; // 主标题 / 区块标题 / 文件名(设计实取 #1F2937) @project-card: #ffffff;
@project-label: #6b7280; // 「项目编号:」等字段标签(设计实取 #6B7280) @project-field-bg: #f3f4f6;
@project-value: #1f2937; // 字段值(设计实取 #1F2937) @project-input-border: #dde1e7;
@project-muted: #9ca3af; // 次行说明:上传时间 / 占位文字(设计实取 #9CA3AF) @project-head-bg: #f7f8fa;
@project-cell: #4b5563; // 只读值 / 行内操作「预览·下载」(设计实取 #4B5563) @project-upload-tag: #ef4444;
@project-card: #ffffff; // 表格区白底(设计实取) @project-banner-soft: #fdeeee;
@project-field-bg: #f3f4f6; // 只读字段底(设计实取 #F3F4F6)
@project-input-border: #dde1e7; // 只读框 / 输入框描边(设计实取 #DDE1E7)
@project-head-bg: #f7f8fa; // 里程碑 / 文件行浅底(设计实取 #F7F8FA)
@project-upload-tag: #ef4444; // 上传文档红字(设计实取 #EF4444)
@project-banner-soft: #fdeeee; // 售后工单横幅底(设计实取 #FDEEEE)
// 项目详情 → 里程碑(设计实测) @milestone-node: 356px;
@milestone-node: 356px; // 节点列宽(相邻节点 x70→426 = 356) @milestone-dot: 26px;
@milestone-dot: 26px; // 已完成实心点直径(x235..261 = 26) @milestone-ring: #e5e7eb;
@milestone-ring: #e5e7eb; // 待开始连接线(设计实取 #E5E7EB) @milestone-idle-text: #9ca3af;
@milestone-idle-text: #9ca3af; // 待开始 / 进行中的状态说明(设计实取 #9CA3AF)
// 项目详情 → 项目团队(设计实测:头像 64,相邻中心距 99 → 间距 35)
@team-size: 64px; @team-size: 64px;
@team-gap: 35px; @team-gap: 35px;
// 合同中心(蓝湖设计稿) @contract-accent: #075cff;
// 设计索引:41 顶部横幅+搜索栏,46/47 表格+分页,40/44/52 合同详情,45 面包屑,39 签署。 @contract-muted: #666666;
// ⚠️ 换算约定同上:设计px × 0.77 ≈ CSS px;设计 ≤12px 小字号按可读下限取 11px,不跟缩。 @contract-head-text: #222222;
@contract-accent: #075cff; // 列表:搜索/重置按钮、当前页码、「详情」链接(设计 46 实取 rgb(7,92,255)) @contract-cell-text: #333333;
@contract-muted: #666666; // 面包屑 / 顶部横幅副标语 / 分页箭头(设计 45·46 实取 #666666) @contract-line: #999999;
@contract-head-text: #222222; // 表头文字(设计 46 实取 #222222) @contract-table-head-bg: #ededed;
@contract-cell-text: #333333; // 表格正文(设计 46 实取 #333333) @contract-placeholder: #b3b9c4;
@contract-line: #999999; // 表格边框·行分隔线·分页描边与页码(设计 46 实取 #999999) @contract-reset-bg: #eef0f3;
@contract-table-head-bg: #ededed; // 表头底色(设计 46 实取 (237,237,237)) @contract-qr-bg: #d6d6d6;
@contract-placeholder: #b3b9c4; // 搜索框占位文字(设计 41 实取 (179,185,196))
@contract-reset-bg: #eef0f3; // 搜索行「重置」按钮底色(设计 41 实取)
@contract-qr-bg: #d6d6d6; // 签署页二维码占位底色(设计 39 实取 #D6D6D6)
// 财务中心(蓝湖设计稿)
// 设计索引:37 财务概览,30 账单明细,31 账单详情,33·34 扫码支付,32 支付结果,35·36 发票抬头。
// ⚠️ 换算约定:设计画布内容宽 1780 → 应用内容宽 1368(.container 1400 − 2×16),设计px × 0.77;
// 字号 = 设计px × 0.77,≤14 设计px 折算不足 11px 时按可读下限取 11px(15/16 → 12,18 → 14,20 → 15,22 → 17,26 → 20,34 → 26,38 → 29)。
// 复用同值既有 token:表格/卡片分隔线 @project-divider(#f0f1f3)、占位文字 @contract-placeholder(#b3b9c4)、
// 「重置」按钮底 @project-quiet-btn(#eef0f3)、二维码占位底 @contract-qr-bg(#d6d6d6)。
@finance-hero-gradient: linear-gradient( @finance-hero-gradient: linear-gradient(
115deg, 115deg,
#024fff 0%, #024fff 0%,
#4b4ff3 55%, #4b4ff3 55%,
#7e75ec 100% #7e75ec 100%
); // 概览「累计消费总额」卡渐变(设计 37 实取采样 #034FFF→#7E75EC) );
@finance-chart-bar: #4a90c4; // 概览月度账单柱条(设计 37 实取采样) @finance-chart-bar: #4a90c4;
@finance-chart-grid: #edeef2; // 概览图表网格线(设计 37 实取 rgba(237,239,242)) @finance-chart-grid: #edeef2;
@finance-table-head-bg: #f1f3f5; // 账单表头底色(设计 30 实取) @finance-table-head-bg: #f1f3f5;
@finance-field-bg: #f3f4f6; // 搜索框底色(设计 30 实取 #F3F4F6) @finance-field-bg: #f3f4f6;
@finance-page-active: #d5e2fe; // 分页当前页底色(设计 30/35 实取 #C5D7FE/#D5E2FE) @finance-page-active: #d5e2fe;
@finance-summary-cta: #ffb754; // 底部汇总条「立即支付」按钮(设计 30 实取采样) @finance-summary-cta: #ffb754;
@finance-result-circle: #7ed321; // 支付结果大圆勾底色(设计 32 实取采样) @finance-result-circle: #7ed321;
@finance-safe-bg: #d2eedc; // 扫码支付「已通过安全验证」横幅底(设计 33 实取采样) @finance-safe-bg: #d2eedc;
// 消息中心 / 在线客服(蓝湖设计稿) @message-icon-bg: #3b82f6;
// 设计索引:43 消息中心列表,42 通知详情,38 在线客服聊天。 @service-user-avatar: #8d99ae;
// ⚠️ 换算约定同上:设计px × 0.77 ≈ CSS px;设计 ≤14px 小字号按可读下限取 11px。
// 复用同值既有 token:行分割线/聊天卡描边 @project-divider(#f0f1f3)、灰字 #9ca3af=@text-color-disabled、
// 搜索框底 @finance-field-bg(#f3f4f6)、「重置」底 @contract-reset-bg(#eef0f3)、占位字 @contract-placeholder(#b3b9c4)、
// 附件/输入框底 #f7f8fa=@finance-row-bg、聊天消息区底 #fafbfc=@bg-color-light、未读徽标红=@danger-color、
// 强调蓝(搜索/返回/发送/气泡/头像)#024fff=@primary-strong、分页激活底 #e1eafa≈@cases-page-active-bg。
@message-icon-bg: #3b82f6; // 消息行类型图标圆底(设计 43 实取 #3B82F6)
@service-user-avatar: #8d99ae; // 客服聊天·右侧用户头像底(设计 38 实取 #8D99AE)
// 个人中心(蓝湖设计稿) @profile-nav-active-bg: #e7eeff;
// 设计索引:14 布局+专属客服热线,28 账号安全列表,22~29 安全类弹窗,20 地址管理,17 资质列表, @profile-card-border: #f0f1f3;
// 18 意见反馈,16 帮助中心,19 关于我们。 @profile-line: #e5e7eb;
// ⚠️ 换算约定同上:设计px × 0.77 ≈ CSS px;字号 = round(设计px × 0.77),≤14 设计px 按可读下限取 11px @profile-input-border: #dde1e7;
// (11/12/13/14 → 11,15/16 → 12,19/20 → 15,24 → 18)。 @profile-radius: 6px;
// 复用同值既有 token:页面底 @bg-color、强调蓝 #024fff=@primary-strong、卡片描边 #f0f1f3≈@project-divider、 @profile-page-bg: #f4f6f8;
// 成功绿底徽标 #e7f7ec≈@tag-success-bg、次级按钮底 #eef0f3≈@project-quiet-btn、文字灰阶 @text-color* 系列。 @profile-ink: #1f2937;
@profile-nav-active-bg: #e7eeff; // 左侧菜单选中项底色(设计 28 实取 rgba(231,238,255)) @profile-hotline-bg: #d6e4fb;
@profile-card-border: #f0f1f3; // 列表卡 / 分组卡描边(设计 28 实取 (240,241,243)) @profile-icon-pwd: #c7d2fe;
@profile-line: #e5e7eb; // 按钮描边 / 分隔线(设计 28 注销按钮描边实取 (229,231,235)) @profile-icon-phone: #fdebeb;
@profile-input-border: #dde1e7; // 输入框描边(设计 22 实取 (221,225,231)) @profile-icon-email: #fee2e2;
@profile-radius: 6px; // 输入框 / 小按钮 / 菜单选中项圆角(设计 8px × 0.77) @profile-user-from: #5459e8;
@profile-page-bg: #f4f6f8; // 个人中心页底色(设计稿实取) @profile-user-to: #d2bdf3;
@profile-ink: #1f2937; // 个人中心标题字色(设计稿实取) @profile-user-avatar: #8976ed;
@profile-hotline-bg: #d6e4fb; // 专属客服热线卡底色(设计 14 实取 rgba(214,228,251)) @profile-page-active-bg: #d1e0fe;
@profile-icon-pwd: #c7d2fe; // 账号安全「修改密码」图标块底(设计 28 实取) @profile-quiet-btn: #eef0f3;
@profile-icon-phone: #fdebeb; // 账号安全「绑定手机」图标块底(设计 28 实取)
@profile-icon-email: #fee2e2; // 账号安全「绑定邮箱」图标块底(设计 28 实取)
@profile-user-from: #5459e8; // 用户卡渐变起点(设计 14 左上角实取)
@profile-user-to: #d2bdf3; // 用户卡渐变终点(设计 14 右下角实取)
@profile-user-avatar: #8976ed; // 用户卡头像圆底(设计 14 实取)
@profile-page-active-bg: #d1e0fe; // 分页当前页底色(设计 20 实取 #D1E0FE)
@profile-quiet-btn: #eef0f3; // 弹窗次级(取消)按钮底色(设计 24 实取 rgba(238,240,243))
// 合同中心(蓝湖设计稿)· 补充 @contract-ink: #121212;
@contract-ink: #121212; // 页面最深标题色(设计实取 #121212) @contract-page-bg: #f4f6f8;
@contract-page-bg: #f4f6f8; // 页面底色 / 详情只读值框底(设计 e6c3a361 实取 #F4F6F8)
// 财务中心(蓝湖设计稿)· 补充 @finance-pay-bg: #f7f8f9;
@finance-pay-bg: #f7f8f9; // 扫码支付页底色(设计实取 #f7f8f9) @finance-muted-text: #6b7280;
// 财务中心概览 / 账单(设计 6cd4aa76 等,1920 画布 1:1 实取) @finance-card-line: #f0f1f3;
@finance-muted-text: #6b7280; // 副标语 / 图表刻度 / 图例 / 次级文字(设计实取 #6B7280) @finance-entry-text: #4b5563;
@finance-card-line: #f0f1f3; // 卡片描边(设计实取 #F0F1F3) @finance-axis-text: #9ca3af;
@finance-entry-text: #4b5563; // 快捷入口文字(设计实取 #4B5563) @finance-ink: #1f2937;
@finance-axis-text: #9ca3af; // 图表 Y 轴刻度(设计实取 #9CA3AF)
@finance-ink: #1f2937; // 区块标题(设计实取 #1F2937)

7
src/types/api.d.ts vendored
View File

@@ -1,33 +1,26 @@
// 接口通用结构(PRD 4.8)
/** 业务类型(跨模块共享) */
export type BusinessType = 'internet' | 'hardware' | 'iot' | 'manufacturing' | 'other' export type BusinessType = 'internet' | 'hardware' | 'iot' | 'manufacturing' | 'other'
/** 统一响应结构 */
export interface ApiResponse<T> { export interface ApiResponse<T> {
code: number code: number
msg: string msg: string
data: T data: T
} }
/** 分页结果 */
export interface PageResult<T> { export interface PageResult<T> {
list: T[] list: T[]
total: number total: number
} }
/** 通用分页查询参数 */
export interface PageQuery { export interface PageQuery {
page: number page: number
pageSize: number pageSize: number
} }
/** 附件/文件信息 */
export interface FileInfo { export interface FileInfo {
name: string name: string
size: number size: number
uploadTime: string uploadTime: string
url: string url: string
/** OSS 主键(同源预览用);本地未上传文件为 undefined */
ossId?: string | number ossId?: string | number
} }

View File

@@ -1,23 +1,12 @@
// 账号密码登录/注册真实接口类型(对接 vermis-maker /auth/*)
// 归位于 types/business/auth.d.ts,与 api/auth.ts 对应
/** 登录成功返回,对应后端 LoginVo(字段名以 @JsonProperty 为准) */
export interface LoginResult { export interface LoginResult {
/** 授权令牌(后端字段 access_token) */
access_token: string access_token: string
/** 刷新令牌 */
refresh_token?: string refresh_token?: string
/** access_token 有效期(秒) */
expire_in?: number expire_in?: number
/** refresh_token 有效期(秒) */
refresh_expire_in?: number refresh_expire_in?: number
/** 应用 id */
client_id?: string client_id?: string
/** 令牌权限 */
scope?: string scope?: string
/** 用户 openid */
openid?: string openid?: string
} }
/** 注册返回:后端 R<Void>,无 data */
export type RegisterResult = void export type RegisterResult = void

View File

@@ -1,54 +1,24 @@
// 案例库类型(案例实体 + 后端 VO,与 api/cases.ts 对应)
// //
// 后端:vermis-maker com.ruoyi.vermins.controller.pc.web.ProjectCaseUserController(前缀 /web/projectCase)
// 说明:案例实体类型统一定义在本文件(含首页「案例库精选」消费的 ProjectCaseVo)。
import type { BusinessType } from '@/types/api' import type { BusinessType } from '@/types/api'
/**
* 业务类型筛选项。
*
* <p>选项不是前端写死的,而是取自 GET /web/projectCase/categories 返回的
* {@link ProjectCaseStatisticsVo#projectCaseCounts}——后端按项目提报表统计后下发。</p>
*/
export interface CaseTypeOption { export interface CaseTypeOption {
/**
* 下发值 = **提报主键 id**(如 "2099753433048526850")。
*
* <p>必须用 id 而非类型名:案例表 business_type 列实测存的就是提报主键 id,
* 传类型名(如「硬件电路」)后端按原值精确匹配不到 → 实测 total=0。</p>
*/
value: string value: string
/** 展示名(后端下发的 businessType 名称,如「硬件电路」) */
label: string label: string
/** 该业务类型的案例数(后端统计口径,可能为 0) */
count: number count: number
} }
/** 案例附件(页面消费,由后端 ProjectCaseAttachmentVo 映射而来) */
export interface CaseAttachment { export interface CaseAttachment {
/** 原始文件名 */
name: string name: string
/** 文件大小(后端已格式化的文本形式,可能为空) */
size: string size: string
/** 附件访问地址 */
url: string url: string
} }
/**
* 案例正文区块(后端「自定义目录」一项 = 页面一张白卡)。
*
* <p>标题由后端下发,前端不写死:增删区块(项目背景 / 客户痛点 / 需求描述 / 交付物 …)
* 完全由 vm_project_case_catalog 数据决定。</p>
*/
export interface CaseSection { export interface CaseSection {
/** 区块标题(vm_project_case_catalog.title) */
title: string title: string
/** 区块内容:富文本 HTML(可能内嵌 img / video) */
content: string content: string
} }
/** 案例 */
export interface Case { export interface Case {
id: string id: string
title: string title: string
@@ -56,224 +26,124 @@ export interface Case {
cover: string cover: string
finishDate: string finishDate: string
teamSize: number teamSize: number
/** 规模展示值(如 '300万'、'10-50万'),筛选区间见 CaseQuery.scales */
scale: string scale: string
industry: string industry: string
duration: string duration: string
clientType: string clientType: string
/**
* 正文区块列表(标题 + 富文本)。
*
* <p>来自后端 catalogs(vm_project_case_catalog,按 sort_order 升序);
* 列表 / 精选接口不下发,故那些场景为空数组。</p>
*/
sections: CaseSection[] sections: CaseSection[]
/** 附件列表(仅案例详情接口返回,列表页为空) */
attachments?: CaseAttachment[] attachments?: CaseAttachment[]
video: string video: string
isFavorite: boolean isFavorite: boolean
/** 收藏量(详情/列表接口下发,本地收藏翻转时同步 ±1) */
favoriteCount: number favoriteCount: number
} }
/**
* 案例筛选条件。
*
* <p>三类选项全部由后端下发,前端不写死,且都来自 GET /categories({@link CaseSelectionModel}):
* 业务类型({@link CaseTypeOption})、所属行业、项目规模;仅完成日期区间由前端生成。</p>
*/
export interface CaseQuery { export interface CaseQuery {
/** 业务类型**提报主键 id**(多选,空数组 = 全部;值域见 CaseTypeOption.value) */
categories: string[] categories: string[]
/** 所属行业名称(多选,空数组 = 全部;值域见 BusinessIndustryOption.name) */
industries: string[] industries: string[]
/** 项目规模档位(多选,空数组 = 全部;整档对象直接下发,值域见 /categories) */
scales: ScaleLimit[] scales: ScaleLimit[]
dateRange: [string, string] | null dateRange: [string, string] | null
/** 只看收藏(对应后端 isFavorite=1) */
favoriteOnly: boolean favoriteOnly: boolean
keyword: string keyword: string
page: number page: number
pageSize: number pageSize: number
} }
/* ============================ 后端 VO / BO ============================ */
/**
* GET /web/projectCase/list 的查询条件(后端 ProjectCaseUserBo)。
*
* <p>businessTypes 按案例表 business_type 列的**原值**精确匹配(in),而该列实测存的是
* **提报主键 id**(非类型名),所以前端下发的是 CaseTypeOption.value(id)。</p>
*/
export interface ProjectCaseUserBo { export interface ProjectCaseUserBo {
/** 业务类型提报主键 id 列表 */
businessTypes?: string[] businessTypes?: string[]
/** 业务/行业名称列表 */
businessIndustries?: string[] businessIndustries?: string[]
/** 规模区间列表(区间之间取并集) */
scales?: ScaleLimit[] scales?: ScaleLimit[]
/** 只看收藏:1 = 是;不传 = 全部 */
isFavorite?: number isFavorite?: number
/** 关键字(案例名 / 客户类型 / 项目周期 / 团队人数) */
key?: string key?: string
/** 开始时间 */
beginTime?: string beginTime?: string
/** 结束时间 */
endTime?: string endTime?: string
} }
/** 规模区间(后端 UpperAndLowerLimitsModel):任一端为空表示该方向不设限 */
export interface ScaleLimit { export interface ScaleLimit {
lowerLimit?: number | null lowerLimit?: number | null
upperLimit?: number | null upperLimit?: number | null
/** 展示样式:以下 / 区间 / 以上 */
model?: string | null model?: string | null
/** 档位文字(后端拼好,如「28–46万」) */
label?: string | null label?: string | null
sort?: number | null sort?: number | null
} }
/** 所属行业选项(后端 BusinessIndustryModel) */
export interface BusinessIndustryOption { export interface BusinessIndustryOption {
name: string | null name: string | null
sort: number | null sort: number | null
} }
/**
* GET /web/projectCase/categories —— 筛选面板选项。
*
* <p>左侧筛选**除完成时间外**的选项全部由本接口一次下发:
* 业务类型({@link CaseSelectionModel.projectCaseStatisticsVo})、
* 所属行业({@link CaseSelectionModel.businessIndustry})、
* 项目规模({@link CaseSelectionModel.upperAndLowerLimits})。</p>
*/
export interface CaseSelectionModel { export interface CaseSelectionModel {
businessIndustry: BusinessIndustryOption[] | null businessIndustry: BusinessIndustryOption[] | null
upperAndLowerLimits: ScaleLimit[] | null upperAndLowerLimits: ScaleLimit[] | null
/** 业务类型统计(承载业务类型筛选项与案例总数) */
projectCaseStatisticsVo: ProjectCaseStatisticsVo | null projectCaseStatisticsVo: ProjectCaseStatisticsVo | null
} }
/** 案例总数 + 各业务类型案例数 */
export interface ProjectCaseStatisticsVo { export interface ProjectCaseStatisticsVo {
/** 案例总数(口径:未删除且在架) */
total: number | null total: number | null
/** 各业务类型的案例数(按案例数倒序) */
projectCaseCounts: ProjectCaseCountVo[] | null projectCaseCounts: ProjectCaseCountVo[] | null
} }
/** 单个业务类型的案例数 */
export interface ProjectCaseCountVo { export interface ProjectCaseCountVo {
/** 业务类型原值(提报主键 id 的字符串形式) */
id: string | null id: string | null
/** 业务类型名称 */
businessType: string | null businessType: string | null
caseCount: number | null caseCount: number | null
} }
/**
* GET /web/projectCase/list —— 案例分页列表 + 业务数据统计。
*
* <p>统计口径是「本次查询结果」,即跟随筛选条件变化,不是全表统计。</p>
*/
export interface ProjectCaseAndCaseCountVo { export interface ProjectCaseAndCaseCountVo {
projectCasePage: ProjectCasePageVo | null projectCasePage: ProjectCasePageVo | null
projectCaseStatisticsVo: ProjectCaseStatisticsVo | null projectCaseStatisticsVo: ProjectCaseStatisticsVo | null
} }
/** 分页体(后端 PageResult:rows + total) */
export interface ProjectCasePageVo { export interface ProjectCasePageVo {
total: number total: number
rows: ProjectCaseVo[] rows: ProjectCaseVo[]
} }
/** 案例库视图对象(列表 / 详情 / mostViewed 共用;后端 Long 主键序列化为字符串) */
export interface ProjectCaseVo { export interface ProjectCaseVo {
id: string id: string
/** 案例编号 */
caseNo: string | null caseNo: string | null
/** 案例名称 */
caseName: string | null caseName: string | null
/** 业务类型原值(案例表该列存提报主键 id 或类型名称) */
businessType: string | null businessType: string | null
/** 业务类型名称(后端 JOIN 提报表翻译后的展示名) */
projectTypeName: string | null projectTypeName: string | null
/** 业务/行业 */
businessIndustry: string | null businessIndustry: string | null
/** 上下架:1 上架,0 下架 */
enabled: number | null enabled: number | null
/** 规模数值(单位:万) */
scale: number | null scale: number | null
/** 预览量(详情接口每次调用自增 1) */
previewCount: number | null previewCount: number | null
/** 浏览量(已废弃,勿用于排序) */
viewCount: number | null viewCount: number | null
/** 收藏量 */
favoriteCount: number | null favoriteCount: number | null
/** 封面(后端下发的是 OSS 文件标识,未必是可直接访问的 URL) */
coverUrl: string | null coverUrl: string | null
/** 客户类型 */
customerType: string | null customerType: string | null
/** 所属行业 */
industry: string | null industry: string | null
/** 项目周期 */
projectCycle: string | null projectCycle: string | null
/** 服务团队人数 */
serviceTeam: number | null serviceTeam: number | null
/** 完成时间 yyyy-MM-dd */
completedAt: string | null completedAt: string | null
/** 是否收藏:1 是,0 否(详情与列表接口均会回填) */
isFavorite: number | null isFavorite: number | null
/**
* 项目背景富文本段列表(仅详情返回)。
*
* @deprecated 后端已改用 {@link ProjectCaseVo.catalogs}(带标题的自定义目录),该字段恒为 null。
*/
backgrounds: ProjectCaseBackgroundVo[] | null backgrounds: ProjectCaseBackgroundVo[] | null
/** 正文区块列表(仅详情返回;后端按 sort_order、id 升序下发) */
catalogs: ProjectCaseCatalogVo[] | null catalogs: ProjectCaseCatalogVo[] | null
/** 附件列表(仅详情返回) */
attachments: ProjectCaseAttachmentVo[] | null attachments: ProjectCaseAttachmentVo[] | null
/** 更新时间 yyyy-MM-dd HH:mm:ss */
updateTime: string | null updateTime: string | null
} }
/**
* 案例项目背景富文本段(vm_project_case_detail)。
*
* @deprecated 已被 {@link ProjectCaseCatalogVo} 取代(新结构带 title),后端恒下发 null。
*/
export interface ProjectCaseBackgroundVo { export interface ProjectCaseBackgroundVo {
id: string | null id: string | null
projectId: string | null projectId: string | null
/** 富文本 HTML */
content: string | null content: string | null
sortOrder: number | null sortOrder: number | null
} }
/** 案例自定义目录 / 正文区块(vm_project_case_catalog) */
export interface ProjectCaseCatalogVo { export interface ProjectCaseCatalogVo {
id: string | null id: string | null
projectId: string | null projectId: string | null
/** 目录标题(如「项目背景」「客户痛点」) */
title: string | null title: string | null
/** 目录内容富文本 HTML */
content: string | null content: string | null
/** 排序值,升序 */
sortOrder: number | null sortOrder: number | null
} }
/** 案例附件(vm_project_case_attachment) */
export interface ProjectCaseAttachmentVo { export interface ProjectCaseAttachmentVo {
id: string | null id: string | null
projectId: string | null projectId: string | null
/** OSS 标识 */
ossId: string | null ossId: string | null
/** 原始文件名 */
originalName: string | null originalName: string | null
/** 文件大小(文本形式) */
fileSize: string | null fileSize: string | null
/** 附件访问地址 */
url: string | null url: string | null
sortOrder: number | null sortOrder: number | null
} }

View File

@@ -1,11 +1,8 @@
// 合同中心实体(PRD M6)
import type { FileInfo } from '@/types/api' import type { FileInfo } from '@/types/api'
/** 签署状态 */
export type SignStatus = '履行中' | '已过期' | '待签署' export type SignStatus = '履行中' | '已过期' | '待签署'
/** 合同 */
export interface Contract { export interface Contract {
id: string id: string
contractNo: string contractNo: string
@@ -21,7 +18,6 @@ export interface Contract {
files: FileInfo[] files: FileInfo[]
} }
/** 合同列表查询条件 */
export interface ContractQuery { export interface ContractQuery {
keyword: string keyword: string
page: number page: number

View File

@@ -1,162 +1,70 @@
// 需求提报(对接真实后端)
// 草稿:POST /web/workOrderDraft/{getDraft,add,upload,reset} → vm_work_order_draft(WorkOrderDraftBo / Vo)
// 提交:POST /web/requirementPool/create → vm_project_requirement(入参 WorkOrderDraftBo,与草稿同 BO,附件随请求体)
// (同控制器的 /submit 入参是 RdProjectBo、会额外落 vm_rd_project,前端当前未使用)
// 业务类型:GET /web/projectSubmission/list(vm_project_submission,@SaIgnore 免登录)
// 与首页 /web/home-page/service-type-list 是同一个 service 方法,数据完全一致
// 预算范围:GET /system/dict/data/type/vm_budget_range(字典)
// 注意:草稿 / 提交 / 字典 需登录态(匿名实测 401);业务类型列表免登录
/** 业务类型图标标识(对应前端内联 SVG,见 views/demand/cpns/BizTypeIcon.vue) */
export type BizIconKey = 'code' | 'chip' | 'iot' | 'factory' | 'other' export type BizIconKey = 'code' | 'chip' | 'iot' | 'factory' | 'other'
/** 字典项(GET /system/dict/data/type/{dictType}) */
export interface DictOption { export interface DictOption {
dictLabel: string dictLabel: string
dictValue: string dictValue: string
dictSort: number dictSort: number
} }
/** 项目提报列表项(GET /web/projectSubmission/list,只取业务类型卡片用得到的字段) */
export interface SubmissionBizTypeVo { export interface SubmissionBizTypeVo {
/** 提报主键(业务详情接口 /web/projectSubmission/{id} 用它) */
id: string | number id: string | number
/** 业务类型(既作展示名,也作下发给后端的值) */
businessType: string | null businessType: string | null
/** 标题 */
title: string | null title: string | null
/** 副标题(卡片一句话描述的数据源) */
subtitle: string | null subtitle: string | null
/** 封面(⚠️ 实测存的是 OSS 文件标识,非完整地址) */
coverUrl: string | null coverUrl: string | null
/** Logo(⚠️ 同上,需经 /resource/oss/listByIds 换取真实地址) */
logoUrl: string | null logoUrl: string | null
} }
/**
* 业务类型卡片选项。
*
* <p>值域**完全由接口决定**:提报列表返回几条就渲染几张卡,前端不追加任何兜底项
* (「其他」也不例外——接口有才显示,没有就不显示)。字段映射:`businessType` 作下发值、
* `title` 作展示名、`subtitle` 作描述、`logoUrl` 作图标。但后端这两个字段目前是**半成品**——
* subtitle 常与类型名重复、logo 存的是 OSS 文件标识——故 desc / icon 保留前端映射表兜底
* (见 `utils/bizTypeMeta.ts`,只按类型名补描述/图标,不产生新选项)。</p>
*/
export interface BizTypeOption { export interface BizTypeOption {
/** 下发给后端的值(= 提报 businessType,如「互联网软件」) */
value: string value: string
/** 展示名 */
label: string label: string
/** 一句话描述(后端 subtitle 可用时优先,否则用前端映射) */
desc: string desc: string
/** 图标标识(前端内联 SVG;`iconUrl` 为空时渲染它) */
icon: BizIconKey icon: BizIconKey
/** 后端 Logo 图片地址(已换链可直接访问时优先渲染,否则回退 `icon` 内联 SVG) */
iconUrl?: string iconUrl?: string
} }
/** 草稿附件(vm_work_order_draft_attachment) */
export interface DemandAttachment { export interface DemandAttachment {
id?: string | number | null id?: string | number | null
draftId?: string | number | null draftId?: string | number | null
/** OSS 文件主键 */
ossId?: string | number | null ossId?: string | number | null
/** 原始文件名 */
originalName?: string | null originalName?: string | null
/** 文件访问地址 */
url?: string | null url?: string | null
/** 文件大小(字节) */
fileSize?: number | null fileSize?: number | null
/** 文件格式后缀(pdf / docx / zip …) */
type?: string | null type?: string | null
/** 上传时间(BaseEntity.createTime;本地上传时由前端补当前时间) */
createTime?: string | null createTime?: string | null
} }
/** 需求提报表单(字段与后端 WorkOrderDraftBo 对应,另有 1 个纯前端状态字段:budgetManual) */
export interface Demand { export interface Demand {
/** 草稿主键(编辑已有草稿时回填) */
id?: string | number | null id?: string | number | null
/** 业务类型(= 项目提报的 businessType,如「互联网软件」;后端落库存的也是这个名称,不是提报 id) */
bizType: string bizType: string
/** 项目名称 */
projectName: string projectName: string
/** 核心需求描述 */
requirementDesc: string requirementDesc: string
/**
* 预算范围:选预设档位时为字典 `vm_budget_range` 的 dictValue('0'~'3');
* 在第 5 个「手填」输入框里写字时为用户自己填的金额文本(如「80万」)。
*
* <p>⚠️ 手填内容只能放本字段:后端 `WorkOrderDraftBo.budgetCustom` 是被注释掉的、
* `vm_work_order_draft` 表也没有 `budget_custom` 列(VO 上那个 `budgetCustom` 恒为 null),
* 所以没有第二个位置可以承载手填文本。提交落 `vm_project_requirement.budget_range`,
* 该列为 **varchar(30)**,故手填文本限长见 `utils/budgetMeta.ts#MANUAL_BUDGET_MAX`。</p>
*/
budgetRange: string budgetRange: string
/** 是否由「手填」输入框提供预算(**纯前端状态**:后端 BO/VO 与两张表都没有该字段,下发前需剔除) */
budgetManual: boolean budgetManual: boolean
/** 预计开发周期(工作日) */
developmentCycle: number | '' developmentCycle: number | ''
/** 补充备注 */
supplementaryRemarks: string supplementaryRemarks: string
/** 用户名称 */
userName: string userName: string
/** 用户电话 */
userPhone: string userPhone: string
/** 附件列表(后端按此逐条转存为需求附件) */
attachmentInfo: DemandAttachment[] attachmentInfo: DemandAttachment[]
} }
/**
* 直接提报请求体(对应后端 `RdProjectBo` 的可写子集,POST /web/requirementPool/submit)。
*
* <p>⚠️ **当前前端未使用**:提交走的是 `/web/requirementPool/create`(入参 `WorkOrderDraftBo`,
* 与草稿同一个 BO,附件随请求体下发)。本类型对应的是后端 `/submit` 链路,保留以备切回。</p>
*
* <p>⚠️ 与草稿 BO 的字段名**不同**,别混用:业务类型叫 `businessType`(草稿叫 `bizType`)、
* 补充备注叫 `extraRemark`(草稿叫 `supplementaryRemarks`)。</p>
*
* <p>⚠️ 两个字段**不在**此请求体里:</p>
* <ul>
* <li>`developmentCycle`(预计开发周期)—— `vm_project_requirement` 与 `vm_rd_project`
* 两张表都**没有**该列,所以提报链路无处存放(草稿表 `vm_work_order_draft.development_cycle`
* 里有,提交后即丢失,草稿路径同样如此);</li>
* <li>`attachmentInfo` —— `RdProjectBo` 无附件字段,后端按登录用户从**已落库草稿**里
* 按 `ossId` 搬运到 `vm_project_requirement_attachment`(故走该链路时调用方需先落一次草稿,
* 这也正是前端改回 `/create` 的原因之一)。</li>
* </ul>
*
* <p>`projectNo` / `sourceType` / `priority` / `status` / `progress` 在 BO 上虽标了 `@NotBlank`,
* 但注解都挂 `AddGroup` / `EditGroup`,而控制器是裸 `@Validated`(未指定分组)⇒ 实际**不生效**;
* 这些值由后端统一生成/兜默认(`project_no` 复用需求编号、`source_type=EXTERNAL`、
* `priority=UNASSESSED`、`status=IN_PROGRESS`、`progress=0`),前端不下发。</p>
*/
export interface DemandSubmitBo { export interface DemandSubmitBo {
/** 业务类型名称(如「互联网软件」;后端落库存的是名称,不是提报 id) */
businessType: string businessType: string
/** 项目名称 */
projectName: string projectName: string
/** 核心需求描述 */
requirementDesc: string requirementDesc: string
/** 联系人姓名(缺失时后端回退取登录用户资料) */
customerName: string customerName: string
/** 联系人电话(同上) */
customerPhone: string customerPhone: string
/** 预算范围:预设档位 = 字典码值;手填 = 用户输入的金额文本 */
budgetRange: string | null budgetRange: string | null
/** 补充备注(对应草稿的 supplementaryRemarks) */
extraRemark: string | null extraRemark: string | null
} }
/** 草稿详情(后端 WorkOrderDraftVo,取前端用得到的子集) */
export interface DemandDraftVo { export interface DemandDraftVo {
id: string | number | null id: string | number | null
bizType: string | null bizType: string | null
projectName: string | null projectName: string | null
requirementDesc: string | null requirementDesc: string | null
/** 预算范围:预设档位=字典 dictValue;手填=用户在第 5 个输入框里写的文本(前端据此反推 budgetManual) */
budgetRange: string | null budgetRange: string | null
/** ⚠️ 后端 VO 声明了该字段但**恒为 null**(Bo 里被注释、表无该列)→ 不要依赖它回填手填内容 */
budgetCustom: string | null budgetCustom: string | null
developmentCycle: number | null developmentCycle: number | null
supplementaryRemarks: string | null supplementaryRemarks: string | null
@@ -165,7 +73,6 @@ export interface DemandDraftVo {
attachmentInfo: DemandAttachment[] | null attachmentInfo: DemandAttachment[] | null
} }
/** 附件上传返回(后端 SysOssVo) */
export interface OssUploadVo { export interface OssUploadVo {
ossId: string | number ossId: string | number
url: string url: string
@@ -173,9 +80,7 @@ export interface OssUploadVo {
fileSuffix: string fileSuffix: string
} }
/** OSS 换链返回项(GET /resource/oss/listByIds/{ids} 的子集) */
export interface OssUrlVo { export interface OssUrlVo {
ossId: string | number ossId: string | number
/** 可直接访问的地址(阿里云 OSS 公网 URL) */
url: string url: string
} }

View File

@@ -1,21 +1,8 @@
// 财务中心实体(账单/支付/发票/抬头/统计)
/**
* 账单状态(页面展示口径,中文)。
*
* <p>与后端 {@code vm_bill.status} 的英文编码(见 `BillStatusCode`)是多对一的展示映射,
* 转换收口在 `utils/billVo.ts#toBillStatus`。</p>
*/
export type BillStatus = '待支付' | '已支付' | '未到期' | '已取消' export type BillStatus = '待支付' | '已支付' | '未到期' | '已取消'
/** 后端账单状态编码(`vm_bill.status` 值域) */
export type BillStatusCode = 'UNSTARTED' | 'PENDING' | 'PAID' | 'CANCELLED' export type BillStatusCode = 'UNSTARTED' | 'PENDING' | 'PAID' | 'CANCELLED'
/**
* 后端账单视图对象 —— `GET /web/bill/list` 的响应元素(对应后端 `BillVo`)。
*
* <p>金额单位 **元**;`id` 为雪花 id 时 JSON 里是字符串,小 id 是数字,一律 `String(id)` 归一。</p>
*/
export interface BillVo { export interface BillVo {
id: number | string id: number | string
billNo?: string billNo?: string
@@ -24,36 +11,19 @@ export interface BillVo {
projectName?: string projectName?: string
userId?: number | string userId?: number | string
amount?: number amount?: number
/** 业务类型字典值:internet_software / hardware_circuit / iot_solution / manufacturing */
bizType?: string bizType?: string
/** 账单类型:ADVANCE / PROGRESS / FINAL / OTHER */
billType?: string billType?: string
/** 期次名,如「第2期·尾款」(页面「账单名称」列) */
periodName?: string periodName?: string
/** 占项目总额比例(%) */
periodRatio?: number periodRatio?: number
status?: BillStatusCode | string status?: BillStatusCode | string
/** 支付通道:WECHAT / ALIPAY / BANK / WORKWX */
payChannel?: string payChannel?: string
/** 交易流水号(支付成功回写) */
transactionNo?: string transactionNo?: string
/** 交易名称(支付时回传) */
tradeName?: string tradeName?: string
payTime?: string payTime?: string
dueDate?: string dueDate?: string
createTime?: string createTime?: string
} }
/**
* 后端分期计划视图对象(`vm_bill_plan` + 该期支付单信息,对应 `BillPlanVo`)。
*
* <p>由项目详情「合同财务」那个现成接口 `GET /web/requirementPool/get/finance/{需求id}`
* 的 `billPlans` 下发;收银台「订单明细」复用同一份数据
* (见 `api/finance.ts#getCheckoutBills`),没有再单开端点。</p>
*
* <p>⚠️ 本栈支付口径是**计划 id**:`vm_finance_payment.bill_id` 实测存的就是 `vm_bill_plan.id`,
* 所以收银台 `?ids=` 传的是这个 `id`,而不是 `vm_bill.id`。</p>
*/
export interface BillPlanVo { export interface BillPlanVo {
id: number | string id: number | string
projectId?: number | string projectId?: number | string
@@ -61,74 +31,46 @@ export interface BillPlanVo {
projectName?: string projectName?: string
userId?: number | string userId?: number | string
bizType?: string bizType?: string
/** ⚠️ BigDecimal ⇒ 后端以**字符串**下发("120000.00"),用前必须 `Number()` 归一 */
totalAmount?: number | string totalAmount?: number | string
/** 分期总期数 */
periods?: number periods?: number
/** 本期期次序号(1 起) */
currentPeriod?: number currentPeriod?: number
/** 每期比例规则,如 "40,30,30" */
periodRule?: string periodRule?: string
nextDate?: string nextDate?: string
/** 计划状态:ACTIVE / DONE / CLOSED / UNSTARTED */
status?: string status?: string
/** 该期支付单的商户订单号(页面「账单号」用它);未支付为空 */
orderNo?: string orderNo?: string
transactionNo?: string transactionNo?: string
payChannel?: string payChannel?: string
payAmount?: number | string payAmount?: number | string
/** 支付单状态:CREATED / SUCCESS / FAILED / REFUNDED */
payStatus?: string payStatus?: string
payTime?: string payTime?: string
} }
/** 发票状态 */
export type InvoiceStatus = '已开票' | '开票中' | '未开票' export type InvoiceStatus = '已开票' | '开票中' | '未开票'
/** 支付方式 */
export type PayMethod = 'wechat' | 'alipay' export type PayMethod = 'wechat' | 'alipay'
/**
* 后端统一支付响应 `org.dromara.pay.domain.PaymentResponse`(字段名与后端一致)。
*
* <p>扫码(Native)渠道回 `codeUrl`;支付宝电脑网站支付回 `formBody`
* ——注意该字段名虽叫 formBody,实测后端走 `pageExecute(request, "GET")`,
* 内容其实是**收银台跳转地址**而非 HTML 表单,前端两种形态都兜住。</p>
*/
export interface PayOrderResponse { export interface PayOrderResponse {
/** 支付渠道:WECHAT / ALIPAY / UNIONPAY */
channel: string channel: string
/** 支付场景:JSAPI / NATIVE / WEB_SCAN / WEB_PAGE / APP */
scene: string scene: string
/** 扫码支付二维码内容:微信 Native 为 code_url,支付宝订单码为 qr_code */
codeUrl?: string codeUrl?: string
/** 支付宝电脑网站支付返回的收银台跳转地址 / HTML 表单 */
formBody?: string formBody?: string
/** 支付宝 APP 支付订单串 */
orderString?: string orderString?: string
/** H5 支付链接 */
h5Url?: string h5Url?: string
} }
/** 账单(页面视图模型;由 `utils/billVo.ts#toBill` 从后端 `BillVo` 映射而来) */
export interface Bill { export interface Bill {
id: string id: string
/** 分期计划名,如 '第一期·预付款 比例33%'(后端 `periodName`) */
planName: string planName: string
billNo: string billNo: string
/** 交易流水号(后端 `transactionNo`) */
tradeNo?: string tradeNo?: string
projectName: string projectName: string
amount: number amount: number
status: BillStatus status: BillStatus
createTime: string createTime: string
/** 支付通道英文编码(WECHAT / ALIPAY / BANK / WORKWX) */
payChannel?: string payChannel?: string
/** 支付时间(后端 `payTime`) */
payTime?: string payTime?: string
} }
/** 账单查询条件 */
export interface BillQuery { export interface BillQuery {
keyword: string keyword: string
status: BillStatus | 'all' status: BillStatus | 'all'
@@ -136,7 +78,6 @@ export interface BillQuery {
pageSize: number pageSize: number
} }
/** 支付单(二维码) */
export interface Payment { export interface Payment {
billId: string billId: string
method: PayMethod method: PayMethod
@@ -144,7 +85,6 @@ export interface Payment {
qrExpiresInSeconds: number qrExpiresInSeconds: number
} }
/** 支付结果 */
export interface PaymentResult { export interface PaymentResult {
method: PayMethod method: PayMethod
amount: number amount: number
@@ -153,7 +93,6 @@ export interface PaymentResult {
bills: Bill[] bills: Bill[]
} }
/** 发票 */
export interface Invoice { export interface Invoice {
id: string id: string
planName: string planName: string
@@ -165,7 +104,6 @@ export interface Invoice {
applyTime: string applyTime: string
} }
/** 发票查询条件 */
export interface InvoiceQuery { export interface InvoiceQuery {
keyword: string keyword: string
status: InvoiceStatus | 'all' status: InvoiceStatus | 'all'
@@ -173,7 +111,6 @@ export interface InvoiceQuery {
pageSize: number pageSize: number
} }
/** 发票抬头 */
export interface InvoiceTitle { export interface InvoiceTitle {
id: string id: string
type: '企业单位' | '非企业单位' type: '企业单位' | '非企业单位'
@@ -182,14 +119,12 @@ export interface InvoiceTitle {
isDefault: boolean isDefault: boolean
} }
/** 新增/编辑发票抬头入参 */
export interface InvoiceTitlePayload { export interface InvoiceTitlePayload {
type: InvoiceTitle['type'] type: InvoiceTitle['type']
title: string title: string
taxNo: string taxNo: string
} }
/** 财务统计 */
export interface FinanceStats { export interface FinanceStats {
totalExpense: number totalExpense: number
month: string month: string

View File

@@ -1,53 +1,34 @@
// 首页类型(VIEW 类型 + 后端真实 VO 类型 + 门户入口类型,统一归位于 types/business/home.d.ts)
// //
// - VIEW 类型(BusinessLine / HomeStats / ServiceType / ServiceContentBlock / QuickLink):页面消费层
// - 后端 VO 类型(Project* / BannerVo / AgreementFormVo / ProjectCaseVo):vermis-maker
// com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController 与 ProjectCaseUserController 的真实返回
// - 门户入口类型(PortalEntry* / EntryTone / PortalEntryCard):IPortalEntryUserController,属首页快捷入口功能
import type { BusinessType } from '@/types/api' import type { BusinessType } from '@/types/api'
/* ============================ VIEW 类型(页面消费层) ============================ */
/** 数据看板 */
export interface HomeStats { export interface HomeStats {
pending: number pending: number
inProgress: number inProgress: number
total: number total: number
} }
/** 首页头图轮播(VIEW 层,由 BannerVo 映射) */
export interface Banner { export interface Banner {
id: number id: number
imageUrl: string imageUrl: string
} }
/** 快捷入口(VIEW 层占位,实际入口由门户接口驱动) */
export interface QuickLink { export interface QuickLink {
label: string label: string
path: string path: string
} }
/** 服务类型子项 */
export interface ServiceType { export interface ServiceType {
label: string label: string
desc: string desc: string
/** 图标(后端 icon_url 原样透传:图片地址时渲染为图,否则用名称首字兜底) */
icon?: string icon?: string
} }
/**
* 服务内容区块(对应后端富文本目录 `vm_project_submission_catalog` 的一条:
* 一条目录 = 详情页的一个「服务内容」区块,content 为富文本 HTML,可能内嵌图片/视频)
*/
export interface ServiceContentBlock { export interface ServiceContentBlock {
/** 区块标题(后端 catalog.title,可为空) */
title: string title: string
/** 区块内容(富文本 HTML 原文,直接渲染) */
content: string content: string
} }
/** 业务线(首页业务卡片 + 业务详情页共用) */
export interface BusinessLine { export interface BusinessLine {
id: BusinessType id: BusinessType
name: string name: string
@@ -55,25 +36,17 @@ export interface BusinessLine {
slogan: string slogan: string
icon: string icon: string
serviceContent: string serviceContent: string
/** 服务内容区块列表(一条富文本目录一项,详情页逐块渲染,保留 HTML) */
contents: ServiceContentBlock[] contents: ServiceContentBlock[]
serviceTypes: ServiceType[] serviceTypes: ServiceType[]
video: string video: string
} }
/* ============================ 后端真实 VO 类型 ============================ */
/** GET /web/home-page/project-status-count —— 项目状态统计 */
export interface ProjectStatusCount { export interface ProjectStatusCount {
/** 项目数量 */
projectCount: number projectCount: number
/** 进行中的项目数目 */
ongoingProjectCount: number ongoingProjectCount: number
/** 待处理数目 */
pendingProjectCount: number pendingProjectCount: number
} }
/** 服务类型子项(service-type-list / service-type-detail 内含) */
export interface ProjectServiceTypeVo { export interface ProjectServiceTypeVo {
id: number | null id: number | null
projectId: number | null projectId: number | null
@@ -82,7 +55,6 @@ export interface ProjectServiceTypeVo {
sortOrder: number | null sortOrder: number | null
} }
/** 项目背景(富文本) */
export interface ProjectBackgroundVo { export interface ProjectBackgroundVo {
id: number | null id: number | null
projectId: number | null projectId: number | null
@@ -90,13 +62,11 @@ export interface ProjectBackgroundVo {
sortOrder: number | null sortOrder: number | null
} }
/** 项目提报视图对象:service-type-list / service-type-detail/{id} 的返回 */
export interface ProjectSubmissionVo { export interface ProjectSubmissionVo {
id: number id: number
businessType: string | null businessType: string | null
title: string | null title: string | null
subtitle: string | null subtitle: string | null
/** 是否启用:1 启用,0 停用 */
enabled: number | null enabled: number | null
coverUrl: string | null coverUrl: string | null
logoUrl: string | null logoUrl: string | null
@@ -106,18 +76,13 @@ export interface ProjectSubmissionVo {
updateTime: string | null updateTime: string | null
} }
/** GET /web/home-page/service-type-detail/{id} —— 项目提报详情(聚合 VO:主表单对象,三个子表均为一对多 List) */
export interface ProjectSubmissionInfo { export interface ProjectSubmissionInfo {
projectSubmission: ProjectSubmissionFlat | null projectSubmission: ProjectSubmissionFlat | null
/** 一对多:服务类型列表 */
projectServiceType: ProjectServiceTypeInfo[] | null projectServiceType: ProjectServiceTypeInfo[] | null
/** 一对多:附件列表 */
projectSubmissionAttachment: ProjectSubmissionAttachmentInfo[] | null projectSubmissionAttachment: ProjectSubmissionAttachmentInfo[] | null
/** 一对多:富文本目录列表 */
projectSubmissionCatalog: ProjectSubmissionCatalogInfo[] | null projectSubmissionCatalog: ProjectSubmissionCatalogInfo[] | null
} }
/** 主表 vm_project_submission(详情接口下发) */
export interface ProjectSubmissionFlat { export interface ProjectSubmissionFlat {
id: number | null id: number | null
businessType: string | null businessType: string | null
@@ -128,7 +93,6 @@ export interface ProjectSubmissionFlat {
logoUrl: string | null logoUrl: string | null
} }
/** 服务类型 vm_project_service_type(详情接口各取一条) */
export interface ProjectServiceTypeInfo { export interface ProjectServiceTypeInfo {
id: number | null id: number | null
projectId: number | null projectId: number | null
@@ -137,7 +101,6 @@ export interface ProjectServiceTypeInfo {
sortOrder: number | null sortOrder: number | null
} }
/** 附件 vm_project_submission_attachment(详情接口各取一条) */
export interface ProjectSubmissionAttachmentInfo { export interface ProjectSubmissionAttachmentInfo {
id: number | null id: number | null
projectId: number | null projectId: number | null
@@ -147,7 +110,6 @@ export interface ProjectSubmissionAttachmentInfo {
sortOrder: number | null sortOrder: number | null
} }
/** 目录(富文本)vm_project_submission_catalog(详情接口各取一条) */
export interface ProjectSubmissionCatalogInfo { export interface ProjectSubmissionCatalogInfo {
id: number | null id: number | null
projectId: number | null projectId: number | null
@@ -156,78 +118,41 @@ export interface ProjectSubmissionCatalogInfo {
sortOrder: number | null sortOrder: number | null
} }
/** GET /web/home-page/carousel-list —— 轮播图 */
export interface BannerVo { export interface BannerVo {
id: number | null id: number | null
imageUrl: string | null imageUrl: string | null
sort: number | null sort: number | null
/** 是否启用(true 启用,false 停用) */
enabled: boolean | null enabled: boolean | null
} }
/** GET /web/home-page/platform-intro、contact-info —— 协议/静态文章富文本 */
export interface AgreementFormVo { export interface AgreementFormVo {
id: number | null id: number | null
/** 协议类型(目前只有「关于我们」一条) */
type: string | null type: string | null
/** 文章内容(富文本 HTML) */
content: string | null content: string | null
} }
// 案例库的实体类型与 VO(ProjectCaseVo / ProjectCaseUserBo / CaseSelectionModel 等)
// 已按「一页一套」归位到 `@/types/business/cases`,本文件不再重复定义。
/* ============================ 门户入口(首页快捷入口功能) ============================ */
/**
* 后端 IPortalEntryVo(入口目录 + 用户个性化分组)
*
* <p>注意字段来源分两处:</p>
* - 登录分支(selectByUserId):name / id / icon / route / groupType / sortOrder / status,
* 其中 id 取的是 vm_portal_entry.id(入口目录主键);
* - 匿名分支(selectVoList,MyBatis-Plus 自动 SQL):多带 entryId 等目录字段,groupType 为目录默认值。
*/
export interface PortalEntryVo { export interface PortalEntryVo {
/** 入口主键 */
id: number | null id: number | null
/** 入口标识 */
entryId: string | null entryId: string | null
/** 显示名 */
name: string | null name: string | null
/** 图标名 / URL */
icon: string | null icon: string | null
/** 跳转路由 */
route: string | null route: string | null
/** 权限标识(空 = 无需权限) */
permission: string | null permission: string | null
/** 1 = 快捷、0 = 其他 */
groupType: string | null groupType: string | null
/** 组内顺序 */
sortOrder: number | null sortOrder: number | null
/** 1 = 启用、0 = 停用 */
status: boolean | null status: boolean | null
} }
/** 入口分组:1 = 快捷入口、0 = 其他入口 */
export type PortalEntryGroup = '1' | '0' export type PortalEntryGroup = '1' | '0'
/** 页面消费的入口卡片(视图层类型,与后端字段解耦) */
export interface PortalEntryCard { export interface PortalEntryCard {
/** 入口 id(切换分组时回传后端) */
id: number id: number
/** 显示名 */
label: string label: string
/** 跳转路由(可能为空,为空时不可点击跳转) */
route: string route: string
/** 当前分组:1 = 快捷、0 = 其他 */
groupType: PortalEntryGroup groupType: PortalEntryGroup
/** 图标:emoji 字符或图片地址 */
icon: string icon: string
/** icon 是否为图片地址 */
isImage: boolean isImage: boolean
/** 图标底色主题 */
tone: EntryTone tone: EntryTone
} }
/** 图标底色主题(设计稿为五色循环) */
export type EntryTone = 'purple' | 'green' | 'blue' | 'amber' | 'red' | 'cyan' export type EntryTone = 'purple' | 'green' | 'blue' | 'amber' | 'red' | 'cyan'

View File

@@ -1,24 +1,19 @@
// 消息实体(PRD M8 消息中心)
import type { FileInfo } from '@/types/api' import type { FileInfo } from '@/types/api'
/** 消息类型 */
export type MessageType = '在线客服' | '状态通知' | '系统通知' export type MessageType = '在线客服' | '状态通知' | '系统通知'
/** 站内消息 */
export interface Message { export interface Message {
id: string id: string
type: MessageType type: MessageType
title: string title: string
summary: string summary: string
/** 正文纯文本,详情页 pre-wrap 渲染 */
content: string content: string
publishTime: string publishTime: string
isRead: boolean isRead: boolean
attachments: FileInfo[] attachments: FileInfo[]
} }
/** 消息查询条件 */
export interface MessageQuery { export interface MessageQuery {
keyword: string keyword: string
type: MessageType | 'all' type: MessageType | 'all'

View File

@@ -1,23 +1,18 @@
// 个人中心实体(M10:security/address/favorites/qualifications/feedback/help/about/hotline 共享)
import type { BusinessType, FileInfo } from '@/types/api' import type { BusinessType, FileInfo } from '@/types/api'
/** 账号资料 */
export interface Profile { export interface Profile {
nickname: string nickname: string
phone: string phone: string
email: string email: string
/** 密码上次修改时间(展示值,如 '15天前') */
passwordModifiedAt: string passwordModifiedAt: string
} }
/** 修改密码入参 */
export interface ChangePasswordParams { export interface ChangePasswordParams {
code: string code: string
password: string password: string
} }
/** 收货地址 */
export interface Address { export interface Address {
id: string id: string
receiverName: string receiverName: string
@@ -27,7 +22,6 @@ export interface Address {
isDefault: boolean isDefault: boolean
} }
/** 资质类型 */
export type QualificationType = export type QualificationType =
| '营业执照' | '营业执照'
| '生产许可证' | '生产许可证'
@@ -36,10 +30,8 @@ export type QualificationType =
| '授权书' | '授权书'
| '其他资质' | '其他资质'
/** 资质审核状态 */
export type QualificationStatus = '已认证' | '审核中' | '未通过' export type QualificationStatus = '已认证' | '审核中' | '未通过'
/** 资质 */
export interface Qualification { export interface Qualification {
id: string id: string
type: QualificationType type: QualificationType
@@ -52,35 +44,29 @@ export interface Qualification {
attachments: FileInfo[] attachments: FileInfo[]
} }
/** 资质新增/编辑提交载荷(id 存在为编辑) */
export type QualificationInput = Omit<Qualification, 'id' | 'status'> & { id?: string } export type QualificationInput = Omit<Qualification, 'id' | 'status'> & { id?: string }
/** 意见反馈 */
export interface Feedback { export interface Feedback {
title: string title: string
description: string description: string
attachments: FileInfo[] attachments: FileInfo[]
} }
/** 列表分页查询 */
export interface ListQuery { export interface ListQuery {
page: number page: number
pageSize: number pageSize: number
} }
/** 我的收藏查询(关键字 + 分类 + 分页) */
export interface FavoritesQuery extends ListQuery { export interface FavoritesQuery extends ListQuery {
keyword: string keyword: string
category: BusinessType | 'all' category: BusinessType | 'all'
} }
/** 帮助中心 FAQ 条目 */
export interface HelpFaqItem { export interface HelpFaqItem {
q: string q: string
a: string a: string
} }
/** 帮助中心 FAQ 分组 */
export interface HelpFaqGroup { export interface HelpFaqGroup {
title: string title: string
items: HelpFaqItem[] items: HelpFaqItem[]

View File

@@ -1,15 +1,6 @@
// 项目中心实体(PRD M5)
import type { BusinessType, FileInfo } from '@/types/api' import type { BusinessType, FileInfo } from '@/types/api'
/**
* 项目状态(详情页展示口径,中文)。
*
* <p>取值 = 两张表状态码值的并集经 {@link PROJECT_STATUS_LABEL} 映射后的中文:
* 需求侧 `INITIATING`(待立项)/ `CANCELLED`(已取消),项目侧 `IN_PROGRESS` /
* `PENDING_ACCEPTANCE` / `PENDING_INTERNAL_ACCEPTANCE` / `COMPLETED` / `AFTER_SALE`。
* 「待内部验收」数量少但确实存在,故保留。</p>
*/
export type ProjectStatus = export type ProjectStatus =
| '待立项' | '待立项'
| '进行中' | '进行中'
@@ -19,17 +10,6 @@ export type ProjectStatus =
| '售后中' | '售后中'
| '已取消' | '已取消'
/**
* 列表行状态码值 —— 后端 `COALESCE(rd_project.status, project_requirement.status)`。
*
* <p>列表接口已改为「需求表为主 + LEFT JOIN 项目表」:已立项取项目状态,未立项回落到需求状态。
* 因此这里 = 两张表状态的<b>并集</b>:<br>
* · 项目表 `vm_rd_project.status`:IN_PROGRESS / PENDING_ACCEPTANCE /
* PENDING_INTERNAL_ACCEPTANCE / COMPLETED / AFTER_SALE / CANCELLED<br>
* · 需求表 `vm_project_requirement.status` 额外提供:<b>INITIATING(待立项)</b></p>
*
* <p>所以设计稿画的「待立项」现在是真实码值(= 需求已提报、尚未立项)。</p>
*/
export type RdProjectStatus = export type RdProjectStatus =
| 'INITIATING' | 'INITIATING'
| 'IN_PROGRESS' | 'IN_PROGRESS'
@@ -39,20 +19,12 @@ export type RdProjectStatus =
| 'AFTER_SALE' | 'AFTER_SALE'
| 'CANCELLED' | 'CANCELLED'
/**
* 项目列表行 —— 后端 `RdProjectVo`(`GET /web/requirementPool/list` 的 `requirementPage.rows`)。
*
* <p>`id` 后端是 Long,但全局 Jackson 配置序列化成字符串(已实测),故按 string 声明。
* `progress` 是 Integer,可能为 null(库里大量为空)⇒ 展示层要兜底成「—」。</p>
*/
export interface RdProjectVo { export interface RdProjectVo {
id: string id: string
/** 外部项目关联的需求主键,内部项目为 null */
requirementId?: string | null requirementId?: string | null
projectNo: string projectNo: string
/** EXTERNAL / INTERNAL */ /** EXTERNAL / INTERNAL */
sourceType?: string | null sourceType?: string | null
/** 业务类型快照:存的是**提报表主键 id**(如 '1'),不是类型名 ⇒ 展示要经 label 映射 */
businessType?: string | null businessType?: string | null
projectName: string projectName: string
requirementDesc?: string | null requirementDesc?: string | null
@@ -74,29 +46,17 @@ export interface RdProjectVo {
updateTime?: string | null updateTime?: string | null
} }
/** 业务类型统计单项(后端 `ProjectRequirementCountVo`) */
export interface ProjectBusinessTypeCount { export interface ProjectBusinessTypeCount {
/** 提报表主键 id(与列表筛选参数 `businessType` 同口径) */
businessTypeId: string businessTypeId: string
/** 业务类型名称,如「互联网软件」 */
businessTypeLabel: string businessTypeLabel: string
count: number count: number
} }
/**
* 业务数据统计(后端 `ProjectRequirementStatisticsVo`)。
*
* <p>来源是独立端点 `GET /web/requirementPool/getFilterConditions`(**全量口径**,不随筛选 / 分页变化)。</p>
*
* <p>⚠️ 列表接口 `/list` 响应里的同名字段后端仍是注释状态、实测恒为 `null`,**不要**拿它当统计源。
* 这里仍按「可能为 null」处理:拿不到时筛选栏只剩「全部」一行。</p>
*/
export interface ProjectStatistics { export interface ProjectStatistics {
total?: number | null total?: number | null
businessTypeCounts?: ProjectBusinessTypeCount[] | null businessTypeCounts?: ProjectBusinessTypeCount[] | null
} }
/** 列表响应(后端 `ProjectRequirementAndCountVo`:分页数据 + 统计平级) */
export interface ProjectListResult { export interface ProjectListResult {
requirementPage?: { requirementPage?: {
total: number total: number
@@ -105,38 +65,22 @@ export interface ProjectListResult {
requirementStatistics?: ProjectStatistics | null requirementStatistics?: ProjectStatistics | null
} }
/** 里程碑节点 */
export interface Milestone { export interface Milestone {
name: string name: string
status: '待开始' | '进行中' | '已完成' status: '待开始' | '进行中' | '已完成'
time?: string time?: string
} }
/** 团队成员 */
export interface TeamMember { export interface TeamMember {
role: string role: string
name: string name: string
avatarChar: string avatarChar: string
} }
/** 项目账单(付款计划) */
export interface ProjectBill { export interface ProjectBill {
id: string id: string
/**
* 数据来源:
* - `bill` = 真账单(`vm_bill`)—— `id` 是账单 id,可跳收银台支付;
* - `plan` = 仅付款计划(`vm_bill_plan`)—— 后端未重启(响应无 `bills`)时的只读回落,
* **没有账单 id,不可支付**(计划 id 与账单 id 值域重叠,拿来当收银台入参会查到别的账单)。
*/
source: 'bill' | 'plan' source: 'bill' | 'plan'
planName: string planName: string
/**
* 期次占比(%),已归一成**可展示文本**(如 `"30"`;非整数比例保留一位小数)。
*
* <p>数据源:账单 `vm_bill.period_ratio`(BigDecimal,字符串下发),回落卡则由
* `periodRule` 第 N 段折算。设计稿卡片标题是「第2期·进度款 比例30%」⇒ 标题 = 期次名 + 比例。
* 缺失/非法时为空串,模板据此不渲染「比例」片段。</p>
*/
periodRatio?: string periodRatio?: string
billNo: string billNo: string
amount: number amount: number
@@ -145,48 +89,19 @@ export interface ProjectBill {
projectName: string projectName: string
} }
/**
* 售后工单(详情页组件契约)。
*
* <p>两态:`处理中` = 售后工单存在且未撤回/未作废(含后端 REQUESTED / IN_PROGRESS);
* `无` = 没有工单,或最近一条已 WITHDRAWN(撤回后回到可再次申请的状态)。</p>
*/
export interface AfterSales { export interface AfterSales {
status: '无' | '处理中' status: '无' | '处理中'
/**
* 售后类型 —— 申请页下拉的固定 8 项:软件 BUG 修复 / 软件 / 固件功能迭代 /
* 硬件 PCB & 电路改版调整 / 硬件故障排查维修 / 成品不良品返修 / 元器件物料替换变更 /
* 物联网整机联调与版本升级 / 交付资料补齐与技术答疑。
* 库内暂无对应字典,值域先写在前端(`views/project/after-sales.vue#TYPE_OPTIONS`)。
*/
type: string type: string
/** 问题描述(后端 `reason`) */
description: string description: string
/** 用户补充备注(后端 `remark`) */
remark: string remark: string
/** 售后工单附件(后端 `afterSaleAttachments`,phase = AFTER_SALE) */
attachments: FileInfo[] attachments: FileInfo[]
/** 申请时间(后端 `requestTime`) */
createTime: string createTime: string
} }
/**
* 售后附件(已上传到 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 { export interface AfterSalesAttachment extends FileInfo {
/** OSS 主键(提交时进 `attachmentOssIds`) */
ossId: string | number ossId: string | number
} }
/**
* OSS 上传返回(后端 `SysOssVo` 的子集)—— 与需求提报共用同一个上传口。
*
* <p>⚠️ `fileSuffix` 带前导点(`.pdf`);响应里**没有**文件大小,大小要取本地 `File.size`。</p>
*/
export interface AfterSalesUploadVo { export interface AfterSalesUploadVo {
ossId: string | number ossId: string | number
url: string url: string
@@ -194,42 +109,18 @@ export interface AfterSalesUploadVo {
fileSuffix?: string fileSuffix?: string
} }
/**
* 售后申请入参 —— **对齐后端 `ProjectRequirementActionBo`**。
*
* <p>字段名与页面语义的对应:页面「问题描述」→ {@link reason}、「补充备注」→ {@link note}。</p>
*/
export interface AfterSalesPayload { export interface AfterSalesPayload {
/** 售后类型(下拉 8 项之一),最长 50 */
type: string type: string
/** 问题描述 → 后端 `reason`,最长 500 */
reason: string reason: string
/** 补充备注 → 后端 `note`(页面限 500,后端 BO 上限 2000) */
note: string note: string
/** 附件 OSS 主键集合(空数组安全,后端 null 会走空判断) */
attachmentOssIds: Array<string | number> attachmentOssIds: Array<string | number>
} }
/**
* 项目详情页数据模型 —— **详情页组件契约**,不是后端原样字段。
*
* <p>后端详情已拆成 4 个页签接口(见 {@link RequirementDetailBundle}),由
* `utils/projectVo.ts#toProjectDetail` 合并映射成本类型;详情页 6 个子组件
* (InfoCard/Team/Milestones/Files/Finance/AfterSales)只认这套字段,因此换数据来源
* 不需要动组件。</p>
*/
export interface Project { export interface Project {
id: string id: string
projectNo: string projectNo: string
projectName: string projectName: string
/** 归一化后的枚举(未知类型会落到 'other',仅供图标/配色等视觉用途) */
businessType: BusinessType businessType: BusinessType
/**
* 后端**原样下发**的业务类型名称(如「互联网软件」,来自提报表)。
*
* <p>⚠️ 展示一律用它:`businessType` 只有 5 个固定枚举,后台新增业务类型时会全部落成
* 「其他」——那是枚举的兜底,不是接口的值。</p>
*/
businessTypeName: string businessTypeName: string
progress: number progress: number
status: ProjectStatus status: ProjectStatus
@@ -245,61 +136,32 @@ export interface Project {
afterSales?: AfterSales afterSales?: AfterSales
} }
/** 业务类型筛选项 + 件数(左侧筛选栏「业务类型」列表;「全部」的 value 为 'all') */
export interface ProjectTypeCount { export interface ProjectTypeCount {
/** 'all' 表示「全部」;否则是后端下发的 `businessTypeId` */
value: string value: string
/** 展示文案(后端 `businessTypeLabel`;「全部」由前端给) */
label: string label: string
/** 该类型下的项目件数(后端全量口径;统计缺失时为 0) */
count: number count: number
} }
/**
* 项目列表查询条件(**已是后端口径**)。
*
* <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>
*/
export interface ProjectQuery { export interface ProjectQuery {
keyword: string keyword: string
/** 空数组 = 全部;元素为后端统计下发的 businessTypeId(**多选**,后端走 IN) */
businessTypes: string[] businessTypes: string[]
/** '' = 全部;否则为 {@link RdProjectStatus} 码值 */
status: string status: string
/** 提交时间快捷区间;与 startDate/endDate 互斥(选其一会把另一组清空) */
timeShortcut: 'all' | '3m' | '6m' | '1y' timeShortcut: 'all' | '3m' | '6m' | '1y'
/** 提交时间自选区间起(YYYY-MM-DD,空串 = 不限) */
startDate: string startDate: string
/** 提交时间自选区间止(YYYY-MM-DD,空串 = 不限) */
endDate: string endDate: string
pageNum: number pageNum: number
pageSize: number pageSize: number
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 详情契约:后端 4 个页签接口(都继承 ProjectRequirementBaseVo)
// GET /web/requirementPool/get/progress/{id} → ProjectRequirementProgressVo // GET /web/requirementPool/get/progress/{id} → ProjectRequirementProgressVo
// GET /web/requirementPool/get/files/{id} → ProjectRequirementFilesVo // GET /web/requirementPool/get/files/{id} → ProjectRequirementFilesVo
// GET /web/requirementPool/get/finance/{id} → ProjectRequirementFinanceVo // GET /web/requirementPool/get/finance/{id} → ProjectRequirementFinanceVo
// GET /web/requirementPool/get/afterSales/{id} → ProjectRequirementAfterSalesVo // GET /web/requirementPool/get/afterSales/{id} → ProjectRequirementAfterSalesVo
// //
// 为什么拆 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`) */
export interface RequirementProjectStage { export interface RequirementProjectStage {
id: string id: string
projectId: string | null projectId: string | null
@@ -309,12 +171,10 @@ export interface RequirementProjectStage {
startTime: string | null startTime: string | null
planTime: string | null planTime: string | null
finishTime: string | null finishTime: string | null
/** 开发进度百分比(列表页那个 progress 的详情侧来源) */
developmentProgress: number | null developmentProgress: number | null
sort: number | null sort: number | null
} }
/** 里程碑(后端 `RdProjectMilestone`,表 `vm_rd_project_milestone`) */
export interface RequirementMilestone { export interface RequirementMilestone {
id: string id: string
projectId: string | null projectId: string | null
@@ -326,22 +186,18 @@ export interface RequirementMilestone {
parallelFlag: boolean | null parallelFlag: boolean | null
} }
/** 需求附件(后端 `ProjectRequirementAttachment`,表 `vm_project_requirement_attachment`) */
export interface RequirementAttachment { export interface RequirementAttachment {
id: string id: string
requirementId: string | null requirementId: string | null
/** 附件所属阶段,实测有 `REQUIREMENT` */
phase: string | null phase: string | null
ossId: string | null ossId: string | null
originalName: string | null originalName: string | null
url: string | null url: string | null
fileSize: number | null fileSize: number | null
sortOrder: number | null sortOrder: number | null
/** 上传时间(继承 BaseEntity,后端会下发) */
createTime?: string | null createTime?: string | null
} }
/** 项目团队(后端 `RdProjectMember`,表 `vm_rd_project_member`) */
export interface RequirementMember { export interface RequirementMember {
id: string id: string
projectId: string | null projectId: string | null
@@ -352,12 +208,6 @@ export interface RequirementMember {
isOwner: boolean | null isOwner: boolean | null
} }
/**
* 账单(后端 `Bill`,表 `vm_bill`)—— 「合同财务」页签卡片的**数据源**。
*
* <p>设计稿卡片上的字段(「账单号」+ 已支付/待支付/未到期)只有这张表才有;
* 且 `id` 直接就是收银台 `?ids=` 要传的**账单 id**(不是付款计划 id)。</p>
*/
export interface RequirementBill { export interface RequirementBill {
id: string id: string
billNo: string | null billNo: string | null
@@ -365,14 +215,11 @@ export interface RequirementBill {
projectNo: string | null projectNo: string | null
projectName: string | null projectName: string | null
userId: string | null userId: string | null
/** ⚠️ BigDecimal ⇒ 后端以**字符串**下发("24000.00"),用前必须 `Number()` 归一 */
amount: string | number | null amount: string | number | null
contractId: string | null contractId: string | null
/** ADVANCE / PROGRESS / FINAL / OTHER */ /** ADVANCE / PROGRESS / FINAL / OTHER */
billType: string | null billType: string | null
/** 期次名,如「第2期·进度款」 */
periodName: string | null periodName: string | null
/** 占项目总额比例(%),如 40.00 */
periodRatio: string | number | null periodRatio: string | number | null
/** UNSTARTED / PENDING / PAID / CANCELLED */ /** UNSTARTED / PENDING / PAID / CANCELLED */
status: string | null status: string | null
@@ -381,95 +228,62 @@ export interface RequirementBill {
tradeName: string | null tradeName: string | null
payTime: string | null payTime: string | null
dueDate: string | null dueDate: string | null
/** 创建时间(继承 BaseEntity,后端会下发) */
createTime?: string | null createTime?: string | null
} }
/** 付款计划(后端 `BillPlan`,表 `vm_bill_plan`) */
export interface RequirementBillPlan { export interface RequirementBillPlan {
id: string id: string
projectId: string | null projectId: string | null
projectNo: string | null projectNo: string | null
projectName: string | null projectName: string | null
/** ⚠️ BigDecimal ⇒ 后端以**字符串**下发("120000.00"),用前必须 `Number()` 归一 */
totalAmount: string | number | null totalAmount: string | number | null
periods: number | null periods: number | null
currentPeriod: number | null currentPeriod: number | null
periodRule: string | null periodRule: string | null
nextDate: string | null nextDate: string | null
status: string | null status: string | null
/**
* 该期支付单的商户订单号(`vm_finance_payment.out_trade_no`)——
* **页面「账单号」就显示它**;未支付(该期还没下过单)时为 null。
*/
orderNo: string | null orderNo: string | null
/** 支付成功回写的交易流水号(`vm_finance_payment.transaction_no`);未支付为 null */
transactionNo: string | null transactionNo: string | null
/** 支付通道:WECHAT/ALIPAY/...;未支付为 null */
payChannel: string | null payChannel: string | null
/** 该期实付金额(元);未支付为 null */
payAmount: string | number | null payAmount: string | number | null
/** 支付单状态:CREATED/SUCCESS/FAILED/REFUNDED;未支付为 null */
payStatus: string | null payStatus: string | null
/** 支付时间;未支付为 null */
payTime: string | null payTime: string | null
} }
/** 发票(后端 `Invoice`,表 `vm_invoice`) */
export interface RequirementInvoice { export interface RequirementInvoice {
id: string id: string
billId: string | null billId: string | null
invoiceTitle: string | null invoiceTitle: string | null
taxNo: string | null taxNo: string | null
invoiceType: string | null invoiceType: string | null
/** ⚠️ BigDecimal ⇒ 字符串下发,用前 `Number()` 归一 */
amount: string | number | null amount: string | number | null
status: string | null status: string | null
invoiceNo: string | null invoiceNo: string | null
/** 电子发票文件地址 */
invoiceFile: string | null invoiceFile: string | null
applyTime: string | null applyTime: string | null
invoiceDate: string | null invoiceDate: string | null
/** 申请时间(继承 BaseEntity,后端会下发) */
createTime?: string | null createTime?: string | null
} }
/** 合同(后端 `Contract`,表 `vm_contract`) */
export interface RequirementContract { export interface RequirementContract {
id: string id: string
contractNo: string | null contractNo: string | null
projectId: string | null projectId: string | null
title: string | null title: string | null
/** ⚠️ BigDecimal ⇒ 字符串下发,用前 `Number()` 归一 */
amount: string | number | null amount: string | number | null
signStatus: string | null signStatus: string | null
status: string | null status: string | null
signDate: string | null signDate: string | null
/** 合同文件地址 */
file: string | null file: string | null
/** 签署时间(继承 BaseEntity,后端会下发) */
createTime?: string | null createTime?: string | null
} }
/** 售后工单(后端 `ProjectRequirementAfterSale`,表 `vm_project_requirement_after_sale`) */
export interface RequirementAfterSale { export interface RequirementAfterSale {
id: string id: string
requirementId: string | null requirementId: string | null
/**
* 售后类型 —— 申请页下拉的固定 8 项:软件 BUG 修复 / 软件 / 固件功能迭代 /
* 硬件 PCB & 电路改版调整 / 硬件故障排查维修 / 成品不良品返修 / 元器件物料替换变更 /
* 物联网整机联调与版本升级 / 交付资料补齐与技术答疑。
* 库内暂无对应字典,值域先写在前端(`views/project/after-sales.vue#TYPE_OPTIONS`)。
*/
type: string | null type: string | null
/** 售后原因(用户在申请页填的「问题描述」) */
reason: string | null reason: string | null
/** 用户补充备注(申请页的「补充备注」) */
remark: string | null remark: string | null
/**
* REQUESTED(待受理)/ IN_PROGRESS(处理中)/ WITHDRAWN(已撤回)/ COMPLETED(已完成)
* —— 见 `ProjectRequirementConstants`。
*/
status: string | null status: string | null
requestUserName: string | null requestUserName: string | null
requestTime: string | null requestTime: string | null
@@ -479,81 +293,39 @@ export interface RequirementAfterSale {
withdrawReason: string | null withdrawReason: string | null
} }
/**
* 4 个页签接口的公共基础信息(后端 `ProjectRequirementBaseVo`)。
*
* <p>字段全部来自需求表 `vm_project_requirement`,**与是否立项无关**,任意页签都填充。</p>
*/
export interface RequirementBaseVo { export interface RequirementBaseVo {
/** 需求 id(= 详情接口路径参数 = 列表行 RdProjectVo.id) */
id: string id: string
projectNo: string | null projectNo: string | null
projectName: string | null projectName: string | null
/** 业务类型**中文名**(如「硬件电路」),非英文 code */
businessType: string | null businessType: string | null
/** 状态码值:需求侧 INITIATING/CANCELLED,项目侧 IN_PROGRESS/… */
status: string | null status: string | null
/** 提交时间 */
createTime: string | null createTime: string | null
/** 提交人名称 */
submitUserName: string | null submitUserName: string | null
} }
/** 「项目进度」页签(后端 `ProjectRequirementProgressVo`):基础信息 + 开发进度 + 里程碑 + 团队 */
export interface RequirementProgressVo extends RequirementBaseVo { export interface RequirementProgressVo extends RequirementBaseVo {
/** 开发进度;未立项为 null */
projectStage: RequirementProjectStage | null projectStage: RequirementProjectStage | null
/**
* 总体进度百分比(= 列表 `RdProjectVo.progress`,同源 `vm_rd_project.progress`)。
* 后端在项目行没有 progress 时回落到 `projectStage.developmentProgress`;未立项为 null。
*/
developmentProgress?: number | null developmentProgress?: number | null
projectMilestones: RequirementMilestone[] projectMilestones: RequirementMilestone[]
/** 项目团队;未立项为空数组 */
members: RequirementMember[] members: RequirementMember[]
} }
/** 「项目文件」页签(后端 `ProjectRequirementFilesVo`):基础信息 + 需求附件 */
export interface RequirementFilesVo extends RequirementBaseVo { export interface RequirementFilesVo extends RequirementBaseVo {
/** 需求附件;与是否立项无关,始终填充 */
requirementAttachments: RequirementAttachment[] requirementAttachments: RequirementAttachment[]
} }
/**
* 「合同财务」页签(后端 `ProjectRequirementFinanceVo`):基础信息 + 账单 + 付款计划 + 发票 + 合同。
*
* <p><b>卡片优先用 `bills`</b>(真账单:账单号 / 本期金额 / 真状态 / 可支付 id)。
* 该字段**只在含「账单下发」改动的后端构建里才有**——运行态若是旧构建,响应里根本没有
* 这个 key,所以声明成可选,由 `utils/projectVo.ts` 判别后回落到 `billPlans`(只读卡片)。</p>
*/
export interface RequirementFinanceVo extends RequirementBaseVo { export interface RequirementFinanceVo extends RequirementBaseVo {
/** 真账单(`vm_bill`);**旧构建不下发该字段(undefined)**,新构建即使无账单也会给空数组 */
bills?: RequirementBill[] bills?: RequirementBill[]
/** 付款计划(`vm_bill_plan`):分期规则,作为 `bills` 缺失时的只读回落 */
billPlans: RequirementBillPlan[] billPlans: RequirementBillPlan[]
invoices: RequirementInvoice[] invoices: RequirementInvoice[]
contracts: RequirementContract[] contracts: RequirementContract[]
} }
/** 「售后信息」页签(后端 `ProjectRequirementAfterSalesVo`):基础信息 + 售后工单 + 工单附件 */
export interface RequirementAfterSalesVo extends RequirementBaseVo { export interface RequirementAfterSalesVo extends RequirementBaseVo {
/** 售后工单;无则 null */
afterSale: RequirementAfterSale | null afterSale: RequirementAfterSale | null
/**
* 售后工单附件 —— 与需求附件同表 `vm_project_requirement_attachment`,
* 靠 `phase = AFTER_SALE` + `target_id = 售后工单 id` 区分。
* 后端无条件下发(无工单时为空数组),前端直接渲染即可。
*/
afterSaleAttachments: RequirementAttachment[] afterSaleAttachments: RequirementAttachment[]
} }
/**
* 详情页四接口聚合结果。
*
* <p>`api/project.ts#getProjectDetail` 同帧并行取回 4 个页签后交给
* `utils/projectVo.ts#toProjectDetail` 合并成组件契约 {@link Project};
* 任一接口返回 null(需求不存在 / 非本人)即整页空态。</p>
*/
export interface RequirementDetailBundle { export interface RequirementDetailBundle {
progress: RequirementProgressVo progress: RequirementProgressVo
files: RequirementFilesVo files: RequirementFilesVo

View File

@@ -1,14 +1,10 @@
// 在线客服会话实体(PRD M9 在线客服)
/** 会话角色 */
export type ChatRole = 'user' | 'assistant' export type ChatRole = 'user' | 'assistant'
/** 会话消息 */
export interface ChatMessage { export interface ChatMessage {
id: string id: string
role: ChatRole role: ChatRole
content: string content: string
/** 文件消息卡片(有值时按文件渲染) */
file?: { file?: {
name: string name: string
size: number size: number

View File

@@ -1,6 +1,4 @@
// 用户/认证实体(M4,page-architecture)
/** 用户 */
export interface User { export interface User {
id: string id: string
nickname: string nickname: string
@@ -10,11 +8,6 @@ export interface User {
passwordModifiedAt: string passwordModifiedAt: string
} }
/**
* 登录用户资料(GET /system/user/profile,后端 SysUserVo 的子集)。
*
* <p>字段名与后端一致:`nickName` / `phoneNumber`(不是 nickname / phone)。</p>
*/
export interface UserProfileVo { export interface UserProfileVo {
userId: string userId: string
userName: string userName: string
@@ -25,25 +18,21 @@ export interface UserProfileVo {
deptName: string deptName: string
} }
/** 账号密码登录参数 */
export interface LoginByAccountParams { export interface LoginByAccountParams {
account: string account: string
password: string password: string
} }
/** 短信验证码登录参数 */
export interface LoginBySmsParams { export interface LoginBySmsParams {
phone: string phone: string
code: string code: string
} }
/** 登录结果 */
export interface LoginResult { export interface LoginResult {
token: string token: string
user: User user: User
} }
/** 注册参数 */
export interface RegisterParams { export interface RegisterParams {
phone: string phone: string
code: string code: string
@@ -52,7 +41,6 @@ export interface RegisterParams {
agree: boolean agree: boolean
} }
/** 找回/重置密码参数 */
export interface ResetPasswordParams { export interface ResetPasswordParams {
phone: string phone: string
code: string code: string

View File

@@ -1,8 +1,6 @@
// 全局声明(Window 扩展等,按需添加)
export {} export {}
declare global { declare global {
interface Window { interface Window {
// 全局挂载扩展位
} }
} }

View File

@@ -1,18 +1,9 @@
// RouteMeta 扩展
export {} export {}
declare module 'vue-router' { declare module 'vue-router' {
interface RouteMeta { interface RouteMeta {
/** 页面标题(document.title) */
title?: string title?: string
/** 是否需要登录 */
requiresAuth?: boolean requiresAuth?: boolean
/**
* 是否展示公共底栏(AppFooter)。
*
* <p>默认不展示:底栏信息量大、只在门户首页作为站点信息区出现,
* 其余页面(列表/详情/登录区)不渲染,避免干扰正文。</p>
*/
footer?: boolean footer?: boolean
} }
} }

View File

@@ -1,15 +1,5 @@
// 资源地址工具(后端多个接口会下发「OSS 文件标识」而不是完整地址)
// //
// 实测:案例 ProjectCaseVo.coverUrl、项目提报 ProjectSubmissionVo.coverUrl/logoUrl
// 都可能是 OSS 主键(如 2099753406859292674)。直接当 <img src> 用会请求到当前站点的
// 错误路径上(表现为 404 / 空白),故统一在这里收口判断。
/**
* 仅当值本身是可直接访问的地址时才返回它,否则返回空串。
*
* <p>可接受:`http(s)://…`、协议相对 `//…`、站内绝对路径 `/…`。
* OSS 文件标识(纯数字)、相对路径(`a/b.png`)一律判为不可直接使用。</p>
*/
export function toAssetUrl(raw: string | null | undefined): string { export function toAssetUrl(raw: string | null | undefined): string {
const value = (raw ?? '').trim() const value = (raw ?? '').trim()
if (!value) return '' if (!value) return ''

View File

@@ -1,14 +1,5 @@
import type { Bill, BillStatus, BillVo } from '@/types/business/finance' import type { Bill, BillStatus, BillVo } from '@/types/business/finance'
/**
* 后端账单实体 → 页面视图模型的映射收口。
*
* <p>后端 `vm_bill` 用的是英文状态编码(`UNSTARTED/PENDING/PAID/CANCELLED`)与
* `periodName` / `transactionNo` 等字段名,页面用的是中文状态与 `planName` / `tradeNo`,
* 两侧口径的换算**只在这里做**,页面与 store 一律只认 `Bill`。</p>
*/
/** 后端状态编码 → 页面中文状态 */
const STATUS_TEXT: Record<string, BillStatus> = { const STATUS_TEXT: Record<string, BillStatus> = {
UNSTARTED: '未到期', UNSTARTED: '未到期',
PENDING: '待支付', PENDING: '待支付',
@@ -16,21 +7,13 @@ const STATUS_TEXT: Record<string, BillStatus> = {
CANCELLED: '已取消', CANCELLED: '已取消',
} }
/**
* 后端状态编码 → 页面中文状态。
*
* <p>值域外(历史脏数据,如早期 `updateBillStatus` 硬编码写进去的 `'1'`)一律回落
* 「未到期」——宁可少标一个「待支付」按钮,也不要在页面上渲染出 `undefined`。</p>
*/
export function toBillStatus(code?: string): BillStatus { export function toBillStatus(code?: string): BillStatus {
if (!code) return '未到期' if (!code) return '未到期'
return STATUS_TEXT[code] ?? '未到期' return STATUS_TEXT[code] ?? '未到期'
} }
/** 单条账单映射;入参为空时返回 null(便于 `?ids=` 查不到时直接判空) */
export function toBill(vo: BillVo): Bill { export function toBill(vo: BillVo): Bill {
return { return {
// 雪花 id 序列化成 string、小 id 是 number ⇒ 统一归一成 string
id: String(vo.id), id: String(vo.id),
planName: vo.periodName ?? '', planName: vo.periodName ?? '',
billNo: vo.billNo ?? '', billNo: vo.billNo ?? '',
@@ -38,26 +21,17 @@ export function toBill(vo: BillVo): Bill {
projectName: vo.projectName ?? '', projectName: vo.projectName ?? '',
amount: Number(vo.amount ?? 0), amount: Number(vo.amount ?? 0),
status: toBillStatus(vo.status), status: toBillStatus(vo.status),
// 后端 Jackson 已按 yyyy-MM-dd HH:mm:ss 序列化,直接透出
createTime: vo.createTime ?? '', createTime: vo.createTime ?? '',
payChannel: vo.payChannel, payChannel: vo.payChannel,
payTime: vo.payTime, payTime: vo.payTime,
} }
} }
/** 列表映射(过滤掉没有 id 的异常行) */
export function toBills(list: BillVo[] | null | undefined): Bill[] { export function toBills(list: BillVo[] | null | undefined): Bill[] {
if (!list || list.length === 0) return [] if (!list || list.length === 0) return []
return list.filter((item) => item != null && item.id != null).map(toBill) return list.filter((item) => item != null && item.id != null).map(toBill)
} }
/**
* 后端**计划**状态编码(`vm_bill_plan`)→ 页面中文状态。
*
* <p>为什么不能复用 {@link toBillStatus}:计划表用的是 `DONE/ACTIVE`,账单表用的是
* `PAID/PENDING`,值域不重合——代跑会把「已支付」「待支付」全落成「未到期」。
* (`utils/projectVo.ts` 里那份私有的 `toBillStatus` 是同一口径的另一种写法。)</p>
*/
export function toPlanStatus(code?: string | null): BillStatus { export function toPlanStatus(code?: string | null): BillStatus {
const value = (code ?? '').toUpperCase() const value = (code ?? '').toUpperCase()
if (value === 'DONE' || value === 'PAID' || value === 'COMPLETED') return '已支付' if (value === 'DONE' || value === 'PAID' || value === 'COMPLETED') return '已支付'
@@ -66,14 +40,6 @@ export function toPlanStatus(code?: string | null): BillStatus {
return '未到期' return '未到期'
} }
/**
* 「付款计划」字段子集 —— 入参用**宽松结构**,两种声明都能喂进来。
*
* <p>同一张 `vm_bill_plan` 在前端有两份声明:`types/business/finance.d.ts#BillPlanVo`
* (字段可选)与 `types/business/project.d.ts#RequirementBillPlan`(字段可空 `| null`)。
* 后者赋给前者会被 TS 的可空检查拦下(`string | null` 不能给 `string | undefined`),
* 故映射函数统一收这个子集类型,两边都满足。</p>
*/
export interface PlanLike { export interface PlanLike {
id?: number | string | null id?: number | string | null
projectName?: string | null projectName?: string | null
@@ -89,21 +55,12 @@ export interface PlanLike {
payTime?: string | null payTime?: string | null
} }
/** 期次名,与后端 `BillServiceImpl#buildPeriodName` 同口径:首期预付款、末期尾款、中间进度款 */
function periodLabel(period: number, periods: number): string { function periodLabel(period: number, periods: number): string {
if (period <= 1) return '预付款' if (period <= 1) return '预付款'
if (period >= periods) return '尾款' if (period >= periods) return '尾款'
return '进度款' return '进度款'
} }
/**
* 分期计划(`vm_bill_plan`)→ 账单/订单条目。
*
* <p>两处都消费它:项目详情「合同财务」卡片(`utils/projectVo.ts#toPlanCard`)与收银台
* 「订单明细」(`api/finance.ts`,数据来自 `GET /web/requirementPool/get/finance/{需求id}`
* 下发的 `billPlans`)。期次名与金额按后端 `BillServiceImpl#resolveRatio/resolveAmount`
* 同口径折算;「账单号」取该期支付单的 `out_trade_no`(未支付为空 ⇒ 页面显示「—」)。</p>
*/
export function toPlanBill(vo: PlanLike): Bill { export function toPlanBill(vo: PlanLike): Bill {
const period = vo.currentPeriod ?? 0 const period = vo.currentPeriod ?? 0
const periods = vo.periods ?? 0 const periods = vo.periods ?? 0
@@ -111,7 +68,6 @@ export function toPlanBill(vo: PlanLike): Bill {
const segment = rule[period - 1] const segment = rule[period - 1]
const ratio = Number.isFinite(segment) ? segment : periods > 0 ? 100 / periods : 0 const ratio = Number.isFinite(segment) ? segment : periods > 0 ? 100 / periods : 0
const total = Number(vo.totalAmount ?? 0) const total = Number(vo.totalAmount ?? 0)
// 支付成功以支付单为准:计划状态可能没被回调推到 DONE,但流水已经是 SUCCESS
const paid = (vo.payStatus ?? '').toUpperCase() === 'SUCCESS' const paid = (vo.payStatus ?? '').toUpperCase() === 'SUCCESS'
return { return {
id: String(vo.id ?? ''), id: String(vo.id ?? ''),
@@ -129,7 +85,6 @@ export function toPlanBill(vo: PlanLike): Bill {
} }
} }
/** 计划列表映射(过滤掉没有 id 的异常行) */
export function toPlanBills(list: readonly PlanLike[] | null | undefined): Bill[] { export function toPlanBills(list: readonly PlanLike[] | null | undefined): Bill[] {
if (!list || list.length === 0) return [] if (!list || list.length === 0) return []
return list.filter((item) => item != null && item.id != null).map(toPlanBill) return list.filter((item) => item != null && item.id != null).map(toPlanBill)

View File

@@ -1,14 +1,7 @@
// 业务类型卡片展示元信息(图标 + 一句话描述)
// //
// 值域来源:项目提报列表 GET /web/projectSubmission/list(见 api/demand.ts),字段对应关系:
// value ← businessType(类型名,也是提交时下发给后端的值)
// label ← title // label ← title
// desc ← subtitle // 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 type { BizIconKey, BizTypeOption, SubmissionBizTypeVo } from '@/types/business/demand'
import { toAssetUrl } from '@/utils/asset' import { toAssetUrl } from '@/utils/asset'
@@ -17,23 +10,14 @@ interface BizMeta {
icon: BizIconKey icon: BizIconKey
} }
/** 按业务类型名索引的展示元信息(前端兜底,后端字段缺失时使用) */
const BIZ_META: Record<string, BizMeta> = { const BIZ_META: Record<string, BizMeta> = {
互联网软件: { desc: 'Web/小程序/APP/二次开发/桌面工具等', icon: 'code' }, 互联网软件: { desc: 'Web/小程序/APP/二次开发/桌面工具等', icon: 'code' },
硬件电路: { desc: '原理图设计/PCB设计/电气效验等', icon: 'chip' }, 硬件电路: { desc: '原理图设计/PCB设计/电气效验等', icon: 'chip' },
物联网全案: { desc: '软硬件开发+云端一体化/定制化开发等', icon: 'iot' }, 物联网全案: { desc: '软硬件开发+云端一体化/定制化开发等', icon: 'iot' },
生产制造: { desc: 'PCB制版/SMT/整机装配/定制生产等', icon: 'factory' }, 生产制造: { desc: 'PCB制版/SMT/整机装配/定制生产等', icon: 'factory' },
// 「其他」只在此处作为**展示元信息**(描述/图标)备着:只有当接口真的下发一条
// businessType = 「其他」时才用得上,本文件不会据此凭空生成选项。
其他: { desc: '若没有对应的服务内容请选择该选项', icon: 'other' }, 其他: { desc: '若没有对应的服务内容请选择该选项', icon: 'other' },
} }
/**
* 提报项 → 业务类型卡片选项。
*
* @param ossUrlById OSS 文件标识 → 可访问地址 的映射(换链结果;取不到时传空对象)
* @returns 类型名为空的脏数据返回 null,由调用方过滤
*/
export function toBizTypeOption( export function toBizTypeOption(
vo: SubmissionBizTypeVo, vo: SubmissionBizTypeVo,
ossUrlById: Record<string, string> = {}, ossUrlById: Record<string, string> = {},
@@ -43,7 +27,6 @@ export function toBizTypeOption(
const label = (vo.title ?? '').trim() || value const label = (vo.title ?? '').trim() || value
const meta = BIZ_META[value] const meta = BIZ_META[value]
const subtitle = (vo.subtitle ?? '').trim() const subtitle = (vo.subtitle ?? '').trim()
// 后端 subtitle 常常直接填类型名本身,这种「描述」没有信息量 → 视为缺失,回退前端映射
const desc = subtitle && subtitle !== value && subtitle !== label const desc = subtitle && subtitle !== value && subtitle !== label
? subtitle ? subtitle
: (meta?.desc ?? '') : (meta?.desc ?? '')
@@ -53,12 +36,10 @@ export function toBizTypeOption(
label, label,
desc, desc,
icon: meta?.icon ?? 'other', icon: meta?.icon ?? 'other',
// logoUrl 有时本就填的是地址,有时是 OSS 标识(需换链)
iconUrl: toAssetUrl(logoRaw) || ossUrlById[logoRaw] || '', iconUrl: toAssetUrl(logoRaw) || ossUrlById[logoRaw] || '',
} }
} }
/** 字典项 → 业务类型卡片选项(仅当提报表无可用数据、回退字典兜底时使用) */
export function toBizTypeOptionFromDict(value: string, label: string): BizTypeOption { export function toBizTypeOptionFromDict(value: string, label: string): BizTypeOption {
const meta = BIZ_META[value] const meta = BIZ_META[value]
return { return {

View File

@@ -1,45 +1,15 @@
// 预算范围(字典 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' import type { DictOption } from '@/types/business/demand'
/** 字典里表示「自己填写」的那一项(后端 label 现为「手填」) */
const MANUAL_PATTERN = /手填|手写|自定义/ const MANUAL_PATTERN = /手填|手写|自定义/
/**
* 手写内容最大长度。
*
* <p>= `vm_project_requirement.budget_range` 的列宽 varchar(30)。注意后端 BO 上
* `@Size(max = 100)` 更宽松,真正的瓶颈是数据库列宽,故以 30 为准。</p>
*/
export const MANUAL_BUDGET_MAX = 30 export const MANUAL_BUDGET_MAX = 30
/** 该字典项是否为「手写」项(决定它在页面上渲染成按钮还是输入框) */
export function isManualBudget(label: string): boolean { export function isManualBudget(label: string): boolean {
return MANUAL_PATTERN.test(label) return MANUAL_PATTERN.test(label)
} }
/**
* 回填草稿时区分「预设档位」与「手填内容」,并把预设档位**归一成 dictValue**。
*
* <p>判据:`budgetRange` 命中字典的 **dictValue 或 dictLabel** 即为预设档位;否则(非空)
* 视为手填文本。后端只回传一个 budgetRange 字符串、没有模式标记(`budgetCustom` 恒为 null),
* 只能这样反推。</p>
*
* <p>为什么要连 dictLabel 一起比、并且归一:字典 `vm_budget_range` 的 dictValue 是 '0'~'4',
* 但库里的历史草稿存的是**文案**(如 `1万以下`、`10万以上`)。只比 dictValue 的话,这些历史档位
* 会被误判成手填、串进输入框;而直接回填文案的话,页面按钮是按 dictValue 判选中的,
* 又会出现"值在、按钮不亮"的空档。故统一转换成 dictValue(下次保存草稿即把历史文案收敛成码值)。</p>
*/
export function resolveDraftBudget( export function resolveDraftBudget(
budgetRange: string | null | undefined, budgetRange: string | null | undefined,
dict: DictOption[], dict: DictOption[],

View File

@@ -1,24 +1,13 @@
// 案例后端 VO → 页面实体映射(纯函数,无 Vue 依赖)
// //
// 后端 ProjectCaseVo 与页面 Case 的差异(字段命名、业务类型取值、富文本、数值单位)
// 统一在此对齐,供首页 store(案例库精选)与案例库 store(列表 / 详情)共用。
import type { Case, CaseAttachment, CaseSection, ProjectCaseVo } from '@/types/business/cases' import type { Case, CaseAttachment, CaseSection, ProjectCaseVo } from '@/types/business/cases'
import { toAssetUrl } from '@/utils/asset' import { toAssetUrl } from '@/utils/asset'
import { toBusinessType } from '@/utils/format' import { toBusinessType } from '@/utils/format'
/** 规模展示值:后端下发「万」为单位的数值,页面按设计稿拼成「10万」 */
function toScaleText(scale: number | null): string { function toScaleText(scale: number | null): string {
return scale == null ? '' : `${scale}万` return scale == null ? '' : `${scale}万`
} }
/**
* 正文区块:后端 catalogs 按顺序映射为「标题 + 富文本」。
*
* <p>标题由后端下发(vm_project_case_catalog.title),前端不再写死区块名;顺序沿用后端
* 的 sort_order、id 升序。去掉标签后为空的内容会被过滤掉,避免渲染出只有标题的空白卡。
* 列表 / 精选接口不下发 catalogs,故那些场景返回空数组。</p>
*/
function toSections(list: ProjectCaseVo['catalogs']): CaseSection[] { function toSections(list: ProjectCaseVo['catalogs']): CaseSection[] {
return (list ?? []) return (list ?? [])
.map((item) => ({ .map((item) => ({
@@ -28,7 +17,6 @@ function toSections(list: ProjectCaseVo['catalogs']): CaseSection[] {
.filter((section) => section.content.replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' ').trim().length > 0) .filter((section) => section.content.replace(/<[^>]*>/g, '').replace(/&nbsp;/gi, ' ').trim().length > 0)
} }
/** 附件:过滤掉没有访问地址的脏数据 */
function toAttachments(list: ProjectCaseVo['attachments']): CaseAttachment[] { function toAttachments(list: ProjectCaseVo['attachments']): CaseAttachment[] {
return (list ?? []) return (list ?? [])
.filter((item) => (item.url ?? '').trim().length > 0) .filter((item) => (item.url ?? '').trim().length > 0)
@@ -39,16 +27,8 @@ function toAttachments(list: ProjectCaseVo['attachments']): CaseAttachment[] {
})) }))
} }
/** 提报主键 id → 业务类型名称 的字典(来源 GET /categories 的 projectCaseCounts) */
export type CaseTypeNameMap = Record<string, string> export type CaseTypeNameMap = Record<string, string>
/**
* 业务类型展示名解析。
*
* <p>优先级:后端 JOIN 出的 projectTypeName → 接口字典(id 反查名称)→ 原值。
* 实测案例表 business_type 列存的是**提报主键 id**(如 "2099753433048526850")且
* projectTypeName 为 null —— 若不查字典直接交给 toBusinessType,会全部归到「其他」。</p>
*/
function resolveCategoryName(vo: ProjectCaseVo, typeNameById?: CaseTypeNameMap): string { function resolveCategoryName(vo: ProjectCaseVo, typeNameById?: CaseTypeNameMap): string {
return ( return (
vo.projectTypeName?.trim() vo.projectTypeName?.trim()
@@ -58,19 +38,10 @@ function resolveCategoryName(vo: ProjectCaseVo, typeNameById?: CaseTypeNameMap):
) )
} }
/**
* 后端案例 VO → 页面案例实体。
*
* <p>列表接口不返回 catalogs / attachments,页面实体对应字段为空
* (正文区块与附件仅详情接口下发)。</p>
*
* @param typeNameById 业务类型字典(id → 名称),取自案例库 store 的 /categories 结果
*/
export function toCase(vo: ProjectCaseVo, typeNameById?: CaseTypeNameMap): Case { export function toCase(vo: ProjectCaseVo, typeNameById?: CaseTypeNameMap): Case {
return { return {
id: String(vo.id), id: String(vo.id),
title: vo.caseName ?? '', title: vo.caseName ?? '',
// 案例表 business_type 存提报主键 id,展示名需经字典翻译(见 resolveCategoryName)
category: toBusinessType(resolveCategoryName(vo, typeNameById)), category: toBusinessType(resolveCategoryName(vo, typeNameById)),
cover: toAssetUrl(vo.coverUrl), cover: toAssetUrl(vo.coverUrl),
finishDate: vo.completedAt ?? '', finishDate: vo.completedAt ?? '',
@@ -79,7 +50,6 @@ export function toCase(vo: ProjectCaseVo, typeNameById?: CaseTypeNameMap): Case
industry: vo.industry || vo.businessIndustry || '', industry: vo.industry || vo.businessIndustry || '',
duration: vo.projectCycle ?? '', duration: vo.projectCycle ?? '',
clientType: vo.customerType ?? '', clientType: vo.customerType ?? '',
// 正文区块(标题 + 富文本)由后端 catalogs 下发,前端不再写死区块名
sections: toSections(vo.catalogs), sections: toSections(vo.catalogs),
attachments: toAttachments(vo.attachments), attachments: toAttachments(vo.attachments),
video: '', video: '',

View File

@@ -1,16 +1,7 @@
import type { EntryTone, PortalEntryCard, PortalEntryGroup, PortalEntryVo } from '@/types/business/home' import type { EntryTone, PortalEntryCard, PortalEntryGroup, PortalEntryVo } from '@/types/business/home'
/**
* 门户入口的视图映射:后端 icon 字段 → 页面可直接渲染的图标 + 底色
*
* <p>后端 icon 是「图标名 / url」自由文本,可能为空、可能是图片地址、
* 也可能是一个标识符(如 folder)。这里按优先级兜底,保证任何取值都能渲染出可视化图标。</p>
*/
/** 图标底色(设计稿为五色循环),按序号轮转分配 */
const TONE_CYCLE: EntryTone[] = ['purple', 'green', 'blue', 'amber', 'red'] const TONE_CYCLE: EntryTone[] = ['purple', 'green', 'blue', 'amber', 'red']
/** 显示名 → emoji 兜底表(后端 icon 为空或非 emoji 时使用) */
const NAME_ICON_MAP: Record<string, string> = { const NAME_ICON_MAP: Record<string, string> = {
项目中心: '🗂️', 项目中心: '🗂️',
需求中心: '🧾', 需求中心: '🧾',
@@ -42,20 +33,16 @@ const NAME_ICON_MAP: Record<string, string> = {
关于我们: 'ℹ️', 关于我们: 'ℹ️',
} }
/** 兜底图标 */
const DEFAULT_ICON = '📦' const DEFAULT_ICON = '📦'
/** 是否图片地址 */
function isImageUrl(value: string): boolean { function isImageUrl(value: string): boolean {
return /^(https?:)?\/\//i.test(value) || value.startsWith('/') return /^(https?:)?\/\//i.test(value) || value.startsWith('/')
} }
/** 是否可直接当作 emoji / 字符图标使用(非纯字母数字标识符) */
function isGlyph(value: string): boolean { function isGlyph(value: string): boolean {
return !/^[a-zA-Z0-9_-]+$/.test(value) return !/^[a-zA-Z0-9_-]+$/.test(value)
} }
/** 解析单项图标与底色 */
function resolveIcon(icon: string | null, name: string): { icon: string; isImage: boolean } { function resolveIcon(icon: string | null, name: string): { icon: string; isImage: boolean } {
const raw = (icon ?? '').trim() const raw = (icon ?? '').trim()
if (raw) { if (raw) {
@@ -65,12 +52,6 @@ function resolveIcon(icon: string | null, name: string): { icon: string; isImage
return { icon: NAME_ICON_MAP[name] ?? DEFAULT_ICON, 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 { export function toPortalEntryCard(vo: PortalEntryVo, index: number): PortalEntryCard | null {
if (vo.id == null) return null if (vo.id == null) return null
const label = (vo.name ?? '').trim() || '未命名入口' const label = (vo.name ?? '').trim() || '未命名入口'
@@ -86,7 +67,6 @@ export function toPortalEntryCard(vo: PortalEntryVo, index: number): PortalEntry
} }
} }
/** 后端入口列表 → 页面卡片列表(过滤无效项,保持后端下发顺序) */
export function toPortalEntryCards(list: PortalEntryVo[]): PortalEntryCard[] { export function toPortalEntryCards(list: PortalEntryVo[]): PortalEntryCard[] {
return list return list
.map((vo, index) => toPortalEntryCard(vo, index)) .map((vo, index) => toPortalEntryCard(vo, index))

View File

@@ -1,15 +1,4 @@
/**
* 文件「类型标识」的唯一口径(后缀 → 标识色 / 缩写)。
*
* <p>设计稿里文件行左侧是一个按类型着色的标识块(PDF 红、Word 蓝、Excel 绿…),
* 需求提报页(`views/demand/cpns/DemandAttachments.vue`)已按这套色板实现,
* 项目详情「合同财务」的发票/合同行沿用同一份,避免两处各写一张色表后走偏。</p>
*
* <p>用法:`fileToneOf(file)` 取色、`fileLabelOf(file)` 取缩写,把它们放到
* `style="{ color, backgroundColor: color + '1a' }"`(1a ≈ 10% 透明)即可。</p>
*/
/** 文件标识色(设计稿口径)。未命中的后缀统一走中性灰。 */
const FILE_TONES: Record<string, string> = { const FILE_TONES: Record<string, string> = {
pdf: '#f54a45', pdf: '#f54a45',
doc: '#2f6bff', doc: '#2f6bff',
@@ -29,7 +18,6 @@ const FILE_TONES: Record<string, string> = {
webp: '#7a3fd6', webp: '#7a3fd6',
} }
/** 缩写展示(长后缀截断,压缩包统一 ZIP) */
const FILE_LABELS: Record<string, string> = { const FILE_LABELS: Record<string, string> = {
doc: 'DOC', doc: 'DOC',
docx: 'DOC', docx: 'DOC',
@@ -43,16 +31,6 @@ const FILE_LABELS: Record<string, string> = {
'7z': 'ZIP', '7z': 'ZIP',
} }
/**
* 文件对象的**最小**形状:详情页/提报页/消息页的 `FileInfo` 都能满足。
*
* <p>`type` 是需求提报页带上来的后缀(可能带前导点 `.pdf`,后端 `fileSuffix` 口径如此);
* `name` / `originalName` 二者取其一(提报页用 `originalName`,详情页用 `name`)。</p>
*
* <p>⚠️ `url` 是**最后**的兜底:合同财务页的发票行,展示名取的是发票抬头
* (如「贵州命勤科技有限公司」)而不是文件名 ⇒ 只能从 `invoiceFile` 地址上认后缀,
* 否则那一行的类型标识会全落成灰底 `FILE`。</p>
*/
export interface FileLike { export interface FileLike {
name?: string | null name?: string | null
originalName?: string | null originalName?: string | null
@@ -60,7 +38,6 @@ export interface FileLike {
url?: string | null url?: string | null
} }
/** 取后缀(小写、无前导点);优先显式 `type`,其次文件名,最后从 url 猜 */
export function fileSuffixOf(file: FileLike | string): string { export function fileSuffixOf(file: FileLike | string): string {
if (typeof file === 'string') return suffixFromName(file) if (typeof file === 'string') return suffixFromName(file)
const type = (file.type ?? '').replace(/^\./, '').toLowerCase() const type = (file.type ?? '').replace(/^\./, '').toLowerCase()
@@ -75,19 +52,16 @@ function suffixFromName(name: string): string {
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '' return dot >= 0 ? name.slice(dot + 1).toLowerCase() : ''
} }
/** 从地址认后缀:先剥掉 query/hash,再取最后一段的扩展名(带签名参数的 OSS 直链常见) */
function suffixFromUrl(url: string): string { function suffixFromUrl(url: string): string {
const path = url.split('?')[0].split('#')[0] const path = url.split('?')[0].split('#')[0]
const last = path.slice(path.lastIndexOf('/') + 1) const last = path.slice(path.lastIndexOf('/') + 1)
return suffixFromName(last) return suffixFromName(last)
} }
/** 标识色(未命中后缀 → 中性灰 `#8a94a6`) */
export function fileToneOf(file: FileLike | string): string { export function fileToneOf(file: FileLike | string): string {
return FILE_TONES[fileSuffixOf(file)] ?? '#8a94a6' return FILE_TONES[fileSuffixOf(file)] ?? '#8a94a6'
} }
/** 缩写(未命中 → 后缀前 3 位大写;连后缀都没有 → `FILE`) */
export function fileLabelOf(file: FileLike | string): string { export function fileLabelOf(file: FileLike | string): string {
const suffix = fileSuffixOf(file) const suffix = fileSuffixOf(file)
return FILE_LABELS[suffix] ?? (suffix ? suffix.slice(0, 3).toUpperCase() : 'FILE') return FILE_LABELS[suffix] ?? (suffix ? suffix.slice(0, 3).toUpperCase() : 'FILE')

View File

@@ -1,26 +1,16 @@
/**
* 预览类型判定(纯函数,无副作用,便于单测与复用)。
*
* <p>为什么集中在一个文件:前端多处 `FileList` 预览都按「后缀 → 渲染方式」分流,
* 统一这里避免每个页面各写一套正则 / 后缀表。后端同源预览端点(`/web/workOrderDraft/preview/{ossId}`)
* 按后缀纠正 Content-Type,这里与之后端口径保持一致的归类。</p>
*/
export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio' | 'text' | 'unsupported' export type PreviewKind = 'image' | 'pdf' | 'video' | 'audio' | 'text' | 'unsupported'
const IMAGE_SUFFIXES = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp'] const IMAGE_SUFFIXES = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']
const PDF_SUFFIXES = ['pdf'] const PDF_SUFFIXES = ['pdf']
const VIDEO_SUFFIXES = ['mp4', 'webm', 'mov', 'avi', 'm4v'] const VIDEO_SUFFIXES = ['mp4', 'webm', 'mov', 'avi', 'm4v']
const AUDIO_SUFFIXES = ['mp3', 'wav', 'ogg', 'm4a'] const AUDIO_SUFFIXES = ['mp3', 'wav', 'ogg', 'm4a']
// 文本类:含 html/htm/svg —— 统一按纯文本展示源码,避免内联渲染触发存储型 XSS
const TEXT_SUFFIXES = ['txt', 'sql', 'log', 'csv', 'json', 'xml', 'md', 'yml', 'yaml', 'html', 'htm', 'svg'] const TEXT_SUFFIXES = ['txt', 'sql', 'log', 'csv', 'json', 'xml', 'md', 'yml', 'yaml', 'html', 'htm', 'svg']
/** 取小写字尾(不含点);无点返回空串 */
export function getSuffix(name: string): string { export function getSuffix(name: string): string {
const idx = name.lastIndexOf('.') const idx = name.lastIndexOf('.')
return idx >= 0 ? name.slice(idx + 1).toLowerCase() : '' return idx >= 0 ? name.slice(idx + 1).toLowerCase() : ''
} }
/** 按文件名判定预览类型;office / 未知后缀一律 unsupported(前端不内联,给下载) */
export function resolvePreviewKind(name: string): PreviewKind { export function resolvePreviewKind(name: string): PreviewKind {
const ext = getSuffix(name) const ext = getSuffix(name)
if (IMAGE_SUFFIXES.includes(ext)) return 'image' if (IMAGE_SUFFIXES.includes(ext)) return 'image'
@@ -31,18 +21,12 @@ export function resolvePreviewKind(name: string): PreviewKind {
return 'unsupported' return 'unsupported'
} }
/** 是否可被前端内联预览(true = image/pdf/video/audio/text) */
export function isPreviewable(name: string): boolean { export function isPreviewable(name: string): boolean {
return resolvePreviewKind(name) !== 'unsupported' return resolvePreviewKind(name) !== 'unsupported'
} }
/**
* 文本内联预览上限(1MB)。
* 整文件塞进 `<pre>` 会卡死浏览器(如 5MB+ 的 .sql/.log),故只读取前 1MB 渲染,超出截断并提示下载。
*/
export const PREVIEW_TEXT_MAX_BYTES = 1024 * 1024 export const PREVIEW_TEXT_MAX_BYTES = 1024 * 1024
/** 人类可读体积(B/KB/MB/GB) */
export function formatBytes(bytes: number): string { export function formatBytes(bytes: number): string {
if (!bytes || bytes < 0) return '0 B' if (!bytes || bytes < 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB'] const units = ['B', 'KB', 'MB', 'GB']

View File

@@ -1,4 +1,3 @@
// 格式化工具(无 Vue 依赖)
import type { BusinessType } from '@/types/api' import type { BusinessType } from '@/types/api'
@@ -12,12 +11,6 @@ const BUSINESS_TYPE_LABELS: Record<BusinessType, string> = {
const BUSINESS_TYPE_CODES = Object.keys(BUSINESS_TYPE_LABELS) as BusinessType[] const BUSINESS_TYPE_CODES = Object.keys(BUSINESS_TYPE_LABELS) as BusinessType[]
/**
* 后端业务类型归一化:兼容英文 code(internet)与中文名称(互联网软件),未知值归 other。
*
* <p>后端案例表 / 项目提报表的 business_type 是自由文本,历史数据里两种写法混存,
* 展示与筛选都需要先归一化到前端 BusinessType。</p>
*/
export function toBusinessType(raw: string | null | undefined): BusinessType { export function toBusinessType(raw: string | null | undefined): BusinessType {
const value = (raw ?? '').trim() const value = (raw ?? '').trim()
if (!value) return 'other' if (!value) return 'other'
@@ -29,19 +22,10 @@ export function toBusinessType(raw: string | null | undefined): BusinessType {
) )
} }
/** 业务类型展示名 */
export function formatBusinessType(type: BusinessType): string { export function formatBusinessType(type: BusinessType): string {
return BUSINESS_TYPE_LABELS[type] return BUSINESS_TYPE_LABELS[type]
} }
/**
* 金额千分位(默认两位小数)。
*
* <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 { export function formatMoney(value: number | string | null | undefined, decimals = 2): string {
const num = Number(value) const num = Number(value)
const fixed = (Number.isFinite(num) ? num : 0).toFixed(decimals) const fixed = (Number.isFinite(num) ? num : 0).toFixed(decimals)
@@ -54,7 +38,6 @@ function pad(n: number): string {
return n < 10 ? `0${n}` : String(n) return n < 10 ? `0${n}` : String(n)
} }
/** 日期格式化(YYYY-MM-DD HH:mm:ss 可裁剪) */
export function formatDate(input: Date | string | number, pattern = 'YYYY-MM-DD HH:mm:ss'): string { export function formatDate(input: Date | string | number, pattern = 'YYYY-MM-DD HH:mm:ss'): string {
const date = input instanceof Date ? input : new Date(input) const date = input instanceof Date ? input : new Date(input)
if (Number.isNaN(date.getTime())) return '' if (Number.isNaN(date.getTime())) return ''
@@ -69,7 +52,6 @@ export function formatDate(input: Date | string | number, pattern = 'YYYY-MM-DD
return pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (token) => map[token] ?? token) return pattern.replace(/YYYY|MM|DD|HH|mm|ss/g, (token) => map[token] ?? token)
} }
/** 文件大小(B/KB/MB/GB) */
export function formatFileSize(bytes: number): string { export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B` if (bytes < 1024) return `${bytes}B`
const kb = bytes / 1024 const kb = bytes / 1024

View File

@@ -1,34 +1,21 @@
import type { PayMethod } from '@/types/business/finance' import type { PayMethod } from '@/types/business/finance'
/** 收银台「选择支付方式」条目 */
export interface PayMethodMeta { export interface PayMethodMeta {
/** 前端标识,与 `PayMethod` 同源 */
value: PayMethod value: PayMethod
/** 展示名 */
label: string label: string
/** 副标题(设计稿:微信 = 推荐使用 / 支付宝 = 其他用户选择) */
hint: string hint: string
/** 后端 `PayChannelEnum` 取值 */
channel: 'WECHAT' | 'ALIPAY' channel: 'WECHAT' | 'ALIPAY'
} }
/** 支付方式展示元数据(顺序即收银台渲染顺序,默认选中第一项) */
export const PAY_METHODS: PayMethodMeta[] = [ export const PAY_METHODS: PayMethodMeta[] = [
{ value: 'wechat', label: '微信支付', hint: '推荐使用', channel: 'WECHAT' }, { value: 'wechat', label: '微信支付', hint: '推荐使用', channel: 'WECHAT' },
{ value: 'alipay', label: '支付宝支付', hint: '其他用户选择', channel: 'ALIPAY' }, { value: 'alipay', label: '支付宝支付', hint: '其他用户选择', channel: 'ALIPAY' },
] ]
/** 前端支付方式 → 后端渠道枚举 */
export function toPayChannel(method: PayMethod): PayMethodMeta['channel'] { export function toPayChannel(method: PayMethod): PayMethodMeta['channel'] {
return PAY_METHODS.find((item) => item.value === method)?.channel ?? 'WECHAT' return PAY_METHODS.find((item) => item.value === method)?.channel ?? 'WECHAT'
} }
/**
* 后端渠道编码 → 展示名。
*
* <p>同一份语义在两张表里大小写不一致(`vm_bill.pay_channel` 存 `WECHAT`,
* `vm_finance_payment.pay_channel` 存 `wechat`),这里统一 `toUpperCase` 兜住。</p>
*/
const CHANNEL_LABELS: Record<string, string> = { const CHANNEL_LABELS: Record<string, string> = {
WECHAT: '微信支付', WECHAT: '微信支付',
ALIPAY: '支付宝支付', ALIPAY: '支付宝支付',
@@ -36,13 +23,11 @@ const CHANNEL_LABELS: Record<string, string> = {
WORKWX: '企业微信', WORKWX: '企业微信',
} }
/** 后端渠道编码 → 中文展示名;未知编码原样返回,空值返回「—」 */
export function toChannelLabel(code?: string): string { export function toChannelLabel(code?: string): string {
if (!code) return '—' if (!code) return '—'
return CHANNEL_LABELS[code.toUpperCase()] ?? code return CHANNEL_LABELS[code.toUpperCase()] ?? code
} }
/** 后端渠道编码 → 前端 `PayMethod`(用于支付结果页);无法识别时按微信兜底 */
export function toPayMethod(code?: string): PayMethod { export function toPayMethod(code?: string): PayMethod {
return code?.toUpperCase() === 'ALIPAY' ? 'alipay' : 'wechat' return code?.toUpperCase() === 'ALIPAY' ? 'alipay' : 'wechat'
} }
@@ -51,16 +36,6 @@ function pad(value: number, length = 2): string {
return String(value).padStart(length, '0') return String(value).padStart(length, '0')
} }
/**
* 生成商户订单号。
*
* <p>后端没有「生成订单号」的接口(Native / 支付宝都要求调用方传 `outTradeNo`),
* 这里按后端 `SequenceUtils.getDateId("PAY")` 的同款口径在前端生成:
* 形如 `PAY202609281530120731` = `PAY` + yyyyMMddHHmmss + 4 位随机。</p>
*
* <p>微信 Native 与支付宝的 `out_trade_no` 都要求 **6–32 位**、同一商户号下唯一,
* 19 位满足要求;`PAY` 前缀也与后端「PAY…」的既有流水号风格一致,便于对账。</p>
*/
export function createMerchantOrderNo(now: Date = new Date()): string { export function createMerchantOrderNo(now: Date = new Date()): string {
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
+ `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}` + `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`

View File

@@ -1,22 +1,5 @@
import type { RdProjectStatus } from '@/types/business/project' import type { RdProjectStatus } from '@/types/business/project'
/**
* 项目中心筛选栏的静态选项。
*
* <p>⚠️ 这里**只放后端当前没有下发的组**。业务类型的值域与件数必须走数据层
* (`stores/modules/project.ts#typeCounts`,来自后端 `requirementStatistics.businessTypeCounts`),
* 不要在这个文件里再定义一份,否则接口一改就错位。</p>
*/
/**
* 项目状态筛选项。
*
* <p>`value` 是后端列表状态的**码值**(`COALESCE(rd_project.status, project_requirement.status)`),
* 空串表示「全部」(不出现在 querystring 里)。顺序与设计稿的 2 列网格一致。</p>
*
* <p>列表改成「需求表为主 + LEFT JOIN 项目表」后,未立项的需求状态回落为 `INITIATING`
* ⇒ 设计稿画的「待立项」成了真实码值,故补进来(8 项 = 4 行 × 2 列,正好铺满)。</p>
*/
export const PROJECT_STATUS_OPTIONS: Array<{ label: string; value: RdProjectStatus | '' }> = [ export const PROJECT_STATUS_OPTIONS: Array<{ label: string; value: RdProjectStatus | '' }> = [
{ label: '全部', value: '' }, { label: '全部', value: '' },
{ label: '待立项', value: 'INITIATING' }, { label: '待立项', value: 'INITIATING' },
@@ -28,7 +11,6 @@ export const PROJECT_STATUS_OPTIONS: Array<{ label: string; value: RdProjectStat
{ label: '已取消', value: 'CANCELLED' }, { label: '已取消', value: 'CANCELLED' },
] ]
/** 码值 → 中文(表格状态列用;由上面那份选项派生,避免两处维护) */
export const PROJECT_STATUS_LABEL: Record<string, string> = PROJECT_STATUS_OPTIONS export const PROJECT_STATUS_LABEL: Record<string, string> = PROJECT_STATUS_OPTIONS
.filter((option) => option.value !== '') .filter((option) => option.value !== '')
.reduce<Record<string, string>>((acc, option) => { .reduce<Record<string, string>>((acc, option) => {
@@ -36,7 +18,6 @@ export const PROJECT_STATUS_LABEL: Record<string, string> = PROJECT_STATUS_OPTIO
return acc return acc
}, {}) }, {})
/** 提交时间快捷区间(后端未下发该组,前端换算成 beginTime/endTime 后下发) */
export const PROJECT_TIME_OPTIONS: Array<{ export const PROJECT_TIME_OPTIONS: Array<{
label: string label: string
value: 'all' | '3m' | '6m' | '1y' value: 'all' | '3m' | '6m' | '1y'

View File

@@ -18,44 +18,16 @@ import type {
import { toBusinessType } from '@/utils/format' import { toBusinessType } from '@/utils/format'
import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta' import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta'
/**
* 需求详情(后端 **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` / `bills` / `billPlans` / `invoices` / `contracts`
* 全为空 —— 这里一律兜底成空数组 / 0 / undefined,详情页显示为空态,不报错。</p>
*/
/**
* 状态 → 详情页中文。
*
* <p>⚠️ 后端 `selectUserProjectPage` / 详情侧已用 `sys_dict_data`(`vm_requirement_status`)
* 把状态翻成**中文**下发(如「进行中」),但码值(`INITIATING` 等)也可能出现
* (直接查子表、或字典缺项时)⇒ 两种口径都要认,认不出的一律兜底「待立项」(未立项最常见)。</p>
*/
const PROJECT_STATUS_LABELS: string[] = Object.values(PROJECT_STATUS_LABEL) const PROJECT_STATUS_LABELS: string[] = Object.values(PROJECT_STATUS_LABEL)
function toProjectStatus(raw: string | null | undefined): ProjectStatus { function toProjectStatus(raw: string | null | undefined): ProjectStatus {
const value = (raw ?? '').trim() const value = (raw ?? '').trim()
if (!value) return '待立项' if (!value) return '待立项'
// 先按码值查表;后端已是中文名时直接采信
const label = PROJECT_STATUS_LABEL[value] const label = PROJECT_STATUS_LABEL[value]
?? (PROJECT_STATUS_LABELS.includes(value) ? value : undefined) ?? (PROJECT_STATUS_LABELS.includes(value) ? value : undefined)
return (label as ProjectStatus | undefined) ?? '待立项' return (label as ProjectStatus | undefined) ?? '待立项'
} }
/**
* 里程碑码值 → 三态。
*
* <p>详情页 `Milestone.status` 只有「待开始 / 进行中 / 已完成」三态(设计稿如此),
* 而后端 `status` 是自由文本 ⇒ 按关键词归类,认不出的一律「待开始」。</p>
*/
function toMilestoneStatus(raw: string | null | undefined): Milestone['status'] { function toMilestoneStatus(raw: string | null | undefined): Milestone['status'] {
const value = (raw ?? '').toUpperCase() const value = (raw ?? '').toUpperCase()
if (value.includes('COMPLET') || value.includes('DONE') || value.includes('FINISH')) return '已完成' if (value.includes('COMPLET') || value.includes('DONE') || value.includes('FINISH')) return '已完成'
@@ -63,78 +35,42 @@ function toMilestoneStatus(raw: string | null | undefined): Milestone['status']
return '待开始' return '待开始'
} }
/**
* 账单/付款计划状态 → 三态。
*
* <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'] { function toBillStatus(raw: string | null | undefined): ProjectBill['status'] {
const value = (raw ?? '').toUpperCase() const value = (raw ?? '').toUpperCase()
// ⚠️ 关键词别用宽泛的「支付」:中文「**待**支付」也包含它,会先被这里截走误判成「已支付」。
if (value.includes('PAID') || value.includes('DONE') || value.includes('CLOSE') if (value.includes('PAID') || value.includes('DONE') || value.includes('CLOSE')
|| value.includes('已支付')) return '已支付' || value.includes('已支付')) return '已支付'
if (value.includes('ACTIVE') || value.includes('WAIT') || value.includes('PENDING') if (value.includes('ACTIVE') || value.includes('WAIT') || value.includes('PENDING')
|| value.includes('待支付')) return '待支付' || value.includes('待支付')) return '待支付'
if (value.includes('UNSTART') || value.includes('NOT_DUE') || value.includes('未到期') if (value.includes('UNSTART') || value.includes('NOT_DUE') || value.includes('未到期')
|| value.includes('未开始')) return '未到期' || value.includes('未开始')) return '未到期'
// 认不出的一律「未到期」——不臆造成「已支付」(那是更坏的错)
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 { function toNumber(value: number | string | null | undefined, fallback = 0): number {
const num = Number(value) const num = Number(value)
return Number.isFinite(num) ? num : fallback 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 { function toDate(value: string | null | undefined): string | undefined {
return value ? value.slice(0, 10) : undefined return value ? value.slice(0, 10) : undefined
} }
/**
* 期次占比 → 可展示文本。
*
* <p>后端 `period_ratio` / 计划 `periodRule` 都是「百分数」语义(30 表示 30%),但一个是
* BigDecimal 字符串(`"40.00"`)、一个是逗号串里的一段(`"40,30,30"` 的第 2 段 `"30"`)⇒
* 统一走 Number 归一。设计稿卡片标题写的是「比例33%」这种整数口径,非整数(按 3 期均分的
* 33.33)保留一位小数,避免出现 `比例33.333333%`。</p>
*/
function toRatioText(value: string | number | null | undefined): string { function toRatioText(value: string | number | null | undefined): string {
const num = Number(value) const num = Number(value)
if (!Number.isFinite(num) || num <= 0) return '' if (!Number.isFinite(num) || num <= 0) return ''
return Number.isInteger(num) ? String(num) : String(Math.round(num * 10) / 10) return Number.isInteger(num) ? String(num) : String(Math.round(num * 10) / 10)
} }
/** 需求附件 → 详情页文件项(文件 tab 用) */
function attachmentToFile(item: RequirementAttachment): FileInfo { function attachmentToFile(item: RequirementAttachment): FileInfo {
return { return {
name: item.originalName ?? '', name: item.originalName ?? '',
size: item.fileSize ?? 0, size: item.fileSize ?? 0,
url: item.url ?? '', url: item.url ?? '',
uploadTime: item.createTime ?? '', uploadTime: item.createTime ?? '',
// 透传 ossId,供详情页「预览」走同源后端接口(绕开 OSS 桶强制下载)
ossId: item.ossId ?? undefined, ossId: item.ossId ?? undefined,
} }
} }
/** 发票 → 详情页文件项(财务 tab 的发票记录只是文件列表) */
function invoiceToFile(item: RequirementInvoice): FileInfo { function invoiceToFile(item: RequirementInvoice): FileInfo {
return { return {
name: item.invoiceTitle ?? item.invoiceNo ?? '发票', name: item.invoiceTitle ?? item.invoiceNo ?? '发票',
@@ -144,80 +80,48 @@ function invoiceToFile(item: RequirementInvoice): FileInfo {
} }
} }
/** 合同 → 详情页文件项(财务 tab 只展示一份合同文件,取第一条) */
function contractToFile(item: RequirementContract): FileInfo { function contractToFile(item: RequirementContract): FileInfo {
return { return {
name: item.title ?? item.contractNo ?? '合同文件', name: item.title ?? item.contractNo ?? '合同文件',
size: 0, size: 0,
url: item.file ?? '', url: item.file ?? '',
// signDate 是 DATE 列,后端会下发成 '2026-08-20 00:00:00' ⇒ 只保留到日
uploadTime: toDate(item.signDate ?? item.createTime) ?? '', uploadTime: toDate(item.signDate ?? item.createTime) ?? '',
} }
} }
/**
* 账单(`vm_bill`)→ 详情页「付款计划」卡片。
*
* <p><b>这是卡片的正解数据源。</b>设计稿卡片上的「账单号」与三态「已支付/待支付/未到期」
* 只有 `vm_bill` 才有:`billNo` 是真账单号、`amount` 是**本期**金额、
* `status` 是账单自己的 `PAID/PENDING/UNSTARTED`,而 `id` 正是收银台 `?ids=` 要传的
* **账单 id**,所以卡片上的「立即支付」可以直接跳过去。</p>
*
* <p>期次名直接用后端下发的 `periodName`(如「第2期·进度款」)——不再像过去那样
* 从付款计划拼「第N期/共M期」。</p>
*/
function toProjectBill(item: RequirementBill): ProjectBill { function toProjectBill(item: RequirementBill): ProjectBill {
return { return {
id: String(item.id), id: String(item.id),
source: 'bill', source: 'bill',
planName: item.periodName ?? '', planName: item.periodName ?? '',
// 设计稿标题「第2期·进度款 比例30%」= 期次名 + 期次占比(后端 period_ratio)
periodRatio: toRatioText(item.periodRatio), periodRatio: toRatioText(item.periodRatio),
billNo: item.billNo ?? '', billNo: item.billNo ?? '',
amount: toNumber(item.amount), amount: toNumber(item.amount),
status: toBillStatus(item.status), status: toBillStatus(item.status),
// 后端存的是 transactionNo,页面叫 tradeNo(详见 types/business/finance.d.ts 的同一处映射)
tradeNo: item.transactionNo ?? undefined, tradeNo: item.transactionNo ?? undefined,
projectName: item.projectName ?? '', projectName: item.projectName ?? '',
} }
} }
/** 期次名,与后端 `BillServiceImpl#buildPeriodName` 同口径:首期预付款、末期尾款、中间进度款 */
function planPeriodLabel(period: number, periods: number): string { function planPeriodLabel(period: number, periods: number): string {
if (period <= 1) return '预付款' if (period <= 1) return '预付款'
if (period >= periods) return '尾款' if (period >= periods) return '尾款'
return '进度款' return '进度款'
} }
/**
* 分期计划(`vm_bill_plan`)→ 卡片。**这是「合同财务」卡片的数据源。**
*
* <p>为什么是计划而不是账单:本栈的支付口径就是**计划 id** ——
* `vm_finance_payment.bill_id` 实测存的是 `vm_bill_plan.id`(该列 5 个取值在
* `vm_bill_plan` 全命中、在 `vm_bill` 只命中 2 个)。所以卡片「立即支付」传的也是计划 id,
* 下单落库、回调回写全都对得上,链路自洽。</p>
*
* <p>字段来源:期次名/比例/金额由 `currentPeriod` + `periodRule` 折算(与后端
* `BillServiceImpl#resolveRatio/resolveAmount` 同口径);「账单号」= 该期支付单的
* `out_trade_no`(后端别名 `orderNo`,未支付时为空 ⇒ 模板显示「—」);
* 「交易流水号」= `transactionNo`;状态优先看支付单(`payStatus=SUCCESS` 即已支付),
* 否则用计划状态(`DONE/ACTIVE/UNSTARTED`)。</p>
*/
function toPlanCard(item: RequirementBillPlan): ProjectBill { function toPlanCard(item: RequirementBillPlan): ProjectBill {
const period = item.currentPeriod ?? 0 const period = item.currentPeriod ?? 0
const periods = item.periods ?? 0 const periods = item.periods ?? 0
const rule = (item.periodRule ?? '').split(',').map((part) => Number(part.trim())) const rule = (item.periodRule ?? '').split(',').map((part) => Number(part.trim()))
const segment = rule[period - 1] const segment = rule[period - 1]
const ratio = Number.isFinite(segment) ? segment : periods > 0 ? 100 / periods : 0 const ratio = Number.isFinite(segment) ? segment : periods > 0 ? 100 / periods : 0
// 支付成功以支付单为准:计划状态可能没被回调推到 DONE,但流水已经是 SUCCESS
const paid = (item.payStatus ?? '').toUpperCase() === 'SUCCESS' const paid = (item.payStatus ?? '').toUpperCase() === 'SUCCESS'
return { return {
id: String(item.id), // 计划 id —— 与收银台 ?ids= / vm_finance_payment.bill_id 同一口径 id: String(item.id),
source: 'plan', source: 'plan',
planName: period > 0 && periods > 0 planName: period > 0 && periods > 0
? `第${period}期·${planPeriodLabel(period, periods)}` ? `第${period}期·${planPeriodLabel(period, periods)}`
: (item.periodRule ?? '付款计划'), : (item.periodRule ?? '付款计划'),
// 比例口径与后端一致(见上方 ratio 折算),标题照样带「比例30%」
periodRatio: toRatioText(ratio), periodRatio: toRatioText(ratio),
billNo: item.orderNo ?? '', billNo: item.orderNo ?? '',
amount: Math.round(toNumber(item.totalAmount) * ratio) / 100, amount: Math.round(toNumber(item.totalAmount) * ratio) / 100,
@@ -227,7 +131,6 @@ function toPlanCard(item: RequirementBillPlan): ProjectBill {
} }
} }
/** 项目成员 → 详情页团队成员卡 */
function toTeamMember(item: RequirementMember): TeamMember { function toTeamMember(item: RequirementMember): TeamMember {
const name = item.userName ?? '' const name = item.userName ?? ''
return { return {
@@ -237,16 +140,6 @@ function toTeamMember(item: RequirementMember): TeamMember {
} }
} }
/**
* 售后工单。
*
* <p>详情页 `AfterSales` 只有「无 / 处理中」两态:撤回(WITHDRAWN)/ 作废 / 关闭 ⇒ 「无」
* (页面回到可再次申请的状态),其余(REQUESTED / IN_PROGRESS / COMPLETED)都算「处理中」。</p>
*
* <p>字段对应:`type` = 售后类型、`description` = 用户填的问题描述(后端 `reason`)、
* `remark` = 用户补充备注(后端 `remark`,不是处理人写的 `processNote`)、
* `attachments` = 售后工单附件(同表 `phase = AFTER_SALE`,由后端随页签下发)。</p>
*/
function toAfterSales( function toAfterSales(
item: RequirementAfterSale, item: RequirementAfterSale,
attachments: RequirementAttachment[] = [], attachments: RequirementAttachment[] = [],
@@ -263,10 +156,8 @@ function toAfterSales(
} }
} }
/** 4 个页签接口的聚合结果 → 详情页数据模型(未立项时项目侧字段自动为空) */
export function toProjectDetail(bundle: RequirementDetailBundle): Project { export function toProjectDetail(bundle: RequirementDetailBundle): Project {
const { progress, files, finance, afterSales } = bundle const { progress, files, finance, afterSales } = bundle
// 基础信息 4 支同源(需求表同一行),统一取 progress 那一支
const base = progress const base = progress
const { projectStage: stage } = progress const { projectStage: stage } = progress
const contract = finance.contracts?.[0] const contract = finance.contracts?.[0]
@@ -274,39 +165,24 @@ export function toProjectDetail(bundle: RequirementDetailBundle): Project {
id: String(base.id), id: String(base.id),
projectNo: base.projectNo ?? '', projectNo: base.projectNo ?? '',
projectName: base.projectName ?? '', projectName: base.projectName ?? '',
// 后端下发的是中文类型名(如「硬件电路」),归一化成前端 code;'0' 之类脏值归 other
businessType: toBusinessType(base.businessType), businessType: toBusinessType(base.businessType),
// 同时保留原样名称:前端枚举只有 5 项,后台新增类型时会全落成「其他」,展示要用原名
businessTypeName: base.businessType ?? '', businessTypeName: base.businessType ?? '',
// 总体进度百分比 —— 后端 `developmentProgress` 就是列表那个 progress(同源),
// 它缺失时才回落到阶段表的 development_progress;未立项两处都没有 ⇒ 0
progress: progress.developmentProgress ?? stage?.developmentProgress ?? 0, progress: progress.developmentProgress ?? stage?.developmentProgress ?? 0,
status: toProjectStatus(base.status), status: toProjectStatus(base.status),
submitTime: base.createTime ?? '', submitTime: base.createTime ?? '',
startTime: toDate(stage?.startTime), startTime: toDate(stage?.startTime),
endTime: toDate(stage?.finishTime ?? stage?.planTime), endTime: toDate(stage?.finishTime ?? stage?.planTime),
// ⚠️ 后端里程碑/团队都是 `selectList(...eq(projectId))`,**没有 orderBy** ⇒ 返回顺序不保证
// (实测同一项目返回 id 5,8,6,7)。里程碑顺序会直接毁掉时间线语义,必须按 sortOrder 归位;
// 团队同理,项目负责人排最前。
milestones: [...(progress.projectMilestones ?? [])] milestones: [...(progress.projectMilestones ?? [])]
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
.map((item) => ({ .map((item) => ({
name: item.milestoneName ?? '', name: item.milestoneName ?? '',
status: toMilestoneStatus(item.status), status: toMilestoneStatus(item.status),
// 后端是 LocalDateTime('2026-09-03 00:00:00'),设计稿只到「日」
time: toDate(item.startTime), time: toDate(item.startTime),
})), })),
team: [...(progress.members ?? [])] team: [...(progress.members ?? [])]
.sort((a, b) => Number(b.isOwner ?? false) - Number(a.isOwner ?? false)) .sort((a, b) => Number(b.isOwner ?? false) - Number(a.isOwner ?? false))
.map(toTeamMember), .map(toTeamMember),
files: (files.requirementAttachments ?? []).map(attachmentToFile), files: (files.requirementAttachments ?? []).map(attachmentToFile),
// ⚠️ 卡片优先 = 真账单(vm_bill)。判别位是 `bills` **这个 key 在不在**:
// 新构建无论有没有账单都会下发(无账单 = `[]` ⇒ 走空态,那才是「真的没有账单」);
// 旧构建(未重启)压根没这个 key ⇒ 回落`billPlans`(只读卡,`source='plan'`,不带支付按钮)。
// 教训:只认 `bills` 会让未重启的后端上整个页签「数据凭空消失」,所以必须留回落。
// 卡片主数据源 = 付款计划(`vm_bill_plan`):本栈的支付口径就是计划 id
// (`vm_finance_payment.bill_id` 实测存的就是它)。仅当计划为空时才回落 `bills`
// (`vm_bill`),不再像上一轮那样优先读 `bills`。
paymentPlans: (finance.billPlans ?? []).length > 0 paymentPlans: (finance.billPlans ?? []).length > 0
? (finance.billPlans ?? []).map(toPlanCard) ? (finance.billPlans ?? []).map(toPlanCard)
: (finance.bills ?? []).map(toProjectBill), : (finance.bills ?? []).map(toProjectBill),

View File

@@ -3,21 +3,10 @@ import { ElMessage } from 'element-plus'
import type { ApiResponse } from '@/types/api' import type { ApiResponse } from '@/types/api'
import { storage } from '@/utils/storage' import { storage } from '@/utils/storage'
// 后端 R.ok() 成功码为 200(org.dromara.common.core.constant.HttpStatus.SUCCESS)。
// 鉴权头 Authorization: Bearer <token> 已与后端 sa-token.token-prefix=Bearer 对齐,无需改动。
const SUCCESS_CODE = 200 const SUCCESS_CODE = 200
/**
* PC 端 clientId(sys_client 种子数据,grantType 含 password)。
*
* 后端 SecurityConfig 对每个已登录请求做多端隔离校验:
* header / query 里的 clientid 必须与登录时写入 token 的 clientid 一致,
* 否则抛 NotLoginException「客户端ID与Token不匹配」。
* 所以此值必须与登录、注册请求体的 clientId 完全同源。
*/
export const CLIENT_ID = 'e5cd7e4891bf95d1d19206ce24a7b32e' export const CLIENT_ID = 'e5cd7e4891bf95d1d19206ce24a7b32e'
/** 全项目唯一 axios 实例 */
const service = axios.create({ const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL, baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000, timeout: 15000,
@@ -28,7 +17,6 @@ service.interceptors.request.use((config) => {
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
// 带 token 的请求必须回传 clientid,否则后端 SecurityConfig:97-106 校验不通过
config.headers.clientid = CLIENT_ID config.headers.clientid = CLIENT_ID
return config return config
}) })
@@ -38,13 +26,6 @@ service.interceptors.response.use(undefined, (error) => {
return Promise.reject(error) return Promise.reject(error)
}) })
/**
* 查询参数序列化(按 Spring MVC WebDataBinder 的绑定约定)。
*
* <p>axios 默认把嵌套对象序列化成 `scales[0][lowerLimit]`,而 Spring 只认点号路径
* `scales[0].lowerLimit`——实测用默认形式后端直接 HTTP 500,故需要序列化的接口
* 统一走本函数(`paramsSerializer: { serialize: serializeQuery }`)。</p>
*/
export function serializeQuery(params: Record<string, unknown>): string { export function serializeQuery(params: Record<string, unknown>): string {
const parts: string[] = [] const parts: string[] = []
@@ -67,10 +48,6 @@ export function serializeQuery(params: Record<string, unknown>): string {
return parts.join('&') return parts.join('&')
} }
/**
* 统一请求:自动解包 ApiResponse<T>,失败统一提示并 reject
* 页面/组件禁止直接 import axios,一律经 store 调用本函数
*/
export async function request<T>(config: AxiosRequestConfig): Promise<T> { export async function request<T>(config: AxiosRequestConfig): Promise<T> {
const resp = await service.request<ApiResponse<T>>(config) const resp = await service.request<ApiResponse<T>>(config)
const payload = resp.data const payload = resp.data

View File

@@ -1,4 +1,3 @@
// localStorage/sessionStorage 泛型封装(无 Vue 依赖)
const PREFIX = 'cz:' const PREFIX = 'cz:'

View File

@@ -1,11 +1,8 @@
// 校验规则(无 Vue 依赖)
/** 手机号 */
export function isPhone(value: string): boolean { export function isPhone(value: string): boolean {
return /^1[3-9]\d{9}$/.test(value) return /^1[3-9]\d{9}$/.test(value)
} }
/** 邮箱 */
export function isEmail(value: string): boolean { export function isEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
} }
@@ -17,10 +14,6 @@ export function isUrl(value: string): boolean {
export type PasswordLevel = 'weak' | 'medium' | 'strong' export type PasswordLevel = 'weak' | 'medium' | 'strong'
/**
* 密码强度等级(6-20 位)
* weak:仅字母或仅数字;medium:字母+数字;strong:字母+数字+特殊符号
*/
export function getPasswordLevel(password: string): PasswordLevel { export function getPasswordLevel(password: string): PasswordLevel {
const hasLetter = /[a-zA-Z]/.test(password) const hasLetter = /[a-zA-Z]/.test(password)
const hasDigit = /\d/.test(password) const hasDigit = /\d/.test(password)
@@ -30,7 +23,6 @@ export function getPasswordLevel(password: string): PasswordLevel {
return 'weak' return 'weak'
} }
/** 登录密码合法性(6-20 位) */
export function isValidPassword(password: string): boolean { export function isValidPassword(password: string): boolean {
return password.length >= 6 && password.length <= 20 return password.length >= 6 && password.length <= 20
} }

View File

@@ -13,12 +13,10 @@ const emit = defineEmits<{
share: [id: string] share: [id: string]
}>() }>()
/** 空值兜底(后端 customerType / industry 等可能不下发) */
function dash(value: string): string { function dash(value: string): string {
return value || '--' return value || '--'
} }
/** 概览信息(设计稿:标签 + 全角冒号 + 值,单行铺开) */
const meta = computed(() => [ const meta = computed(() => [
{ label: '客户类型', value: dash(props.item.clientType) }, { label: '客户类型', value: dash(props.item.clientType) },
{ label: '所属行业', value: dash(props.item.industry) }, { label: '所属行业', value: dash(props.item.industry) },
@@ -126,7 +124,6 @@ const meta = computed(() => [
} }
&__meta { &__meta {
// 显式 stretch:否则元信息行会被居中收缩(设计稿为左起铺满整行)
.flex(column, flex-start, stretch); .flex(column, flex-start, stretch);
flex: 1; flex: 1;
gap: @spacing-sm; gap: @spacing-sm;

View File

@@ -9,22 +9,14 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
/** 业务类型标签(设计稿:蓝色实心胶囊 + 白字) */
const tag = computed(() => formatBusinessType(props.item.category)) const tag = computed(() => formatBusinessType(props.item.category))
/**
* 封面图。
*
* <p>toCase 已把「非可访问地址」收口成空串,无封面时回退主题色底,
* 避免裂图或空白块。</p>
*/
const coverStyle = computed(() => const coverStyle = computed(() =>
props.item.cover ? { backgroundImage: `url(${props.item.cover})` } : undefined, props.item.cover ? { backgroundImage: `url(${props.item.cover})` } : undefined,
) )
</script> </script>
<template> <template>
<!-- 设计稿:封面通栏(不随内容容器收窄、无圆角),左下叠类型标签 + 案例名 -->
<section class="case-hero" :style="coverStyle"> <section class="case-hero" :style="coverStyle">
<div class="container case-hero__inner"> <div class="container case-hero__inner">
<span v-if="tag" class="case-hero__tag">{{ tag }}</span> <span v-if="tag" class="case-hero__tag">{{ tag }}</span>
@@ -49,7 +41,6 @@ const coverStyle = computed(() =>
content: ''; content: '';
position: absolute; position: absolute;
inset: 0; inset: 0;
// 底部压暗,保证白字可读
background: linear-gradient(180deg, rgba(15, 23, 42, 0) 42%, rgba(15, 23, 42, 0.55)); background: linear-gradient(180deg, rgba(15, 23, 42, 0) 42%, rgba(15, 23, 42, 0.55));
} }
@@ -76,7 +67,6 @@ const coverStyle = computed(() =>
font-size: @font-size-xxl; font-size: @font-size-xxl;
font-weight: 600; font-weight: 600;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.35); text-shadow: 0 1px 8px rgba(0, 0, 0, 0.35);
// 最多两行,避免超长案例名撑破 hero
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;

View File

@@ -7,13 +7,6 @@ interface Props {
const props = defineProps<Props>() const props = defineProps<Props>()
/**
* 案例正文区块(设计稿:一段一张白卡,卡片标题带蓝色竖条)。
*
* <p>区块的**数量与标题都来自后端**(vm_project_case_catalog:title + content,
* 按 sort_order 升序),前端不再写死「项目背景 / 客户痛点 / 需求描述 / 交付物」,
* 后端增删目录即自动生效。空内容已在 utils/caseVo.ts#toSections 里过滤。</p>
*/
</script> </script>
<template> <template>
@@ -26,7 +19,6 @@ const props = defineProps<Props>()
<h3 v-if="section.title" class="case-rich__title"> <h3 v-if="section.title" class="case-rich__title">
{{ section.title }} {{ section.title }}
</h3> </h3>
<!-- 后端下发富文本 HTML(可能内嵌 img / video),直接渲染 -->
<div class="case-rich__html" v-html="section.content" /> <div class="case-rich__html" v-html="section.content" />
</section> </section>
</div> </div>
@@ -34,7 +26,6 @@ const props = defineProps<Props>()
<style lang="less" scoped> <style lang="less" scoped>
.case-rich { .case-rich {
// 同 detail.vue:显式 stretch,避免正文块被压成内容宽
.flex(column, flex-start, stretch); .flex(column, flex-start, stretch);
gap: @spacing-base; gap: @spacing-base;
@@ -70,8 +61,6 @@ const props = defineProps<Props>()
:deep(p) { :deep(p) {
margin-bottom: @spacing-sm; margin-bottom: @spacing-sm;
// 后端富文本常把「1、…2、…」分条内容放在同一个 <p> 里用 \r\n 分行,
// 默认会被 HTML 折叠成一段,故保留换行(与旧纯文本分支的 pre-wrap 行为一致)
white-space: pre-wrap; white-space: pre-wrap;
} }

View File

@@ -7,15 +7,10 @@ import type { BusinessIndustryOption, CaseQuery, CaseTypeOption, ScaleLimit } fr
interface Props { interface Props {
query: CaseQuery query: CaseQuery
/** 业务类型选项(接口下发:/categories 的统计;value=提报 id,label=类型名) */
typeOptions: CaseTypeOption[] typeOptions: CaseTypeOption[]
/** 所属行业选项(接口下发:/categories) */
industryOptions: BusinessIndustryOption[] industryOptions: BusinessIndustryOption[]
/** 项目规模选项(接口下发:/categories,label 已由后端拼好) */
scaleOptions: ScaleLimit[] scaleOptions: ScaleLimit[]
/** 案例总数(全量口径,用于业务类型「全部」行角标) */
caseTotal: number caseTotal: number
/** 待提交条件的结果数(「确定筛选(N 个结果)」) */
previewTotal: number previewTotal: number
} }
@@ -29,7 +24,6 @@ const emit = defineEmits<{
type TimeShortcut = 'all' | '3m' | '6m' | '1y' | 'custom' type TimeShortcut = 'all' | '3m' | '6m' | '1y' | 'custom'
/** 完成时间后端未下发选项,保留前端快捷区间 */
const timeShortcuts: Array<{ label: string; value: Exclude<TimeShortcut, 'custom'>; months: number }> = [ const timeShortcuts: Array<{ label: string; value: Exclude<TimeShortcut, 'custom'>; months: number }> = [
{ label: '全部', value: 'all', months: 0 }, { label: '全部', value: 'all', months: 0 },
{ label: '近3个月', value: '3m', months: 3 }, { label: '近3个月', value: '3m', months: 3 },
@@ -37,12 +31,6 @@ const timeShortcuts: Array<{ label: string; value: Exclude<TimeShortcut, 'custom
{ label: '近一年', value: '1y', months: 12 }, { label: '近一年', value: '1y', months: 12 },
] ]
/**
* 规模档位的稳定标识。
*
* <p>后端档位是「上下限」对象(label 只是展示文字),用上下限拼 key 比 label 可靠:
* 区间值唯一、且不依赖后端文案。</p>
*/
function scaleKey(item: ScaleLimit): string { function scaleKey(item: ScaleLimit): string {
return `${item.lowerLimit ?? ''}-${item.upperLimit ?? ''}` return `${item.lowerLimit ?? ''}-${item.upperLimit ?? ''}`
} }
@@ -51,7 +39,6 @@ function scaleLabel(item: ScaleLimit): string {
return item.label ?? `${item.lowerLimit ?? 0}-${item.upperLimit ?? '不限'}万` return item.label ?? `${item.lowerLimit ?? 0}-${item.upperLimit ?? '不限'}万`
} }
/** 快捷区间 → 具体日期区间 */
function rangeOf(months: number): [string, string] { function rangeOf(months: number): [string, string] {
const end = new Date() const end = new Date()
const start = new Date() const start = new Date()
@@ -59,7 +46,6 @@ function rangeOf(months: number): [string, string] {
return [formatDate(start, 'YYYY-MM-DD'), formatDate(end, 'YYYY-MM-DD')] return [formatDate(start, 'YYYY-MM-DD'), formatDate(end, 'YYYY-MM-DD')]
} }
/** 当前区间命中的快捷项(用于选中态回显,避免额外维护一份状态) */
const activeShortcut = computed<TimeShortcut>(() => { const activeShortcut = computed<TimeShortcut>(() => {
const range = props.query.dateRange const range = props.query.dateRange
if (!range) return 'all' if (!range) return 'all'
@@ -160,7 +146,6 @@ function clearChips(): void {
</button> </button>
</div> </div>
<!-- 业务类型(接口下发;带案例数) -->
<section class="case-filter__group"> <section class="case-filter__group">
<h3 class="case-filter__group-title"> <h3 class="case-filter__group-title">
业务类型 业务类型
@@ -191,7 +176,6 @@ function clearChips(): void {
</ul> </ul>
</section> </section>
<!-- 所属行业(接口下发,多选) -->
<section class="case-filter__group"> <section class="case-filter__group">
<h3 class="case-filter__group-title"> <h3 class="case-filter__group-title">
所属行业 所属行业
@@ -216,7 +200,6 @@ function clearChips(): void {
</div> </div>
</section> </section>
<!-- 项目规模(接口下发,多选) -->
<section class="case-filter__group"> <section class="case-filter__group">
<h3 class="case-filter__group-title"> <h3 class="case-filter__group-title">
项目规模 项目规模
@@ -241,7 +224,6 @@ function clearChips(): void {
</div> </div>
</section> </section>
<!-- 完成时间(快捷区间 + 自定义;后端未下发该组选项) -->
<section class="case-filter__group"> <section class="case-filter__group">
<h3 class="case-filter__group-title"> <h3 class="case-filter__group-title">
完成时间 完成时间
@@ -271,7 +253,6 @@ function clearChips(): void {
</div> </div>
</section> </section>
<!-- 已选条件 -->
<section class="case-filter__selected"> <section class="case-filter__selected">
<div class="case-filter__selected-head"> <div class="case-filter__selected-head">
<span class="case-filter__selected-title">已选条件({{ chips.length }}个)</span> <span class="case-filter__selected-title">已选条件({{ chips.length }}个)</span>
@@ -309,7 +290,6 @@ function clearChips(): void {
<style lang="less" scoped> <style lang="less" scoped>
.case-filter { .case-filter {
flex-shrink: 0; flex-shrink: 0;
// 设计稿筛选面板 矩形 148286 @(70,272) 516×1461 白底
width: 516px; width: 516px;
padding: 15px 0 40px; padding: 15px 0 40px;
border-radius: @radius-base; border-radius: @radius-base;
@@ -318,7 +298,6 @@ function clearChips(): void {
&__head, &__head,
&__group, &__group,
&__actions { &__actions {
// 设计稿内容左 x105 → 卡内 x35;行右 x536 → 卡内右 50
padding: 0 50px 0 35px; padding: 0 50px 0 35px;
} }
@@ -327,7 +306,6 @@ function clearChips(): void {
} }
&__title { &__title {
// 设计 @(105,287) 28/500 #222222
font-size: 28px; font-size: 28px;
font-weight: 500; font-weight: 500;
line-height: 41px; line-height: 41px;
@@ -347,20 +325,17 @@ function clearChips(): void {
margin-top: 24px; margin-top: 24px;
} }
// 标题 → 第一组(设计 287+41=328 → 业务类型标题 339)
&__head + &__group { &__head + &__group {
margin-top: 11px; margin-top: 11px;
} }
&__group-title { &__group-title {
// 设计 @(105,339) 24/500 #333333
font-size: 24px; font-size: 24px;
font-weight: 500; font-weight: 500;
line-height: 35px; line-height: 35px;
color: @cases-text; color: @cases-text;
} }
/* 业务类型:整行可点(设计行高 50,文案 20/400) */
&__rows { &__rows {
margin-top: 19px; margin-top: 19px;
} }
@@ -419,7 +394,6 @@ function clearChips(): void {
} }
&__row-count { &__row-count {
// 设计计数 16/500 #666666
color: @cases-muted; color: @cases-muted;
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 500;
@@ -430,7 +404,6 @@ function clearChips(): void {
font-weight: inherit; font-weight: inherit;
} }
/* 胶囊选项(两列;设计胶囊高 53、行距 24、列距 33) */
&__pills { &__pills {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -506,7 +479,6 @@ function clearChips(): void {
} }
} }
/* 已选条件 */
&__selected { &__selected {
margin: 23px 50px 0 35px; margin: 23px 50px 0 35px;
padding: 16px 24px 21px; padding: 16px 24px 21px;
@@ -552,7 +524,6 @@ function clearChips(): void {
} }
} }
/* 底部操作 */
&__actions { &__actions {
.flex(row, flex-start, center); .flex(row, flex-start, center);
gap: 13px; gap: 13px;

View File

@@ -4,7 +4,6 @@ import type { Case } from '@/types/business/cases'
interface Props { interface Props {
cases: Case[] cases: Case[]
/** 当前筛选条件下的案例总数 */
total: number total: number
} }
@@ -41,13 +40,11 @@ const emit = defineEmits<{
<style lang="less" scoped> <style lang="less" scoped>
.cases-grid { .cases-grid {
&__total { &__total {
// 设计计数 20px / 700 / #121212
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
color: @heading-color; color: @heading-color;
} }
// 设计卡片 396×267,列 x606/1030/1454 → 列距 28,行 y423/713 → 行距 23
&__list { &__list {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);

View File

@@ -3,7 +3,6 @@ import { computed } from 'vue'
interface Props { interface Props {
keyword: string keyword: string
/** 是否只看收藏 */
favoriteOnly: boolean favoriteOnly: boolean
} }
@@ -60,7 +59,6 @@ const keyword = computed<string>({
<style lang="less" scoped> <style lang="less" scoped>
.case-search { .case-search {
.flex(row, flex-start, center); .flex(row, flex-start, center);
// 设计稿搜索卡 矩形 148317 @(606,272) 1244×128;输入框 @(642,312) 592×49
gap: 24px; gap: 24px;
height: 128px; height: 128px;
box-sizing: border-box; box-sizing: border-box;
@@ -70,7 +68,6 @@ const keyword = computed<string>({
&__field { &__field {
position: relative; position: relative;
// 设计输入框 592 宽
flex: 0 0 592px; flex: 0 0 592px;
} }
@@ -100,7 +97,6 @@ const keyword = computed<string>({
transition: border-color 0.2s; transition: border-color 0.2s;
&::placeholder { &::placeholder {
// 设计 placeholder @(660,322) 20/400 #999999
color: @text-color-disabled; color: @text-color-disabled;
} }
@@ -110,7 +106,6 @@ const keyword = computed<string>({
} }
&__btn { &__btn {
// 设计按钮文字 20/400 #FFFFFF,白底蓝字按钮宽 100
min-width: 100px; min-width: 100px;
height: 49px; height: 49px;
padding: 0 @spacing-base; padding: 0 @spacing-base;

View File

@@ -2,7 +2,6 @@
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
// 页面容器:数据只经 store,不直接 import api
import { useCasesStore } from '@/stores/modules/cases' import { useCasesStore } from '@/stores/modules/cases'
import CaseHero from './cpns/CaseHero.vue' import CaseHero from './cpns/CaseHero.vue'
import CaseDetailHeader from './cpns/CaseDetailHeader.vue' import CaseDetailHeader from './cpns/CaseDetailHeader.vue'
@@ -52,14 +51,12 @@ onMounted(load)
</div> </div>
<template v-if="item"> <template v-if="item">
<!-- 封面通栏:设计稿中不被内容容器收窄 -->
<CaseHero :item="item" /> <CaseHero :item="item" />
<div class="container case-detail__main"> <div class="container case-detail__main">
<CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" /> <CaseDetailHeader :item="item" @favorite="onFavorite" @share="onShare" />
<CaseRichText :item="item" /> <CaseRichText :item="item" />
<!-- 设计稿:内容区整宽蓝色按钮 -->
<button <button
class="case-detail__cta" class="case-detail__cta"
type="button" type="button"
@@ -86,8 +83,6 @@ onMounted(load)
font-size: @font-size-base; font-size: @font-size-base;
} }
// ⚠️ .flex 第三个参数是 align(默认 center),必须显式传 stretch,
// 否则各白块会被压成「内容宽 + 水平居中」(设计稿要求整宽)
&__main { &__main {
.flex(column, flex-start, stretch); .flex(column, flex-start, stretch);
gap: @spacing-base; gap: @spacing-base;

View File

@@ -4,7 +4,6 @@ import { onMounted } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { PaginationBar } from '@/components' import { PaginationBar } from '@/components'
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
import { useCasesStore } from '@/stores/modules/cases' import { useCasesStore } from '@/stores/modules/cases'
import type { CaseQuery } from '@/types/business/cases' import type { CaseQuery } from '@/types/business/cases'
import CasesFilter from './cpns/CasesFilter.vue' import CasesFilter from './cpns/CasesFilter.vue'
@@ -49,7 +48,6 @@ function onViewDetail(id: string): void {
} }
onMounted(() => { onMounted(() => {
// 筛选面板选项全部来自接口:业务类型 / 所属行业 / 项目规模三组都走 /categories,完成时间由前端算
void Promise.all([casesStore.fetchCategories(), casesStore.fetchCases()]) void Promise.all([casesStore.fetchCategories(), casesStore.fetchCases()])
}) })
</script> </script>
@@ -112,43 +110,39 @@ onMounted(() => {
<style lang="less" scoped> <style lang="less" scoped>
.cases { .cases {
// 设计实测:页脚顶 y1811,正文最底约 y1733 → 底距 78
padding-bottom: 78px; padding-bottom: 78px;
// 设计稿页头条 矩形 148284 @(0,73) 1920×179 白底通栏;文案左缩进 112
&__hero { &__hero {
height: 179px; height: 179px;
box-sizing: border-box; box-sizing: border-box;
padding-top: 32px; // 标题 @(112,105) → 73+32 padding-top: 32px;
background: @white; background: @white;
} }
// 页头文案与内容区不对齐:设计实测文案在 x112(内容区 x70 + 42)
&__hero-inner { &__hero-inner {
padding-left: 42px; padding-left: 42px;
} }
&__title { &__title {
margin: 0; margin: 0;
font-size: 36px; // 设计 @(112,105) 36/700 #121212 font-size: 36px;
font-weight: 700; font-weight: 700;
line-height: 52px; line-height: 52px;
color: @cases-hero-title; color: @cases-hero-title;
} }
&__slogan { &__slogan {
margin: 15px 0 0; // 副标题 @(112,172),标题底 105+52=157 → 间距 15 margin: 15px 0 0;
font-size: 24px; // 设计 24/500 #666666 font-size: 24px;
font-weight: 500; font-weight: 500;
line-height: 37px; line-height: 37px;
color: @cases-muted; color: @cases-muted;
} }
// 设计稿:筛选面板 @(70,272) 516 宽,卡片区 @(606,423) 起 1244 宽,主区间距 20
&__body { &__body {
.flex(row, flex-start, flex-start); .flex(row, flex-start, flex-start);
gap: 20px; gap: 20px;
padding-top: 20px; // 筛选面板顶 272 − 页头条底(73+179) = 20 padding-top: 20px;
} }
&__main { &__main {

View File

@@ -25,7 +25,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
<template> <template>
<div class="contract-info"> <div class="contract-info">
<!-- 区块 01:合同信息(相对卡顶 y36) -->
<h3 class="contract-info__section contract-info__section--1"> <h3 class="contract-info__section contract-info__section--1">
<span class="contract-info__step">01</span> <span class="contract-info__step">01</span>
<span class="contract-info__section-title">合同信息</span> <span class="contract-info__section-title">合同信息</span>
@@ -59,7 +58,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
<StatusTag :label="contract.signStatus" :variant="SIGN_VARIANTS[contract.signStatus]" /> <StatusTag :label="contract.signStatus" :variant="SIGN_VARIANTS[contract.signStatus]" />
</div> </div>
<!-- 区块 02:关联项目(相对卡顶 y649) -->
<h3 class="contract-info__section contract-info__section--2"> <h3 class="contract-info__section contract-info__section--2">
<span class="contract-info__step">02</span> <span class="contract-info__step">02</span>
<span class="contract-info__section-title">关联项目</span> <span class="contract-info__section-title">关联项目</span>
@@ -76,7 +74,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
<span class="contract-info__value">{{ contract.projectNo }}</span> <span class="contract-info__value">{{ contract.projectNo }}</span>
</div> </div>
<!-- 区块 03:合同文件(相对卡顶 y901) -->
<h3 class="contract-info__section contract-info__section--3"> <h3 class="contract-info__section contract-info__section--3">
<span class="contract-info__step">03</span> <span class="contract-info__step">03</span>
<span class="contract-info__section-title">合同文件</span> <span class="contract-info__section-title">合同文件</span>
@@ -110,26 +107,18 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
// 合同详情信息区(设计 e6c3a361,1920 画布 1:1 绝对定位)
// 白卡 (70,152) 1780×1152;卡内三行「步骤区」相对卡顶:
// 区块 01 合同信息 y36(步骤号 ink 188、区块名 211)、首标签 283、值框 326
// 区块 02 关联项目 y649(步骤号 801、区块名 824)、首标签 901、值框 944
// 区块 03 合同文件 y901(步骤号 1053、区块名 1076)、文件行 1153
// 值框 764×49 @x134/1010(卡内左 64 / 940),描边 #999999、圆角 8、底 #F4F6F8
// 行距 122(标签 283 → 405 → 527 → 649);列间距 112(776 → 940 = 164 − 764)
.contract-info { .contract-info {
position: relative; position: relative;
width: 1780px; width: 1780px;
height: 1152px; height: 1152px;
margin-top: 21px; // 面包屑盒底 131(72+32+27)→ 卡顶 152 margin-top: 21px;
border-radius: @radius-lg; border-radius: @radius-lg;
background: @white; background: @white;
box-sizing: border-box; box-sizing: border-box;
// 区块(步骤号 + 区块名)—— 绝对定位,四个区块
&__section { &__section {
position: absolute; position: absolute;
left: 43px; // 步骤号 x124 = 70 + 43(123)→ 区块行内相对 11 → 113 ✓ left: 43px;
top: 0; top: 0;
height: 0; height: 0;
@@ -140,7 +129,7 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
&__section-title { &__section-title {
position: absolute; position: absolute;
left: 60px; // 区块名 ink x184 = 卡内 114 = 行 left 43 + 60 left: 60px;
margin: 0; margin: 0;
font-size: 28px; font-size: 28px;
font-weight: 500; font-weight: 500;
@@ -148,8 +137,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
color: @contract-cell-text; color: @contract-cell-text;
} }
// 步骤号 01/02/03:48/500 #024FFF。absolute 定到区块名左侧 ——
// 区块行 left 43,区块名 ink 相对行左 60(= 184 − 124);步骤号 ink 落 0
&__step { &__step {
position: absolute; position: absolute;
left: 0; left: 0;
@@ -162,30 +149,24 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
transform: translateY(-50%); transform: translateY(-50%);
} }
// 字段(标签 + 值框)—— 绝对定位到卡面,行间距 122
&__field { &__field {
.flex(column, flex-start, stretch); .flex(column, flex-start, stretch);
position: absolute; position: absolute;
width: 764px; width: 764px;
gap: 14px; // 标签盒底 312 → 值框顶 326 gap: 14px;
} }
// 值框顶(相对卡顶 = 设计 y − 152):326/448/570/692/944
&__field--1 { top: 174px; } &__field--1 { top: 174px; }
&__field--2 { top: 296px; } &__field--2 { top: 296px; }
&__field--3 { top: 418px; } &__field--3 { top: 418px; }
&__field--4 { top: 540px; } &__field--4 { top: 540px; }
&__field--5 { top: 770px; } // 标签 901 − 109(label 行盒 29 + gap 14 = 43 补偿) &__field--5 { top: 770px; }
// 第 5 行(关联项目)标签需落设计 y901 → 卡内 749,而 --5 槽位顶 770 是给
// 「合同状态」这类值框直排的字段用的;带标签的行统一再抬 21
&__field--5:has(&__label) { top: 749px; } &__field--5:has(&__label) { top: 749px; }
// 左右列:值框 x134 / x1010 → 卡内左 64 / 940
&__field--l { left: 64px; } &__field--l { left: 64px; }
&__field--r { left: 940px; } &__field--r { left: 940px; }
// 标签 20/500 #333
&__label { &__label {
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 500;
@@ -193,7 +174,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
color: @contract-cell-text; color: @contract-cell-text;
} }
// 值框:764×49、#F4F6F8 底、1px #999999、圆角 8;文字内缩 18
&__value { &__value {
.flex(row, flex-start, center); .flex(row, flex-start, center);
width: 100%; width: 100%;
@@ -209,7 +189,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
.ellipsis(); .ellipsis();
} }
// 合同状态(StatusTag)同样入值框,去彩色胶囊
&__field :deep(.status-tag) { &__field :deep(.status-tag) {
.flex(row, flex-start, center); .flex(row, flex-start, center);
width: 100%; width: 100%;
@@ -240,7 +219,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
font-size: 16px; font-size: 16px;
} }
// ── 合同文件:通栏 1640×90 @(124,1153),底 #EDEDED ──
&__files { &__files {
.flex(column, flex-start, stretch); .flex(column, flex-start, stretch);
position: absolute; position: absolute;

View File

@@ -23,11 +23,10 @@ const emit = defineEmits<{ refresh: [] }>()
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
// 签署卡(设计 39:白底圆角卡、二维码直接置于卡面、右侧四步指引)
.sign-panel { .sign-panel {
.flex(row, flex-start, flex-start); .flex(row, flex-start, flex-start);
gap: 48px; // 二维码区 → 步骤区(设计 63 × 0.77) gap: 48px;
padding: 28px 32px; // 卡内边距(设计 37/40 × 0.77) padding: 28px 32px;
border-radius: @radius-lg; border-radius: @radius-lg;
background: @white; background: @white;

View File

@@ -83,7 +83,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
overflow-x: auto; overflow-x: auto;
.scrollbar(); .scrollbar();
// 表格本体(设计 31ee32fc:1654×740,外框 1px #999、灰表头 64、行高 97、无竖线)
&__grid { &__grid {
width: 100%; width: 100%;
border: 1px solid @contract-line; border: 1px solid @contract-line;
@@ -100,7 +99,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
font-size: 20px; font-size: 20px;
} }
// 长名称列按设计稿换行显示(设计首行双行文本)
td.contract-table__name { td.contract-table__name {
white-space: normal; white-space: normal;
word-break: break-all; word-break: break-all;
@@ -117,7 +115,7 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
} }
td { td {
height: 96px; // 设计行距 475→571 height: 96px;
color: @contract-cell-text; color: @contract-cell-text;
line-height: 29px; line-height: 29px;
} }
@@ -130,7 +128,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
background: @bg-color-light; background: @bg-color-light;
} }
// 列宽按设计稿栅格比例微调(10 列合计 100%,日期/编号列留足 nowrap 宽度)
th:nth-child(1) { width: 11%; } th:nth-child(1) { width: 11%; }
th:nth-child(2) { width: 9%; } th:nth-child(2) { width: 9%; }
th:nth-child(3) { width: 10%; } th:nth-child(3) { width: 10%; }
@@ -143,7 +140,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
th:nth-child(10) { width: 11%; } th:nth-child(10) { width: 11%; }
} }
// 长名称列继承正文灰(设计 46 单元格统一 #333333)
&__name { &__name {
color: @contract-cell-text; color: @contract-cell-text;
} }
@@ -177,7 +173,6 @@ const SIGN_VARIANTS: Record<SignStatus, 'primary' | 'success' | 'warning' | 'dan
} }
} }
// 设计 46 状态列为纯文字,不使用彩色胶囊
:deep(.status-tag) { :deep(.status-tag) {
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;

View File

@@ -36,44 +36,43 @@ const steps = [
</template> </template>
<style lang="less" scoped> <style lang="less" scoped>
// 四步签署指引(设计 39:小蓝圆序号 + 14/12 设计px 标题与说明,步距 63 设计px)
.sign-steps { .sign-steps {
.flex(column, flex-start, stretch); .flex(column, flex-start, stretch);
gap: 17px; // 步骤间距(设计 22 × 0.77) gap: 17px;
&__item { &__item {
.flex(row, flex-start, flex-start); .flex(row, flex-start, flex-start);
gap: 11px; // 序号 → 文字(设计 14 × 0.77) gap: 11px;
} }
&__index { &__index {
.flex(row, center); .flex(row, center);
flex-shrink: 0; flex-shrink: 0;
width: 16px; // 序号圆(设计 21 × 0.77) width: 16px;
height: 16px; height: 16px;
border-radius: @radius-round; border-radius: @radius-round;
background: @primary-strong; background: @primary-strong;
color: @white; color: @white;
font-size: 11px; // 设计 12 → 可读下限 font-size: 11px;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
} }
&__body { &__body {
.flex(column, flex-start, stretch); .flex(column, flex-start, stretch);
gap: 5px; // 标题 → 说明(设计 6 × 0.77) gap: 5px;
min-width: 0; min-width: 0;
} }
&__title { &__title {
font-size: 11px; // 设计 14 → 可读下限 font-size: 11px;
font-weight: 700; font-weight: 700;
line-height: 1.4; line-height: 1.4;
color: @heading-color; color: @heading-color;
} }
&__desc { &__desc {
font-size: 11px; // 设计 12 → 可读下限 font-size: 11px;
line-height: 1.5; line-height: 1.5;
} }
} }

View File

@@ -5,7 +5,6 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { AppButton } from '@/components' import { AppButton } from '@/components'
import type { FileInfo } from '@/types/api' import type { FileInfo } from '@/types/api'
// 页面容器:数据只经 store,不直接 import api
import { useContractStore } from '@/stores/modules/contract' import { useContractStore } from '@/stores/modules/contract'
import ContractInfoCard from './cpns/ContractInfoCard.vue' import ContractInfoCard from './cpns/ContractInfoCard.vue'
@@ -80,12 +79,10 @@ onMounted(load)
.contract-detail { .contract-detail {
padding-bottom: @spacing-xxl; padding-bottom: @spacing-xxl;
// 内容列(面包屑 → 信息卡):卡自带 80 上距,这里不再叠加 gap
.container { .container {
.flex(column, flex-start, stretch); .flex(column, flex-start, stretch);
gap: 0; gap: 0;
} }
// 面包屑(设计 e6c3a361:(70,104) 24/500 #666666;顶栏 72 → 行盒顶 104)
&__breadcrumb { &__breadcrumb {
padding-top: 32px; padding-top: 32px;
font-size: 24px; font-size: 24px;
@@ -99,7 +96,6 @@ onMounted(load)
width: 100%; width: 100%;
margin-top: 30px; margin-top: 30px;
// 「返回」按钮
:deep(.app-button) { :deep(.app-button) {
height: 40px; height: 40px;
padding: 0 32px; padding: 0 32px;

View File

@@ -4,7 +4,6 @@ import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { FilterPanel, PaginationBar } from '@/components' import { FilterPanel, PaginationBar } from '@/components'
// 页面容器:只做组件引入、props 分发、事件中转,数据只经 store
import { useContractStore } from '@/stores/modules/contract' import { useContractStore } from '@/stores/modules/contract'
import ContractTable from './cpns/ContractTable.vue' import ContractTable from './cpns/ContractTable.vue'
@@ -44,7 +43,6 @@ onMounted(() => {
<template> <template>
<div class="contract"> <div class="contract">
<!-- 顶部横幅:白底通栏 + 标题/副标语(设计 41 横幅区、46 标题规格) -->
<div class="contract__head"> <div class="contract__head">
<div class="container"> <div class="container">
<h1 class="contract__title"> <h1 class="contract__title">
@@ -57,7 +55,6 @@ onMounted(() => {
</div> </div>
<div class="container"> <div class="container">
<!-- 内容白卡:搜索行 + 表格 + 分页(设计 46/47 卡片) -->
<div class="contract__card"> <div class="contract__card">
<FilterPanel <FilterPanel
v-model:keyword="keyword" v-model:keyword="keyword"
@@ -87,13 +84,10 @@ onMounted(() => {
.contract { .contract {
padding-bottom: @spacing-xxl; padding-bottom: @spacing-xxl;
// 横幅:设计 31ee32fc y72..251(1920×179)白底通栏
// 标题盒顶 96(ink 104)、副标语盒顶 161.5(ink 171)、横幅底 251
&__head { &__head {
padding: 24px 0 52px; padding: 24px 0 52px;
background: @white; background: @white;
// 标题/副标语落 x112 → 内容区左缘 70 + 42
.container { .container {
padding-left: 42px; padding-left: 42px;
} }
@@ -115,16 +109,14 @@ onMounted(() => {
} }
} }
// 内容白卡(设计 31ee32fc:x70 y271 1780×992 圆角白卡,无阴影)
.contract .contract__card { .contract .contract__card {
margin-top: 20px; // 横幅底 251 → 卡顶 271 margin-top: 20px;
padding: 51px 64px 59px; // 搜索框 x134 = 70+64;表格 x135 padding: 51px 64px 59px;
border-radius: @radius-lg; border-radius: @radius-lg;
background: @white; background: @white;
// 搜索行并入卡片:去掉 FilterPanel 自带的卡面
:deep(.filter-panel) { :deep(.filter-panel) {
margin-bottom: 39px; // 搜索框底 371 → 表头 411 margin-bottom: 39px;
padding: 0; padding: 0;
border-radius: 0; border-radius: 0;
background: transparent; background: transparent;
@@ -138,8 +130,6 @@ onMounted(() => {
:deep(.filter-panel__bar) { :deep(.filter-panel__bar) {
gap: 8px; gap: 8px;
// 设计:重置 x816、搜索 x940。搜索框右缘 726 →
// 重置 816(margin 90 − 12 padding = 78)、搜索 940(margin 52 − 12 = 40)
.filter-panel__btn { .filter-panel__btn {
margin-left: 78px; margin-left: 78px;
} }
@@ -148,7 +138,6 @@ onMounted(() => {
margin-left: 40px; margin-left: 40px;
} } } }
// 搜索框 134,322 592×49;占位 20/400 #999999
:deep(.filter-panel__input) { :deep(.filter-panel__input) {
flex: 0 0 592px; flex: 0 0 592px;
width: 592px; width: 592px;
@@ -170,7 +159,6 @@ onMounted(() => {
border-color: @primary-strong; border-color: @primary-strong;
} }
// 设计按钮:重置 x816、搜索 x940,均 20/400 纯文字(DOM 序相反,用 order 对齐)
:deep(.filter-panel__btn) { :deep(.filter-panel__btn) {
order: 1; order: 1;
min-width: 0; min-width: 0;
@@ -200,7 +188,6 @@ onMounted(() => {
color: @primary-color; color: @primary-color;
} }
// 分页(设计:右对齐,页码 ink 1180/1181,`<上一页` x1388、`下一页>` x1740)
:deep(.pagination) { :deep(.pagination) {
justify-content: flex-end; justify-content: flex-end;
gap: 10px; gap: 10px;
@@ -236,7 +223,6 @@ onMounted(() => {
color: @contract-muted; color: @contract-muted;
} }
// 首尾「上一页/下一页」:设计为无框纯文字
:deep(.pagination__btn:first-child), :deep(.pagination__btn:first-child),
:deep(.pagination__btn:last-child) { :deep(.pagination__btn:last-child) {
min-width: auto; min-width: auto;

View File

@@ -4,7 +4,6 @@ import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { AppButton } from '@/components' import { AppButton } from '@/components'
// 页面容器:数据只经 store,不直接 import api
import { useContractStore } from '@/stores/modules/contract' import { useContractStore } from '@/stores/modules/contract'
import ContractSignPanel from './cpns/ContractSignPanel.vue' import ContractSignPanel from './cpns/ContractSignPanel.vue'
@@ -67,13 +66,11 @@ onMounted(load)
padding-top: @spacing-lg; padding-top: @spacing-lg;
padding-bottom: @spacing-xxl; padding-bottom: @spacing-xxl;
// 内容列(面包屑 → 签署卡 → 返回):面包屑 → 卡片 12(设计 16 × 0.77)
.container { .container {
.flex(column, flex-start, stretch); .flex(column, flex-start, stretch);
gap: 12px; gap: 12px;
} }
// 面包屑(设计 39:12 设计px → 可读下限 11、#9CA3AF,当前项 #4B5563 加粗)
&__breadcrumb { &__breadcrumb {
font-size: 11px; font-size: 11px;
color: @text-color-disabled; color: @text-color-disabled;
@@ -87,17 +84,16 @@ onMounted(load)
&__footer { &__footer {
.flex(row, flex-end); .flex(row, flex-end);
width: 100%; width: 100%;
margin-top: 19px; // 卡片底 → 按钮:设计 40 × 0.77(另计容器 12) margin-top: 19px;
// 「返回」按钮(设计 39:#024FFF 实底、白字、小圆角)
:deep(.app-button) { :deep(.app-button) {
height: 30px; height: 30px;
padding: 0 24px; // 按钮宽约 98 设计 × 0.77 padding: 0 24px;
border: none; border: none;
border-radius: @radius-sm; border-radius: @radius-sm;
background: @primary-strong; background: @primary-strong;
color: @white; color: @white;
font-size: 11px; // 设计 13 → 可读下限 font-size: 11px;
} }
:deep(.app-button:hover) { :deep(.app-button:hover) {

Some files were not shown because too many files have changed in this diff Show More