This commit is contained in:
2026-08-10 16:24:54 +08:00
parent e119b48662
commit 820432a381
52 changed files with 1015 additions and 705 deletions
@@ -3,7 +3,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowLeft, BarChart3, Box, Building2, CircleDollarSign, ClipboardList, Eye,
FileCheck2, FileText, History, Pencil, Power, ReceiptText, RefreshCw,
FileCheck2, FileText, History, Pencil, Power, ReceiptText,
Scale, Ship, UserRound, UsersRound,
} from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -29,6 +29,11 @@ import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
@@ -311,37 +316,37 @@ watch(orderPage, (page, previousPage) => {
FORM: 精确继承原型的详情工作台结构,以现有后台组件体系实现。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
<div class="customer-detail-page">
<section v-if="loadError" class="detail-error" role="alert">
<div><strong>客户详情暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div>
<el-button type="primary" :loading="loading" @click="loadDetail"><RefreshCw :size="16" />重新加载</el-button>
</section>
<AdminPage as="div" class="customer-detail-page">
<LoadErrorState v-if="loadError" title="客户详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadDetail">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
<template v-else>
<header class="detail-header">
<div class="detail-heading">
<DetailPageLayout v-else v-loading="loading" aria-label="客户详情">
<template #header>
<header class="detail-header">
<el-button class="back-button" @click="router.push({ name: 'customers' })"><ArrowLeft :size="17" />返回列表</el-button>
<div class="company-mark" aria-hidden="true"><Building2 :size="23" /></div>
<div class="company-title">
<h1>{{ customer?.tenantName || '客户详情' }}</h1>
<p>{{ customer?.tenantCode || tenantId }}</p>
</div>
<el-tag v-if="customer" :type="statusMeta(customer.status).type" effect="light" round>{{ statusMeta(customer.status).label }}</el-tag>
</div>
<div v-if="customer" class="header-actions">
<el-button v-if="access.can('platform:customer:edit')" @click="goEdit"><Pencil :size="16" />编辑信息</el-button>
<el-button v-if="access.can('platform:customer:status') && ['ACTIVE', 'DISABLED'].includes(customer.status)" :type="customer.status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusChanging" @click="changeStatus"><Power :size="16" />{{ customer.status === 'ACTIVE' ? '停用账号' : '启用账号' }}</el-button>
</div>
</header>
<PageToolbar class="detail-page-toolbar" as="section" :title="customer?.tenantName || '客户详情'" :description="customer?.tenantCode || tenantId">
<template #actions>
<div v-if="customer" class="header-actions">
<el-tag :type="statusMeta(customer.status).type" effect="light" round>{{ statusMeta(customer.status).label }}</el-tag>
<el-button v-if="access.can('platform:customer:edit')" @click="goEdit"><Pencil :size="16" />编辑信息</el-button>
<el-button v-if="access.can('platform:customer:status') && ['ACTIVE', 'DISABLED'].includes(customer.status)" :type="customer.status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusChanging" @click="changeStatus"><Power :size="16" />{{ customer.status === 'ACTIVE' ? '停用账号' : '启用账号' }}</el-button>
</div>
</template>
</PageToolbar>
</header>
</template>
<div v-loading="loading" class="detail-content">
<template #summary>
<section class="overview-strip" aria-label="客户概况">
<article><span class="metric-icon is-blue"><Ship :size="20" /></span><div><p>累计运单</p><strong>{{ orderCount ?? '—' }}<small>票</small></strong><span>{{ access.can('platform:order:list') ? '来自头程订单服务' : '暂无运单查看权限' }}</span></div></article>
<article><span class="metric-icon is-green"><UsersRound :size="20" /></span><div><p>企业用户</p><strong>{{ enterpriseMembers.length }}<small>人</small></strong><span>{{ activeEnterpriseMembers }} 人正常使用</span></div></article>
<article><span class="metric-icon is-amber"><FileCheck2 :size="20" /></span><div><p>资质文件</p><strong>{{ qualifications.length }}<small>份</small></strong><span>{{ approvedQualifications }} 份已通过</span></div></article>
</section>
</template>
<div ref="tabList" class="detail-tabs" role="tablist" aria-label="客户详情模块">
<div ref="tabList" class="detail-tabs" role="tablist" aria-label="客户详情模块">
<button v-for="tab in tabs" :id="`customer-tab-${tab.key}`" :key="tab.key" type="button" role="tab" :class="{ active: activeTab === tab.key }" :aria-selected="activeTab === tab.key" :aria-controls="`customer-panel-${tab.key}`" :tabindex="activeTab === tab.key ? 0 : -1" @click="selectTab(tab.key, $event.currentTarget as HTMLElement)" @keydown="handleTabKeydown($event, tab.key)"><component :is="tab.icon" :size="18" /><span>{{ tab.label }}</span></button>
</div>
@@ -425,31 +430,24 @@ watch(orderPage, (page, previousPage) => {
<section v-else-if="activeTab === 'finance'" class="detail-panel module-panel"><div class="panel-title"><div><h2>财务账单</h2><p>客户应收、回款与对账记录</p></div><ReceiptText :size="19" /></div><div class="module-empty"><ReceiptText :size="30" /><strong>财务账单尚未接入</strong><p>当前阶段没有可用的客户账单 API,页面不会使用原型中的演示金额。</p></div></section>
<section v-else-if="activeTab === 'logs'" class="detail-panel module-panel"><div class="panel-title"><div><h2>操作日志</h2><p>客户档案与状态变更记录</p></div><History :size="19" /></div><div class="module-empty"><History :size="30" /><strong>操作日志尚未接入</strong><p>等待审计服务提供按租户查询的只读接口后展示真实操作记录。</p></div></section>
<section v-else class="detail-panel module-panel"><CustomerBillingRulesPanel :customer-id="tenantId" :customer-name="customer.tenantName" :customer-code="customer.tenantCode" /><CustomerOverseasStorageFeePanel :customer-id="tenantId" :customer-name="customer.tenantName" /></section>
</div>
</div>
</template>
</DetailPageLayout>
<el-dialog v-model="previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body>
<AppDialog v-model="previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
<div class="qualification-preview"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
</el-dialog>
</div>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.customer-detail-page { min-width: 0; }
.detail-error { display:flex; align-items:center; justify-content:space-between; gap:20px; padding:20px; border:1px solid #fecdca; border-radius:12px; background:#fffafa; }
.detail-error strong { color:#7a271a; } .detail-error p { margin:5px 0; color:#b54708; font-size:12px; }
.detail-header { display:flex; align-items:center; justify-content:space-between; gap:20px; margin-bottom:18px; }
.detail-heading { display:flex; min-width:0; align-items:center; gap:12px; }
.detail-header { display:flex; min-width:0; align-items:center; gap:12px; }
.back-button { flex:0 0 auto; }
.company-mark { display:grid; width:44px; height:44px; flex:0 0 auto; place-items:center; border-radius:12px; background:#eef1ff; color:var(--cc-primary); }
.company-title { min-width:0; }
.company-title h1 { margin:0; overflow:hidden; color:#172033; font-size:23px; line-height:1.25; text-overflow:ellipsis; white-space:nowrap; }
.company-title p { margin:4px 0 0; color:var(--cc-muted); font-size:12px; }
.detail-page-toolbar { min-width:0; flex:1; }
.header-actions { display:flex; flex:0 0 auto; gap:8px; }
.header-actions :deep(.el-button + .el-button) { margin-left:0; }
.detail-content { min-height:460px; }
.overview-strip { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); overflow:hidden; margin-bottom:18px; border-radius:14px; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.08); }
.overview-strip { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); overflow:hidden; border-radius:14px; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.08); }
.overview-strip article { display:flex; min-width:0; align-items:center; gap:14px; padding:22px 24px; border-right:1px solid var(--cc-border); }
.overview-strip article:last-child { border-right:0; }
.metric-icon { display:grid; width:44px; height:44px; flex:0 0 auto; place-items:center; border-radius:12px; }
@@ -487,6 +485,6 @@ table { width:100%; min-width:720px; border-collapse:collapse; font-size:12px; }
.contract-note { margin-top:18px; padding:16px 18px; border-radius:10px; background:#f8fafc; }.contract-note strong { color:#344054; font-size:12px; }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; line-height:1.7; }
.qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:#f8fafc; }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:#fff; }
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.detail-tabs { grid-template-columns:repeat(6,150px); }.qualification-panel { order:3; }.user-panel { order:4; } }
@media (max-width:720px) { .detail-header { align-items:stretch; flex-direction:column; }.detail-heading { display:grid; grid-template-columns:auto 40px minmax(0,1fr) auto; }.company-mark { width:40px; height:40px; }.company-title h1 { font-size:18px; }.header-actions { display:grid; grid-template-columns:1fr 1fr; }.header-actions :deep(.el-button) { min-height:44px; }.overview-strip { grid-template-columns:1fr; }.overview-strip article { border-right:0; border-bottom:1px solid var(--cc-border); }.overview-strip article:last-child { border-bottom:0; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; }.business-summary { grid-template-columns:1fr; }.business-summary > div { min-height:100px; border-right:0; border-bottom:1px solid var(--cc-border); }.business-summary > div:last-child { border-bottom:0; }.detail-error { align-items:stretch; flex-direction:column; } }
@media (max-width:420px) { .detail-heading { grid-template-columns:1fr auto; }.back-button { grid-column:1 / -1; justify-self:start; }.company-mark { display:none; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } }
@media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.overview-strip { grid-template-columns:1fr; }.overview-strip article { border-right:0; border-bottom:1px solid var(--cc-border); }.overview-strip article:last-child { border-bottom:0; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; }.business-summary { grid-template-columns:1fr; }.business-summary > div { min-height:100px; border-right:0; border-bottom:1px solid var(--cc-border); }.business-summary > div:last-child { border-bottom:0; } }
@media (max-width:420px) { .company-mark { display:none; }.header-actions { grid-template-columns:1fr; }.header-actions :deep(.el-tag) { grid-column:auto; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } }
</style>