项目中心

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

@@ -2,27 +2,32 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import type {
AfterSalesAttachment,
AfterSalesPayload,
Project,
ProjectQuery,
ProjectStatistics,
ProjectTypeCount,
RdProjectVo,
RequirementDetailBundle,
} from '@/types/business/project'
import {
cancelAfterSales as cancelAfterSalesApi,
deleteProjectFile,
getProjectDetail,
getCompletedProjects,
getProjectDetailBundle,
getProjectDetailTab,
getProjectFilterConditions,
getProjectList,
submitAfterSales as submitAfterSalesApi,
uploadProjectFile,
uploadAfterSalesFile as uploadAfterSalesFileApi,
} from '@/api/project'
import type { ProjectDetailTab } from '@/api/project'
import { toProjectDetail } from '@/utils/projectVo'
function createDefaultQuery(): ProjectQuery {
return {
keyword: '',
businessType: '',
businessTypes: [],
status: '',
timeShortcut: 'all',
startDate: '',
@@ -65,6 +70,31 @@ export const useProjectStore = defineStore('project', () => {
/** 业务类型筛选条件是否已拉过(值域在运行期不变,进页面拉一次即可) */
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` ⇒ 页面显示空态;
* 请求本身失败(如 500)也归为 null,不让异常冒到组件里。</p>
* <p>失败只告警不清空:保留上一次的结果,页面下拉至少还有数据;首次即失败则为空数组,
* 申请页会退化成只显示当前项目的单项下拉(见 `after-sales.vue` 的兜底合并)。</p>
*/
async function fetchDetail(id: string): Promise<void> {
async function fetchCompletedProjects(): Promise<void> {
try {
current.value = await getProjectDetail(id)
completedProjects.value = await getCompletedProjects()
}
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
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 {
query.value = next
}
@@ -143,30 +250,35 @@ export const useProjectStore = defineStore('project', () => {
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> {
if (!current.value) return
current.value.afterSales = await submitAfterSalesApi(current.value.id, payload)
ElMessage.success('已提交售后工单')
}
/** 取消售后工单 */
async function cancelAfterSales(): Promise<void> {
if (!current.value) return
current.value.afterSales = await cancelAfterSalesApi(current.value.id)
ElMessage.success('已取消售后工单')
/** 撤回售后申请(成功后就地重拉「售后信息」页签) */
async function cancelAfterSales(id: string): Promise<void> {
await cancelAfterSalesApi(id)
ElMessage.success('售后申请已撤回')
if (current.value?.id === id) await refreshTab('afterSales')
}
return {
@@ -177,15 +289,24 @@ export const useProjectStore = defineStore('project', () => {
typeCounts,
ensureFilterConditions,
fetchList,
/** 已完成项目(申请售后页「项目名称」下拉候选) */
completedProjects,
fetchCompletedProjects,
fetchDetail,
/** 切页签刷新(每次点击都调用,只拉该页签那一支) */
refreshTab,
/** 正在刷新的页签,供按钮显示加载态 */
loadingTab,
setQuery,
load,
search,
resetQuery,
changePage,
uploadFile,
deleteFile,
/** 上传售后附件(申请页用) */
uploadAfterSalesFile,
/** 申请售后(成功后自动刷新售后页签) */
submitAfterSales,
/** 撤回售后申请(成功后自动刷新售后页签) */
cancelAfterSales,
}
})