This commit is contained in:
2026-09-14 17:06:55 +08:00
parent 9e28e10e5f
commit 7e29e6c25c
12 changed files with 258 additions and 122 deletions
@@ -1,11 +1,9 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/modules/customers/customerDensity.css'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowLeft, Building2, Eye,
FileCheck2, FileText, Pencil, Power,
UserRound, UsersRound,
} from '@lucide/vue'
import { Building2, FileCheck2, FileText, UserRound, UsersRound } from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
changePlatformTenantStatus,
@@ -166,7 +164,7 @@ async function changeStatus() {
const verb = next === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(`确认${verb}客户“${customer.value.tenantName}”吗?`, `${verb}客户`, {
type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消',
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消',
})
statusChanging.value = true
customer.value = await changePlatformTenantStatus(tenantId.value, next)
@@ -217,7 +215,7 @@ onBeforeUnmount(() => {
FORM: 精确继承原型的详情工作台结构,以现有后台组件体系实现。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
<AdminPage as="div" class="customer-detail-page">
<AdminPage as="div" class="customer-detail-page admin-density customer-density">
<LoadErrorState v-if="loadError" title="客户详情暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadDetail">
<template #details><ApiErrorMeta :details="loadError" /></template>
</LoadErrorState>
@@ -225,14 +223,13 @@ onBeforeUnmount(() => {
<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>
<el-button class="back-button" @click="router.push({ name: 'customers' })">返回列表</el-button>
<PageToolbar class="detail-page-toolbar" as="section" :title="customer?.tenantName || '客户详情'" :description="customer?.tenantCode || '客户资料'">
<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') && customer.status !== 'ARCHIVED'" @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>
<el-button v-if="access.can('platform:customer:edit') && customer.status !== 'ARCHIVED'" @click="goEdit">编辑信息</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">{{ customer.status === 'ACTIVE' ? '停用账号' : '启用账号' }}</el-button>
</div>
</template>
</PageToolbar>
@@ -271,14 +268,14 @@ onBeforeUnmount(() => {
<div class="panel-title"><div><h2>资质文件</h2><p>共 {{ qualifications.length }} 份</p></div><FileText :size="19" /></div>
<div v-if="qualifications.length" class="qualification-list">
<button v-for="item in qualifications" :key="String(item.id)" type="button" :disabled="previews[String(item.id)]?.loading" @click="openPreview(item)">
<span class="file-icon"><FileText :size="18" /></span><span><strong>{{ qualificationLabel(item.qualificationType) }}</strong><small>{{ qualificationName(item) }}</small></span><el-tag :type="qualificationStatus(item.status).type" effect="light" size="small">{{ qualificationStatus(item.status).label }}</el-tag><span class="file-date">{{ item.validTo || '长期有效' }}</span><Eye :size="17" />
<span><strong>{{ qualificationLabel(item.qualificationType) }}</strong><small>{{ qualificationName(item) }}</small></span><el-tag :type="qualificationStatus(item.status).type" effect="light" size="small">{{ qualificationStatus(item.status).label }}</el-tag><span class="file-date">{{ item.validTo || '长期有效' }}</span><span class="file-preview-label">预览</span>
</button>
</div>
<div v-else class="section-empty"><FileCheck2 :size="22" /><strong>暂无资质文件</strong><span>客户尚未上传企业资质</span></div>
</section>
<section class="detail-panel contact-panel">
<div class="panel-title"><div><h2>联系与合作信息</h2><p>客户联络及内部跟进信息</p></div><UserRound :size="19" /></div>
<dl class="info-grid">
<dl class="info-grid info-grid--three">
<div><dt>联系人</dt><dd>{{ displayText(customer.contactName) }}</dd></div><div><dt>公司联系电话</dt><dd>{{ displayText(customer.contactPhone) }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ displayText(customer.contactEmail) }}</dd></div><div><dt>联系微信</dt><dd>{{ displayText(customer.contactWechat) }}</dd></div>
<div><dt>客户套餐</dt><dd>{{ displayText(customer.servicePackage) }}</dd></div><div><dt>账期</dt><dd>{{ customer.paymentTermDays ?? 0 }} 天</dd></div>
@@ -310,7 +307,7 @@ onBeforeUnmount(() => {
</div>
</DetailPageLayout>
<AppDialog v-model="previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
<AppDialog class="admin-density-dialog customer-density-dialog" 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>
</AppDialog>
</AdminPage>
@@ -320,7 +317,6 @@ onBeforeUnmount(() => {
.customer-detail-page { min-width: 0; }
.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:linear-gradient(135deg,rgba(102,126,234,.18),rgba(240,147,251,.18)); color:#667eea; box-shadow:0 4px 12px rgba(102,126,234,.16); }
.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; }
@@ -336,12 +332,12 @@ onBeforeUnmount(() => {
.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid var(--cc-border); }
.panel-title h2 { margin:0; color:#344054; font-size:16px; }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; }.panel-title > svg { color:var(--cc-primary); }
.info-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; margin:0; }
.info-grid--three { grid-template-columns:repeat(3,minmax(0,1fr)); }
.info-grid > div { min-width:0; padding:12px 14px; border-radius:10px; background:#f8f9fc; }.info-grid > div.wide { grid-column:1 / -1; }
.info-grid dt { margin-bottom:5px; color:var(--cc-muted); font-size:11px; }.info-grid dd { margin:0; color:#25324a; font-size:13px; font-weight:650; line-height:1.55; overflow-wrap:anywhere; }
.qualification-list { display:flex; flex-direction:column; gap:8px; }
.qualification-list button { display:grid; width:100%; grid-template-columns:38px minmax(0,1fr) auto auto 20px; align-items:center; gap:10px; padding:10px; border:1px solid var(--cc-border); border-radius:10px; background:#fff; color:inherit; text-align:left; cursor:pointer; }
.qualification-list button:hover { border-color:#b9c3f5; background:#fafbff; }.qualification-list button:disabled { cursor:wait; opacity:.65; }
.file-icon { display:grid; width:38px; height:38px; place-items:center; border-radius:9px; background:linear-gradient(135deg,rgba(102,126,234,.15),rgba(118,75,162,.1)); color:#667eea; }
.qualification-list strong,.qualification-list small { display:block; }.qualification-list strong { font-size:12px; }.qualification-list small,.file-date { margin-top:3px; color:var(--cc-muted); font-size:10px; }.file-date { margin:0; white-space:nowrap; }
.section-empty,.module-empty { display:flex; min-height:150px; align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }
.section-empty strong,.module-empty strong { color:#475467; font-size:13px; }.section-empty span,.module-empty p { max-width:600px; margin:0; color:var(--cc-muted); font-size:11px; line-height:1.7; }
@@ -353,7 +349,7 @@ table { width:100%; min-width:720px; border-collapse:collapse; font-size:12px; }
.module-panel { min-height:360px; }.module-empty { min-height:260px; }.module-empty > svg { color:#8797b2; }.module-empty p { max-width:520px; font-size:12px; }
.contract-note { 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; }.qualification-panel { order:3; }.user-panel { order:4; } }
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.qualification-panel { order:3; }.user-panel { order:4; }.info-grid--three { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@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; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; } }
@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>