项目中心
This commit is contained in:
@@ -1,16 +1,15 @@
|
|||||||
import type { FileInfo } from '@/types/api'
|
|
||||||
import type {
|
import type {
|
||||||
AfterSales,
|
AfterSalesAttachment,
|
||||||
AfterSalesPayload,
|
AfterSalesPayload,
|
||||||
Project,
|
AfterSalesUploadVo,
|
||||||
ProjectListResult,
|
ProjectListResult,
|
||||||
ProjectQuery,
|
ProjectQuery,
|
||||||
ProjectStatistics,
|
ProjectStatistics,
|
||||||
RequirementDetailVo,
|
RdProjectVo,
|
||||||
|
RequirementDetailBundle,
|
||||||
} from '@/types/business/project'
|
} from '@/types/business/project'
|
||||||
import { request } from '@/utils/request'
|
import { request, serializeQuery } from '@/utils/request'
|
||||||
import { formatDate } from '@/utils/format'
|
import { formatDate } from '@/utils/format'
|
||||||
import { toProjectDetail } from '@/utils/projectVo'
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// 列表:真实接口
|
// 列表:真实接口
|
||||||
@@ -21,7 +20,7 @@ import { toProjectDetail } from '@/utils/projectVo'
|
|||||||
// 鉴权:需登录态(匿名 401);每请求带 clientid 头(见 utils/request.ts 拦截器)。
|
// 鉴权:需登录态(匿名 401);每请求带 clientid 头(见 utils/request.ts 拦截器)。
|
||||||
// 筛选参数实测(tools/probe_project_api.py):
|
// 筛选参数实测(tools/probe_project_api.py):
|
||||||
// key → like projectName OR projectNo OR requirementDesc
|
// key → like projectName OR projectNo OR requirementDesc
|
||||||
// businessType → eq business_type(值是提报表主键 id,不是类型名)
|
// businessTypes → IN business_type(**多选**;值是提报表主键 id,不是类型名)
|
||||||
// status → eq status(码值,见 RdProjectStatus)
|
// status → eq status(码值,见 RdProjectStatus)
|
||||||
// beginTime/endTime → between create_time(不是 submit_time!)
|
// beginTime/endTime → between create_time(不是 submit_time!)
|
||||||
// pageNum/pageSize → PageQuery
|
// pageNum/pageSize → PageQuery
|
||||||
@@ -83,12 +82,17 @@ 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 },
|
||||||
params: {
|
params: {
|
||||||
pageNum: query.pageNum,
|
pageNum: query.pageNum,
|
||||||
pageSize: query.pageSize,
|
pageSize: query.pageSize,
|
||||||
// 空串不出现在 querystring 里,与后端 eqIfText 的「有值才等值匹配」语义一致
|
// 空串不出现在 querystring 里,与后端 eqIfText 的「有值才等值匹配」语义一致
|
||||||
key: query.keyword || undefined,
|
key: query.keyword || undefined,
|
||||||
businessType: query.businessType || undefined,
|
// 多选:空数组不发;非空由 serializeQuery 展开成 businessTypes[0]=…&businessTypes[1]=…
|
||||||
|
businessTypes: query.businessTypes.length > 0 ? [...query.businessTypes] : undefined,
|
||||||
status: query.status || undefined,
|
status: query.status || undefined,
|
||||||
...toTimeRange(query),
|
...toTimeRange(query),
|
||||||
},
|
},
|
||||||
@@ -108,240 +112,177 @@ export function getProjectFilterConditions(): Promise<ProjectStatistics> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
/**
|
||||||
// 详情:真实接口
|
* 已完成项目(申请售后页「项目名称」下拉的候选)。
|
||||||
// GET /web/requirementPool/get/{id} → ProjectRequirementUserController#get
|
*
|
||||||
// → ProjectRequirementServiceImpl#queryRequirementByUserId
|
* <p>直接复用列表接口做状态过滤:`status=COMPLETED` 在后端比的是
|
||||||
// 出参 ProjectRequirementUserVo,经 utils/projectVo.ts 映射成详情页沿用的 Project 形态。
|
* `COALESCE(vm_rd_project.status, vm_project_requirement.status)`,而 `COMPLETED` 只会
|
||||||
// ⚠️ id 传的是**需求 id**(vm_project_requirement.id)= 列表行 RdProjectVo.id。
|
* 出现在**项目表**(未立项的需求是 `INITIATING`)⇒ 结果天然就是「自己名下已立项且已完成的项目」,
|
||||||
// 未立项的需求也能查到(项目侧字段为空);不存在 / 非本人 → 后端 data 为 null。
|
* 与后端申请售后的准入校验(只有 COMPLETED 可申请)口径一致。</p>
|
||||||
//
|
*
|
||||||
// 文件 / 售后:**仍是前端 mock** —— 该 Controller 只有 create / list / get /
|
* <p>售后申请场景下候选量级不大,一次取回较大分页(后端无专用端点);后端默认排序
|
||||||
// serviceTypeList 四个接口,没有附件上传、售后提交/取消的后端实现。
|
* `update_time DESC`。返回行的 `id` = **需求 id**,正是申请接口要传的那个 id。</p>
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
*/
|
||||||
|
export function getCompletedProjects(): Promise<RdProjectVo[]> {
|
||||||
/** 构造 mock 文件信息 */
|
return request<ProjectListResult>({
|
||||||
function mockFile(name: string, size: number, uploadTime: string): FileInfo {
|
url: '/web/requirementPool/list',
|
||||||
return { name, size, uploadTime, url: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 详情 mock 所属项目 id */
|
|
||||||
const DETAIL_ID = 'project-004'
|
|
||||||
|
|
||||||
/** 项目列表 mock(6 条,覆盖全部状态,internet/iot 混合)——仅详情页链路在用 */
|
|
||||||
const MOCK_PROJECTS: Project[] = [
|
|
||||||
{
|
|
||||||
id: 'project-001',
|
|
||||||
projectNo: 'P8K2M202408001',
|
|
||||||
projectName: '某大型银行数字化转型方案',
|
|
||||||
businessType: 'internet',
|
|
||||||
progress: 64,
|
|
||||||
status: '待立项',
|
|
||||||
submitTime: '2026-8-17 14:00',
|
|
||||||
milestones: [],
|
|
||||||
team: [],
|
|
||||||
files: [],
|
|
||||||
paymentPlans: [],
|
|
||||||
invoiceRecords: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'project-002',
|
|
||||||
projectNo: 'P8K2M202408002',
|
|
||||||
projectName: '智慧城市大数据平台建设项目',
|
|
||||||
businessType: 'iot',
|
|
||||||
progress: 64,
|
|
||||||
status: '待验收',
|
|
||||||
submitTime: '2026-8-17 14:00',
|
|
||||||
milestones: [],
|
|
||||||
team: [],
|
|
||||||
files: [],
|
|
||||||
paymentPlans: [],
|
|
||||||
invoiceRecords: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'project-003',
|
|
||||||
projectNo: 'P8K2M202408003',
|
|
||||||
projectName: '三甲医院指挥医疗信息化系统',
|
|
||||||
businessType: 'internet',
|
|
||||||
progress: 64,
|
|
||||||
status: '已完成',
|
|
||||||
submitTime: '2026-8-17 14:00',
|
|
||||||
milestones: [],
|
|
||||||
team: [],
|
|
||||||
files: [],
|
|
||||||
paymentPlans: [],
|
|
||||||
invoiceRecords: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: DETAIL_ID,
|
|
||||||
projectNo: '20260813RJ251545',
|
|
||||||
projectName: '社区康养智能服务系统',
|
|
||||||
businessType: 'internet',
|
|
||||||
progress: 64,
|
|
||||||
status: '进行中',
|
|
||||||
submitTime: '2026-8-17 14:00',
|
|
||||||
milestones: [],
|
|
||||||
team: [],
|
|
||||||
files: [],
|
|
||||||
paymentPlans: [],
|
|
||||||
invoiceRecords: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'project-005',
|
|
||||||
projectNo: 'P8K2M202408005',
|
|
||||||
projectName: '智能仓储物流分拣系统',
|
|
||||||
businessType: 'iot',
|
|
||||||
progress: 64,
|
|
||||||
status: '售后中',
|
|
||||||
submitTime: '2026-8-17 14:00',
|
|
||||||
milestones: [],
|
|
||||||
team: [],
|
|
||||||
files: [],
|
|
||||||
paymentPlans: [],
|
|
||||||
invoiceRecords: [],
|
|
||||||
afterSales: {
|
|
||||||
status: '处理中',
|
|
||||||
type: 'BUG问题',
|
|
||||||
description: '分拣识别偶发误判,需要排查',
|
|
||||||
remark: '',
|
|
||||||
attachments: [],
|
|
||||||
createTime: '2026-8-19 17:00',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'project-006',
|
|
||||||
projectNo: 'P8K2M202408006',
|
|
||||||
projectName: '新能源电站运维监控平台',
|
|
||||||
businessType: 'internet',
|
|
||||||
progress: 64,
|
|
||||||
status: '已取消',
|
|
||||||
submitTime: '2026-8-17 14:00',
|
|
||||||
milestones: [],
|
|
||||||
team: [],
|
|
||||||
files: [],
|
|
||||||
paymentPlans: [],
|
|
||||||
invoiceRecords: [],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
/** 项目详情 mock(社区康养智能服务系统) */
|
|
||||||
const PROJECT_DETAIL: Project = {
|
|
||||||
...MOCK_PROJECTS[3],
|
|
||||||
progress: 65,
|
|
||||||
startTime: '2026-8-13 11:04:12',
|
|
||||||
endTime: '2026-10-30 18:00:00',
|
|
||||||
milestones: [
|
|
||||||
{ name: '需求确认', status: '已完成', time: '2026-8-13 11:04:12' },
|
|
||||||
{ name: '原型设计', status: '已完成', time: '2026-8-13 11:04:12' },
|
|
||||||
{ name: '开发实现', status: '进行中' },
|
|
||||||
{ name: '测试验收', status: '待开始' },
|
|
||||||
{ name: '项目交付', status: '待开始' },
|
|
||||||
],
|
|
||||||
team: [
|
|
||||||
{ role: '项目经理', name: '张三', avatarChar: '张' },
|
|
||||||
{ role: '项目经理', name: '张三', avatarChar: '张' },
|
|
||||||
],
|
|
||||||
files: [
|
|
||||||
mockFile('项目需求规格说明书.pdf', 2516582, '2026-08-19 17:00'),
|
|
||||||
mockFile('项目需求说明书.word', 1887437, '2026-08-19 17:00'),
|
|
||||||
],
|
|
||||||
paymentPlans: [
|
|
||||||
{
|
|
||||||
id: 'bill-001',
|
|
||||||
planName: '第一期·预付款(比例33%)',
|
|
||||||
billNo: '20260813ZZ251245',
|
|
||||||
amount: 20100,
|
|
||||||
status: '已支付',
|
|
||||||
tradeNo: 'T20260819153035678K2M001',
|
|
||||||
projectName: '社区康养智能服务系统',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bill-002',
|
|
||||||
planName: '第二期·付款(比例33%)',
|
|
||||||
billNo: '20260813ZZ251245',
|
|
||||||
amount: 20100,
|
|
||||||
status: '待支付',
|
|
||||||
projectName: '社区康养智能服务系统',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bill-003',
|
|
||||||
planName: '第三期·尾款(比例33%)',
|
|
||||||
billNo: '20260813ZZ251245',
|
|
||||||
amount: 20100,
|
|
||||||
status: '未到期',
|
|
||||||
projectName: '社区康养智能服务系统',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
invoiceRecords: [
|
|
||||||
mockFile('增值税专用发票.pdf', 325120, '2026-08-19 17:00'),
|
|
||||||
mockFile('电子回单.jpg', 186368, '2026-08-19 17:00'),
|
|
||||||
],
|
|
||||||
contractFile: mockFile('社区康养智能服务系统开发合同.pdf', 1039360, '2026-08-19 17:00'),
|
|
||||||
afterSales: {
|
|
||||||
status: '处理中',
|
|
||||||
type: 'BUG问题',
|
|
||||||
description: '具体请看清单',
|
|
||||||
remark: '',
|
|
||||||
attachments: [],
|
|
||||||
createTime: '2026-8-19 17:00',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 项目详情(真实接口;不存在 / 非本人需求返回 null) */
|
|
||||||
export function getProjectDetail(id: string): Promise<Project | null> {
|
|
||||||
return request<RequirementDetailVo | null>({
|
|
||||||
url: `/web/requirementPool/get/${id}`,
|
|
||||||
method: 'get',
|
method: 'get',
|
||||||
}).then((vo) => (vo ? toProjectDetail(vo) : null))
|
params: {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 200,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
},
|
||||||
|
}).then((result) => result?.requirementPage?.rows ?? [])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传项目文件(mock:追加进详情文件列表) */
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
export function uploadProjectFile(file: File): Promise<FileInfo> {
|
// 详情:真实接口(**4 个页签各一个端点**,同帧并行取回后合并)
|
||||||
const info: FileInfo = {
|
// GET /web/requirementPool/get/progress/{id} → ProjectRequirementProgressVo
|
||||||
name: file.name,
|
// GET /web/requirementPool/get/files/{id} → ProjectRequirementFilesVo
|
||||||
|
// GET /web/requirementPool/get/finance/{id} → ProjectRequirementFinanceVo
|
||||||
|
// GET /web/requirementPool/get/afterSales/{id} → ProjectRequirementAfterSalesVo
|
||||||
|
// → ProjectRequirementUserController#getProgress / #getFiles / #getFinance / #getAfterSales
|
||||||
|
// → ProjectRequirementServiceImpl#queryProgress / queryFiles / queryFinance / queryAfterSales
|
||||||
|
//
|
||||||
|
// 4 个 VO 都继承 ProjectRequirementBaseVo(基础信息始终带)⇒ 任一响应里都有项目名 /
|
||||||
|
// 编号 / 业务类型 / 状态 / 提交时间,所以详情页顶部信息卡只依赖其中一支即可,切页签不缺数据。
|
||||||
|
// 首屏用 getProjectDetailBundle **同帧并行**发 4 个请求(一次到位,避免首屏缺数据);
|
||||||
|
// **之后每次点击页签**再用 getProjectDetailTab 只拉对应那一支(点一次 = 一次请求),
|
||||||
|
// 响应回填到 store 里的 bundle 后重新映射 ⇒ 页面始终是**最新数据**,不重拉另外三支。
|
||||||
|
// 出参由 stores/modules/project.ts 缓存成 bundle,经 utils/projectVo.ts#toProjectDetail
|
||||||
|
// 映射成详情页沿用的 Project 形态。
|
||||||
|
// ⚠️ id 传的是**需求 id**(vm_project_requirement.id)= 列表行 RdProjectVo.id;
|
||||||
|
// 未立项的需求也能查到(项目侧字段为空);不存在 / 非本人 → 后端 4 支 data 均为 null。
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情页签(= `RequirementDetailBundle` 的键名,也就是 4 个端点的后缀)。
|
||||||
|
*
|
||||||
|
* <p>`views/project/detail.vue` 里的 `DetailTab` 是同一组值;那边不 import 本模块
|
||||||
|
* (页面层不直接依赖 api),靠字面量对齐。</p>
|
||||||
|
*/
|
||||||
|
export type ProjectDetailTab = 'progress' | 'files' | 'finance' | 'afterSales'
|
||||||
|
|
||||||
|
/** 页签 → 端点(4 支都继承 ProjectRequirementBaseVo,基础信息任一支都有) */
|
||||||
|
const DETAIL_TAB_URL: Record<ProjectDetailTab, string> = {
|
||||||
|
progress: '/web/requirementPool/get/progress',
|
||||||
|
files: '/web/requirementPool/get/files',
|
||||||
|
finance: '/web/requirementPool/get/finance',
|
||||||
|
afterSales: '/web/requirementPool/get/afterSales',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个页签取数(**点一次页签 = 一次请求**,拿该页签的最新数据)。
|
||||||
|
*
|
||||||
|
* <p>返回类型按页签收窄:`getProjectDetailTab(id, 'finance')` → `RequirementFinanceVo | null`,
|
||||||
|
* 所以调用方可以直接把结果写回 bundle 的对应键。</p>
|
||||||
|
*
|
||||||
|
* <p>`null` = 需求不存在 / 不属于当前用户(后端 `data: null`),调用方应保留现有数据并提示。</p>
|
||||||
|
*/
|
||||||
|
export function getProjectDetailTab<T extends ProjectDetailTab>(
|
||||||
|
id: string,
|
||||||
|
tab: T,
|
||||||
|
): Promise<RequirementDetailBundle[T] | null> {
|
||||||
|
return request<RequirementDetailBundle[T] | null>({
|
||||||
|
url: `${DETAIL_TAB_URL[tab]}/${id}`,
|
||||||
|
method: 'get',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情四支并行取回(**首屏用**);任一为 null ⇒ 整个 bundle 为 null。
|
||||||
|
*
|
||||||
|
* <p>4 支同源(都查同一行需求),正常不会只缺一支;缺一支即视为「需求不存在 / 非本人」,
|
||||||
|
* 详情页走整页空态。</p>
|
||||||
|
*/
|
||||||
|
export function getProjectDetailBundle(id: string): Promise<RequirementDetailBundle | null> {
|
||||||
|
return Promise.all([
|
||||||
|
getProjectDetailTab(id, 'progress'),
|
||||||
|
getProjectDetailTab(id, 'files'),
|
||||||
|
getProjectDetailTab(id, 'finance'),
|
||||||
|
getProjectDetailTab(id, 'afterSales'),
|
||||||
|
]).then(([progress, files, finance, afterSales]) => {
|
||||||
|
if (!progress || !files || !finance || !afterSales) return null
|
||||||
|
return { progress, files, finance, afterSales }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// 售后(**真实接口**)
|
||||||
|
// 申请 POST /web/requirementPool/afterSales/request body ProjectRequirementActionBo
|
||||||
|
// 撤回 POST /web/requirementPool/afterSales/withdraw body ProjectRequirementActionBo
|
||||||
|
// → ProjectRequirementUserController#requestAfterSale / #withdrawAfterSale
|
||||||
|
// → ProjectRequirementServiceImpl#requestAfterSale / #withdrawAfterSale
|
||||||
|
// 附件上传 POST /web/workOrderDraft/upload(multipart,字段名 `file`)—— 只传 OSS 不落业务表;
|
||||||
|
// 提交时下发 `attachmentOssIds`,后端转存进 `vm_project_requirement_attachment`
|
||||||
|
// (phase = AFTER_SALE、target_id = 售后工单 id),OSS 文件不重复上传。
|
||||||
|
//
|
||||||
|
// ⚠️ 后端业务校验(失败抛 ServiceException,由统一 request 层弹提示):
|
||||||
|
// · 只有「已完成(COMPLETED)」的项目能申请售后;
|
||||||
|
// · 已有 REQUESTED / IN_PROGRESS 的售后时拒绝重复申请;
|
||||||
|
// · 撤回只允许 REQUESTED 状态,撤回后售后置 WITHDRAWN、项目回到已完成。
|
||||||
|
// ⚠️ 撤回接口收的是**需求 id**(不是售后工单 id),与后端 `checkOwner` 的校验口径一致。
|
||||||
|
//
|
||||||
|
// ⚠️ 详情页「项目文件」页签的上传 / 删除**仍无用户端接口**(上传入口在需求提报页),
|
||||||
|
// 所以这里不再保留 mock —— 详情数据来自真实接口,塞本地假数据会让刷新前后不一致。
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传售后附件(复用需求提报的 OSS 上传口)。
|
||||||
|
*
|
||||||
|
* <p>只传 OSS、不落业务表;返回项带 `ossId`,提交时由后端转存成售后附件。
|
||||||
|
* 后端响应没有文件大小,大小取本地 `File.size`。</p>
|
||||||
|
*/
|
||||||
|
export function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
|
||||||
|
const data = new FormData()
|
||||||
|
data.append('file', file)
|
||||||
|
return request<AfterSalesUploadVo>({
|
||||||
|
url: '/web/workOrderDraft/upload',
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
}).then((vo) => ({
|
||||||
|
ossId: vo.ossId,
|
||||||
|
name: vo.originalName || file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
|
url: vo.url,
|
||||||
uploadTime: formatDate(new Date()),
|
uploadTime: formatDate(new Date()),
|
||||||
url: '',
|
}))
|
||||||
}
|
|
||||||
PROJECT_DETAIL.files.push(info)
|
|
||||||
return Promise.resolve({ ...info })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除项目文件(mock:按索引从详情文件列表移除) */
|
/**
|
||||||
export function deleteProjectFile(index: number): Promise<void> {
|
* 申请售后(用户端)。
|
||||||
if (index >= 0 && index < PROJECT_DETAIL.files.length) {
|
*
|
||||||
PROJECT_DETAIL.files.splice(index, 1)
|
* <p>`id` 是**需求 id**(= 详情页路由参数);`payload` 的 `reason` / `note` 分别对应
|
||||||
}
|
* 页面的「问题描述」与「补充备注」。</p>
|
||||||
return Promise.resolve()
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
/** 提交售后工单(mock) */
|
|
||||||
export function submitAfterSales(
|
export function submitAfterSales(
|
||||||
projectId: string,
|
id: string,
|
||||||
payload: AfterSalesPayload,
|
payload: AfterSalesPayload,
|
||||||
): Promise<AfterSales> {
|
): Promise<void> {
|
||||||
const result: AfterSales = {
|
return request<void>({
|
||||||
status: '处理中',
|
url: '/web/requirementPool/afterSales/request',
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
type: payload.type,
|
type: payload.type,
|
||||||
description: payload.description,
|
reason: payload.reason,
|
||||||
remark: payload.remark,
|
note: payload.note,
|
||||||
attachments: [...payload.attachments],
|
// 空数组是安全的(后端 `appendAttachments` 对空集合直接 return,不 NPE)
|
||||||
createTime: formatDate(new Date()),
|
attachmentOssIds: payload.attachmentOssIds,
|
||||||
}
|
},
|
||||||
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
|
})
|
||||||
return Promise.resolve({ ...result, attachments: [...result.attachments] })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取消售后工单(mock) */
|
/**
|
||||||
export function cancelAfterSales(projectId: string): Promise<AfterSales> {
|
* 撤回售后申请(用户端)。
|
||||||
const result: AfterSales = {
|
*
|
||||||
status: '无',
|
* <p>`id` 传**需求 id**;`reason` 是撤回原因(选填,后端会存进 `withdraw_reason`)。</p>
|
||||||
type: '',
|
*/
|
||||||
description: '',
|
export function cancelAfterSales(id: string, reason = ''): Promise<void> {
|
||||||
remark: '',
|
return request<void>({
|
||||||
attachments: [],
|
url: '/web/requirementPool/afterSales/withdraw',
|
||||||
createTime: '',
|
method: 'post',
|
||||||
}
|
data: { id, reason },
|
||||||
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
|
})
|
||||||
return Promise.resolve({ ...result })
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
// 用户web端首页接口(真实后端绑定)
|
|
||||||
// 后端: vermis-maker com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
|
|
||||||
// 前缀: /web/home-page 鉴权: 公开接口,无需 token
|
|
||||||
//
|
|
||||||
// 使用约定(与项目 SKILL.md 一致):
|
|
||||||
// 页面/组件禁止直接 import axios,一律经 store 调用本模块的 request<T>。
|
|
||||||
// 本文件只放「函数签名 + 请求路径」,不写业务逻辑。
|
|
||||||
import { request } from '@/utils/request'
|
|
||||||
import type {
|
|
||||||
ProjectStatusCount,
|
|
||||||
ProjectSubmissionVo,
|
|
||||||
BannerVo,
|
|
||||||
AgreementFormVo,
|
|
||||||
} from '@/types/web/home'
|
|
||||||
|
|
||||||
/** 项目状态统计 */
|
|
||||||
export function getProjectStatusCount(): Promise<ProjectStatusCount> {
|
|
||||||
return request<ProjectStatusCount>({
|
|
||||||
url: '/web/home-page/project-status-count',
|
|
||||||
method: 'get',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 业务服务类型列表 */
|
|
||||||
export function getServiceTypeList(): Promise<ProjectSubmissionVo[]> {
|
|
||||||
return request<ProjectSubmissionVo[]>({
|
|
||||||
url: '/web/home-page/service-type-list',
|
|
||||||
method: 'get',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 业务类型详情 */
|
|
||||||
export function getServiceTypeDetail(id: number | string): Promise<ProjectSubmissionVo> {
|
|
||||||
return request<ProjectSubmissionVo>({
|
|
||||||
url: `/web/home-page/service-type-detail/${id}`,
|
|
||||||
method: 'get',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 轮播图列表 */
|
|
||||||
export function getCarouselList(): Promise<BannerVo[]> {
|
|
||||||
return request<BannerVo[]>({
|
|
||||||
url: '/web/home-page/carousel-list',
|
|
||||||
method: 'get',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 平台介绍(富文本 HTML) */
|
|
||||||
export function getPlatformIntro(): Promise<AgreementFormVo> {
|
|
||||||
return request<AgreementFormVo>({
|
|
||||||
url: '/web/home-page/platform-intro',
|
|
||||||
method: 'get',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 联系信息(富文本 HTML) */
|
|
||||||
export function getContactInfo(): Promise<AgreementFormVo> {
|
|
||||||
return request<AgreementFormVo>({
|
|
||||||
url: '/web/home-page/contact-info',
|
|
||||||
method: 'get',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -2,27 +2,32 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type {
|
import type {
|
||||||
|
AfterSalesAttachment,
|
||||||
AfterSalesPayload,
|
AfterSalesPayload,
|
||||||
Project,
|
Project,
|
||||||
ProjectQuery,
|
ProjectQuery,
|
||||||
ProjectStatistics,
|
ProjectStatistics,
|
||||||
ProjectTypeCount,
|
ProjectTypeCount,
|
||||||
RdProjectVo,
|
RdProjectVo,
|
||||||
|
RequirementDetailBundle,
|
||||||
} from '@/types/business/project'
|
} from '@/types/business/project'
|
||||||
import {
|
import {
|
||||||
cancelAfterSales as cancelAfterSalesApi,
|
cancelAfterSales as cancelAfterSalesApi,
|
||||||
deleteProjectFile,
|
getCompletedProjects,
|
||||||
getProjectDetail,
|
getProjectDetailBundle,
|
||||||
|
getProjectDetailTab,
|
||||||
getProjectFilterConditions,
|
getProjectFilterConditions,
|
||||||
getProjectList,
|
getProjectList,
|
||||||
submitAfterSales as submitAfterSalesApi,
|
submitAfterSales as submitAfterSalesApi,
|
||||||
uploadProjectFile,
|
uploadAfterSalesFile as uploadAfterSalesFileApi,
|
||||||
} from '@/api/project'
|
} from '@/api/project'
|
||||||
|
import type { ProjectDetailTab } from '@/api/project'
|
||||||
|
import { toProjectDetail } from '@/utils/projectVo'
|
||||||
|
|
||||||
function createDefaultQuery(): ProjectQuery {
|
function createDefaultQuery(): ProjectQuery {
|
||||||
return {
|
return {
|
||||||
keyword: '',
|
keyword: '',
|
||||||
businessType: '',
|
businessTypes: [],
|
||||||
status: '',
|
status: '',
|
||||||
timeShortcut: 'all',
|
timeShortcut: 'all',
|
||||||
startDate: '',
|
startDate: '',
|
||||||
@@ -65,6 +70,31 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
/** 业务类型筛选条件是否已拉过(值域在运行期不变,进页面拉一次即可) */
|
/** 业务类型筛选条件是否已拉过(值域在运行期不变,进页面拉一次即可) */
|
||||||
let filterConditionsLoaded = false
|
let filterConditionsLoaded = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已完成项目(**申请售后页「项目名称」下拉的候选**)。
|
||||||
|
*
|
||||||
|
* <p>口径见 `api/project.ts#getCompletedProjects`:自己名下已立项且状态为 COMPLETED 的项目,
|
||||||
|
* 与后端「只有已完成的项目可以申请售后」的准入校验一致。行的 `id` 就是申请接口要传的需求 id。</p>
|
||||||
|
*
|
||||||
|
* <p>与业务类型的筛选条件不同,这个值域**会随项目状态变化**,所以不缓存、
|
||||||
|
* 每次进申请页都重拉(见 {@link fetchCompletedProjects})。</p>
|
||||||
|
*/
|
||||||
|
const completedProjects = ref<RdProjectVo[]>([])
|
||||||
|
|
||||||
|
/** 详情原始 bundle(4 支接口的响应);切页签时按页签替换其中一支后重新映射 */
|
||||||
|
const detailBundle = ref<RequirementDetailBundle | null>(null)
|
||||||
|
/** 详情请求序号:需求 id 一变就自增,用来丢弃在途的「上一个需求」的响应 */
|
||||||
|
let detailSeq = 0
|
||||||
|
/** 各页签自己的请求序号:同一页签连点只应用最后一次响应 */
|
||||||
|
const tabSeq: Record<ProjectDetailTab, number> = {
|
||||||
|
progress: 0,
|
||||||
|
files: 0,
|
||||||
|
finance: 0,
|
||||||
|
afterSales: 0,
|
||||||
|
}
|
||||||
|
/** 正在刷新的页签('' = 无);页签按钮据此显示加载态 */
|
||||||
|
const loadingTab = ref<ProjectDetailTab | ''>('')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取业务类型筛选条件(左侧筛选栏「业务类型」的值域 + 件数)。
|
* 取业务类型筛选条件(左侧筛选栏「业务类型」的值域 + 件数)。
|
||||||
*
|
*
|
||||||
@@ -97,21 +127,98 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取详情(真实接口)。
|
* 取「已完成项目」(申请售后页「项目名称」下拉的值域)。
|
||||||
*
|
*
|
||||||
* <p>后端在「需求不存在」或「不属于当前用户」时返回 `data: null` ⇒ 页面显示空态;
|
* <p>失败只告警不清空:保留上一次的结果,页面下拉至少还有数据;首次即失败则为空数组,
|
||||||
* 请求本身失败(如 500)也归为 null,不让异常冒到组件里。</p>
|
* 申请页会退化成只显示当前项目的单项下拉(见 `after-sales.vue` 的兜底合并)。</p>
|
||||||
*/
|
*/
|
||||||
async function fetchDetail(id: string): Promise<void> {
|
async function fetchCompletedProjects(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
current.value = await getProjectDetail(id)
|
completedProjects.value = await getCompletedProjects()
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
console.warn('[project] 已完成项目列表加载失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取详情(**首屏**:4 支并行,一次到位)。
|
||||||
|
*
|
||||||
|
* <p>后端在「需求不存在」或「不属于当前用户」时 4 支都返回 `data: null` ⇒ 页面显示空态;
|
||||||
|
* 请求本身失败(如 500)也归为 null,不让异常冒到组件里。</p>
|
||||||
|
*
|
||||||
|
* <p>原始响应同时缓存进 `detailBundle`,之后切页签只替换其中一支(见 {@link refreshTab}),
|
||||||
|
* 不必重拉另外三支。</p>
|
||||||
|
*/
|
||||||
|
async function fetchDetail(id: string): Promise<void> {
|
||||||
|
const seq = ++detailSeq
|
||||||
|
loadingTab.value = ''
|
||||||
|
try {
|
||||||
|
const bundle = await getProjectDetailBundle(id)
|
||||||
|
// 期间已切到别的需求 ⇒ 丢弃这次响应,别用旧项目覆盖新项目
|
||||||
|
if (seq !== detailSeq) return
|
||||||
|
if (!bundle) {
|
||||||
|
detailBundle.value = null
|
||||||
|
current.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detailBundle.value = bundle
|
||||||
|
current.value = toProjectDetail(bundle)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (seq !== detailSeq) return
|
||||||
|
detailBundle.value = null
|
||||||
current.value = null
|
current.value = null
|
||||||
console.warn('[project] 项目详情加载失败', error)
|
console.warn('[project] 项目详情加载失败', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切页签刷新:**只调该页签对应的那一个接口**,拿到最新数据后落回 bundle 再重新映射。
|
||||||
|
*
|
||||||
|
* <p>后端 4 个页签各一个端点 ⇒ 「点一次页签 = 一次请求」,不需要重拉另外三支。
|
||||||
|
* 页签内容(里程碑 / 付款计划 / 附件 / 售后…)可能被别的入口改动,所以每次点击都重新取。</p>
|
||||||
|
*
|
||||||
|
* <p>并发保护两层:① 需求 id 变了(切项目)⇒ 丢弃在途响应;② 同一页签连点 ⇒ 只应用
|
||||||
|
* 最后一次响应(先到的慢响应被后到的覆盖)。</p>
|
||||||
|
*
|
||||||
|
* <p>后端返回 `null`(需求不存在 / 非本人)时**保留现有数据**,只告警、不清空页面。</p>
|
||||||
|
*/
|
||||||
|
async function refreshTab(tab: ProjectDetailTab): Promise<void> {
|
||||||
|
const requestId = current.value?.id
|
||||||
|
if (!requestId) return
|
||||||
|
const seq = ++tabSeq[tab]
|
||||||
|
loadingTab.value = tab
|
||||||
|
try {
|
||||||
|
const vo = await getProjectDetailTab(requestId, tab)
|
||||||
|
if (seq !== tabSeq[tab] || current.value?.id !== requestId) return
|
||||||
|
if (!vo || !detailBundle.value) return
|
||||||
|
const next: RequirementDetailBundle = { ...detailBundle.value }
|
||||||
|
switch (tab) {
|
||||||
|
case 'progress':
|
||||||
|
next.progress = vo as RequirementDetailBundle['progress']
|
||||||
|
break
|
||||||
|
case 'files':
|
||||||
|
next.files = vo as RequirementDetailBundle['files']
|
||||||
|
break
|
||||||
|
case 'finance':
|
||||||
|
next.finance = vo as RequirementDetailBundle['finance']
|
||||||
|
break
|
||||||
|
case 'afterSales':
|
||||||
|
next.afterSales = vo as RequirementDetailBundle['afterSales']
|
||||||
|
break
|
||||||
|
}
|
||||||
|
detailBundle.value = next
|
||||||
|
current.value = toProjectDetail(next)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn(`[project] 页签「${tab}」数据刷新失败`, error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (seq === tabSeq[tab]) loadingTab.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setQuery(next: ProjectQuery): void {
|
function setQuery(next: ProjectQuery): void {
|
||||||
query.value = next
|
query.value = next
|
||||||
}
|
}
|
||||||
@@ -143,30 +250,35 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传项目文件(追加到当前详情) */
|
/**
|
||||||
async function uploadFile(file: File): Promise<void> {
|
* 上传售后附件(提交前的暂存步骤)。
|
||||||
const info = await uploadProjectFile(file)
|
*
|
||||||
current.value?.files.push(info)
|
* <p>只传 OSS、不落业务表;返回项带 `ossId`,由申请页收集后在提交时下发
|
||||||
|
* `attachmentOssIds`,后端再转存成售后附件。</p>
|
||||||
|
*/
|
||||||
|
async function uploadAfterSalesFile(file: File): Promise<AfterSalesAttachment> {
|
||||||
|
return uploadAfterSalesFileApi(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除当前详情中的项目文件 */
|
/**
|
||||||
async function deleteFile(index: number): Promise<void> {
|
* 申请售后(成功后就地重拉「售后信息」页签)。
|
||||||
await deleteProjectFile(index)
|
*
|
||||||
current.value?.files.splice(index, 1)
|
* <p>不拿返回值本地拼状态:售后状态与工单附件都由后端决定,本地拼容易和真实数据漂移。
|
||||||
|
* 接口失败(如「只有已完成的项目可以申请售后」)会抛出,由统一 request 层弹提示,
|
||||||
|
* 这里不做兜底吞异常。</p>
|
||||||
|
*/
|
||||||
|
async function submitAfterSales(id: string, payload: AfterSalesPayload): Promise<void> {
|
||||||
|
await submitAfterSalesApi(id, payload)
|
||||||
|
ElMessage.success('售后申请已提交')
|
||||||
|
// 详情页在场时同步刷新该页签;从申请页提交时会立刻跳回详情,那边 onMounted 会重新取数
|
||||||
|
if (current.value?.id === id) await refreshTab('afterSales')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 提交售后工单 */
|
/** 撤回售后申请(成功后就地重拉「售后信息」页签) */
|
||||||
async function submitAfterSales(payload: AfterSalesPayload): Promise<void> {
|
async function cancelAfterSales(id: string): Promise<void> {
|
||||||
if (!current.value) return
|
await cancelAfterSalesApi(id)
|
||||||
current.value.afterSales = await submitAfterSalesApi(current.value.id, payload)
|
ElMessage.success('售后申请已撤回')
|
||||||
ElMessage.success('已提交售后工单')
|
if (current.value?.id === id) await refreshTab('afterSales')
|
||||||
}
|
|
||||||
|
|
||||||
/** 取消售后工单 */
|
|
||||||
async function cancelAfterSales(): Promise<void> {
|
|
||||||
if (!current.value) return
|
|
||||||
current.value.afterSales = await cancelAfterSalesApi(current.value.id)
|
|
||||||
ElMessage.success('已取消售后工单')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -177,15 +289,24 @@ export const useProjectStore = defineStore('project', () => {
|
|||||||
typeCounts,
|
typeCounts,
|
||||||
ensureFilterConditions,
|
ensureFilterConditions,
|
||||||
fetchList,
|
fetchList,
|
||||||
|
/** 已完成项目(申请售后页「项目名称」下拉候选) */
|
||||||
|
completedProjects,
|
||||||
|
fetchCompletedProjects,
|
||||||
fetchDetail,
|
fetchDetail,
|
||||||
|
/** 切页签刷新(每次点击都调用,只拉该页签那一支) */
|
||||||
|
refreshTab,
|
||||||
|
/** 正在刷新的页签,供按钮显示加载态 */
|
||||||
|
loadingTab,
|
||||||
setQuery,
|
setQuery,
|
||||||
load,
|
load,
|
||||||
search,
|
search,
|
||||||
resetQuery,
|
resetQuery,
|
||||||
changePage,
|
changePage,
|
||||||
uploadFile,
|
/** 上传售后附件(申请页用) */
|
||||||
deleteFile,
|
uploadAfterSalesFile,
|
||||||
|
/** 申请售后(成功后自动刷新售后页签) */
|
||||||
submitAfterSales,
|
submitAfterSales,
|
||||||
|
/** 撤回售后申请(成功后自动刷新售后页签) */
|
||||||
cancelAfterSales,
|
cancelAfterSales,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,6 +36,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 卡片区块标题(左侧圆头竖条 + 16px 半粗)
|
||||||
|
// 项目详情设计稿规格:竖条 5×20 圆头 @project-accent,标题 16px/600,竖条→文字间距 10px
|
||||||
|
.section-title() {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 15px;
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 20px;
|
||||||
|
color: @heading-color;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 5px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @project-accent;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 表单文本控件(输入框 / 文本域共用外观;需求提报页设计稿规格:白底 + 浅灰描边 + 4px 圆角)
|
// 表单文本控件(输入框 / 文本域共用外观;需求提报页设计稿规格:白底 + 浅灰描边 + 4px 圆角)
|
||||||
.text-control() {
|
.text-control() {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
@font-size-base: 14px;
|
@font-size-base: 14px;
|
||||||
@font-size-lg: 16px;
|
@font-size-lg: 16px;
|
||||||
@font-size-xl: 20px;
|
@font-size-xl: 20px;
|
||||||
|
@font-size-title: 22px; // 详情页主标题(项目详情设计稿 28 设计px × 0.77)
|
||||||
@font-size-xxl: 28px;
|
@font-size-xxl: 28px;
|
||||||
|
|
||||||
// 布局(容器宽度按设计稿标注校准:设计稿为约 1400 宽的整幅版面)
|
// 布局(容器宽度按设计稿标注校准:设计稿为约 1400 宽的整幅版面)
|
||||||
@@ -74,3 +75,12 @@
|
|||||||
// 项目中心(按项目中心设计稿标注校准)
|
// 项目中心(按项目中心设计稿标注校准)
|
||||||
@project-table-head-bg: #e8e8e8; // 表头底色(设计稿实取 (232,232,232))
|
@project-table-head-bg: #e8e8e8; // 表头底色(设计稿实取 (232,232,232))
|
||||||
@project-table-line: #bbbbbb; // 表格网格线(设计稿实取 (187,187,187))
|
@project-table-line: #bbbbbb; // 表格网格线(设计稿实取 (187,187,187))
|
||||||
|
|
||||||
|
// 项目详情(按 docs/prototype/项目中心-演示.html 设计稿校准)
|
||||||
|
// ⚠️ 换算系数:设计画布内容宽 1776px → 应用内容宽 1368px(.container 1400 − 2×16),
|
||||||
|
// 即 设计px × 0.77 ≈ CSS px。下列数值 = 设计稿实测值 × 0.77 后取整。
|
||||||
|
@project-accent: #2563eb; // 竖条 / 状态文字 / 进度条 / 激活页签 / 里程碑进行中(设计稿实取 rgba(37,99,235,1))
|
||||||
|
@project-track: #e8e8e8; // 进度条轨道(设计稿实取)
|
||||||
|
@milestone-done: #34c724; // 里程碑已完成圆点
|
||||||
|
@milestone-idle: #cecece; // 里程碑待开始圆环 + 连接线(设计稿实取)
|
||||||
|
@team-avatar-bg: #54bcbd; // 团队头像底色(设计稿实取)
|
||||||
|
|||||||
204
src/types/business/project.d.ts
vendored
204
src/types/business/project.d.ts
vendored
@@ -130,34 +130,92 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 售后提交入参 */
|
/**
|
||||||
export interface AfterSalesPayload {
|
* 售后附件(已上传到 OSS,携带 ossId 以便提交时由后端转存)。
|
||||||
type: string
|
*
|
||||||
description: string
|
* <p>上传走需求提报的同一个 OSS 上传口(`POST /web/workOrderDraft/upload`),
|
||||||
remark: string
|
* 提交时只传 `ossId` 列表,后端按 `phase = AFTER_SALE`、`target_id = 售后工单 id`
|
||||||
attachments: FileInfo[]
|
* 写进 `vm_project_requirement_attachment`,OSS 文件不重复上传。</p>
|
||||||
|
*/
|
||||||
|
export interface AfterSalesAttachment extends FileInfo {
|
||||||
|
/** OSS 主键(提交时进 `attachmentOssIds`) */
|
||||||
|
ossId: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 项目详情(**详情页仍是前端 mock 形态**,尚未接后端)。
|
* OSS 上传返回(后端 `SysOssVo` 的子集)—— 与需求提报共用同一个上传口。
|
||||||
*
|
*
|
||||||
* <p>列表已改走真实接口(见 {@link RdProjectVo}),本类型只服务详情页那几个组件。</p>
|
* <p>⚠️ `fileSuffix` 带前导点(`.pdf`);响应里**没有**文件大小,大小要取本地 `File.size`。</p>
|
||||||
|
*/
|
||||||
|
export interface AfterSalesUploadVo {
|
||||||
|
ossId: string | number
|
||||||
|
url: string
|
||||||
|
originalName: string
|
||||||
|
fileSuffix?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 售后申请入参 —— **对齐后端 `ProjectRequirementActionBo`**。
|
||||||
|
*
|
||||||
|
* <p>字段名与页面语义的对应:页面「问题描述」→ {@link reason}、「补充备注」→ {@link note}。</p>
|
||||||
|
*/
|
||||||
|
export interface AfterSalesPayload {
|
||||||
|
/** 售后类型(下拉 8 项之一),最长 50 */
|
||||||
|
type: string
|
||||||
|
/** 问题描述 → 后端 `reason`,最长 500 */
|
||||||
|
reason: string
|
||||||
|
/** 补充备注 → 后端 `note`(页面限 500,后端 BO 上限 2000) */
|
||||||
|
note: string
|
||||||
|
/** 附件 OSS 主键集合(空数组安全,后端 null 会走空判断) */
|
||||||
|
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
|
||||||
progress: number
|
progress: number
|
||||||
status: ProjectStatus
|
status: ProjectStatus
|
||||||
submitTime: string
|
submitTime: string
|
||||||
@@ -185,16 +243,16 @@ export interface ProjectTypeCount {
|
|||||||
/**
|
/**
|
||||||
* 项目列表查询条件(**已是后端口径**)。
|
* 项目列表查询条件(**已是后端口径**)。
|
||||||
*
|
*
|
||||||
* <p>与 `RdProjectBo` 的字段对应关系:`keyword → key`、`businessType → businessType`(提报表 id)、
|
* <p>与 `RdProjectBo` 的字段对应关系:`keyword → key`、`businessTypes → businessTypes`(提报表 id,多选 IN)、
|
||||||
* `status → status`(码值)、`startDate/endDate 或 timeShortcut → beginTime/endTime`(两者都落在
|
* `status → status`(码值)、`startDate/endDate 或 timeShortcut → beginTime/endTime`(两者都落在
|
||||||
* `create_time` 上)、`pageNum/pageSize → PageQuery`。</p>
|
* `create_time` 上)、`pageNum/pageSize → PageQuery`。</p>
|
||||||
*
|
*
|
||||||
* <p>用空串表示「不限」而不是 'all'——空串直接不出现在 querystring 里,与后端 `eqIfText` 的语义一致。</p>
|
* <p>用空串 / 空数组表示「不限」而不是 'all'——空值直接不出现在 querystring 里,与后端 `eqIfText` 的语义一致。</p>
|
||||||
*/
|
*/
|
||||||
export interface ProjectQuery {
|
export interface ProjectQuery {
|
||||||
keyword: string
|
keyword: string
|
||||||
/** '' = 全部;否则为后端统计下发的 businessTypeId */
|
/** 空数组 = 全部;元素为后端统计下发的 businessTypeId(**多选**,后端走 IN) */
|
||||||
businessType: string
|
businessTypes: string[]
|
||||||
/** '' = 全部;否则为 {@link RdProjectStatus} 码值 */
|
/** '' = 全部;否则为 {@link RdProjectStatus} 码值 */
|
||||||
status: string
|
status: string
|
||||||
/** 提交时间快捷区间;与 startDate/endDate 互斥(选其一会把另一组清空) */
|
/** 提交时间快捷区间;与 startDate/endDate 互斥(选其一会把另一组清空) */
|
||||||
@@ -208,12 +266,22 @@ export interface ProjectQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// 详情契约:后端 `ProjectRequirementUserVo`(GET /web/requirementPool/get/{id})
|
// 详情契约:后端 4 个页签接口(都继承 ProjectRequirementBaseVo)
|
||||||
|
// GET /web/requirementPool/get/progress/{id} → ProjectRequirementProgressVo
|
||||||
|
// GET /web/requirementPool/get/files/{id} → ProjectRequirementFilesVo
|
||||||
|
// GET /web/requirementPool/get/finance/{id} → ProjectRequirementFinanceVo
|
||||||
|
// GET /web/requirementPool/get/afterSales/{id} → ProjectRequirementAfterSalesVo
|
||||||
//
|
//
|
||||||
// 与列表不同,详情是「需求表为主 + 各子表按需填充」:需求未立项时 `vm_rd_project`
|
// 为什么拆 4 个:详情页本就是 4 个页签,单接口要一次查全 8 张表;拆开后可「按页签取数」。
|
||||||
// 没有对应行,项目侧字段(projectStage / projectMilestones / members / billPlans /
|
// 4 个 VO 都带基础信息(继承 `ProjectRequirementBaseVo`)⇒ 任一页签的响应里都有项目名 /
|
||||||
// invoices / contracts)一律为空,需求侧字段(基本信息 / requirementAttachments /
|
// 编号 / 业务类型 / 状态 / 提交时间,所以前端可以只用 `progress` 那一支渲染顶部信息卡,
|
||||||
// afterSale)照常返回。
|
// 并保证切页签后信息卡不缺数据(合并逻辑见 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`) */
|
/** 开发进度(后端 `ProjectStage`,表 `vm_project_stage`) */
|
||||||
@@ -275,7 +343,8 @@ export interface RequirementBillPlan {
|
|||||||
projectId: string | null
|
projectId: string | null
|
||||||
projectNo: string | null
|
projectNo: string | null
|
||||||
projectName: string | null
|
projectName: string | null
|
||||||
totalAmount: number | null
|
/** ⚠️ BigDecimal ⇒ 后端以**字符串**下发("120000.00"),用前必须 `Number()` 归一 */
|
||||||
|
totalAmount: string | number | null
|
||||||
periods: number | null
|
periods: number | null
|
||||||
currentPeriod: number | null
|
currentPeriod: number | null
|
||||||
periodRule: string | null
|
periodRule: string | null
|
||||||
@@ -290,7 +359,8 @@ export interface RequirementInvoice {
|
|||||||
invoiceTitle: string | null
|
invoiceTitle: string | null
|
||||||
taxNo: string | null
|
taxNo: string | null
|
||||||
invoiceType: string | null
|
invoiceType: string | null
|
||||||
amount: number | null
|
/** ⚠️ BigDecimal ⇒ 字符串下发,用前 `Number()` 归一 */
|
||||||
|
amount: string | number | null
|
||||||
status: string | null
|
status: string | null
|
||||||
invoiceNo: string | null
|
invoiceNo: string | null
|
||||||
/** 电子发票文件地址 */
|
/** 电子发票文件地址 */
|
||||||
@@ -307,7 +377,8 @@ export interface RequirementContract {
|
|||||||
contractNo: string | null
|
contractNo: string | null
|
||||||
projectId: string | null
|
projectId: string | null
|
||||||
title: string | null
|
title: string | null
|
||||||
amount: number | null
|
/** ⚠️ BigDecimal ⇒ 字符串下发,用前 `Number()` 归一 */
|
||||||
|
amount: string | number | null
|
||||||
signStatus: string | null
|
signStatus: string | null
|
||||||
status: string | null
|
status: string | null
|
||||||
signDate: string | null
|
signDate: string | null
|
||||||
@@ -321,8 +392,21 @@ export interface RequirementContract {
|
|||||||
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
|
||||||
|
/** 售后原因(用户在申请页填的「问题描述」) */
|
||||||
reason: string | null
|
reason: 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
|
||||||
@@ -333,40 +417,74 @@ export interface RequirementAfterSale {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 需求详情响应(后端 `ProjectRequirementUserVo`)。
|
* 4 个页签接口的公共基础信息(后端 `ProjectRequirementBaseVo`)。
|
||||||
*
|
*
|
||||||
* <p>注意两个「名字骗人」的字段:`attachments` / `afterSales`(复数)后端**从未赋值**,
|
* <p>字段全部来自需求表 `vm_project_requirement`,**与是否立项无关**,任意页签都填充。</p>
|
||||||
* 真正有数据的是 `requirementAttachments` 与 `afterSale`(单数)。前端只取后者。</p>
|
|
||||||
*/
|
*/
|
||||||
export interface RequirementDetailVo {
|
export interface RequirementBaseVo {
|
||||||
|
/** 需求 id(= 详情接口路径参数 = 列表行 RdProjectVo.id) */
|
||||||
id: string
|
id: string
|
||||||
projectNo: string | null
|
projectNo: string | null
|
||||||
submitUserId: string | null
|
projectName: string | null
|
||||||
submitUserName: string | null
|
|
||||||
submitPhone: string | null
|
|
||||||
/** 业务类型**中文名**(如「硬件电路」),非英文 code */
|
/** 业务类型**中文名**(如「硬件电路」),非英文 code */
|
||||||
businessType: string | null
|
businessType: string | null
|
||||||
projectName: string | null
|
|
||||||
requirementDesc: string | null
|
|
||||||
priority: string | null
|
|
||||||
/** 状态码值:需求侧 INITIATING/CANCELLED,项目侧 IN_PROGRESS/… */
|
/** 状态码值:需求侧 INITIATING/CANCELLED,项目侧 IN_PROGRESS/… */
|
||||||
status: string | null
|
status: string | null
|
||||||
customerServiceName: string | null
|
/** 提交时间 */
|
||||||
customerServicePhone: string | null
|
|
||||||
technicalOwnerName: string | null
|
|
||||||
internalRemark: string | null
|
|
||||||
attachmentCount: number | null
|
|
||||||
createTime: string | null
|
createTime: string | null
|
||||||
updateTime: string | null
|
/** 提交人名称 */
|
||||||
launchNote: string | null
|
submitUserName: string | null
|
||||||
cancelReason: string | null
|
}
|
||||||
/** 未立项时为 null */
|
|
||||||
|
/** 「项目进度」页签(后端 `ProjectRequirementProgressVo`):基础信息 + 开发进度 + 里程碑 + 团队 */
|
||||||
|
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
|
||||||
projectMilestones: RequirementMilestone[]
|
projectMilestones: RequirementMilestone[]
|
||||||
requirementAttachments: RequirementAttachment[]
|
/** 项目团队;未立项为空数组 */
|
||||||
members: RequirementMember[]
|
members: RequirementMember[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「项目文件」页签(后端 `ProjectRequirementFilesVo`):基础信息 + 需求附件 */
|
||||||
|
export interface RequirementFilesVo extends RequirementBaseVo {
|
||||||
|
/** 需求附件;与是否立项无关,始终填充 */
|
||||||
|
requirementAttachments: RequirementAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「合同财务」页签(后端 `ProjectRequirementFinanceVo`):基础信息 + 付款计划 + 发票 + 合同 */
|
||||||
|
export interface RequirementFinanceVo extends RequirementBaseVo {
|
||||||
billPlans: RequirementBillPlan[]
|
billPlans: RequirementBillPlan[]
|
||||||
invoices: RequirementInvoice[]
|
invoices: RequirementInvoice[]
|
||||||
contracts: RequirementContract[]
|
contracts: RequirementContract[]
|
||||||
afterSale: RequirementAfterSale | null
|
}
|
||||||
|
|
||||||
|
/** 「售后信息」页签(后端 `ProjectRequirementAfterSalesVo`):基础信息 + 售后工单 + 工单附件 */
|
||||||
|
export interface RequirementAfterSalesVo extends RequirementBaseVo {
|
||||||
|
/** 售后工单;无则 null */
|
||||||
|
afterSale: RequirementAfterSale | null
|
||||||
|
/**
|
||||||
|
* 售后工单附件 —— 与需求附件同表 `vm_project_requirement_attachment`,
|
||||||
|
* 靠 `phase = AFTER_SALE` + `target_id = 售后工单 id` 区分。
|
||||||
|
* 后端无条件下发(无工单时为空数组),前端直接渲染即可。
|
||||||
|
*/
|
||||||
|
afterSaleAttachments: RequirementAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情页四接口聚合结果。
|
||||||
|
*
|
||||||
|
* <p>`api/project.ts#getProjectDetail` 同帧并行取回 4 个页签后交给
|
||||||
|
* `utils/projectVo.ts#toProjectDetail` 合并成组件契约 {@link Project};
|
||||||
|
* 任一接口返回 null(需求不存在 / 非本人)即整页空态。</p>
|
||||||
|
*/
|
||||||
|
export interface RequirementDetailBundle {
|
||||||
|
progress: RequirementProgressVo
|
||||||
|
files: RequirementFilesVo
|
||||||
|
finance: RequirementFinanceVo
|
||||||
|
afterSales: RequirementAfterSalesVo
|
||||||
}
|
}
|
||||||
|
|||||||
65
src/types/web/home.d.ts
vendored
65
src/types/web/home.d.ts
vendored
@@ -1,65 +0,0 @@
|
|||||||
// 后端「用户web端首页」接口真实返回类型
|
|
||||||
// 对应 vermis-maker: com.ruoyi.vermins.controller.pc.web.UserSideWebHomePageController
|
|
||||||
// 统一前缀: /web/home-page (公开接口,无需登录)
|
|
||||||
// 字段与后端 VO 严格对齐(驼峰、可空用 | null)。
|
|
||||||
|
|
||||||
/** GET /web/home-page/project-status-count —— 项目状态统计 */
|
|
||||||
export interface ProjectStatusCount {
|
|
||||||
/** 项目数量 */
|
|
||||||
projectCount: number
|
|
||||||
/** 进行中的项目数目 */
|
|
||||||
ongoingProjectCount: number
|
|
||||||
/** 待处理数目 */
|
|
||||||
pendingProjectCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 服务类型子项(service-type-list / service-type-detail 内含) */
|
|
||||||
export interface ProjectServiceTypeVo {
|
|
||||||
id: number | null
|
|
||||||
projectId: number | null
|
|
||||||
serviceName: string | null
|
|
||||||
iconUrl: string | null
|
|
||||||
sortOrder: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 项目背景(富文本) */
|
|
||||||
export interface ProjectBackgroundVo {
|
|
||||||
id: number | null
|
|
||||||
projectId: number | null
|
|
||||||
content: string | null
|
|
||||||
sortOrder: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 项目提报视图对象:service-type-list / service-type-detail/{id} 的返回 */
|
|
||||||
export interface ProjectSubmissionVo {
|
|
||||||
id: number
|
|
||||||
businessType: string | null
|
|
||||||
title: string | null
|
|
||||||
subtitle: string | null
|
|
||||||
/** 是否启用:1 启用,0 停用 */
|
|
||||||
enabled: number | null
|
|
||||||
coverUrl: string | null
|
|
||||||
logoUrl: string | null
|
|
||||||
serviceTypes: ProjectServiceTypeVo[]
|
|
||||||
backgrounds: ProjectBackgroundVo[]
|
|
||||||
/** yyyy-MM-dd HH:mm:ss */
|
|
||||||
updateTime: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /web/home-page/carousel-list —— 轮播图 */
|
|
||||||
export interface BannerVo {
|
|
||||||
id: number | null
|
|
||||||
imageUrl: string | null
|
|
||||||
sort: number | null
|
|
||||||
/** 是否启用(true 启用,false 停用) */
|
|
||||||
enabled: boolean | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /web/home-page/platform-intro、contact-info —— 协议/静态文章富文本 */
|
|
||||||
export interface AgreementFormVo {
|
|
||||||
id: number | null
|
|
||||||
/** 协议类型(目前只有「关于我们」一条) */
|
|
||||||
type: string | null
|
|
||||||
/** 文章内容(富文本 HTML) */
|
|
||||||
content: string | null
|
|
||||||
}
|
|
||||||
@@ -34,9 +34,17 @@ export function formatBusinessType(type: BusinessType): string {
|
|||||||
return BUSINESS_TYPE_LABELS[type]
|
return BUSINESS_TYPE_LABELS[type]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 金额千分位(默认两位小数) */
|
/**
|
||||||
export function formatMoney(value: number, decimals = 2): string {
|
* 金额千分位(默认两位小数)。
|
||||||
const fixed = value.toFixed(decimals)
|
*
|
||||||
|
* <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 {
|
||||||
|
const num = Number(value)
|
||||||
|
const fixed = (Number.isFinite(num) ? num : 0).toFixed(decimals)
|
||||||
const [int = '0', fraction] = fixed.split('.')
|
const [int = '0', fraction] = fixed.split('.')
|
||||||
const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
return fraction ? `${withSep}.${fraction}` : withSep
|
return fraction ? `${withSep}.${fraction}` : withSep
|
||||||
|
|||||||
@@ -9,32 +9,43 @@ import type {
|
|||||||
RequirementAttachment,
|
RequirementAttachment,
|
||||||
RequirementBillPlan,
|
RequirementBillPlan,
|
||||||
RequirementContract,
|
RequirementContract,
|
||||||
RequirementDetailVo,
|
RequirementDetailBundle,
|
||||||
RequirementInvoice,
|
RequirementInvoice,
|
||||||
|
RequirementMember,
|
||||||
TeamMember,
|
TeamMember,
|
||||||
} from '@/types/business/project'
|
} from '@/types/business/project'
|
||||||
import { toBusinessType } from '@/utils/format'
|
import { toBusinessType } from '@/utils/format'
|
||||||
import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta'
|
import { PROJECT_STATUS_LABEL } from '@/utils/projectMeta'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 需求详情(后端 `ProjectRequirementUserVo`)→ 详情页 `Project`。
|
* 需求详情(后端 **4 个页签接口**的聚合结果 `RequirementDetailBundle`)→ 详情页 `Project`。
|
||||||
*
|
*
|
||||||
* <p>为什么不直接改组件:详情页 6 个子组件(InfoCard/Team/Milestones/Files/Finance/
|
* <p>为什么不直接改组件:详情页 6 个子组件(InfoCard/Team/Milestones/Files/Finance/
|
||||||
* AfterSales)都按 `Project` 这套字段写的,改数据来源不动组件契约,改动面最小。</p>
|
* AfterSales)都按 `Project` 这套字段写的,改数据来源不动组件契约,改动面最小。</p>
|
||||||
*
|
*
|
||||||
|
* <p><b>基础信息取哪一支都行</b>:4 个页签 VO 都继承 `ProjectRequirementBaseVo` 且同源
|
||||||
|
* (需求表同一行),这里统一取 `bundle.progress` 那一支当基础信息(UI 上它最先渲染)。</p>
|
||||||
|
*
|
||||||
* <p><b>未立项是常态</b>:需求在 `vm_rd_project` 里没有对应行时,后端返回的
|
* <p><b>未立项是常态</b>:需求在 `vm_rd_project` 里没有对应行时,后端返回的
|
||||||
* `projectStage` / `projectMilestones` / `members` / `billPlans` / `invoices` / `contracts`
|
* `projectStage` / `projectMilestones` / `members` / `billPlans` / `invoices` / `contracts`
|
||||||
* 全为空 —— 这里一律兜底成空数组 / 0 / undefined,详情页显示为空态,不报错。</p>
|
* 全为空 —— 这里一律兜底成空数组 / 0 / undefined,详情页显示为空态,不报错。</p>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态码值 → 详情页中文。
|
* 状态 → 详情页中文。
|
||||||
*
|
*
|
||||||
* <p>码值域是两张表的并集(见 {@link ProjectStatus}):需求侧 `INITIATING`/`CANCELLED`,
|
* <p>⚠️ 后端 `selectUserProjectPage` / 详情侧已用 `sys_dict_data`(`vm_requirement_status`)
|
||||||
* 项目侧 `IN_PROGRESS`/`PENDING_ACCEPTANCE`/… 未知码值兜底「待立项」(未立项是最常见态)。</p>
|
* 把状态翻成**中文**下发(如「进行中」),但码值(`INITIATING` 等)也可能出现
|
||||||
|
* (直接查子表、或字典缺项时)⇒ 两种口径都要认,认不出的一律兜底「待立项」(未立项最常见)。</p>
|
||||||
*/
|
*/
|
||||||
|
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 label = PROJECT_STATUS_LABEL[raw ?? '']
|
const value = (raw ?? '').trim()
|
||||||
|
if (!value) return '待立项'
|
||||||
|
// 先按码值查表;后端已是中文名时直接采信
|
||||||
|
const label = PROJECT_STATUS_LABEL[value]
|
||||||
|
?? (PROJECT_STATUS_LABELS.includes(value) ? value : undefined)
|
||||||
return (label as ProjectStatus | undefined) ?? '待立项'
|
return (label as ProjectStatus | undefined) ?? '待立项'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,16 +65,48 @@ function toMilestoneStatus(raw: string | null | undefined): Milestone['status']
|
|||||||
/**
|
/**
|
||||||
* 账单/付款计划状态 → 三态。
|
* 账单/付款计划状态 → 三态。
|
||||||
*
|
*
|
||||||
* <p>⚠️ 后端 `BillPlan.status` 的码值语义**未确认**(`vm_bill_plan` 当前 0 行,无样本),
|
* <p>⚠️ 数据源是 `vm_bill_plan`,实体注释的值域只有 **ACTIVE / DONE / CLOSED**
|
||||||
* 只认字面量,其余一律「未到期」——不臆造成「已支付」。</p>
|
* (不是 `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('支付')) return '已支付'
|
// ⚠️ 关键词别用宽泛的「支付」:中文「**待**支付」也包含它,会先被这里截走误判成「已支付」。
|
||||||
if (value.includes('WAIT') || value.includes('PENDING') || value.includes('待支付')) return '待支付'
|
if (value.includes('PAID') || value.includes('DONE') || value.includes('CLOSE')
|
||||||
|
|| value.includes('已支付')) return '已支付'
|
||||||
|
if (value.includes('ACTIVE') || value.includes('WAIT') || value.includes('PENDING')
|
||||||
|
|| value.includes('待支付')) return '待支付'
|
||||||
|
if (value.includes('UNSTART') || value.includes('NOT_DUE') || value.includes('未到期')
|
||||||
|
|| value.includes('未开始')) return '未到期'
|
||||||
|
// 认不出的一律「未到期」——不臆造成「已支付」(那是更坏的错)
|
||||||
return '未到期'
|
return '未到期'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端数值字段归一(BigDecimal 会以**字符串**下发,如 `"120000.00"`)。
|
||||||
|
*
|
||||||
|
* <p>直接当 number 用会崩:`formatMoney('120000.00')` → `value.toFixed is not a function`
|
||||||
|
* ⇒ 组件渲染失败、整个页签白屏(实测「合同财务」页签)。</p>
|
||||||
|
*/
|
||||||
|
function toNumber(value: number | string | null | undefined, fallback = 0): number {
|
||||||
|
const num = Number(value)
|
||||||
|
return Number.isFinite(num) ? num : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LocalDateTime 字符串 → 设计稿口径的「日」。
|
||||||
|
*
|
||||||
|
* <p>后端下发 `2026-09-03 00:00:00`(或 ISO `…T00:00:00`),两种都能截前 10 位;
|
||||||
|
* 空值返回 undefined,交给组件显示「—」。</p>
|
||||||
|
*/
|
||||||
|
function toDate(value: string | null | undefined): string | undefined {
|
||||||
|
return value ? value.slice(0, 10) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
/** 需求附件 → 详情页文件项(文件 tab 用) */
|
/** 需求附件 → 详情页文件项(文件 tab 用) */
|
||||||
function attachmentToFile(item: RequirementAttachment): FileInfo {
|
function attachmentToFile(item: RequirementAttachment): FileInfo {
|
||||||
return {
|
return {
|
||||||
@@ -90,15 +133,17 @@ function contractToFile(item: RequirementContract): FileInfo {
|
|||||||
name: item.title ?? item.contractNo ?? '合同文件',
|
name: item.title ?? item.contractNo ?? '合同文件',
|
||||||
size: 0,
|
size: 0,
|
||||||
url: item.file ?? '',
|
url: item.file ?? '',
|
||||||
uploadTime: item.signDate ?? item.createTime ?? '',
|
// signDate 是 DATE 列,后端会下发成 '2026-08-20 00:00:00' ⇒ 只保留到日
|
||||||
|
uploadTime: toDate(item.signDate ?? item.createTime) ?? '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 付款计划。
|
* 付款计划。
|
||||||
*
|
*
|
||||||
* <p>`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用 `periodRule`(付不出
|
* <p>`BillPlan` 没有「单据号」也没有「期次名」,所以:`planName` 用「第 N 期/共 M 期」
|
||||||
* 就用「第 N 期/共 M 期」拼),`billNo` 回落到 `projectNo`。</p>
|
* (**期次优先**,`periodRule` 的 '30,30,40' 只作兜底——直接把比例当名字显示不可读),
|
||||||
|
* `billNo` 回落到 `projectNo`。</p>
|
||||||
*/
|
*/
|
||||||
function toProjectBill(item: RequirementBillPlan): ProjectBill {
|
function toProjectBill(item: RequirementBillPlan): ProjectBill {
|
||||||
const period = item.currentPeriod != null && item.periods != null
|
const period = item.currentPeriod != null && item.periods != null
|
||||||
@@ -106,16 +151,16 @@ function toProjectBill(item: RequirementBillPlan): ProjectBill {
|
|||||||
: ''
|
: ''
|
||||||
return {
|
return {
|
||||||
id: String(item.id),
|
id: String(item.id),
|
||||||
planName: item.periodRule || period || '付款计划',
|
planName: period || item.periodRule || '付款计划',
|
||||||
billNo: item.projectNo ?? '',
|
billNo: item.projectNo ?? '',
|
||||||
amount: item.totalAmount ?? 0,
|
amount: toNumber(item.totalAmount),
|
||||||
status: toBillStatus(item.status),
|
status: toBillStatus(item.status),
|
||||||
projectName: item.projectName ?? '',
|
projectName: item.projectName ?? '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 项目成员 → 详情页团队成员卡 */
|
/** 项目成员 → 详情页团队成员卡 */
|
||||||
function toTeamMember(item: RequirementDetailVo['members'][number]): TeamMember {
|
function toTeamMember(item: RequirementMember): TeamMember {
|
||||||
const name = item.userName ?? ''
|
const name = item.userName ?? ''
|
||||||
return {
|
return {
|
||||||
role: item.roleName ?? (item.isOwner ? '项目负责人' : '项目成员'),
|
role: item.roleName ?? (item.isOwner ? '项目负责人' : '项目成员'),
|
||||||
@@ -127,48 +172,71 @@ function toTeamMember(item: RequirementDetailVo['members'][number]): TeamMember
|
|||||||
/**
|
/**
|
||||||
* 售后工单。
|
* 售后工单。
|
||||||
*
|
*
|
||||||
* <p>详情页 `AfterSales` 只有「无 / 处理中」两态,且 `type`(问题类型)后端没有对应字段
|
* <p>详情页 `AfterSales` 只有「无 / 处理中」两态:撤回(WITHDRAWN)/ 作废 / 关闭 ⇒ 「无」
|
||||||
* ⇒ 留空,`description` 用售后原因、`remark` 用处理说明。</p>
|
* (页面回到可再次申请的状态),其余(REQUESTED / IN_PROGRESS / COMPLETED)都算「处理中」。</p>
|
||||||
|
*
|
||||||
|
* <p>字段对应:`type` = 售后类型、`description` = 用户填的问题描述(后端 `reason`)、
|
||||||
|
* `remark` = 用户补充备注(后端 `remark`,不是处理人写的 `processNote`)、
|
||||||
|
* `attachments` = 售后工单附件(同表 `phase = AFTER_SALE`,由后端随页签下发)。</p>
|
||||||
*/
|
*/
|
||||||
function toAfterSales(item: RequirementAfterSale): AfterSales {
|
function toAfterSales(
|
||||||
|
item: RequirementAfterSale,
|
||||||
|
attachments: RequirementAttachment[] = [],
|
||||||
|
): AfterSales {
|
||||||
const value = (item.status ?? '').toUpperCase()
|
const value = (item.status ?? '').toUpperCase()
|
||||||
const closed = value.includes('CLOSE') || value.includes('CANCEL') || value.includes('WITHDRAW')
|
const closed = value.includes('CLOSE') || value.includes('CANCEL') || value.includes('WITHDRAW')
|
||||||
return {
|
return {
|
||||||
status: closed ? '无' : '处理中',
|
status: closed ? '无' : '处理中',
|
||||||
type: '',
|
type: item.type ?? '',
|
||||||
description: item.reason ?? '',
|
description: item.reason ?? '',
|
||||||
remark: item.processNote ?? '',
|
remark: item.remark ?? '',
|
||||||
attachments: [],
|
attachments: attachments.map(attachmentToFile),
|
||||||
createTime: item.requestTime ?? '',
|
createTime: item.requestTime ?? '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 详情接口返回体 → 详情页数据模型(未立项时项目侧字段自动为空) */
|
/** 4 个页签接口的聚合结果 → 详情页数据模型(未立项时项目侧字段自动为空) */
|
||||||
export function toProjectDetail(vo: RequirementDetailVo): Project {
|
export function toProjectDetail(bundle: RequirementDetailBundle): Project {
|
||||||
const stage = vo.projectStage
|
const { progress, files, finance, afterSales } = bundle
|
||||||
const contract = vo.contracts?.[0]
|
// 基础信息 4 支同源(需求表同一行),统一取 progress 那一支
|
||||||
|
const base = progress
|
||||||
|
const { projectStage: stage } = progress
|
||||||
|
const contract = finance.contracts?.[0]
|
||||||
return {
|
return {
|
||||||
id: String(vo.id),
|
id: String(base.id),
|
||||||
projectNo: vo.projectNo ?? '',
|
projectNo: base.projectNo ?? '',
|
||||||
projectName: vo.projectName ?? '',
|
projectName: base.projectName ?? '',
|
||||||
// 后端下发的是中文类型名(如「硬件电路」),归一化成前端 code;'0' 之类脏值归 other
|
// 后端下发的是中文类型名(如「硬件电路」),归一化成前端 code;'0' 之类脏值归 other
|
||||||
businessType: toBusinessType(vo.businessType),
|
businessType: toBusinessType(base.businessType),
|
||||||
// 列表页的 progress 就是详情侧 developmentProgress;未立项无进度 ⇒ 0
|
// 同时保留原样名称:前端枚举只有 5 项,后台新增类型时会全落成「其他」,展示要用原名
|
||||||
progress: stage?.developmentProgress ?? 0,
|
businessTypeName: base.businessType ?? '',
|
||||||
status: toProjectStatus(vo.status),
|
// 总体进度百分比 —— 后端 `developmentProgress` 就是列表那个 progress(同源),
|
||||||
submitTime: vo.createTime ?? '',
|
// 它缺失时才回落到阶段表的 development_progress;未立项两处都没有 ⇒ 0
|
||||||
startTime: stage?.startTime ?? undefined,
|
progress: progress.developmentProgress ?? stage?.developmentProgress ?? 0,
|
||||||
endTime: stage?.finishTime ?? stage?.planTime ?? undefined,
|
status: toProjectStatus(base.status),
|
||||||
milestones: (vo.projectMilestones ?? []).map((item) => ({
|
submitTime: base.createTime ?? '',
|
||||||
|
startTime: toDate(stage?.startTime),
|
||||||
|
endTime: toDate(stage?.finishTime ?? stage?.planTime),
|
||||||
|
// ⚠️ 后端里程碑/团队都是 `selectList(...eq(projectId))`,**没有 orderBy** ⇒ 返回顺序不保证
|
||||||
|
// (实测同一项目返回 id 5,8,6,7)。里程碑顺序会直接毁掉时间线语义,必须按 sortOrder 归位;
|
||||||
|
// 团队同理,项目负责人排最前。
|
||||||
|
milestones: [...(progress.projectMilestones ?? [])]
|
||||||
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
|
.map((item) => ({
|
||||||
name: item.milestoneName ?? '',
|
name: item.milestoneName ?? '',
|
||||||
status: toMilestoneStatus(item.status),
|
status: toMilestoneStatus(item.status),
|
||||||
time: item.startTime ?? undefined,
|
// 后端是 LocalDateTime('2026-09-03 00:00:00'),设计稿只到「日」
|
||||||
|
time: toDate(item.startTime),
|
||||||
})),
|
})),
|
||||||
team: (vo.members ?? []).map(toTeamMember),
|
team: [...(progress.members ?? [])]
|
||||||
files: (vo.requirementAttachments ?? []).map(attachmentToFile),
|
.sort((a, b) => Number(b.isOwner ?? false) - Number(a.isOwner ?? false))
|
||||||
paymentPlans: (vo.billPlans ?? []).map(toProjectBill),
|
.map(toTeamMember),
|
||||||
invoiceRecords: (vo.invoices ?? []).map(invoiceToFile),
|
files: (files.requirementAttachments ?? []).map(attachmentToFile),
|
||||||
|
paymentPlans: (finance.billPlans ?? []).map(toProjectBill),
|
||||||
|
invoiceRecords: (finance.invoices ?? []).map(invoiceToFile),
|
||||||
contractFile: contract ? contractToFile(contract) : undefined,
|
contractFile: contract ? contractToFile(contract) : undefined,
|
||||||
afterSales: vo.afterSale ? toAfterSales(vo.afterSale) : undefined,
|
afterSales: afterSales.afterSale
|
||||||
|
? toAfterSales(afterSales.afterSale, afterSales.afterSaleAttachments ?? [])
|
||||||
|
: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { BusinessType } from '@/types/api'
|
|
||||||
import type { Demand } from '@/types/business/demand'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
demand: Demand
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{ select: [businessType: BusinessType] }>()
|
|
||||||
|
|
||||||
interface BusinessOption {
|
|
||||||
value: BusinessType
|
|
||||||
name: string
|
|
||||||
desc: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const options: BusinessOption[] = [
|
|
||||||
{ value: 'internet', name: '互联网软件', desc: 'Web/小程序/APP/二次开发/桌面工具等' },
|
|
||||||
{ value: 'iot', name: '物联网全案', desc: '软硬件开发+云端一体化/定制化开发等' },
|
|
||||||
{ value: 'manufacturing', name: '生产制造', desc: 'PCB制版/SMT/整机装配/定制生产等' },
|
|
||||||
{ value: 'hardware', name: '硬件电路', desc: '原理图设计/PCB设计/电气效验等' },
|
|
||||||
{ value: 'other', name: '其他', desc: '若没有对应的服务内容请选择该选项' },
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="demand-step1">
|
|
||||||
<p class="demand-step1__title">
|
|
||||||
请选择业务类型
|
|
||||||
</p>
|
|
||||||
<div class="demand-step1__grid">
|
|
||||||
<button
|
|
||||||
v-for="option in options"
|
|
||||||
:key="option.value"
|
|
||||||
class="demand-step1__card"
|
|
||||||
:class="{ 'is-active': demand.businessType === option.value }"
|
|
||||||
@click="emit('select', option.value)"
|
|
||||||
>
|
|
||||||
<span class="demand-step1__name">{{ option.name }}</span>
|
|
||||||
<span class="demand-step1__desc">{{ option.desc }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.demand-step1 {
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-base;
|
|
||||||
|
|
||||||
&__title {
|
|
||||||
font-size: @font-size-lg;
|
|
||||||
font-weight: 600;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__grid {
|
|
||||||
.flex(row, flex-start);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: @spacing-base;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__card {
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-xs;
|
|
||||||
width: calc((100% - 32px) / 3);
|
|
||||||
min-width: 220px;
|
|
||||||
padding: @spacing-base;
|
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: @radius-base;
|
|
||||||
background: @white;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: @primary-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-active {
|
|
||||||
border-color: @primary-color;
|
|
||||||
background: @primary-light;
|
|
||||||
box-shadow: @shadow-base;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__name {
|
|
||||||
font-size: @font-size-lg;
|
|
||||||
font-weight: 600;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__desc {
|
|
||||||
font-size: @font-size-sm;
|
|
||||||
color: @text-color-secondary;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__card.is-active &__name {
|
|
||||||
color: @primary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { FileList, UploadArea } from '@/components'
|
|
||||||
import type { FileInfo } from '@/types/api'
|
|
||||||
import type { BudgetRange, Demand } from '@/types/business/demand'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
demand: Demand
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:demand': [partial: Partial<Demand>]
|
|
||||||
upload: [file: File]
|
|
||||||
'remove-file': [file: FileInfo]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const budgetOptions: Array<{ label: string; value: Exclude<BudgetRange, ''> }> = [
|
|
||||||
{ label: '1万以下', value: 'lt1w' },
|
|
||||||
{ label: '1-5万', value: '1to5w' },
|
|
||||||
{ label: '5-10万', value: '5to10w' },
|
|
||||||
{ label: '10万+', value: 'gt10w' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const descriptionLength = computed(() => props.demand.coreDescription.length)
|
|
||||||
|
|
||||||
function onProjectName(event: Event): void {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
emit('update:demand', { projectName: target.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCoreDescription(event: Event): void {
|
|
||||||
const target = event.target as HTMLTextAreaElement
|
|
||||||
emit('update:demand', { coreDescription: target.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDuration(event: Event): void {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
const value = target.value === '' ? '' : Number(target.value)
|
|
||||||
emit('update:demand', { expectedDuration: value })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUpload(file: File): void {
|
|
||||||
emit('upload', file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRemoveFile(file: FileInfo): void {
|
|
||||||
emit('remove-file', file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFileAction(action: 'preview' | 'download', file: FileInfo): void {
|
|
||||||
ElMessage.info(`「${file.name}」${action === 'preview' ? '预览' : '下载'}功能待接口接入`)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="demand-step2">
|
|
||||||
<div class="demand-step2__field">
|
|
||||||
<label class="demand-step2__label">项目名称</label>
|
|
||||||
<input
|
|
||||||
class="demand-step2__input"
|
|
||||||
type="text"
|
|
||||||
:value="demand.projectName"
|
|
||||||
placeholder="请输入项目名称"
|
|
||||||
@input="onProjectName"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="demand-step2__field">
|
|
||||||
<label class="demand-step2__label">相关附件</label>
|
|
||||||
<UploadArea @upload="onUpload" />
|
|
||||||
<FileList
|
|
||||||
:files="demand.attachments"
|
|
||||||
@preview="(file) => onFileAction('preview', file)"
|
|
||||||
@download="(file) => onFileAction('download', file)"
|
|
||||||
@delete="onRemoveFile"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="demand-step2__field">
|
|
||||||
<label class="demand-step2__label">核心需求描述</label>
|
|
||||||
<div class="demand-step2__textarea-wrap">
|
|
||||||
<textarea
|
|
||||||
class="demand-step2__textarea"
|
|
||||||
:value="demand.coreDescription"
|
|
||||||
maxlength="500"
|
|
||||||
placeholder="请描述您的核心需求(业务目标、功能范围、交付期望等)"
|
|
||||||
@input="onCoreDescription"
|
|
||||||
/>
|
|
||||||
<span class="demand-step2__counter">{{ descriptionLength }}/500</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="demand-step2__field">
|
|
||||||
<label class="demand-step2__label">预计开发周期</label>
|
|
||||||
<input
|
|
||||||
class="demand-step2__input"
|
|
||||||
type="number"
|
|
||||||
:value="demand.expectedDuration"
|
|
||||||
min="1"
|
|
||||||
placeholder="请输入预计开发周期(工作日)"
|
|
||||||
@input="onDuration"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="demand-step2__field">
|
|
||||||
<label class="demand-step2__label">预算范围</label>
|
|
||||||
<div class="demand-step2__budgets">
|
|
||||||
<button
|
|
||||||
v-for="option in budgetOptions"
|
|
||||||
:key="option.value"
|
|
||||||
class="demand-step2__budget"
|
|
||||||
:class="{ 'is-active': demand.budgetRange === option.value }"
|
|
||||||
@click="emit('update:demand', { budgetRange: option.value })"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.demand-step2 {
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-lg;
|
|
||||||
|
|
||||||
&__field {
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-sm;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: @font-size-base;
|
|
||||||
font-weight: 600;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__input {
|
|
||||||
width: 100%;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 @spacing-base;
|
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: @radius-base;
|
|
||||||
background: @bg-color-light;
|
|
||||||
font-size: @font-size-base;
|
|
||||||
color: @heading-color;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: @primary-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: @text-color-secondary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__textarea-wrap {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__textarea {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 120px;
|
|
||||||
padding: @spacing-sm @spacing-base 28px;
|
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: @radius-base;
|
|
||||||
background: @bg-color-light;
|
|
||||||
font-size: @font-size-base;
|
|
||||||
color: @heading-color;
|
|
||||||
line-height: 1.6;
|
|
||||||
outline: none;
|
|
||||||
resize: vertical;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: @primary-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: @text-color-secondary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__counter {
|
|
||||||
position: absolute;
|
|
||||||
right: @spacing-sm;
|
|
||||||
bottom: @spacing-sm;
|
|
||||||
font-size: @font-size-sm;
|
|
||||||
color: @text-color-secondary;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__budgets {
|
|
||||||
.flex(row, flex-start);
|
|
||||||
gap: @spacing-sm;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__budget {
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 @spacing-base;
|
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: @radius-base;
|
|
||||||
background: @white;
|
|
||||||
color: @text-color;
|
|
||||||
font-size: @font-size-base;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: @primary-color;
|
|
||||||
color: @primary-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-active {
|
|
||||||
border-color: @primary-color;
|
|
||||||
background: @primary-light;
|
|
||||||
color: @primary-color;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import type { Demand } from '@/types/business/demand'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
demand: Demand
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{ 'update:demand': [partial: Partial<Demand>] }>()
|
|
||||||
|
|
||||||
const remarkLength = computed(() => props.demand.remark.length)
|
|
||||||
|
|
||||||
function onUserName(event: Event): void {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
emit('update:demand', { userName: target.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUserPhone(event: Event): void {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
emit('update:demand', { userPhone: target.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRemark(event: Event): void {
|
|
||||||
const target = event.target as HTMLTextAreaElement
|
|
||||||
emit('update:demand', { remark: target.value })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="demand-step3">
|
|
||||||
<div class="demand-step3__row">
|
|
||||||
<div class="demand-step3__field">
|
|
||||||
<label class="demand-step3__label">用户名称</label>
|
|
||||||
<input
|
|
||||||
class="demand-step3__input"
|
|
||||||
type="text"
|
|
||||||
:value="demand.userName"
|
|
||||||
placeholder="请输入用户名称"
|
|
||||||
@input="onUserName"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="demand-step3__field">
|
|
||||||
<label class="demand-step3__label">用户手机号</label>
|
|
||||||
<input
|
|
||||||
class="demand-step3__input"
|
|
||||||
type="text"
|
|
||||||
:value="demand.userPhone"
|
|
||||||
placeholder="请输入用户手机号"
|
|
||||||
@input="onUserPhone"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="demand-step3__field">
|
|
||||||
<label class="demand-step3__label">补充备注</label>
|
|
||||||
<div class="demand-step3__textarea-wrap">
|
|
||||||
<textarea
|
|
||||||
class="demand-step3__textarea"
|
|
||||||
:value="demand.remark"
|
|
||||||
maxlength="500"
|
|
||||||
placeholder="可补充其他说明(选填)"
|
|
||||||
@input="onRemark"
|
|
||||||
/>
|
|
||||||
<span class="demand-step3__counter">{{ remarkLength }}/500</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="demand-step3__tip">
|
|
||||||
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="less" scoped>
|
|
||||||
.demand-step3 {
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-lg;
|
|
||||||
|
|
||||||
&__row {
|
|
||||||
.flex(row, flex-start);
|
|
||||||
gap: @spacing-base;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
.demand-step3__field {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__field {
|
|
||||||
.flex(column, flex-start);
|
|
||||||
gap: @spacing-sm;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__label {
|
|
||||||
font-size: @font-size-base;
|
|
||||||
font-weight: 600;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__input {
|
|
||||||
width: 100%;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 @spacing-base;
|
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: @radius-base;
|
|
||||||
background: @bg-color-light;
|
|
||||||
font-size: @font-size-base;
|
|
||||||
color: @heading-color;
|
|
||||||
outline: none;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: @primary-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: @text-color-secondary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__textarea-wrap {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__textarea {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 120px;
|
|
||||||
padding: @spacing-sm @spacing-base 28px;
|
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: @radius-base;
|
|
||||||
background: @bg-color-light;
|
|
||||||
font-size: @font-size-base;
|
|
||||||
color: @heading-color;
|
|
||||||
line-height: 1.6;
|
|
||||||
outline: none;
|
|
||||||
resize: vertical;
|
|
||||||
transition: border-color 0.2s;
|
|
||||||
box-sizing: border-box;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-color: @primary-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::placeholder {
|
|
||||||
color: @text-color-secondary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__counter {
|
|
||||||
position: absolute;
|
|
||||||
right: @spacing-sm;
|
|
||||||
bottom: @spacing-sm;
|
|
||||||
font-size: @font-size-sm;
|
|
||||||
color: @text-color-secondary;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__tip {
|
|
||||||
padding: @spacing-sm @spacing-base;
|
|
||||||
border-radius: @radius-base;
|
|
||||||
background: @primary-light;
|
|
||||||
font-size: @font-size-sm;
|
|
||||||
color: @primary-color;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -3,11 +3,11 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { storeToRefs } from 'pinia'
|
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, StepForm, UploadArea } from '@/components'
|
import { AppButton, FileList } from '@/components'
|
||||||
// 页面容器:数据只经 store,不直接 import api
|
// 页面容器:数据只经 store,不直接 import api
|
||||||
import { useProjectStore } from '@/stores/modules/project'
|
import { useProjectStore } from '@/stores/modules/project'
|
||||||
import type { BusinessType, FileInfo } from '@/types/api'
|
import type { FileInfo } from '@/types/api'
|
||||||
import { formatDate } from '@/utils/format'
|
import type { AfterSalesAttachment } from '@/types/business/project'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -16,58 +16,61 @@ const { current } = storeToRefs(projectStore)
|
|||||||
|
|
||||||
const projectId = computed(() => String(route.params.id))
|
const projectId = computed(() => String(route.params.id))
|
||||||
|
|
||||||
const STEP_TITLES = ['选择业务类型', '填写问题描述', '补充备注']
|
/**
|
||||||
const step = ref(0)
|
* 售后类型候选(**下拉框的值域**)。
|
||||||
|
*
|
||||||
const businessTypeOptions: Array<{ label: string; value: BusinessType }> = [
|
* <p>⚠️ 库内**没有「售后类型」字典**(`aftersales_status` 是售后状态、`vm_requirement_business_type`
|
||||||
{ label: '互联网软件', value: 'internet' },
|
* 是项目业务类型,都不是它)⇒ 先在前端固定这 8 项。后端补上字典 / 选项接口后,
|
||||||
{ label: '硬件电路', value: 'hardware' },
|
* 只需把这里换成接口驱动(`TYPE_OPTIONS` 改为 ref 接收接口数据),其余逻辑不用动。</p>
|
||||||
{ label: '物联网全案', value: 'iot' },
|
*/
|
||||||
{ label: '生产制造', value: 'manufacturing' },
|
const TYPE_OPTIONS = [
|
||||||
|
'软件 BUG 修复',
|
||||||
|
'软件 / 固件功能迭代',
|
||||||
|
'硬件 PCB & 电路改版调整',
|
||||||
|
'硬件故障排查维修',
|
||||||
|
'成品不良品返修',
|
||||||
|
'元器件物料替换变更',
|
||||||
|
'物联网整机联调与版本升级',
|
||||||
|
'交付资料补齐与技术答疑',
|
||||||
]
|
]
|
||||||
|
|
||||||
const typeChips = ['BUG问题', '功能新增', '其他']
|
|
||||||
|
|
||||||
const businessType = ref<BusinessType | ''>('')
|
|
||||||
const projectName = ref('')
|
|
||||||
const type = ref('')
|
const type = ref('')
|
||||||
|
/** 选中的项目(= 需求 id;提交时下发给后端,由它按此定位项目) */
|
||||||
|
const selectedProjectId = ref(projectId.value)
|
||||||
const description = ref('')
|
const description = ref('')
|
||||||
const remark = ref('')
|
const remark = ref('')
|
||||||
const attachments = ref<FileInfo[]>([])
|
/** 已上传到 OSS 的附件(携带 ossId,提交时进 `attachmentOssIds`) */
|
||||||
|
const attachments = ref<AfterSalesAttachment[]>([])
|
||||||
|
const uploading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const projectNamePlaceholder = computed(() => current.value?.projectName || '默认当前项目')
|
|
||||||
const descriptionCount = computed(() => `${description.value.length}/500`)
|
const descriptionCount = computed(() => `${description.value.length}/500`)
|
||||||
const remarkCount = computed(() => `${remark.value.length}/500`)
|
const remarkCount = computed(() => `${remark.value.length}/500`)
|
||||||
|
|
||||||
function nextStep(): void {
|
/** 项目名称下拉的候选:先取**已完成项目**,再把当前项目兜底并入 */
|
||||||
if (step.value < STEP_TITLES.length - 1) step.value += 1
|
const projectOptions = computed(() => {
|
||||||
}
|
const options = projectStore.completedProjects.map((item) => ({
|
||||||
|
id: String(item.id),
|
||||||
function prevStep(): void {
|
// 项目名称为空时退回编号,避免下拉出现空白项
|
||||||
if (step.value > 0) step.value -= 1
|
label: item.projectName || item.projectNo || '未命名项目',
|
||||||
}
|
}))
|
||||||
|
// 当前项目理论上必然已完成(详情页只有已完成才给「申请售后」入口);
|
||||||
function pickType(chip: string): void {
|
// 但「列表接口结果」与「详情状态」可能因刚变更而不同步 ⇒ 兜底并入,
|
||||||
type.value = chip
|
// 否则 v-model 命不中任何 option,下拉会显示成空白。
|
||||||
}
|
if (projectId.value && !options.some((item) => item.id === projectId.value)) {
|
||||||
|
options.unshift({
|
||||||
function onUpload(file: File): void {
|
id: projectId.value,
|
||||||
attachments.value.push({
|
label: current.value?.projectName || '当前项目',
|
||||||
name: file.name,
|
|
||||||
size: file.size,
|
|
||||||
uploadTime: formatDate(new Date()),
|
|
||||||
url: '',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
return options
|
||||||
function removeAttachment(index: number): void {
|
})
|
||||||
attachments.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetForm(): void {
|
function resetForm(): void {
|
||||||
step.value = 0
|
// 项目名称回到默认(当前项目),其余清空
|
||||||
businessType.value = ''
|
selectedProjectId.value = projectId.value
|
||||||
projectName.value = ''
|
|
||||||
type.value = ''
|
type.value = ''
|
||||||
description.value = ''
|
description.value = ''
|
||||||
remark.value = ''
|
remark.value = ''
|
||||||
@@ -78,102 +81,176 @@ function onCancel(): void {
|
|||||||
void router.push(`/project/${projectId.value}`)
|
void router.push(`/project/${projectId.value}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickFiles(): void {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选完文件后逐个上传到 OSS。
|
||||||
|
*
|
||||||
|
* <p>上传接口一次只收一个 `file` 字段,所以串行上传;全部成功后才进列表
|
||||||
|
* (失败的文件不会留下半条记录)。</p>
|
||||||
|
*/
|
||||||
|
async function onFilesPicked(list: FileList | null): Promise<void> {
|
||||||
|
if (!list || list.length === 0) return
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
for (const file of Array.from(list)) {
|
||||||
|
attachments.value.push(await projectStore.uploadAfterSalesFile(file))
|
||||||
|
}
|
||||||
|
ElMessage.success('附件上传成功')
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.warn('[after-sales] 附件上传失败', error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
uploading.value = false
|
||||||
|
// 清空 input,允许再次选择同一个文件
|
||||||
|
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAttachment(file: FileInfo): void {
|
||||||
|
// FileList 回传的就是列表里的同一个对象引用;用引用定位,避免同名同大小文件删错一条
|
||||||
|
const index = attachments.value.indexOf(file as AfterSalesAttachment)
|
||||||
|
if (index >= 0) attachments.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewAttachment(file: FileInfo): void {
|
||||||
|
if (!file.url) {
|
||||||
|
ElMessage.info('该附件还没有可访问的地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(file.url, '_blank', 'noopener')
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadAttachment(file: FileInfo): void {
|
||||||
|
if (!file.url) {
|
||||||
|
ElMessage.info('该附件还没有可访问的地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = file.url
|
||||||
|
link.download = file.name
|
||||||
|
link.target = '_blank'
|
||||||
|
link.rel = 'noopener'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提交申请。
|
||||||
|
*
|
||||||
|
* <p>提交的 `id` 是「项目名称」下拉**选中的项目**(默认当前项目)——后端只按这个需求 id 定位
|
||||||
|
* 项目并校验「已完成」,所以切换项目后提交的就是那个项目的售后。附件下发 `ossId` 列表,由后端转存。</p>
|
||||||
|
*/
|
||||||
async function onSubmit(): Promise<void> {
|
async function onSubmit(): Promise<void> {
|
||||||
const effectiveName = projectName.value || current.value?.projectName || ''
|
if (!selectedProjectId.value) {
|
||||||
if (!effectiveName) {
|
ElMessage.warning('请选择项目名称')
|
||||||
ElMessage.warning('请输入项目名称')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!type.value) {
|
if (!type.value.trim()) {
|
||||||
ElMessage.warning('请选择或输入售后类型')
|
ElMessage.warning('请选择售后类型')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!description.value) {
|
if (!description.value.trim()) {
|
||||||
ElMessage.warning('请填写问题描述')
|
ElMessage.warning('请填写问题描述')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await projectStore.submitAfterSales({
|
submitting.value = true
|
||||||
type: type.value,
|
try {
|
||||||
description: description.value,
|
await projectStore.submitAfterSales(selectedProjectId.value, {
|
||||||
remark: remark.value,
|
type: type.value.trim(),
|
||||||
attachments: attachments.value,
|
reason: description.value.trim(),
|
||||||
|
note: remark.value.trim(),
|
||||||
|
attachmentOssIds: attachments.value.map((item) => item.ossId),
|
||||||
})
|
})
|
||||||
await router.push(`/project/${projectId.value}`)
|
await router.push(`/project/${selectedProjectId.value}`)
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// 统一 request 层已弹错误提示(如「只有已完成的项目可以申请售后」),这里只恢复按钮
|
||||||
|
console.warn('[after-sales] 售后申请提交失败', error)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!current.value || current.value.id !== projectId.value) {
|
if (!current.value || current.value.id !== projectId.value) {
|
||||||
void projectStore.fetchDetail(projectId.value)
|
void projectStore.fetchDetail(projectId.value)
|
||||||
}
|
}
|
||||||
|
// 「项目名称」下拉的值域(已完成项目);每次进页面都重拉,项目状态会变
|
||||||
|
void projectStore.fetchCompletedProjects()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="container after-sales">
|
<div class="container after-sales">
|
||||||
<nav class="after-sales__breadcrumb text-secondary">
|
<header class="after-sales__header">
|
||||||
<RouterLink to="/project" class="link">
|
<h1 class="after-sales__title">
|
||||||
项目中心
|
申请售后
|
||||||
</RouterLink>
|
</h1>
|
||||||
<span> > </span>
|
<p class="after-sales__subtitle text-secondary">
|
||||||
<RouterLink :to="`/project/${projectId}`" class="link">
|
填写问题描述并上传相关资料,系统自动生成售后工单,全程线上跟踪处理进度。
|
||||||
项目详情
|
</p>
|
||||||
</RouterLink>
|
</header>
|
||||||
<span> > </span>
|
|
||||||
<span>申请售后</span>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<StepForm :titles="STEP_TITLES" :current="step">
|
<!-- ① 选择业务类型 -->
|
||||||
<!-- 第一步:选择业务类型 -->
|
<section class="after-sales__card">
|
||||||
<div v-if="step === 0" class="after-sales__section">
|
<h2 class="after-sales__card-title">
|
||||||
|
<span class="after-sales__step">1</span>
|
||||||
|
选择业务类型
|
||||||
|
</h2>
|
||||||
<div class="after-sales__field">
|
<div class="after-sales__field">
|
||||||
<label class="after-sales__label">业务类型</label>
|
<label class="after-sales__label" for="after-sales-type">
|
||||||
<div class="after-sales__options">
|
售后类型<span class="after-sales__required">*</span>
|
||||||
<button
|
</label>
|
||||||
v-for="option in businessTypeOptions"
|
<select
|
||||||
:key="option.value"
|
|
||||||
class="after-sales__option"
|
|
||||||
:class="{ 'is-active': businessType === option.value }"
|
|
||||||
@click="businessType = option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="after-sales__field">
|
|
||||||
<label class="after-sales__label" for="after-sales-project">项目名称</label>
|
|
||||||
<input
|
|
||||||
id="after-sales-project"
|
|
||||||
v-model="projectName"
|
|
||||||
class="after-sales__input"
|
|
||||||
:placeholder="projectNamePlaceholder"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 第二步:填写问题描述 -->
|
|
||||||
<div v-if="step === 1" class="after-sales__section">
|
|
||||||
<div class="after-sales__field">
|
|
||||||
<label class="after-sales__label" for="after-sales-type">售后类型</label>
|
|
||||||
<input
|
|
||||||
id="after-sales-type"
|
id="after-sales-type"
|
||||||
v-model="type"
|
v-model="type"
|
||||||
class="after-sales__input"
|
class="after-sales__select"
|
||||||
placeholder="选择下方推荐的售后类型或手动输入售后类型"
|
|
||||||
>
|
>
|
||||||
<div class="after-sales__options">
|
<option value="">
|
||||||
<button
|
请选择售后类型
|
||||||
v-for="chip in typeChips"
|
</option>
|
||||||
:key="chip"
|
<option v-for="item in TYPE_OPTIONS" :key="item" :value="item">
|
||||||
class="after-sales__option"
|
{{ item }}
|
||||||
:class="{ 'is-active': type === chip }"
|
</option>
|
||||||
@click="pickType(chip)"
|
</select>
|
||||||
>
|
|
||||||
{{ chip }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ② 填写问题描述 -->
|
||||||
|
<section class="after-sales__card">
|
||||||
|
<h2 class="after-sales__card-title">
|
||||||
|
<span class="after-sales__step">2</span>
|
||||||
|
填写问题描述
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div class="after-sales__field">
|
<div class="after-sales__field">
|
||||||
<label class="after-sales__label" for="after-sales-desc">问题描述</label>
|
<label class="after-sales__label" for="after-sales-project">
|
||||||
|
项目名称<span class="after-sales__required">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="after-sales-project"
|
||||||
|
v-model="selectedProjectId"
|
||||||
|
class="after-sales__select"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
请选择项目
|
||||||
|
</option>
|
||||||
|
<option v-for="item in projectOptions" :key="item.id" :value="item.id">
|
||||||
|
{{ item.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="after-sales__field">
|
||||||
|
<label class="after-sales__label" for="after-sales-desc">
|
||||||
|
问题描述<span class="after-sales__required">*</span>
|
||||||
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="after-sales-desc"
|
id="after-sales-desc"
|
||||||
v-model="description"
|
v-model="description"
|
||||||
@@ -182,31 +259,45 @@ onMounted(() => {
|
|||||||
rows="5"
|
rows="5"
|
||||||
placeholder="请详细描述您的问题..."
|
placeholder="请详细描述您的问题..."
|
||||||
/>
|
/>
|
||||||
<span class="after-sales__count text-secondary">{{ descriptionCount }}</span>
|
<span class="after-sales__count text-secondary">{{ descriptionCount }}字</span>
|
||||||
</div>
|
|
||||||
<div class="after-sales__field">
|
|
||||||
<label class="after-sales__label">附件上传</label>
|
|
||||||
<UploadArea hint="支持图片/文档/压缩包,大小不超过1G" @upload="onUpload" />
|
|
||||||
<ul v-if="attachments.length > 0" class="after-sales__files">
|
|
||||||
<li
|
|
||||||
v-for="(file, index) in attachments"
|
|
||||||
:key="`${file.name}-${index}`"
|
|
||||||
class="after-sales__file"
|
|
||||||
>
|
|
||||||
<span class="after-sales__file-name" :title="file.name">{{ file.name }}</span>
|
|
||||||
<button
|
|
||||||
class="after-sales__file-remove"
|
|
||||||
@click="removeAttachment(index)"
|
|
||||||
>
|
|
||||||
移除
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 第三步:补充备注 -->
|
<div class="after-sales__field">
|
||||||
<div v-if="step === 2" class="after-sales__section">
|
<div class="after-sales__field-head">
|
||||||
|
<label class="after-sales__label">附件上传</label>
|
||||||
|
<span class="after-sales__field-hint text-secondary">支持图片/文档/压缩包,大小不超过1G</span>
|
||||||
|
<AppButton
|
||||||
|
class="after-sales__upload-btn"
|
||||||
|
size="sm"
|
||||||
|
:loading="uploading"
|
||||||
|
@click="pickFiles"
|
||||||
|
>
|
||||||
|
上传文件
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
class="after-sales__file-input"
|
||||||
|
@change="onFilesPicked(($event.target as HTMLInputElement).files)"
|
||||||
|
>
|
||||||
|
<FileList
|
||||||
|
v-if="attachments.length > 0"
|
||||||
|
:files="attachments"
|
||||||
|
@preview="previewAttachment"
|
||||||
|
@download="downloadAttachment"
|
||||||
|
@delete="removeAttachment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ③ 补充备注 -->
|
||||||
|
<section class="after-sales__card">
|
||||||
|
<h2 class="after-sales__card-title">
|
||||||
|
<span class="after-sales__step">3</span>
|
||||||
|
补充备注
|
||||||
|
</h2>
|
||||||
<div class="after-sales__field">
|
<div class="after-sales__field">
|
||||||
<label class="after-sales__label" for="after-sales-remark">补充备注</label>
|
<label class="after-sales__label" for="after-sales-remark">补充备注</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -214,34 +305,31 @@ onMounted(() => {
|
|||||||
v-model="remark"
|
v-model="remark"
|
||||||
class="after-sales__textarea"
|
class="after-sales__textarea"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
rows="5"
|
rows="4"
|
||||||
placeholder="其他需要说明的事项..."
|
placeholder="其他需要说明的事项..."
|
||||||
/>
|
/>
|
||||||
<span class="after-sales__count text-secondary">{{ remarkCount }}</span>
|
<span class="after-sales__count text-secondary">{{ remarkCount }}字</span>
|
||||||
<p class="after-sales__tip text-secondary">
|
|
||||||
备注为选填项,如有与问题描述相关的补充信息可在此说明,便于工程师更快定位问题。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</StepForm>
|
|
||||||
|
|
||||||
<div class="after-sales__footer">
|
<!-- 底部操作条(设计稿:左侧提示 + 右侧 取消 / 重置 / 申请售后) -->
|
||||||
<AppButton v-if="step > 0" @click="prevStep">
|
<footer class="after-sales__footer">
|
||||||
上一步
|
<p class="after-sales__footer-tip text-secondary">
|
||||||
</AppButton>
|
<span class="after-sales__footer-icon" aria-hidden="true">i</span>
|
||||||
<AppButton v-if="step < STEP_TITLES.length - 1" @click="nextStep">
|
提交后我们的专业顾问将在一个工作日内与您联系,为您提供一对一服务
|
||||||
下一步
|
</p>
|
||||||
</AppButton>
|
<div class="after-sales__footer-actions">
|
||||||
<AppButton v-if="step === STEP_TITLES.length - 1" type="primary" @click="onSubmit">
|
<AppButton @click="onCancel">
|
||||||
申请售后
|
取消
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton @click="resetForm">
|
<AppButton @click="resetForm">
|
||||||
重置
|
重置
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton @click="onCancel">
|
<AppButton type="primary" :loading="submitting" @click="onSubmit">
|
||||||
取消
|
申请售后
|
||||||
</AppButton>
|
</AppButton>
|
||||||
</div>
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -252,13 +340,49 @@ onMounted(() => {
|
|||||||
padding-top: @spacing-lg;
|
padding-top: @spacing-lg;
|
||||||
padding-bottom: @spacing-xxl;
|
padding-bottom: @spacing-xxl;
|
||||||
|
|
||||||
&__breadcrumb {
|
&__header {
|
||||||
font-size: @font-size-base;
|
.flex(column, flex-start, stretch);
|
||||||
|
gap: @spacing-xs;
|
||||||
|
padding: @spacing-sm 0 @spacing-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__section {
|
&__title {
|
||||||
|
font-size: @font-size-title;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__subtitle {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 三段卡片(设计稿:白底圆角 + 序号圆标标题) ──
|
||||||
|
&__card {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-lg;
|
gap: @spacing-lg;
|
||||||
|
padding: @spacing-lg @spacing-xl;
|
||||||
|
border-radius: @radius-lg;
|
||||||
|
background: @white;
|
||||||
|
box-shadow: @shadow-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__card-title {
|
||||||
|
.flex(row, flex-start, center);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__step {
|
||||||
|
.flex(row, center);
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
background: @primary-color;
|
||||||
|
color: @white;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__field {
|
&__field {
|
||||||
@@ -266,48 +390,49 @@ onMounted(() => {
|
|||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__field-head {
|
||||||
|
.flex(row, flex-start, center);
|
||||||
|
gap: @spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field-hint {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 「上传文件」推到行尾
|
||||||
|
&__upload-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__options {
|
&__required {
|
||||||
.flex(row, flex-start, center);
|
margin-left: 2px;
|
||||||
flex-wrap: wrap;
|
color: @danger-color;
|
||||||
gap: @spacing-sm;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__option {
|
&__select {
|
||||||
height: 30px;
|
width: 100%;
|
||||||
padding: 0 @spacing-base;
|
height: 40px;
|
||||||
border: 1px solid transparent;
|
padding: 0 36px 0 @spacing-base;
|
||||||
border-radius: @radius-sm;
|
|
||||||
background: @bg-color;
|
|
||||||
color: @text-color;
|
|
||||||
font-size: @font-size-sm;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: @primary-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-active {
|
|
||||||
border-color: @primary-color;
|
|
||||||
background: @primary-light;
|
|
||||||
color: @primary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__input {
|
|
||||||
width: 360px;
|
|
||||||
height: 36px;
|
|
||||||
padding: 0 @spacing-base;
|
|
||||||
border: 1px solid @border-color;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background-color: @bg-color-light;
|
||||||
|
color: @text-color;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
|
appearance: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
// 自定义下拉箭头(原生 select 不支持 ::after)
|
||||||
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cpath d='M2 4.5 6 8.5l4-4' fill='none' stroke='%238c8c8c' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 12px center;
|
||||||
|
background-size: 12px 12px;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
@@ -315,12 +440,14 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 「项目名称」与「售后类型」共用同一个下拉样式(见上方 &__select)
|
||||||
&__textarea {
|
&__textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: @spacing-sm @spacing-base;
|
padding: @spacing-sm @spacing-base;
|
||||||
border: 1px solid @border-color;
|
border: 1px solid @border-color;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
background: @white;
|
background: @bg-color-light;
|
||||||
|
color: @text-color;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -337,41 +464,46 @@ onMounted(() => {
|
|||||||
font-size: @font-size-sm;
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tip {
|
&__file-input {
|
||||||
font-size: @font-size-sm;
|
display: none;
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__files {
|
|
||||||
.flex(column, flex-start, stretch);
|
|
||||||
gap: @spacing-xs;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__file {
|
|
||||||
.flex(row, space-between);
|
|
||||||
gap: @spacing-base;
|
|
||||||
width: 360px;
|
|
||||||
padding: @spacing-xs @spacing-sm;
|
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: @radius-sm;
|
|
||||||
background: @bg-color-light;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__file-name {
|
|
||||||
font-size: @font-size-sm;
|
|
||||||
color: @text-color;
|
|
||||||
.ellipsis();
|
|
||||||
}
|
|
||||||
|
|
||||||
&__file-remove {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: @font-size-sm;
|
|
||||||
color: @danger-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 底部固定操作条 ──
|
||||||
&__footer {
|
&__footer {
|
||||||
.flex(row, flex-start);
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10;
|
||||||
|
.flex(row, space-between, center);
|
||||||
|
gap: @spacing-base;
|
||||||
|
margin-top: @spacing-sm;
|
||||||
|
padding: @spacing-base @spacing-xl;
|
||||||
|
border-top: 1px solid @border-color;
|
||||||
|
background: @white;
|
||||||
|
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer-tip {
|
||||||
|
.flex(row, flex-start, center);
|
||||||
|
gap: @spacing-xs;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer-icon {
|
||||||
|
.flex(row, center);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: @radius-round;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer-actions {
|
||||||
|
.flex(row, flex-end, center);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { AfterSales } from '@/types/business/project'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { FileList } from '@/components'
|
||||||
|
import type { FileInfo } from '@/types/api'
|
||||||
|
import type { AfterSales, ProjectStatus } from '@/types/business/project'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/** 售后工单;`undefined` 或 `status === '无'` 表示没有进行中的工单 */
|
||||||
afterSales?: AfterSales
|
afterSales?: AfterSales
|
||||||
projectId: string
|
/** 项目状态 —— 决定售后区的三态展示(见下方模板注释) */
|
||||||
|
projectStatus: ProjectStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -14,47 +19,131 @@ const emit = defineEmits<{
|
|||||||
cancel: []
|
cancel: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isProcessing = computed(() => props.afterSales?.status === '处理中')
|
/**
|
||||||
|
* 售后页签的三态(设计稿便签原文):
|
||||||
|
* 1. 项目**未完成** → 不展示售后信息;
|
||||||
|
* 2. 已完成 + **未提交**售后 → 内容为空,只给「申请售后」;
|
||||||
|
* 3. 已完成 + **已提交**售后 → 展示售后信息 + 「取消售后」。
|
||||||
|
*
|
||||||
|
* <p>「已完成」按详情页状态口径判定(后端 `COMPLETED` 经 projectVo 映射成「已完成」)。
|
||||||
|
* 后端 `requestAfterSale` 也会校验这一点,前端只是提前把入口藏掉。</p>
|
||||||
|
*/
|
||||||
|
const isCompleted = computed(() => props.projectStatus === '已完成')
|
||||||
|
/** 存在进行中的售后工单(撤回后的记录在映射层已归为「无」) */
|
||||||
|
const hasTicket = computed(() => props.afterSales?.status === '处理中')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览 / 下载直接走附件自身的 OSS 地址(后端下发的 `url` 就是可访问地址);
|
||||||
|
* 删除没有接口 —— 售后附件只在「申请 / 完成售后」时整体写入,不支持单独删。
|
||||||
|
*/
|
||||||
|
function previewFile(file: FileInfo): void {
|
||||||
|
if (!file.url) {
|
||||||
|
ElMessage.info('该附件没有可访问的地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(file.url, '_blank', 'noopener')
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(file: FileInfo): void {
|
||||||
|
if (!file.url) {
|
||||||
|
ElMessage.info('该附件没有可访问的地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = file.url
|
||||||
|
link.download = file.name
|
||||||
|
link.target = '_blank'
|
||||||
|
link.rel = 'noopener'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteFile(): void {
|
||||||
|
// TODO(api): 售后工单附件无单独删除接口(后端只在申请 / 完成售后时整体写入)
|
||||||
|
ElMessage.info('售后附件暂不支持单独删除')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="project-after-sales">
|
<section class="project-after-sales">
|
||||||
<!-- 处理中状态卡片 -->
|
<!-- 规则 1:项目未完成 → 不展示售后信息 -->
|
||||||
<div
|
<div v-if="!isCompleted" class="project-after-sales__blank">
|
||||||
v-if="isProcessing && afterSales"
|
<p class="project-after-sales__blank-text text-secondary">
|
||||||
class="project-after-sales__card project-after-sales__card--active"
|
项目完成后可申请售后
|
||||||
>
|
</p>
|
||||||
<div class="project-after-sales__card-head">
|
</div>
|
||||||
<span class="project-after-sales__badge">处理中</span>
|
|
||||||
<h3 class="project-after-sales__card-title">
|
<!-- 规则 3:已完成 + 已提交售后 → 工单横幅 + 售后信息 + 取消售后 -->
|
||||||
|
<template v-else-if="hasTicket && afterSales">
|
||||||
|
<div class="project-after-sales__banner">
|
||||||
|
<span class="project-after-sales__banner-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor">
|
||||||
|
<path d="M6 2h7l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm7 1.6V8h4.4L13 3.6ZM8 12.2h8v1.6H8v-1.6Zm0 3.6h8v1.6H8v-1.6Z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div class="project-after-sales__banner-text">
|
||||||
|
<h3 class="project-after-sales__banner-title">
|
||||||
售后工单处理中
|
售后工单处理中
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
<p class="project-after-sales__banner-desc">
|
||||||
<p class="project-after-sales__desc">
|
|
||||||
工程师正在处理您的修改需求,请耐心等待
|
工程师正在处理您的修改需求,请耐心等待
|
||||||
</p>
|
</p>
|
||||||
<div class="project-after-sales__meta text-secondary">
|
|
||||||
<span v-if="afterSales.type">类型:{{ afterSales.type }}</span>
|
|
||||||
<span v-if="afterSales.createTime">提交时间:{{ afterSales.createTime }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="afterSales.description" class="project-after-sales__desc">
|
</div>
|
||||||
{{ afterSales.description }}
|
|
||||||
|
<h2 class="project-after-sales__title">
|
||||||
|
售后信息
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="project-after-sales__field">
|
||||||
|
<span class="project-after-sales__label">售后类型</span>
|
||||||
|
<div class="project-after-sales__readonly">
|
||||||
|
{{ afterSales.type || '—' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-after-sales__field">
|
||||||
|
<span class="project-after-sales__label">问题描述</span>
|
||||||
|
<div class="project-after-sales__readonly project-after-sales__readonly--block">
|
||||||
|
{{ afterSales.description || '—' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-after-sales__field">
|
||||||
|
<div class="project-after-sales__field-head">
|
||||||
|
<span class="project-after-sales__label">附件上传</span>
|
||||||
|
<span class="project-after-sales__field-hint text-secondary">支持图片/文档/压缩包,大小不超过1G</span>
|
||||||
|
</div>
|
||||||
|
<FileList
|
||||||
|
v-if="afterSales.attachments.length > 0"
|
||||||
|
:files="afterSales.attachments"
|
||||||
|
@preview="previewFile"
|
||||||
|
@download="downloadFile"
|
||||||
|
@delete="onDeleteFile"
|
||||||
|
/>
|
||||||
|
<p v-else class="project-after-sales__none text-secondary">
|
||||||
|
暂无附件
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="project-after-sales__actions">
|
<div class="project-after-sales__actions">
|
||||||
<button
|
<button
|
||||||
class="project-after-sales__btn project-after-sales__btn--default"
|
class="project-after-sales__btn project-after-sales__btn--primary"
|
||||||
@click="emit('cancel')"
|
@click="emit('cancel')"
|
||||||
>
|
>
|
||||||
取消售后
|
取消售后
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 规则 2:已完成 + 未提交售后 → 内容为空 + 申请售后 -->
|
||||||
<div v-else class="project-after-sales__card project-after-sales__card--empty">
|
<template v-else>
|
||||||
<p class="project-after-sales__empty text-secondary">
|
<div class="project-after-sales__blank">
|
||||||
|
<p class="project-after-sales__blank-text text-secondary">
|
||||||
暂无售后工单
|
暂无售后工单
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
<div class="project-after-sales__actions">
|
<div class="project-after-sales__actions">
|
||||||
<button
|
<button
|
||||||
class="project-after-sales__btn project-after-sales__btn--primary"
|
class="project-after-sales__btn project-after-sales__btn--primary"
|
||||||
@@ -63,79 +152,125 @@ const isProcessing = computed(() => props.afterSales?.status === '处理中')
|
|||||||
申请售后
|
申请售后
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.project-after-sales {
|
.project-after-sales {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-base;
|
gap: @spacing-lg;
|
||||||
padding: @spacing-lg @spacing-xl;
|
padding: @spacing-lg @spacing-xl;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-lg;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-shadow: @shadow-base;
|
box-shadow: @shadow-base;
|
||||||
|
|
||||||
&__card {
|
// ── 顶部工单横幅:浅红底 + 红色图标块(设计稿) ──
|
||||||
|
&__banner {
|
||||||
|
.flex(row, flex-start, center);
|
||||||
|
gap: @spacing-base;
|
||||||
|
padding: @spacing-base @spacing-lg;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: fade(@danger-color, 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__banner-icon {
|
||||||
|
.flex(row, center);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @danger-color;
|
||||||
|
color: @white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__banner-text {
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__banner-title {
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: @heading-color;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__banner-desc {
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
color: @text-color-secondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 「售后信息」区块标题:竖条 + 15px 缩进(与其它页签一致) ──
|
||||||
|
&__title {
|
||||||
|
.section-title();
|
||||||
|
|
||||||
|
margin-top: @spacing-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(column, flex-start, stretch);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
padding: @spacing-base;
|
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: @radius-base;
|
|
||||||
background: @bg-color-light;
|
|
||||||
|
|
||||||
&--active {
|
|
||||||
border-color: fade(@warning-color, 40%);
|
|
||||||
background: fade(@warning-color, 6%);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card-head {
|
&__field-head {
|
||||||
.flex(row, center);
|
.flex(row, flex-start, baseline);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__badge {
|
&__field-hint {
|
||||||
padding: 2px @spacing-sm;
|
|
||||||
border-radius: @radius-sm;
|
|
||||||
background: fade(@warning-color, 12%);
|
|
||||||
color: @warning-color;
|
|
||||||
font-size: @font-size-sm;
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card-title {
|
&__label {
|
||||||
font-size: @font-size-lg;
|
font-size: @font-size-base;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__desc {
|
// 只读展示(设计稿里是灰底带边框的框,不是输入控件)
|
||||||
font-size: @font-size-base;
|
&__readonly {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 @spacing-base;
|
||||||
|
border: 1px solid @border-color;
|
||||||
|
border-radius: @radius-base;
|
||||||
|
background: @bg-color-light;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
line-height: 1.6;
|
font-size: @font-size-base;
|
||||||
|
line-height: 40px;
|
||||||
|
|
||||||
|
&--block {
|
||||||
|
min-height: 96px;
|
||||||
|
padding: @spacing-sm @spacing-base;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__meta {
|
&__none {
|
||||||
.flex(row, flex-start);
|
padding: @spacing-sm 0;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: @spacing-base;
|
|
||||||
font-size: @font-size-sm;
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__empty {
|
&__blank {
|
||||||
padding: @spacing-lg 0;
|
.flex(row, center);
|
||||||
text-align: center;
|
padding: @spacing-xl 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__blank-text {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__actions {
|
&__actions {
|
||||||
.flex(row, flex-end);
|
.flex(row, flex-end, center);
|
||||||
gap: @spacing-sm;
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__btn {
|
&__btn {
|
||||||
height: 34px;
|
height: 36px;
|
||||||
padding: 0 @spacing-base;
|
padding: 0 @spacing-lg;
|
||||||
border-radius: @radius-base;
|
border-radius: @radius-base;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
@@ -149,18 +284,6 @@ const isProcessing = computed(() => props.afterSales?.status === '处理中')
|
|||||||
background: @primary-hover;
|
background: @primary-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&--default {
|
|
||||||
border: 1px solid @border-color-dark;
|
|
||||||
background: @white;
|
|
||||||
color: @text-color;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: @primary-color;
|
|
||||||
color: @primary-color;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { UploadArea } from '@/components'
|
|
||||||
import type { FileInfo } from '@/types/api'
|
import type { FileInfo } from '@/types/api'
|
||||||
import { formatFileSize } from '@/utils/format'
|
import { formatFileSize } from '@/utils/format'
|
||||||
|
|
||||||
@@ -9,8 +8,8 @@ interface Props {
|
|||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
|
// ⚠️ 详情页只「展示」文件,不提供上传入口(上传在需求提报页)。
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
upload: [file: File]
|
|
||||||
delete: [index: number]
|
delete: [index: number]
|
||||||
preview: [index: number]
|
preview: [index: number]
|
||||||
download: [index: number]
|
download: [index: number]
|
||||||
@@ -23,11 +22,6 @@ const emit = defineEmits<{
|
|||||||
项目文件
|
项目文件
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<UploadArea
|
|
||||||
hint="支持图片/文档/压缩包,大小不超过1G"
|
|
||||||
@upload="emit('upload', $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ul class="project-files__list">
|
<ul class="project-files__list">
|
||||||
<li
|
<li
|
||||||
v-for="(file, index) in files"
|
v-for="(file, index) in files"
|
||||||
@@ -72,9 +66,7 @@ const emit = defineEmits<{
|
|||||||
box-shadow: @shadow-base;
|
box-shadow: @shadow-base;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: @font-size-lg;
|
.section-title();
|
||||||
font-weight: 600;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import type { ProjectQuery, ProjectTypeCount } from '@/types/business/project'
|
|||||||
* 的 `businessTypeCounts`,全量口径),组件内不定义业务值域;
|
* 的 `businessTypeCounts`,全量口径),组件内不定义业务值域;
|
||||||
* 只有后端未下发的组(项目状态 / 提交时间)才用 `utils/projectMeta.ts` 里的前端常量。</p>
|
* 只有后端未下发的组(项目状态 / 提交时间)才用 `utils/projectMeta.ts` 里的前端常量。</p>
|
||||||
*
|
*
|
||||||
* <p>「不限」统一用**空串**表示(不是 'all'):空串不会出现在 querystring 里,
|
* <p>「不限」统一用**空值**表示(不是 'all'):业务类型用空数组、其余用空串,
|
||||||
* 正好对上后端 `eqIfText` 的「有值才等值匹配」。</p>
|
* 都不会出现在 querystring 里,正好对上后端「有值才匹配」的语义。</p>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -45,10 +45,11 @@ const rangeLabel = computed(() => {
|
|||||||
|
|
||||||
const chips = computed<Chip[]>(() => {
|
const chips = computed<Chip[]>(() => {
|
||||||
const list: Chip[] = []
|
const list: Chip[] = []
|
||||||
if (props.query.businessType) {
|
// 业务类型是**多选**:每个已选类型各出一个 chip,key 带 id 以便单独移除
|
||||||
const found = props.typeCounts.find((item) => item.value === props.query.businessType)
|
props.query.businessTypes.forEach((value) => {
|
||||||
list.push({ key: 'businessType', label: found?.label ?? props.query.businessType })
|
const found = props.typeCounts.find((item) => item.value === value)
|
||||||
}
|
list.push({ key: `businessType:${value}`, label: found?.label ?? value })
|
||||||
|
})
|
||||||
if (props.query.status) {
|
if (props.query.status) {
|
||||||
list.push({ key: 'status', label: PROJECT_STATUS_LABEL[props.query.status] ?? props.query.status })
|
list.push({ key: 'status', label: PROJECT_STATUS_LABEL[props.query.status] ?? props.query.status })
|
||||||
}
|
}
|
||||||
@@ -66,6 +67,27 @@ function patch(partial: Partial<ProjectQuery>): void {
|
|||||||
emit('update:query', { ...props.query, ...partial })
|
emit('update:query', { ...props.query, ...partial })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 业务类型行是否处于选中态(「全部」= 一个都没勾) */
|
||||||
|
function isTypeSelected(value: string): boolean {
|
||||||
|
return value === 'all'
|
||||||
|
? props.query.businessTypes.length === 0
|
||||||
|
: props.query.businessTypes.includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务类型多选切换:点「全部」清空,点具体行按勾选状态增删(后端口径是 IN,多选即并集) */
|
||||||
|
function onToggleType(value: string): void {
|
||||||
|
if (value === 'all') {
|
||||||
|
patch({ businessTypes: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const selected = props.query.businessTypes
|
||||||
|
patch({
|
||||||
|
businessTypes: selected.includes(value)
|
||||||
|
? selected.filter((item) => item !== value)
|
||||||
|
: [...selected, value],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 快捷区间与自选区间互斥:点快捷区间会清掉自选日期 */
|
/** 快捷区间与自选区间互斥:点快捷区间会清掉自选日期 */
|
||||||
function onPickShortcut(value: ProjectQuery['timeShortcut']): void {
|
function onPickShortcut(value: ProjectQuery['timeShortcut']): void {
|
||||||
rangeOpen.value = false
|
rangeOpen.value = false
|
||||||
@@ -78,7 +100,10 @@ function onPickDate(partial: { startDate?: string; endDate?: string }): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onRemoveChip(key: string): void {
|
function onRemoveChip(key: string): void {
|
||||||
if (key === 'businessType') patch({ businessType: '' })
|
if (key.startsWith('businessType:')) {
|
||||||
|
const value = key.slice('businessType:'.length)
|
||||||
|
patch({ businessTypes: props.query.businessTypes.filter((item) => item !== value) })
|
||||||
|
}
|
||||||
else if (key === 'status') patch({ status: '' })
|
else if (key === 'status') patch({ status: '' })
|
||||||
else if (key === 'time') patch({ timeShortcut: 'all' })
|
else if (key === 'time') patch({ timeShortcut: 'all' })
|
||||||
else if (key === 'range') patch({ startDate: '', endDate: '' })
|
else if (key === 'range') patch({ startDate: '', endDate: '' })
|
||||||
@@ -87,7 +112,7 @@ function onRemoveChip(key: string): void {
|
|||||||
function onClearChips(): void {
|
function onClearChips(): void {
|
||||||
emit('update:query', {
|
emit('update:query', {
|
||||||
...props.query,
|
...props.query,
|
||||||
businessType: '',
|
businessTypes: [],
|
||||||
status: '',
|
status: '',
|
||||||
timeShortcut: 'all',
|
timeShortcut: 'all',
|
||||||
startDate: '',
|
startDate: '',
|
||||||
@@ -102,7 +127,7 @@ function onClearChips(): void {
|
|||||||
筛选条件
|
筛选条件
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- 业务类型:复选外观的单选列表(值域 / 件数均来自后端统计) -->
|
<!-- 业务类型:**多选**(值域 / 件数均来自后端统计;「全部」= 清空选择,可单选或多选) -->
|
||||||
<section class="project-filter__group">
|
<section class="project-filter__group">
|
||||||
<h3 class="project-filter__group-title">
|
<h3 class="project-filter__group-title">
|
||||||
业务类型
|
业务类型
|
||||||
@@ -112,8 +137,8 @@ function onClearChips(): void {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="project-filter__row"
|
class="project-filter__row"
|
||||||
:class="{ 'is-active': query.businessType === item.value }"
|
:class="{ 'is-active': isTypeSelected(item.value) }"
|
||||||
@click="patch({ businessType: item.value === 'all' ? '' : item.value })"
|
@click="onToggleType(item.value)"
|
||||||
>
|
>
|
||||||
<span class="project-filter__box" aria-hidden="true" />
|
<span class="project-filter__box" aria-hidden="true" />
|
||||||
<span class="project-filter__row-label">{{ item.label }}</span>
|
<span class="project-filter__row-label">{{ item.label }}</span>
|
||||||
@@ -289,6 +314,7 @@ function onClearChips(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&__box {
|
&__box {
|
||||||
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
@@ -296,6 +322,31 @@ function onClearChips(): void {
|
|||||||
border: 1px solid @border-color-dark;
|
border: 1px solid @border-color-dark;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s;
|
||||||
|
|
||||||
|
// 勾:用两条边框旋转 45° 画出来,不引额外图标
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 4px;
|
||||||
|
width: 3px;
|
||||||
|
height: 6px;
|
||||||
|
border: solid @white;
|
||||||
|
border-width: 0 1.5px 1.5px 0;
|
||||||
|
transform: rotate(45deg) scale(0);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中行:方框实心主色 + 白色勾(多选下每行独立显隐)
|
||||||
|
&__row.is-active &__box {
|
||||||
|
border-color: @primary-color;
|
||||||
|
background: @primary-color;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
transform: rotate(45deg) scale(1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__row-label {
|
&__row-label {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
|
|||||||
<div class="project-finance__file-info">
|
<div class="project-finance__file-info">
|
||||||
<span class="project-finance__file-name" :title="file.name">{{ file.name }}</span>
|
<span class="project-finance__file-name" :title="file.name">{{ file.name }}</span>
|
||||||
<span class="project-finance__file-meta text-secondary">
|
<span class="project-finance__file-meta text-secondary">
|
||||||
{{ formatFileSize(file.size) }} · {{ file.uploadTime }}
|
{{ file.size ? formatFileSize(file.size) + ' · ' : '' }}{{ file.uploadTime }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-finance__file-actions">
|
<div class="project-finance__file-actions">
|
||||||
@@ -110,7 +110,7 @@ const BILL_VARIANTS: Record<ProjectBill['status'], 'primary' | 'success' | 'warn
|
|||||||
<div class="project-finance__file-info">
|
<div class="project-finance__file-info">
|
||||||
<span class="project-finance__file-name" :title="contractFile.name">{{ contractFile.name }}</span>
|
<span class="project-finance__file-name" :title="contractFile.name">{{ contractFile.name }}</span>
|
||||||
<span class="project-finance__file-meta text-secondary">
|
<span class="project-finance__file-meta text-secondary">
|
||||||
{{ formatFileSize(contractFile.size) }} · {{ contractFile.uploadTime }}
|
{{ contractFile.size ? formatFileSize(contractFile.size) + ' · ' : '' }}{{ contractFile.uploadTime }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-finance__file-actions">
|
<div class="project-finance__file-actions">
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { StatusTag } from '@/components'
|
|
||||||
import { formatBusinessType } from '@/utils/format'
|
import { formatBusinessType } from '@/utils/format'
|
||||||
import type { Project, ProjectStatus } from '@/types/business/project'
|
import type { Project, ProjectStatus } from '@/types/business/project'
|
||||||
|
|
||||||
@@ -9,79 +8,148 @@ interface Props {
|
|||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
const STATUS_VARIANTS: Record<ProjectStatus, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
|
/**
|
||||||
'待立项': 'info',
|
* 状态文字配色(项目详情设计稿:状态是**卡片右侧的纯文字**,不是胶囊标签)。
|
||||||
'进行中': 'primary',
|
*
|
||||||
'待验收': 'warning',
|
* <p>设计稿只画了「进行中」= @project-accent,其余状态按语义就近归组;
|
||||||
'待内部验收': 'warning',
|
* 颜色一律走 `styles/variables.less`,组件内不写魔法色值。</p>
|
||||||
'已完成': 'success',
|
*/
|
||||||
'售后中': 'warning',
|
const STATUS_CLASS: Record<ProjectStatus, string> = {
|
||||||
'已取消': 'info',
|
'待立项': 'is-idle',
|
||||||
|
'进行中': 'is-active',
|
||||||
|
'待验收': 'is-warning',
|
||||||
|
'待内部验收': 'is-warning',
|
||||||
|
'已完成': 'is-done',
|
||||||
|
'售后中': 'is-warning',
|
||||||
|
'已取消': 'is-idle',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务类型:优先用后端原样下发的名称(它来自提报表,接口能下发就照显),兜底才查前端枚举 */
|
||||||
|
function businessTypeText(project: Project): string {
|
||||||
|
return project.businessTypeName || formatBusinessType(project.businessType)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="project-info-card">
|
<section class="project-info-card">
|
||||||
<div class="project-info-card__head">
|
<!--
|
||||||
<StatusTag :label="project.status" :variant="STATUS_VARIANTS[project.status]" />
|
项目标识:设计稿是「白底圆角方块 + 蓝色描边徽标(内含 </>)」。
|
||||||
|
⚠️ 接口没有 logo 字段(ProjectRequirementBaseVo 只下发名称/编号/类型/状态/时间),
|
||||||
|
所以这里按设计稿渲染占位徽标,不是真实项目图标。
|
||||||
|
-->
|
||||||
|
<span class="project-info-card__logo" aria-hidden="true">
|
||||||
|
<span class="project-info-card__logo-badge"></></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="project-info-card__main">
|
||||||
<h1 class="project-info-card__name">
|
<h1 class="project-info-card__name">
|
||||||
{{ project.projectName }}
|
{{ project.projectName || '—' }}
|
||||||
</h1>
|
</h1>
|
||||||
|
<p class="project-info-card__meta">
|
||||||
|
<span class="project-info-card__meta-item">
|
||||||
|
<em class="project-info-card__label">项目编号:</em>{{ project.projectNo || '—' }}
|
||||||
|
</span>
|
||||||
|
<span class="project-info-card__meta-item">
|
||||||
|
<em class="project-info-card__label">业务类型:</em>{{ businessTypeText(project) }}
|
||||||
|
</span>
|
||||||
|
<span class="project-info-card__meta-item">
|
||||||
|
<em class="project-info-card__label">提交时间:</em>{{ project.submitTime || '—' }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="project-info-card__meta">
|
|
||||||
<div class="project-info-card__meta-item">
|
<span class="project-info-card__status" :class="STATUS_CLASS[project.status]">
|
||||||
<span class="project-info-card__label text-secondary">项目编号</span>
|
{{ project.status }}
|
||||||
<span class="project-info-card__value">{{ project.projectNo }}</span>
|
</span>
|
||||||
</div>
|
</section>
|
||||||
<div class="project-info-card__meta-item">
|
|
||||||
<span class="project-info-card__label text-secondary">业务类型</span>
|
|
||||||
<span class="project-info-card__value">{{ formatBusinessType(project.businessType) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="project-info-card__meta-item">
|
|
||||||
<span class="project-info-card__label text-secondary">提交时间</span>
|
|
||||||
<span class="project-info-card__value">{{ project.submitTime }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
.project-info-card {
|
.project-info-card {
|
||||||
padding: @spacing-lg @spacing-xl;
|
.flex(row, flex-start, center);
|
||||||
|
gap: @spacing-base;
|
||||||
|
padding: @spacing-lg;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-lg;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-shadow: @shadow-base;
|
box-shadow: @shadow-base;
|
||||||
|
|
||||||
&__head {
|
&__logo {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, center, center);
|
||||||
gap: @spacing-base;
|
flex-shrink: 0;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: @white;
|
||||||
|
box-shadow: @shadow-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__logo-badge {
|
||||||
|
.flex(row, center, center);
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 2px solid @project-accent;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: @project-accent;
|
||||||
|
font-family: Consolas, Menlo, 'Courier New', monospace;
|
||||||
|
font-size: @font-size-base;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__main {
|
||||||
|
.flex(column, flex-start, stretch);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
gap: @spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
font-size: @font-size-xxl;
|
font-size: @font-size-title;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
|
.ellipsis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设计稿:三个字段是**单行**「标签:值」,等距排布
|
||||||
&__meta {
|
&__meta {
|
||||||
display: grid;
|
.flex(row, flex-start, center);
|
||||||
grid-template-columns: repeat(3, 1fr);
|
flex-wrap: wrap;
|
||||||
gap: @spacing-base;
|
gap: @spacing-lg;
|
||||||
margin-top: @spacing-base;
|
font-size: @font-size-sm;
|
||||||
|
color: @heading-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__meta-item {
|
&__meta-item {
|
||||||
.flex(column, flex-start, stretch);
|
.flex(row, flex-start, center);
|
||||||
gap: @spacing-xs;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__label {
|
||||||
font-size: @font-size-sm;
|
color: @text-color-secondary;
|
||||||
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__value {
|
&__status {
|
||||||
font-size: @font-size-base;
|
flex-shrink: 0;
|
||||||
color: @heading-color;
|
font-size: @font-size-title;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
color: @project-accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-done {
|
||||||
|
color: @success-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-warning {
|
||||||
|
color: @warning-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-idle {
|
||||||
|
color: @text-color-secondary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,185 +2,168 @@
|
|||||||
import type { Milestone } from '@/types/business/project'
|
import type { Milestone } from '@/types/business/project'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
progress: number
|
|
||||||
milestones: Milestone[]
|
milestones: Milestone[]
|
||||||
startTime?: string
|
|
||||||
endTime?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
/** 三态 → 修饰类(设计稿:已完成=绿实心+白勾 / 进行中=白底粗蓝环 / 待开始=白底灰环) */
|
||||||
|
function stateClass(status: Milestone['status']): string {
|
||||||
|
if (status === '已完成') return 'is-done'
|
||||||
|
if (status === '进行中') return 'is-active'
|
||||||
|
return 'is-pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接线是否走「已完成」的绿色。
|
||||||
|
*
|
||||||
|
* <p>设计稿实测:只有<b>下一个节点已完成</b>时该段才是绿色(第二个节点已完成 → 第 1 段绿;
|
||||||
|
* 第三个节点进行中 → 第 2 段仍是灰色,不随「进行中」变蓝)。</p>
|
||||||
|
*/
|
||||||
|
function linkDone(index: number): boolean {
|
||||||
|
return props.milestones[index + 1]?.status === '已完成'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="project-milestones">
|
<section class="project-milestones">
|
||||||
<div class="project-milestones__head">
|
|
||||||
<h2 class="project-milestones__title">
|
<h2 class="project-milestones__title">
|
||||||
总体进度
|
项目进度(里程碑)
|
||||||
</h2>
|
</h2>
|
||||||
<span class="project-milestones__percent">{{ progress }}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="project-milestones__track">
|
|
||||||
<div
|
|
||||||
class="project-milestones__bar"
|
|
||||||
:style="{ width: `${progress}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ol class="project-milestones__timeline">
|
<ol v-if="props.milestones.length > 0" class="project-milestones__track">
|
||||||
<li
|
<li
|
||||||
v-for="node in milestones"
|
v-for="(node, index) in props.milestones"
|
||||||
:key="node.name"
|
:key="`${node.name}-${index}`"
|
||||||
class="project-milestones__node"
|
class="project-milestones__node"
|
||||||
:class="`is-${node.status === '已完成' ? 'done' : node.status === '进行中' ? 'active' : 'pending'}`"
|
:class="[stateClass(node.status), { 'is-link-done': linkDone(index) }]"
|
||||||
>
|
>
|
||||||
<span class="project-milestones__dot" />
|
<span class="project-milestones__dot">
|
||||||
<div class="project-milestones__body">
|
<svg
|
||||||
<div class="project-milestones__row">
|
v-if="node.status === '已完成'"
|
||||||
|
class="project-milestones__check"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M3.5 8.4 6.6 11.5 12.5 4.8" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span class="project-milestones__name">{{ node.name }}</span>
|
<span class="project-milestones__name">{{ node.name }}</span>
|
||||||
<span class="project-milestones__status">{{ node.status }}</span>
|
<!-- 设计稿:已完成节点显示完成时间,其余显示状态文案 -->
|
||||||
</div>
|
<span class="project-milestones__sub text-secondary">{{ node.time || node.status }}</span>
|
||||||
<span v-if="node.time" class="project-milestones__time text-secondary">{{ node.time }}</span>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li v-if="milestones.length === 0" class="project-milestones__empty text-secondary">
|
|
||||||
暂无里程碑数据
|
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<div class="project-milestones__range">
|
<p v-else class="project-milestones__empty text-secondary">
|
||||||
<span v-if="startTime" class="text-secondary">开始时间:{{ startTime }}</span>
|
暂无里程碑数据
|
||||||
<span v-if="endTime" class="text-secondary">结束时间:{{ endTime }}</span>
|
</p>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
// 节点列宽 = 设计稿相邻节点中心距(237 设计px × 0.77 ≈ 182px)
|
||||||
|
@milestone-node-width: 182px;
|
||||||
|
@milestone-dot-size: 32px;
|
||||||
|
|
||||||
.project-milestones {
|
.project-milestones {
|
||||||
padding: @spacing-lg @spacing-xl;
|
.flex(column, flex-start, stretch);
|
||||||
|
padding: @spacing-lg;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-lg;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-shadow: @shadow-base;
|
box-shadow: @shadow-base;
|
||||||
|
|
||||||
&__head {
|
|
||||||
.flex(row, space-between, center);
|
|
||||||
}
|
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: @font-size-lg;
|
.section-title();
|
||||||
font-weight: 600;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__percent {
|
|
||||||
font-size: @font-size-xl;
|
|
||||||
font-weight: 600;
|
|
||||||
color: @primary-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设计稿:轨迹从卡片左侧正文位起、**固定节距**左对齐(不铺满整宽)
|
||||||
&__track {
|
&__track {
|
||||||
height: 8px;
|
.flex(row, flex-start, flex-start);
|
||||||
margin-top: @spacing-base;
|
|
||||||
border-radius: @radius-round;
|
|
||||||
background: @bg-color;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__bar {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: @radius-round;
|
|
||||||
background: @primary-color;
|
|
||||||
transition: width 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__timeline {
|
|
||||||
margin-top: @spacing-lg;
|
margin-top: @spacing-lg;
|
||||||
padding-left: @spacing-xs;
|
padding-bottom: @spacing-xs;
|
||||||
|
overflow-x: auto;
|
||||||
|
.scrollbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
&__node {
|
&__node {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 0 0 @spacing-base @spacing-lg;
|
.flex(column, flex-start, center);
|
||||||
border-left: 2px solid @border-color;
|
flex: 0 0 @milestone-node-width;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
&:last-of-type {
|
// 圆点之间的连接线:从本节点圆点右缘连到下一节点圆点左缘
|
||||||
border-left-color: transparent;
|
&:not(:last-child)::after {
|
||||||
padding-bottom: 0;
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: (@milestone-dot-size / 2 - 1px);
|
||||||
|
left: calc(50% + @milestone-dot-size / 2);
|
||||||
|
width: calc(100% - @milestone-dot-size);
|
||||||
|
height: 2px;
|
||||||
|
background: @milestone-idle;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-done {
|
&.is-link-done:not(:last-child)::after {
|
||||||
border-left-color: @success-color;
|
background: @milestone-done;
|
||||||
}
|
|
||||||
|
|
||||||
&.is-active {
|
|
||||||
border-left-color: @primary-color;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__dot {
|
&__dot {
|
||||||
position: absolute;
|
.flex(row, center, center);
|
||||||
top: 2px;
|
position: relative;
|
||||||
left: -7px;
|
z-index: 1;
|
||||||
width: 12px;
|
width: @milestone-dot-size;
|
||||||
height: 12px;
|
height: @milestone-dot-size;
|
||||||
border: 2px solid @border-color-dark;
|
border: 2px solid @milestone-idle;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @white;
|
background: @white;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
.is-done & {
|
.is-done & {
|
||||||
border-color: @success-color;
|
border-color: @milestone-done;
|
||||||
background: @success-color;
|
background: @milestone-done;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-active & {
|
.is-active & {
|
||||||
border-color: @primary-color;
|
border-width: 3px;
|
||||||
background: @primary-color;
|
border-color: @project-accent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__body {
|
&__check {
|
||||||
.flex(column, flex-start, stretch);
|
width: 16px;
|
||||||
gap: 2px;
|
height: 16px;
|
||||||
}
|
|
||||||
|
|
||||||
&__row {
|
|
||||||
.flex(row, center);
|
|
||||||
gap: @spacing-sm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
|
margin-top: @spacing-sm;
|
||||||
|
max-width: 100%;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
color: @heading-color;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
text-align: center;
|
||||||
|
|
||||||
&__status {
|
|
||||||
font-size: @font-size-sm;
|
|
||||||
color: @text-color-secondary;
|
color: @text-color-secondary;
|
||||||
|
.multi-ellipsis(2);
|
||||||
|
|
||||||
.is-done & {
|
.is-done & {
|
||||||
color: @success-color;
|
color: @milestone-done;
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-active & {
|
.is-active & {
|
||||||
color: @primary-color;
|
color: @project-accent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__time {
|
&__sub {
|
||||||
|
margin-top: @spacing-xs;
|
||||||
|
max-width: 100%;
|
||||||
font-size: @font-size-sm;
|
font-size: @font-size-sm;
|
||||||
|
text-align: center;
|
||||||
|
.multi-ellipsis(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
font-size: @font-size-base;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__range {
|
|
||||||
.flex(row, flex-start, center);
|
|
||||||
gap: @spacing-lg;
|
|
||||||
margin-top: @spacing-base;
|
margin-top: @spacing-base;
|
||||||
padding-top: @spacing-base;
|
padding-left: 21px;
|
||||||
border-top: 1px solid @border-color;
|
font-size: @font-size-base;
|
||||||
font-size: @font-size-sm;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -48,9 +48,20 @@ function typeLabel(value: string | null | undefined): string {
|
|||||||
|
|
||||||
function statusLabel(value: string | null | undefined): string {
|
function statusLabel(value: string | null | undefined): string {
|
||||||
if (!value) return '—'
|
if (!value) return '—'
|
||||||
|
// 后端已用字典翻成中文(「进行中」…);码值也能认,认不出就原样显示
|
||||||
return PROJECT_STATUS_LABEL[value] ?? value
|
return PROJECT_STATUS_LABEL[value] ?? value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否已完成(决定是否给「申请售后」入口)。
|
||||||
|
*
|
||||||
|
* <p>⚠️ 后端状态自字典翻译后下发的是**中文**「已完成」,码值 `COMPLETED` 也可能出现
|
||||||
|
* (字典缺项 / 直连子表)⇒ 两种都认。只比码值会让售后按钮永远不出现。</p>
|
||||||
|
*/
|
||||||
|
function isCompleted(value: string | null | undefined): boolean {
|
||||||
|
return value === 'COMPLETED' || value === '已完成'
|
||||||
|
}
|
||||||
|
|
||||||
function progressLabel(value: number | null | undefined): string {
|
function progressLabel(value: number | null | undefined): string {
|
||||||
return value == null ? '—' : `${value}%`
|
return value == null ? '—' : `${value}%`
|
||||||
}
|
}
|
||||||
@@ -82,7 +93,7 @@ function progressLabel(value: number | null | undefined): string {
|
|||||||
<td>{{ statusLabel(item.status) }}</td>
|
<td>{{ statusLabel(item.status) }}</td>
|
||||||
<td class="project-table__ops">
|
<td class="project-table__ops">
|
||||||
<button
|
<button
|
||||||
v-if="item.status === 'COMPLETED'"
|
v-if="isCompleted(item.status)"
|
||||||
type="button"
|
type="button"
|
||||||
class="project-table__link"
|
class="project-table__link"
|
||||||
@click="emit('afterSales', item.id)"
|
@click="emit('afterSales', item.id)"
|
||||||
|
|||||||
@@ -13,80 +13,86 @@ defineProps<Props>()
|
|||||||
<h2 class="project-team__title">
|
<h2 class="project-team__title">
|
||||||
项目团队
|
项目团队
|
||||||
</h2>
|
</h2>
|
||||||
<div class="project-team__list">
|
|
||||||
<div
|
<ul v-if="team.length > 0" class="project-team__list">
|
||||||
|
<li
|
||||||
v-for="(member, index) in team"
|
v-for="(member, index) in team"
|
||||||
:key="`${member.name}-${index}`"
|
:key="`${member.name}-${index}`"
|
||||||
class="project-team__card"
|
class="project-team__item"
|
||||||
>
|
>
|
||||||
<span class="project-team__avatar">{{ member.avatarChar }}</span>
|
<span class="project-team__avatar">{{ member.avatarChar }}</span>
|
||||||
<div class="project-team__info">
|
<span class="project-team__name">{{ member.name || '—' }}</span>
|
||||||
<span class="project-team__role text-secondary">{{ member.role }}</span>
|
<span class="project-team__role text-secondary">{{ member.role }}</span>
|
||||||
<span class="project-team__name">{{ member.name }}</span>
|
</li>
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
|
||||||
<p v-if="team.length === 0" class="project-team__empty text-secondary">
|
<p v-else class="project-team__empty text-secondary">
|
||||||
暂无团队成员
|
暂无团队成员
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="less" scoped>
|
<style lang="less" scoped>
|
||||||
|
// 设计稿实测:头像直径 90 设计px、相邻头像中心距 183 设计px → ×0.77 得 69px / 141px
|
||||||
|
@team-avatar-size: 68px;
|
||||||
|
@team-avatar-gap: 72px;
|
||||||
|
|
||||||
.project-team {
|
.project-team {
|
||||||
padding: @spacing-lg @spacing-xl;
|
.flex(column, flex-start, stretch);
|
||||||
|
padding: @spacing-lg;
|
||||||
border-radius: @radius-lg;
|
border-radius: @radius-lg;
|
||||||
background: @white;
|
background: @white;
|
||||||
box-shadow: @shadow-base;
|
box-shadow: @shadow-base;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
margin-bottom: @spacing-base;
|
.section-title();
|
||||||
font-size: @font-size-lg;
|
|
||||||
font-weight: 600;
|
|
||||||
color: @heading-color;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__list {
|
&__list {
|
||||||
.flex(row, flex-start, center);
|
.flex(row, flex-start, flex-start);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: @spacing-base;
|
gap: @spacing-base @team-avatar-gap;
|
||||||
|
margin-top: @spacing-lg;
|
||||||
|
padding-left: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__card {
|
&__item {
|
||||||
.flex(row, center);
|
.flex(column, flex-start, center);
|
||||||
gap: @spacing-sm;
|
width: @team-avatar-size;
|
||||||
padding: @spacing-sm @spacing-base;
|
|
||||||
border: 1px solid @border-color;
|
|
||||||
border-radius: @radius-base;
|
|
||||||
background: @bg-color-light;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__avatar {
|
&__avatar {
|
||||||
.flex(row, center);
|
.flex(row, center, center);
|
||||||
width: 40px;
|
width: @team-avatar-size;
|
||||||
height: 40px;
|
height: @team-avatar-size;
|
||||||
border-radius: @radius-round;
|
border-radius: @radius-round;
|
||||||
background: @primary-light;
|
background: @team-avatar-bg;
|
||||||
color: @primary-color;
|
color: @white;
|
||||||
font-size: @font-size-lg;
|
font-size: @font-size-title;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
|
||||||
|
|
||||||
&__info {
|
|
||||||
.flex(column, flex-start, stretch);
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__role {
|
|
||||||
font-size: @font-size-sm;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__name {
|
&__name {
|
||||||
font-size: @font-size-base;
|
margin-top: @spacing-sm;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: @font-size-lg;
|
||||||
|
font-weight: 600;
|
||||||
color: @heading-color;
|
color: @heading-color;
|
||||||
|
text-align: center;
|
||||||
|
.ellipsis();
|
||||||
|
}
|
||||||
|
|
||||||
|
&__role {
|
||||||
|
margin-top: @spacing-xs;
|
||||||
|
max-width: 100%;
|
||||||
|
font-size: @font-size-sm;
|
||||||
|
text-align: center;
|
||||||
|
.ellipsis();
|
||||||
}
|
}
|
||||||
|
|
||||||
&__empty {
|
&__empty {
|
||||||
|
margin-top: @spacing-base;
|
||||||
|
padding-left: 18px;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import type { FileInfo } from '@/types/api'
|
|||||||
// 页面容器:数据只经 store,不直接 import api
|
// 页面容器:数据只经 store,不直接 import api
|
||||||
import { useProjectStore } from '@/stores/modules/project'
|
import { useProjectStore } from '@/stores/modules/project'
|
||||||
import ProjectInfoCard from './cpns/ProjectInfoCard.vue'
|
import ProjectInfoCard from './cpns/ProjectInfoCard.vue'
|
||||||
import ProjectTeam from './cpns/ProjectTeam.vue'
|
import ProjectProgress from './cpns/ProjectProgress.vue'
|
||||||
import ProjectMilestones from './cpns/ProjectMilestones.vue'
|
import ProjectMilestones from './cpns/ProjectMilestones.vue'
|
||||||
|
import ProjectTeam from './cpns/ProjectTeam.vue'
|
||||||
import ProjectFiles from './cpns/ProjectFiles.vue'
|
import ProjectFiles from './cpns/ProjectFiles.vue'
|
||||||
import ProjectFinance from './cpns/ProjectFinance.vue'
|
import ProjectFinance from './cpns/ProjectFinance.vue'
|
||||||
import ProjectAfterSales from './cpns/ProjectAfterSales.vue'
|
import ProjectAfterSales from './cpns/ProjectAfterSales.vue'
|
||||||
@@ -19,12 +20,13 @@ type DetailTab = 'progress' | 'files' | 'finance' | 'afterSales'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
const { current } = storeToRefs(projectStore)
|
const { current, loadingTab } = storeToRefs(projectStore)
|
||||||
|
|
||||||
const projectId = computed(() => String(route.params.id))
|
const projectId = computed(() => String(route.params.id))
|
||||||
const activeTab = ref<DetailTab>('progress')
|
const activeTab = ref<DetailTab>('progress')
|
||||||
const cancelVisible = ref(false)
|
const cancelVisible = ref(false)
|
||||||
|
|
||||||
|
// 设计稿:页签是「文字 + 激活项蓝色下划线」(不是胶囊按钮组),顺序固定
|
||||||
const tabs: Array<{ label: string; value: DetailTab }> = [
|
const tabs: Array<{ label: string; value: DetailTab }> = [
|
||||||
{ label: '项目进度', value: 'progress' },
|
{ label: '项目进度', value: 'progress' },
|
||||||
{ label: '项目文件', value: 'files' },
|
{ label: '项目文件', value: 'files' },
|
||||||
@@ -37,16 +39,26 @@ function load(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚠️ 上传 / 删除附件、售后提交都还没有后端实现
|
* 点击页签:**每次都调一次接口**,取该页签的最新数据。
|
||||||
* (`ProjectRequirementUserController` 只有 create / list / get / serviceTypeList)。
|
*
|
||||||
* 所以这里只提示「待接入」,**不往详情里塞本地假数据** —— 详情数据现在来自真实接口,
|
* <p>后端详情本就是「4 个页签 4 个端点」,所以点一次 = 一次请求,且只请求被点的那一支;
|
||||||
* 混入前端自造条目会让刷新前后不一致。
|
* 重复点击当前页签也会重取(用于拿到别处改动后的最新值)。竞态由 store 的请求序号兜住
|
||||||
|
* ——切项目、连点同一页签都不会把旧响应盖回页面。</p>
|
||||||
*/
|
*/
|
||||||
function onUpload(file: File): void {
|
function onTabClick(tab: DetailTab): void {
|
||||||
void file
|
activeTab.value = tab
|
||||||
ElMessage.info('附件上传接口待接入')
|
void projectStore.refreshTab(tab)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写操作的现状(2026-09-28):
|
||||||
|
* · **售后已接真实接口** —— 申请跳 `/project/:id/after-sales`(`/afterSales/request`),
|
||||||
|
* 取消走 `/afterSales/withdraw`;两者成功后都会重拉售后页签;
|
||||||
|
* · **需求附件的删除 / 预览 / 下载仍无用户端接口**(上传入口在需求提报页),
|
||||||
|
* 所以这里只提示「待接入」,**不往详情里塞本地假数据** —— 详情数据来自真实接口,
|
||||||
|
* 混入前端自造条目会让刷新前后不一致。
|
||||||
|
* 另外详情页「只展示文件」:上传入口在需求提报页,这里不提供。
|
||||||
|
*/
|
||||||
function onDeleteFile(index: number): void {
|
function onDeleteFile(index: number): void {
|
||||||
void index
|
void index
|
||||||
ElMessage.info('附件删除接口待接入')
|
ElMessage.info('附件删除接口待接入')
|
||||||
@@ -82,7 +94,8 @@ function onApplyAfterSales(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onConfirmCancel(): void {
|
function onConfirmCancel(): void {
|
||||||
void projectStore.cancelAfterSales()
|
// 撤回接口收的是**需求 id**(后端按需求校验归属)
|
||||||
|
void projectStore.cancelAfterSales(projectId.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(projectId, load)
|
watch(projectId, load)
|
||||||
@@ -102,33 +115,37 @@ onMounted(load)
|
|||||||
|
|
||||||
<template v-if="current">
|
<template v-if="current">
|
||||||
<ProjectInfoCard :project="current" />
|
<ProjectInfoCard :project="current" />
|
||||||
<ProjectTeam :team="current.team" />
|
|
||||||
|
|
||||||
<!-- 本地 Tab 切换 -->
|
<!-- 本地 Tab 切换(设计稿:文字 + 激活项下划线) -->
|
||||||
<div class="project-detail__tabs">
|
<div class="project-detail__tabs">
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
:key="tab.value"
|
:key="tab.value"
|
||||||
class="project-detail__tab"
|
class="project-detail__tab"
|
||||||
:class="{ 'is-active': activeTab === tab.value }"
|
:class="{
|
||||||
@click="activeTab = tab.value"
|
'is-active': activeTab === tab.value,
|
||||||
|
'is-loading': loadingTab === tab.value,
|
||||||
|
}"
|
||||||
|
@click="onTabClick(tab.value)"
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProjectMilestones
|
<!-- 「项目进度」页签 = 开发进度 → 里程碑 → 项目团队(顺序按设计稿) -->
|
||||||
v-if="activeTab === 'progress'"
|
<template v-if="activeTab === 'progress'">
|
||||||
|
<ProjectProgress
|
||||||
:progress="current.progress"
|
:progress="current.progress"
|
||||||
:milestones="current.milestones"
|
|
||||||
:start-time="current.startTime"
|
:start-time="current.startTime"
|
||||||
:end-time="current.endTime"
|
:end-time="current.endTime"
|
||||||
/>
|
/>
|
||||||
|
<ProjectMilestones :milestones="current.milestones" />
|
||||||
|
<ProjectTeam :team="current.team" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<ProjectFiles
|
<ProjectFiles
|
||||||
v-if="activeTab === 'files'"
|
v-if="activeTab === 'files'"
|
||||||
:files="current.files"
|
:files="current.files"
|
||||||
@upload="onUpload"
|
|
||||||
@delete="onDeleteFile"
|
@delete="onDeleteFile"
|
||||||
@preview="onPreviewFile"
|
@preview="onPreviewFile"
|
||||||
@download="onDownloadFile"
|
@download="onDownloadFile"
|
||||||
@@ -144,10 +161,11 @@ onMounted(load)
|
|||||||
@download="onFinanceDownload"
|
@download="onFinanceDownload"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 售后页签三形态:项目未完成 / 已完成未提交 / 已完成已提交(详见组件内注释) -->
|
||||||
<ProjectAfterSales
|
<ProjectAfterSales
|
||||||
v-if="activeTab === 'afterSales'"
|
v-if="activeTab === 'afterSales'"
|
||||||
:after-sales="current.afterSales"
|
:after-sales="current.afterSales"
|
||||||
:project-id="current.id"
|
:project-status="current.status"
|
||||||
@apply="onApplyAfterSales"
|
@apply="onApplyAfterSales"
|
||||||
@cancel="cancelVisible = true"
|
@cancel="cancelVisible = true"
|
||||||
/>
|
/>
|
||||||
@@ -178,36 +196,49 @@ onMounted(load)
|
|||||||
padding-bottom: @spacing-xxl;
|
padding-bottom: @spacing-xxl;
|
||||||
|
|
||||||
&__breadcrumb {
|
&__breadcrumb {
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设计稿:页签无白底容器,仅一行文字 + 激活项 2px 下划线;相邻间距实测 ≈50px
|
||||||
&__tabs {
|
&__tabs {
|
||||||
.flex(row, flex-start);
|
.flex(row, flex-start, center);
|
||||||
gap: @spacing-xs;
|
gap: 50px;
|
||||||
padding: @spacing-xs;
|
padding: 0 @spacing-base;
|
||||||
border-radius: @radius-lg;
|
|
||||||
background: @white;
|
|
||||||
box-shadow: @shadow-base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__tab {
|
&__tab {
|
||||||
height: 34px;
|
position: relative;
|
||||||
padding: 0 @spacing-base;
|
padding: 0 0 @spacing-sm;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: @radius-base;
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
font-size: @font-size-base;
|
font-size: @font-size-base;
|
||||||
transition: all 0.2s;
|
line-height: 20px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: @primary-color;
|
color: @project-accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新中:轻微变淡 + 进度光标(本地接口很快,做太重的 loading 反而闪屏)
|
||||||
|
&.is-loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
background: @primary-light;
|
color: @project-accent;
|
||||||
color: @primary-color;
|
|
||||||
font-weight: 600;
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: @project-accent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const { query, list, total, typeCounts } = storeToRefs(projectStore)
|
|||||||
/** 是否处于筛选态(决定空态文案:没筛过 = 本来就没项目,筛过 = 没有符合条件的) */
|
/** 是否处于筛选态(决定空态文案:没筛过 = 本来就没项目,筛过 = 没有符合条件的) */
|
||||||
const filtered = computed(() => Boolean(
|
const filtered = computed(() => Boolean(
|
||||||
query.value.keyword
|
query.value.keyword
|
||||||
|| query.value.businessType
|
|| query.value.businessTypes.length > 0
|
||||||
|| query.value.status
|
|| query.value.status
|
||||||
|| query.value.timeShortcut !== 'all'
|
|| query.value.timeShortcut !== 'all'
|
||||||
|| query.value.startDate
|
|| query.value.startDate
|
||||||
|
|||||||
Reference in New Issue
Block a user