252 lines
7.7 KiB
Vue
252 lines
7.7 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { storeToRefs } from 'pinia'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import { ElMessage } from 'element-plus'
|
||
import { ConfirmDialog } from '@/components'
|
||
import type { FileInfo } from '@/types/api'
|
||
// 页面容器:数据只经 store,不直接 import api
|
||
import { useProjectStore } from '@/stores/modules/project'
|
||
import ProjectInfoCard from './cpns/ProjectInfoCard.vue'
|
||
import ProjectProgress from './cpns/ProjectProgress.vue'
|
||
import ProjectMilestones from './cpns/ProjectMilestones.vue'
|
||
import ProjectTeam from './cpns/ProjectTeam.vue'
|
||
import ProjectFiles from './cpns/ProjectFiles.vue'
|
||
import ProjectFinance from './cpns/ProjectFinance.vue'
|
||
import ProjectAfterSales from './cpns/ProjectAfterSales.vue'
|
||
|
||
type DetailTab = 'progress' | 'files' | 'finance' | 'afterSales'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const projectStore = useProjectStore()
|
||
const { current, loadingTab } = storeToRefs(projectStore)
|
||
|
||
const projectId = computed(() => String(route.params.id))
|
||
const activeTab = ref<DetailTab>('progress')
|
||
const cancelVisible = ref(false)
|
||
|
||
// 设计稿:页签是「文字 + 激活项蓝色下划线」(不是胶囊按钮组),顺序固定
|
||
const tabs: Array<{ label: string; value: DetailTab }> = [
|
||
{ label: '项目进度', value: 'progress' },
|
||
{ label: '项目文件', value: 'files' },
|
||
{ label: '合同财务', value: 'finance' },
|
||
{ label: '售后信息', value: 'afterSales' },
|
||
]
|
||
|
||
function load(): void {
|
||
void projectStore.fetchDetail(projectId.value)
|
||
}
|
||
|
||
/**
|
||
* 点击页签:**每次都调一次接口**,取该页签的最新数据。
|
||
*
|
||
* <p>后端详情本就是「4 个页签 4 个端点」,所以点一次 = 一次请求,且只请求被点的那一支;
|
||
* 重复点击当前页签也会重取(用于拿到别处改动后的最新值)。竞态由 store 的请求序号兜住
|
||
* ——切项目、连点同一页签都不会把旧响应盖回页面。</p>
|
||
*/
|
||
function onTabClick(tab: DetailTab): void {
|
||
activeTab.value = tab
|
||
void projectStore.refreshTab(tab)
|
||
}
|
||
|
||
/**
|
||
* 写操作的现状(2026-09-28):
|
||
* · **售后已接真实接口** —— 申请跳 `/project/:id/after-sales`(`/afterSales/request`),
|
||
* 取消走 `/afterSales/withdraw`;两者成功后都会重拉售后页签;
|
||
* · **需求附件的删除 / 预览 / 下载仍无用户端接口**(上传入口在需求提报页),
|
||
* 所以这里只提示「待接入」,**不往详情里塞本地假数据** —— 详情数据来自真实接口,
|
||
* 混入前端自造条目会让刷新前后不一致。
|
||
* 另外详情页「只展示文件」:上传入口在需求提报页,这里不提供。
|
||
*/
|
||
function onDeleteFile(index: number): void {
|
||
void index
|
||
ElMessage.info('附件删除接口待接入')
|
||
}
|
||
|
||
function onPreviewFile(): void {
|
||
// TODO(api): 文件预览接口
|
||
ElMessage.info('文件预览待接口接入')
|
||
}
|
||
|
||
function onDownloadFile(): void {
|
||
// TODO(api): 文件下载接口
|
||
ElMessage.info('文件下载待接口接入')
|
||
}
|
||
|
||
function onPay(): void {
|
||
// TODO: 跳财务中心 `/finance/bills`
|
||
ElMessage.info('请前往财务中心完成支付')
|
||
}
|
||
|
||
function onFinancePreview(file: FileInfo): void {
|
||
// TODO(api): 发票/合同文件预览接口
|
||
ElMessage.info(`「${file.name}」预览待接口接入`)
|
||
}
|
||
|
||
function onFinanceDownload(file: FileInfo): void {
|
||
// TODO(api): 发票/合同文件下载接口
|
||
ElMessage.info(`「${file.name}」下载待接口接入`)
|
||
}
|
||
|
||
function onApplyAfterSales(): void {
|
||
void router.push(`/project/${projectId.value}/after-sales`)
|
||
}
|
||
|
||
function onConfirmCancel(): void {
|
||
// 撤回接口收的是**需求 id**(后端按需求校验归属)
|
||
void projectStore.cancelAfterSales(projectId.value)
|
||
}
|
||
|
||
watch(projectId, load)
|
||
|
||
onMounted(load)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="container project-detail">
|
||
<nav class="project-detail__breadcrumb text-secondary">
|
||
<RouterLink to="/project" class="link">
|
||
项目中心
|
||
</RouterLink>
|
||
<span> > </span>
|
||
<span>项目详情</span>
|
||
</nav>
|
||
|
||
<template v-if="current">
|
||
<ProjectInfoCard :project="current" />
|
||
|
||
<!-- 本地 Tab 切换(设计稿:文字 + 激活项下划线) -->
|
||
<div class="project-detail__tabs">
|
||
<button
|
||
v-for="tab in tabs"
|
||
:key="tab.value"
|
||
class="project-detail__tab"
|
||
:class="{
|
||
'is-active': activeTab === tab.value,
|
||
'is-loading': loadingTab === tab.value,
|
||
}"
|
||
@click="onTabClick(tab.value)"
|
||
>
|
||
{{ tab.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 「项目进度」页签 = 开发进度 → 里程碑 → 项目团队(顺序按设计稿) -->
|
||
<template v-if="activeTab === 'progress'">
|
||
<ProjectProgress
|
||
:progress="current.progress"
|
||
:start-time="current.startTime"
|
||
:end-time="current.endTime"
|
||
/>
|
||
<ProjectMilestones :milestones="current.milestones" />
|
||
<ProjectTeam :team="current.team" />
|
||
</template>
|
||
|
||
<ProjectFiles
|
||
v-if="activeTab === 'files'"
|
||
:files="current.files"
|
||
@delete="onDeleteFile"
|
||
@preview="onPreviewFile"
|
||
@download="onDownloadFile"
|
||
/>
|
||
|
||
<ProjectFinance
|
||
v-if="activeTab === 'finance'"
|
||
:payment-plans="current.paymentPlans"
|
||
:invoice-records="current.invoiceRecords"
|
||
:contract-file="current.contractFile"
|
||
@pay="onPay"
|
||
@preview="onFinancePreview"
|
||
@download="onFinanceDownload"
|
||
/>
|
||
|
||
<!-- 售后页签三形态:项目未完成 / 已完成未提交 / 已完成已提交(详见组件内注释) -->
|
||
<ProjectAfterSales
|
||
v-if="activeTab === 'afterSales'"
|
||
:after-sales="current.afterSales"
|
||
:project-status="current.status"
|
||
@apply="onApplyAfterSales"
|
||
@cancel="cancelVisible = true"
|
||
/>
|
||
</template>
|
||
|
||
<p v-else class="project-detail__empty text-secondary">
|
||
项目不存在或已取消
|
||
</p>
|
||
|
||
<ConfirmDialog
|
||
v-model:visible="cancelVisible"
|
||
title="取消售后"
|
||
message="确定取消当前售后工单吗?"
|
||
confirm-text="确定取消"
|
||
danger
|
||
@confirm="onConfirmCancel"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="less" scoped>
|
||
.project-detail {
|
||
// ⚠️ mixin 签名是 .flex(@direction; @justify; @align) —— 第二参是 justify、**第三参才是 align**。
|
||
// 原来只传两参 ⇒ align 取默认的 center ⇒ 纵向容器里所有卡片被压成内容宽度、水平居中(380px 窄条)。
|
||
.flex(column, flex-start, stretch);
|
||
gap: @spacing-base;
|
||
padding-top: @spacing-lg;
|
||
padding-bottom: @spacing-xxl;
|
||
|
||
&__breadcrumb {
|
||
font-size: @font-size-sm;
|
||
}
|
||
|
||
// 设计稿:页签无白底容器,仅一行文字 + 激活项 2px 下划线;相邻间距实测 ≈50px
|
||
&__tabs {
|
||
.flex(row, flex-start, center);
|
||
gap: 50px;
|
||
padding: 0 @spacing-base;
|
||
}
|
||
|
||
&__tab {
|
||
position: relative;
|
||
padding: 0 0 @spacing-sm;
|
||
border: none;
|
||
background: transparent;
|
||
color: @text-color;
|
||
font-size: @font-size-base;
|
||
line-height: 20px;
|
||
transition: color 0.2s;
|
||
|
||
&:hover {
|
||
color: @project-accent;
|
||
}
|
||
|
||
// 刷新中:轻微变淡 + 进度光标(本地接口很快,做太重的 loading 反而闪屏)
|
||
&.is-loading {
|
||
opacity: 0.6;
|
||
cursor: progress;
|
||
}
|
||
|
||
&.is-active {
|
||
color: @project-accent;
|
||
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
right: 0;
|
||
bottom: 0;
|
||
left: 0;
|
||
height: 2px;
|
||
border-radius: 1px;
|
||
background: @project-accent;
|
||
}
|
||
}
|
||
}
|
||
|
||
&__empty {
|
||
padding: @spacing-xxl 0;
|
||
text-align: center;
|
||
font-size: @font-size-base;
|
||
}
|
||
}
|
||
</style>
|