Files
cz-web/src/views/project/detail.vue
2026-09-28 17:47:07 +08:00

252 lines
7.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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> &gt; </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>