This commit is contained in:
2026-08-11 19:59:15 +08:00
parent c690a30422
commit 41618847b7
19 changed files with 117 additions and 142 deletions
@@ -108,7 +108,7 @@ describe('CustomerDetailView', () => {
await flushPromises()
expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.textContent?.trim())).toEqual([
'基本信息', '业务概况', '运单记录', '财务账单', '操作日志', '计费规则',
'基本信息', '业务概况', '运单记录', '计费规则',
])
expect(document.body.textContent).toContain('企业用户1人')
expect(document.body.textContent).toContain('1 人正常使用')
@@ -2,8 +2,8 @@
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,
ArrowLeft, BarChart3, Box, Building2, ClipboardList, Eye,
FileCheck2, FileText, Pencil, Power,
Scale, Ship, UserRound, UsersRound,
} from '@lucide/vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -38,7 +38,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import CustomerBillingRulesPanel from '@/modules/customers/components/CustomerBillingRulesPanel.vue'
import CustomerOverseasStorageFeePanel from '@/modules/customers/CustomerOverseasStorageFeePanel.vue'
type DetailTab = 'basic' | 'business' | 'orders' | 'finance' | 'logs' | 'billing'
type DetailTab = 'basic' | 'business' | 'orders' | 'billing'
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
const route = useRoute()
@@ -74,8 +74,6 @@ const tabs: Array<{ key: DetailTab; label: string; icon: typeof ClipboardList }>
{ key: 'basic', label: '基本信息', icon: ClipboardList },
{ key: 'business', label: '业务概况', icon: BarChart3 },
{ key: 'orders', label: '运单记录', icon: Box },
{ key: 'finance', label: '财务账单', icon: CircleDollarSign },
{ key: 'logs', label: '操作日志', icon: History },
{ key: 'billing', label: '计费规则', icon: Scale },
]
@@ -385,7 +383,7 @@ watch(orderPage, (page, previousPage) => {
</dl>
</section>
<section class="detail-panel user-panel">
<div class="panel-title"><div><h2>企业用户</h2><p>当前客户租户下的登录账号</p></div><UsersRound :size="19" /></div>
<div class="panel-title"><div><h2>企业用户</h2><p>当前客户的登录账号</p></div><UsersRound :size="19" /></div>
<div v-if="enterpriseMembers.length" class="user-table-wrap">
<table><thead><tr><th>账号</th><th>成员类型</th><th>联系方式</th><th>角色</th><th>状态</th></tr></thead>
<tbody><tr v-for="member in enterpriseMembers" :key="String(member.membershipId)">
@@ -427,8 +425,6 @@ watch(orderPage, (page, previousPage) => {
<div v-else class="module-empty"><Ship :size="30" /><strong>暂无运单查看权限</strong><p>请联系管理员申请头程订单查看权限。</p></div>
</section>
<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>
</DetailPageLayout>
@@ -455,7 +451,7 @@ watch(orderPage, (page, previousPage) => {
.overview-strip p,.overview-strip span { margin:0; color:var(--cc-muted); font-size:12px; }
.overview-strip strong { display:block; margin:5px 0 4px; color:#172033; font-size:26px; line-height:1; }
.overview-strip small { margin-left:5px; color:#475467; font-size:12px; font-weight:600; }
.detail-tabs { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); overflow-x:auto; border-radius:14px 14px 0 0; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.06); }
.detail-tabs { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); overflow-x:auto; border-radius:14px 14px 0 0; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.06); }
.detail-tabs button { position:relative; display:flex; min-width:128px; min-height:64px; align-items:center; justify-content:center; gap:8px; padding:0 14px; border:0; background:transparent; color:#667085; font-weight:650; cursor:pointer; }
.detail-tabs button::after { position:absolute; right:0; bottom:0; left:0; height:3px; background:transparent; content:""; }
.detail-tabs button:hover { background:#fafbff; color:#344054; }.detail-tabs button.active { background:#f2f3ff; color:var(--cc-primary); }.detail-tabs button.active::after { background:var(--cc-primary); }
@@ -484,7 +480,7 @@ table { width:100%; min-width:720px; border-collapse:collapse; font-size:12px; }
.business-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); overflow:hidden; border:1px solid var(--cc-border); border-radius:12px; }.business-summary > div { display:grid; min-height:130px; place-items:center; align-content:center; gap:8px; border-right:1px solid var(--cc-border); }.business-summary > div:last-child { border-right:0; }.business-summary svg { color:var(--cc-primary); }.business-summary span { color:var(--cc-muted); font-size:11px; }.business-summary strong { color:#25324a; font-size:20px; }
.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:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.detail-tabs { grid-template-columns:repeat(4,150px); }.qualification-panel { order:3; }.user-panel { order:4; } }
@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>
@@ -120,7 +120,7 @@ const qualificationPreviewKind = ref<'image' | 'pdf' | ''>('')
const filters = ref<CustomerFilters>({ keyword: '', status: '' })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', label: '客户关键词', placeholder: '公司名称、客户ID或联系人' },
{ key: 'keyword', type: 'input', label: '客户关键词', placeholder: '公司名称、客户编号或联系人' },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
{ label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' },
{ label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' },
@@ -188,7 +188,7 @@ const unifiedSocialCreditCodePattern = /^[0-9A-Z]{18}$/
const customerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', minWidth: 280, slotName: 'actions' },
{ key: 'tenantCode', prop: 'tenantCode', label: '客户ID', minWidth: 170 },
{ key: 'tenantCode', prop: 'tenantCode', label: '客户编号', minWidth: 170 },
{ key: 'company', label: '公司名称', minWidth: 230, slotName: 'company' },
{ key: 'unifiedSocialCreditCode', prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', minWidth: 205, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
{ key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) },
@@ -1062,7 +1062,7 @@ onMounted(() => { void loadCustomers() })
</el-dialog>
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户ID:{{ passwordTenant.tenantCode }}</span></div>
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
<el-alert v-if="passwordError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ passwordError.message }}</span><ApiErrorMeta :details="passwordError" /></div></template></el-alert>
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top">
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
@@ -1074,7 +1074,7 @@ onMounted(() => { void loadCustomers() })
</el-dialog>
<el-dialog v-model="impersonationDialogOpen" title="跳转登录客户工作台" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting">
<div v-if="impersonationTenant" class="decision-summary"><div><strong>{{ impersonationTenant.tenantName }}</strong><span>将以企业管理员身份打开客户工作台</span></div><span>客户ID:{{ impersonationTenant.tenantCode }}</span></div>
<div v-if="impersonationTenant" class="decision-summary"><div><strong>{{ impersonationTenant.tenantName }}</strong><span>将以企业管理员身份打开客户工作台</span></div><span>客户编号:{{ impersonationTenant.tenantCode }}</span></div>
<el-alert type="warning" :closable="false" show-icon>本操作会生成一次性、短时有效的客户会话,并记录代登录原因;不会替换当前 Admin 会话。</el-alert>
<el-alert v-if="impersonationError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ impersonationError.message }}</span><ApiErrorMeta :details="impersonationError" /></div></template></el-alert>
<el-form ref="impersonationFormRef" :model="impersonationForm" :rules="impersonationRules" label-position="top"><el-form-item label="代登录原因" prop="reason" required><el-input v-model="impersonationForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写 5-500 字的业务原因" /></el-form-item></el-form>
@@ -28,12 +28,11 @@ const form = reactive({ currency: 'USD', version: 0, tiers: [] as OverseasStorag
const storageTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'STORAGE'))
const returnTiers = computed(() => form.tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE'))
const filters = reactive({ keyword: '' })
const filters = ref({ keyword: '' })
const queryFields = [{ key: 'keyword', label: '客户', type: 'input' as const, placeholder: '搜索客户名称' }]
const columns = [
{ key: 'actions', slotName: 'actions', label: '操作', width: 100, fixed: 'left' as const },
{ key: 'customerName', prop: 'customerName', label: '客户', minWidth: 180 },
{ key: 'customerId', prop: 'customerId', label: '客户ID', minWidth: 180 },
{ key: 'currency', prop: 'currency', label: '币种', width: 90 },
{ key: 'storage', slotName: 'storage', label: '仓租(CBM/天)', minWidth: 300 },
{ key: 'returnStorage', slotName: 'returnStorage', label: '退货仓储费(托/天)', minWidth: 300 },
@@ -48,7 +47,7 @@ async function load() {
catch (cause) { error.value = getApiErrorDetails(cause, '海外仓阶梯费用加载失败,请稍后重试') }
finally { loading.value = false }
}
function search() { keyword.value = filters.keyword; page.value = 1; void load() }
function search() { keyword.value = filters.value.keyword; page.value = 1; void load() }
function openEdit(row: OverseasStorageFeeConfig) { editing.value = row; form.currency = row.currency || 'USD'; form.version = row.version; form.tiers = clone(row.tiers); dialogOpen.value = true }
function addTier(feeType: OverseasStorageFeeTier['feeType']) { const message = addFeeTier(form.tiers, feeType); if (message) ElMessage.error(message) }
function removeTier(tier: OverseasStorageFeeTier) { const message = removeFeeTier(form.tiers, tier); if (message) ElMessage.error(message) }