项目中心

This commit is contained in:
wangzzpeng
2026-09-28 17:47:07 +08:00
parent 43c2dc0d8d
commit b014fffacf
23 changed files with 1630 additions and 1577 deletions

View File

@@ -1,16 +1,15 @@
import type { FileInfo } from '@/types/api'
import type {
AfterSales,
AfterSalesAttachment,
AfterSalesPayload,
Project,
AfterSalesUploadVo,
ProjectListResult,
ProjectQuery,
ProjectStatistics,
RequirementDetailVo,
RdProjectVo,
RequirementDetailBundle,
} from '@/types/business/project'
import { request } from '@/utils/request'
import { request, serializeQuery } from '@/utils/request'
import { formatDate } from '@/utils/format'
import { toProjectDetail } from '@/utils/projectVo'
// ─────────────────────────────────────────────────────────────────────────────
// 列表:真实接口
@@ -21,7 +20,7 @@ import { toProjectDetail } from '@/utils/projectVo'
// 鉴权:需登录态(匿名 401);每请求带 clientid 头(见 utils/request.ts 拦截器)。
// 筛选参数实测(tools/probe_project_api.py):
// key → like projectName OR projectNo OR requirementDesc
// businessType → eq business_type(值是提报表主键 id,不是类型名)
// businessTypes → IN business_type(**多选**;值是提报表主键 id,不是类型名)
// status → eq status(码值,见 RdProjectStatus)
// beginTime/endTime → between create_time(不是 submit_time!)
// pageNum/pageSize → PageQuery
@@ -83,12 +82,17 @@ export function getProjectList(query: ProjectQuery): Promise<ProjectListResult>
return request<ProjectListResult>({
url: '/web/requirementPool/list',
method: 'get',
// 多选数组必须走 serializeQuery,展开成 businessTypes[0]=…&businessTypes[1]=…
//(与 api/cases.ts 同一做法;不传则走 axios 默认的 businessTypes[]=,实测后端也能绑,
// 但这里取与文档/案例库一致的索引形式,避免依赖框架默认行为)
paramsSerializer: { serialize: serializeQuery },
params: {
pageNum: query.pageNum,
pageSize: query.pageSize,
// 空串不出现在 querystring 里,与后端 eqIfText 的「有值才等值匹配」语义一致
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,
...toTimeRange(query),
},
@@ -108,240 +112,177 @@ export function getProjectFilterConditions(): Promise<ProjectStatistics> {
})
}
// ─────────────────────────────────────────────────────────────────────────────
// 详情:真实接口
// GET /web/requirementPool/get/{id} → ProjectRequirementUserController#get
// → ProjectRequirementServiceImpl#queryRequirementByUserId
// 出参 ProjectRequirementUserVo,经 utils/projectVo.ts 映射成详情页沿用的 Project 形态。
// ⚠️ id 传的是**需求 id**(vm_project_requirement.id)= 列表行 RdProjectVo.id。
// 未立项的需求也能查到(项目侧字段为空);不存在 / 非本人 → 后端 data 为 null。
//
// 文件 / 售后:**仍是前端 mock** —— 该 Controller 只有 create / list / get /
// serviceTypeList 四个接口,没有附件上传、售后提交/取消的后端实现。
// ─────────────────────────────────────────────────────────────────────────────
/** 构造 mock 文件信息 */
function mockFile(name: string, size: number, uploadTime: string): FileInfo {
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}`,
/**
* 已完成项目(申请售后页「项目名称」下拉的候选)。
*
* <p>直接复用列表接口做状态过滤:`status=COMPLETED` 在后端比的是
* `COALESCE(vm_rd_project.status, vm_project_requirement.status)`,而 `COMPLETED` 只会
* 出现在**项目表**(未立项的需求是 `INITIATING`)⇒ 结果天然就是「自己名下已立项且已完成的项目」,
* 与后端申请售后的准入校验(只有 COMPLETED 可申请)口径一致。</p>
*
* <p>售后申请场景下候选量级不大,一次取回较大分页(后端无专用端点);后端默认排序
* `update_time DESC`。返回行的 `id` = **需求 id**,正是申请接口要传的那个 id。</p>
*/
export function getCompletedProjects(): Promise<RdProjectVo[]> {
return request<ProjectListResult>({
url: '/web/requirementPool/list',
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> {
const info: FileInfo = {
name: file.name,
// ─────────────────────────────────────────────────────────────────────────────
// 详情:真实接口(**4 个页签各一个端点**,同帧并行取回后合并)
// 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
// → 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,
url: vo.url,
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)
}
return Promise.resolve()
}
/** 提交售后工单(mock) */
/**
* 申请售后(用户端)。
*
* <p>`id` 是**需求 id**(= 详情页路由参数);`payload` 的 `reason` / `note` 分别对应
* 页面的「问题描述」与「补充备注」。</p>
*/
export function submitAfterSales(
projectId: string,
id: string,
payload: AfterSalesPayload,
): Promise<AfterSales> {
const result: AfterSales = {
status: '处理中',
type: payload.type,
description: payload.description,
remark: payload.remark,
attachments: [...payload.attachments],
createTime: formatDate(new Date()),
}
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
return Promise.resolve({ ...result, attachments: [...result.attachments] })
): Promise<void> {
return request<void>({
url: '/web/requirementPool/afterSales/request',
method: 'post',
data: {
id,
type: payload.type,
reason: payload.reason,
note: payload.note,
// 空数组是安全的(后端 `appendAttachments` 对空集合直接 return,不 NPE)
attachmentOssIds: payload.attachmentOssIds,
},
})
}
/** 取消售后工单(mock) */
export function cancelAfterSales(projectId: string): Promise<AfterSales> {
const result: AfterSales = {
status: '无',
type: '',
description: '',
remark: '',
attachments: [],
createTime: '',
}
if (projectId === DETAIL_ID) PROJECT_DETAIL.afterSales = result
return Promise.resolve({ ...result })
/**
* 撤回售后申请(用户端)。
*
* <p>`id` 传**需求 id**;`reason` 是撤回原因(选填,后端会存进 `withdraw_reason`)。</p>
*/
export function cancelAfterSales(id: string, reason = ''): Promise<void> {
return request<void>({
url: '/web/requirementPool/afterSales/withdraw',
method: 'post',
data: { id, reason },
})
}

View File

@@ -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',
})
}