优化
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
downloadTenantQualification,
|
||||
getPlatformTenant,
|
||||
listPlatformTenantOrderCounts,
|
||||
listTenantQualifications,
|
||||
type PlatformTenantResource,
|
||||
} from '@/modules/iam'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import CustomerDetailView from './CustomerDetailView.vue'
|
||||
|
||||
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('vue-router')>(),
|
||||
useRoute: () => ({ params: { tenantId: '8' } }),
|
||||
useRouter: () => ({ push: routerPush }),
|
||||
}))
|
||||
|
||||
vi.mock('@/modules/iam', () => ({
|
||||
changePlatformTenantStatus: vi.fn(),
|
||||
downloadTenantQualification: vi.fn(),
|
||||
getPlatformTenant: vi.fn(),
|
||||
listPlatformTenantOrderCounts: vi.fn(),
|
||||
listTenantQualifications: vi.fn(),
|
||||
qualificationFileName: vi.fn((documentUrl: string) => documentUrl.split('/').at(-1) || '资质文件'),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const tenant: PlatformTenantResource = {
|
||||
id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'ACTIVE', legalName: '柏悦物流有限公司',
|
||||
legalRepresentative: '张三', unifiedSocialCreditCode: '91440300MA5ABCDE12', contactName: '张三',
|
||||
contactPhone: '13800138000', contactEmail: 'ops@example.com', servicePackage: '基础版', contactWechat: 'baiyue-ops',
|
||||
salesOwnerName: '李四', paymentTermDays: 30, enterpriseType: '有限责任公司', establishedDate: '2020-01-01',
|
||||
registeredCapital: 1000, registeredCapitalCurrency: 'CNY', registeredAddress: '深圳市宝安区', internalRemark: '重点客户',
|
||||
createdAt: '2026-08-01T10:00:00', approvedAt: '2026-08-02T10:00:00',
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('CustomerDetailView', () => {
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:qualification-preview') })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
||||
document.body.innerHTML = ''
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = ['platform:customer:view', 'platform:user:list', 'platform:order:list']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(getPlatformTenant).mockResolvedValue(tenant)
|
||||
vi.mocked(listTenantQualifications).mockResolvedValue([{
|
||||
id: 'qualification-1', tenantId: tenant.id, qualificationType: 'BUSINESS_LICENSE', certificateNo: tenant.unifiedSocialCreditCode,
|
||||
documentUrl: '/files/license.png', status: 'SUPERSEDED', reviewComment: '已替换', validTo: null,
|
||||
}])
|
||||
vi.mocked(listPlatformTenantOrderCounts).mockResolvedValue([{ tenantId: tenant.id, orderCount: 12 }])
|
||||
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||
})
|
||||
|
||||
it('renders all six modules without inventing unavailable tenant users', async () => {
|
||||
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect([...document.querySelectorAll('.detail-tabs button')].map((button) => button.textContent?.trim())).toEqual([
|
||||
'基本信息', '业务概况', '运单记录', '财务账单', '操作日志', '计费规则',
|
||||
])
|
||||
expect(document.body.textContent).toContain('企业成员数据暂不可用')
|
||||
expect(document.body.textContent).not.toContain('0 人正常使用')
|
||||
expect(document.body.textContent).toContain('已替换')
|
||||
expect(document.body.textContent).toContain('12票')
|
||||
})
|
||||
|
||||
it('opens an authenticated qualification preview', async () => {
|
||||
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
const fileButton = document.querySelector<HTMLButtonElement>('.qualification-list button')
|
||||
fileButton?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(downloadTenantQualification).toHaveBeenCalledWith(tenant.id, '/files/license.png')
|
||||
expect(document.querySelector('img[alt="license.png预览"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,347 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowLeft, BarChart3, Box, Building2, CircleDollarSign, ClipboardList, Eye,
|
||||
FileCheck2, FileText, History, Pencil, Power, ReceiptText, RefreshCw,
|
||||
Scale, Ship, UserRound, UsersRound,
|
||||
} from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
changePlatformTenantStatus,
|
||||
downloadTenantQualification,
|
||||
getPlatformTenant,
|
||||
listPlatformTenantOrderCounts,
|
||||
listTenantQualifications,
|
||||
qualificationFileName,
|
||||
type PlatformTenantQualification,
|
||||
type PlatformTenantResource,
|
||||
type PlatformTenantStatus,
|
||||
} from '@/modules/iam'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
|
||||
type DetailTab = 'basic' | 'business' | 'orders' | 'finance' | 'logs' | 'billing'
|
||||
type PreviewState = { url: string; kind: 'image' | 'pdf'; loading: boolean; failed: boolean }
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const tenantId = computed(() => String(route.params.tenantId ?? ''))
|
||||
const activeTab = ref<DetailTab>('basic')
|
||||
const loading = ref(true)
|
||||
const statusChanging = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const customer = ref<PlatformTenantResource | null>(null)
|
||||
const qualifications = ref<PlatformTenantQualification[]>([])
|
||||
const orderCount = ref<number | null>(null)
|
||||
const previews = ref<Record<string, PreviewState>>({})
|
||||
const previewOpen = ref(false)
|
||||
const previewName = ref('')
|
||||
const previewUrl = ref('')
|
||||
const previewKind = ref<'image' | 'pdf'>('image')
|
||||
const tabList = ref<HTMLElement | null>(null)
|
||||
|
||||
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 },
|
||||
]
|
||||
|
||||
const approvedQualifications = computed(() => qualifications.value.filter((item) => item.status === 'APPROVED').length)
|
||||
|
||||
function displayText(value?: string | null) { return value?.trim() || '—' }
|
||||
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function formatCapital(value: number | string | null, currency: string | null) {
|
||||
if (value === null || value === '') return '—'
|
||||
const numericValue = Number(value)
|
||||
if (!Number.isFinite(numericValue)) return '—'
|
||||
const label = currency === 'USD' ? '美元' : currency === 'HKD' ? '港币' : '人民币'
|
||||
return `${numericValue.toLocaleString('zh-CN')} ${label}`
|
||||
}
|
||||
function statusMeta(status: PlatformTenantStatus) {
|
||||
if (status === 'ACTIVE') return { label: '启用', type: 'success' as const }
|
||||
if (status === 'PROFILE_REQUIRED') return { label: '待完善', type: 'info' as const }
|
||||
if (status === 'PENDING') return { label: '待审核', type: 'warning' as const }
|
||||
if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const }
|
||||
return { label: '已停用', type: 'info' as const }
|
||||
}
|
||||
function qualificationLabel(type: string) {
|
||||
return ({ BUSINESS_LICENSE: '营业执照', LEGAL_PERSON_ID: '法人身份证', FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书', BANK_ACCOUNT_LICENSE: '银行开户许可证' } as Record<string, string>)[type] || type
|
||||
}
|
||||
function qualificationStatus(status: PlatformTenantQualification['status']) {
|
||||
if (status === 'APPROVED') return { label: '已通过', type: 'success' as const }
|
||||
if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const }
|
||||
if (status === 'SUPERSEDED') return { label: '已替换', type: 'info' as const }
|
||||
return { label: '待审核', type: 'warning' as const }
|
||||
}
|
||||
function qualificationName(item: PlatformTenantQualification) {
|
||||
try { return qualificationFileName(item.documentUrl) }
|
||||
catch { return `${qualificationLabel(item.qualificationType)}文件` }
|
||||
}
|
||||
|
||||
function clearPreviews() {
|
||||
for (const preview of Object.values(previews.value)) if (preview.url) URL.revokeObjectURL(preview.url)
|
||||
previews.value = {}
|
||||
previewOpen.value = false
|
||||
}
|
||||
|
||||
async function loadPreview(item: PlatformTenantQualification, notify = true) {
|
||||
const state = previews.value[String(item.id)]
|
||||
if (!state || state.loading) return false
|
||||
state.loading = true
|
||||
try {
|
||||
const blob = await downloadTenantQualification(tenantId.value, item.documentUrl)
|
||||
if (!blob.size) throw new Error('资质文件内容为空')
|
||||
if (state.url) URL.revokeObjectURL(state.url)
|
||||
state.url = URL.createObjectURL(blob)
|
||||
state.kind = blob.type === 'application/pdf' || qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
|
||||
state.failed = false
|
||||
return true
|
||||
} catch (error) {
|
||||
state.failed = true
|
||||
if (notify) ElMessage.error(getApiErrorMessage(error, '资质文件预览加载失败'))
|
||||
return false
|
||||
} finally { state.loading = false }
|
||||
}
|
||||
|
||||
async function openPreview(item: PlatformTenantQualification) {
|
||||
const state = previews.value[String(item.id)]
|
||||
if (!state) return
|
||||
if (!state.url) await loadPreview(item)
|
||||
if (!state.url) return
|
||||
previewName.value = qualificationName(item)
|
||||
previewUrl.value = state.url
|
||||
previewKind.value = state.kind
|
||||
previewOpen.value = true
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
clearPreviews()
|
||||
try {
|
||||
const [tenantResult, qualificationResult] = await Promise.all([
|
||||
getPlatformTenant(tenantId.value),
|
||||
listTenantQualifications(tenantId.value),
|
||||
])
|
||||
customer.value = tenantResult
|
||||
qualifications.value = qualificationResult
|
||||
previews.value = Object.fromEntries(qualificationResult.map((item) => [String(item.id), {
|
||||
url: '', kind: qualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image', loading: false, failed: false,
|
||||
} satisfies PreviewState]))
|
||||
|
||||
const optional: Promise<void>[] = []
|
||||
if (access.can('platform:order:list')) {
|
||||
optional.push(listPlatformTenantOrderCounts([tenantId.value]).then((records) => {
|
||||
orderCount.value = Number(records.find((item) => String(item.tenantId) === tenantId.value)?.orderCount ?? 0)
|
||||
}).catch(() => { orderCount.value = null }))
|
||||
}
|
||||
await Promise.all(optional)
|
||||
} catch (error) {
|
||||
customer.value = null
|
||||
qualifications.value = []
|
||||
loadError.value = getApiErrorDetails(error, '客户详情加载失败,请稍后重试')
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function changeStatus() {
|
||||
if (!customer.value || statusChanging.value) return
|
||||
const next = customer.value.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
|
||||
const verb = next === 'ACTIVE' ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${verb}客户“${customer.value.tenantName}”吗?`, `${verb}客户`, {
|
||||
type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消',
|
||||
})
|
||||
statusChanging.value = true
|
||||
customer.value = await changePlatformTenantStatus(tenantId.value, next)
|
||||
ElMessage.success(`客户已${verb}`)
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorMessage(error, `客户${verb}失败`))
|
||||
} finally { statusChanging.value = false }
|
||||
}
|
||||
|
||||
function goEdit() { void router.push({ name: 'customers', query: { edit: tenantId.value } }) }
|
||||
function selectTab(key: DetailTab, button?: HTMLElement) {
|
||||
activeTab.value = key
|
||||
button?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||||
}
|
||||
function handleTabKeydown(event: KeyboardEvent, key: DetailTab) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
||||
event.preventDefault()
|
||||
const current = tabs.findIndex((item) => item.key === key)
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : event.key === 'ArrowRight' ? (current + 1) % tabs.length : (current - 1 + tabs.length) % tabs.length
|
||||
const button = tabList.value?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[next]
|
||||
button?.focus()
|
||||
selectTab(tabs[next].key, button)
|
||||
}
|
||||
onMounted(loadDetail)
|
||||
onBeforeUnmount(clearPreviews)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
THESIS: 客户档案是一张可核验的运营工作台,不是被抽屉压缩的字段清单。
|
||||
OWN-WORLD: 延续后台冷灰底、白色数据面板、紫蓝主色与克制的状态色。
|
||||
STORY: 运营先确认企业状态,再从六个业务入口查看资料、用户和后续记录。
|
||||
FIRST VIEWPORT: 客户身份与动作居首,三项真实概况紧随其后,六页签固定形成内容索引。
|
||||
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>
|
||||
|
||||
<template v-else>
|
||||
<header class="detail-header">
|
||||
<div class="detail-heading">
|
||||
<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>
|
||||
|
||||
<div v-loading="loading" class="detail-content">
|
||||
<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>—<small>人</small></strong><span>企业成员接口待接入</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>
|
||||
|
||||
<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>
|
||||
|
||||
<div v-if="customer" :id="`customer-panel-${activeTab}`" class="tab-panel" role="tabpanel" :aria-labelledby="`customer-tab-${activeTab}`" tabindex="0">
|
||||
<template v-if="activeTab === 'basic'">
|
||||
<div class="basic-layout">
|
||||
<section class="detail-panel company-panel">
|
||||
<div class="panel-title"><div><h2>企业基本信息</h2><p>工商档案与合作配置</p></div><Building2 :size="19" /></div>
|
||||
<dl class="info-grid">
|
||||
<div><dt>公司名称</dt><dd>{{ displayText(customer.legalName || customer.tenantName) }}</dd></div>
|
||||
<div><dt>统一社会信用代码</dt><dd>{{ displayText(customer.unifiedSocialCreditCode) }}</dd></div>
|
||||
<div><dt>企业类型</dt><dd>{{ displayText(customer.enterpriseType) }}</dd></div>
|
||||
<div><dt>法定代表人</dt><dd>{{ displayText(customer.legalRepresentative) }}</dd></div>
|
||||
<div><dt>成立日期</dt><dd>{{ formatDate(customer.establishedDate).slice(0, 10) }}</dd></div>
|
||||
<div><dt>注册资本</dt><dd>{{ formatCapital(customer.registeredCapital, customer.registeredCapitalCurrency) }}</dd></div>
|
||||
<div class="wide"><dt>注册地址</dt><dd>{{ displayText(customer.registeredAddress) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="detail-panel qualification-panel">
|
||||
<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" />
|
||||
</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">
|
||||
<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>
|
||||
<div><dt>销售人员</dt><dd>{{ displayText(customer.salesOwnerName) }}</dd></div><div><dt>入驻时间</dt><dd>{{ formatDate(customer.createdAt) }}</dd></div>
|
||||
<div class="wide"><dt>内部备注</dt><dd>{{ displayText(customer.internalRemark) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="detail-panel user-panel">
|
||||
<div class="panel-title"><div><h2>企业用户</h2><p>当前客户租户下的登录账号</p></div><UsersRound :size="19" /></div>
|
||||
<div class="section-empty"><UsersRound :size="22" /><strong>企业成员数据暂不可用</strong><span>当前平台用户接口仅返回平台内部账号,不能据此判断该客户是否已有企业用户</span></div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<section v-else-if="activeTab === 'business'" class="detail-panel module-panel">
|
||||
<div class="panel-title"><div><h2>业务概况</h2><p>基于当前已接入服务的客户业务摘要</p></div><BarChart3 :size="19" /></div>
|
||||
<div class="business-summary"><div><Ship :size="20" /><span>累计运单</span><strong>{{ orderCount ?? '—' }} 票</strong></div><div><UsersRound :size="20" /><span>活跃企业用户</span><strong>— 人</strong></div><div><FileCheck2 :size="20" /><span>有效资质</span><strong>{{ approvedQualifications }} 份</strong></div></div>
|
||||
<div class="contract-note"><strong>趋势、港口与收入分析暂未接入</strong><p>原型中的近 6 个月趋势、运输方式分布、目的港和收入贡献依赖后续统计与财务服务;当前不展示模拟图表。</p></div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'orders'" class="detail-panel module-panel">
|
||||
<div class="panel-title"><div><h2>运单记录</h2><p>客户关联头程运单</p></div><Ship :size="19" /></div>
|
||||
<div class="module-empty"><Ship :size="30" /><strong>当前客户累计 {{ orderCount ?? '—' }} 票运单</strong><p>现有平台接口仅提供客户维度运单数量,尚未提供详情页专用明细查询契约。</p><el-button v-if="access.can('platform:order:list')" type="primary" plain @click="router.push('/first-leg-orders')">前往运单管理</el-button></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"><div class="panel-title"><div><h2>计费规则</h2><p>客户专属费用与箱规计价配置</p></div><Scale :size="19" /></div><div class="module-empty"><Scale :size="30" /><strong>计费规则尚未接入</strong><p>当前没有已确认的计费规则服务契约,暂不开放编辑,避免产生无法落库的配置。</p></div></section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body>
|
||||
<div class="qualification-preview"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</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; }
|
||||
.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; }
|
||||
.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 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; }
|
||||
.metric-icon.is-blue { background:#eef1ff; color:#596fde; }.metric-icon.is-green { background:#eaf8f1; color:#159260; }.metric-icon.is-amber { background:#fff5e8; color:#c87519; }
|
||||
.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 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); }
|
||||
.tab-panel { min-height:400px; padding-top:18px; }
|
||||
.basic-layout { display:grid; grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr); gap:18px; align-items:start; }
|
||||
.detail-panel { min-width:0; padding:22px; border-radius:14px; background:#fff; box-shadow:0 8px 22px rgba(31,43,74,.06); }
|
||||
.contact-panel,.user-panel { grid-column:1 / -1; }
|
||||
.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 > 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:#eef1ff; color:var(--cc-primary); }
|
||||
.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; }
|
||||
.section-empty button { border:0; background:transparent; color:var(--cc-primary); cursor:pointer; }.section-empty.is-error svg { color:#d92d20; }
|
||||
.user-table-wrap { overflow-x:auto; }
|
||||
table { width:100%; min-width:720px; border-collapse:collapse; font-size:12px; }th { padding:10px 12px; background:#f8fafc; color:#667085; text-align:left; font-weight:650; }td { padding:12px; border-top:1px solid #edf0f4; color:#344054; }
|
||||
.user-identity { display:flex; align-items:center; gap:9px; }.user-identity > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:50%; background:#eef1ff; color:var(--cc-primary); font-weight:700; }.user-identity strong,.user-identity small { display:block; }.user-identity small { margin-top:3px; color:var(--cc-muted); font-size:10px; }.role-list { display:flex; flex-wrap:wrap; gap:5px; }
|
||||
.module-panel { min-height:360px; }.module-empty { min-height:260px; }.module-empty > svg { color:#8797b2; }.module-empty p { max-width:520px; 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: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; } }
|
||||
</style>
|
||||
@@ -22,11 +22,12 @@ import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import CustomersView from './CustomersView.vue'
|
||||
|
||||
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
|
||||
const { routerPush, routerReplace } = vi.hoisted(() => ({ routerPush: vi.fn(), routerReplace: vi.fn() }))
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('vue-router')>(),
|
||||
useRouter: () => ({ push: routerPush }),
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => ({ push: routerPush, replace: routerReplace }),
|
||||
}))
|
||||
|
||||
vi.mock('@/modules/iam', () => ({
|
||||
@@ -172,72 +173,14 @@ describe('CustomersView', () => {
|
||||
expect(downloadTenantQualification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows uploaded qualifications in customer detail and opens the full preview', async () => {
|
||||
vi.mocked(listTenantQualifications).mockResolvedValue([{
|
||||
id: 'qualification-1', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
|
||||
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/license.png',
|
||||
status: 'PENDING', reviewComment: null, validTo: null,
|
||||
}])
|
||||
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
|
||||
|
||||
it('opens customer detail as an independent route', async () => {
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
tableButtonByText('详情')?.click()
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
const detailPreview = document.querySelector<HTMLButtonElement>('button[aria-label="查看营业执照:license.png"]')
|
||||
expect(detailPreview?.querySelector('img[alt="营业执照缩略图"]')).not.toBeNull()
|
||||
detailPreview?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.querySelector('.qualification-preview img[alt="license.png预览"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not let an older customer detail response overwrite a newer one', async () => {
|
||||
const secondTenant = {
|
||||
...fullTenant, id: '9', tenantCode: 'TEN9', tenantName: '第二家客户', legalName: '第二家客户有限公司',
|
||||
}
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({
|
||||
records: [fullTenant, secondTenant], page: 1, size: 10, total: 2, pages: 1,
|
||||
})
|
||||
let resolveFirst!: (value: PlatformTenantResource) => void
|
||||
let resolveSecond!: (value: PlatformTenantResource) => void
|
||||
vi.mocked(getPlatformTenant)
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve }))
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { resolveSecond = resolve }))
|
||||
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
const detailButtons = [...document.querySelectorAll<HTMLButtonElement>('.el-table__body button')]
|
||||
.filter((button) => button.textContent?.trim() === '详情')
|
||||
detailButtons[0]?.click()
|
||||
detailButtons[1]?.click()
|
||||
|
||||
resolveSecond(secondTenant)
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.customer-detail')?.textContent).toContain('第二家客户有限公司')
|
||||
|
||||
resolveFirst(fullTenant)
|
||||
await flushPromises()
|
||||
const detailText = document.querySelector('.customer-detail')?.textContent
|
||||
expect(detailText).toContain('第二家客户有限公司')
|
||||
expect(detailText).not.toContain('柏悦物流有限公司')
|
||||
})
|
||||
|
||||
it('labels replaced qualification history without presenting it as pending review', async () => {
|
||||
vi.mocked(listTenantQualifications).mockResolvedValue([{
|
||||
id: 'qualification-old', tenantId: fullTenant.id, qualificationType: 'BUSINESS_LICENSE',
|
||||
certificateNo: fullTenant.unifiedSocialCreditCode, documentUrl: '/files/old-license.pdf',
|
||||
status: 'SUPERSEDED', reviewComment: '已替换', validTo: null,
|
||||
}])
|
||||
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
tableButtonByText('详情')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(document.querySelector('.detail-qualification-meta .el-tag')?.textContent).toContain('已替换')
|
||||
expect(routerPush).toHaveBeenCalledWith({ name: 'customer-detail', params: { tenantId: String(fullTenant.id) } })
|
||||
expect(getPlatformTenant).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('saves customer data and selected qualifications with one action', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Building2, CheckCircle2, Download, Eye, FileCheck2, FileText, FileUp, Pencil, Plus, Power, ShieldCheck, ShieldX } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Building2, CheckCircle2, Download, Eye, FileText, FileUp, Pencil, Plus, Power, ShieldCheck, ShieldX } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
@@ -52,14 +52,9 @@ type QualificationDraft = {
|
||||
uploading: boolean
|
||||
error: string
|
||||
}
|
||||
type DetailQualificationPreview = {
|
||||
url: string
|
||||
kind: 'image' | 'pdf'
|
||||
loading: boolean
|
||||
failed: boolean
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
@@ -86,11 +81,6 @@ const savedFormSnapshot = ref('')
|
||||
const editingId = ref<string | null>(null)
|
||||
const editorStatus = ref<PlatformTenantStatus | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<PlatformTenantResource | null>(null)
|
||||
const detailQualifications = ref<PlatformTenantQualification[]>([])
|
||||
const detailQualificationPreviews = ref<Record<string, DetailQualificationPreview>>({})
|
||||
const qualificationLoadError = ref('')
|
||||
const qualificationPreviewOpen = ref(false)
|
||||
const qualificationPreviewName = ref('')
|
||||
@@ -179,7 +169,6 @@ const customerColumns: DataTableColumn[] = [
|
||||
let loadSequence = 0
|
||||
let reviewLoadSequence = 0
|
||||
let editorLoadSequence = 0
|
||||
let detailLoadSequence = 0
|
||||
const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户')
|
||||
const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value))
|
||||
const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || ''))
|
||||
@@ -197,12 +186,6 @@ function statusMeta(status: PlatformTenantStatus) {
|
||||
return { label: '已停用', type: 'info' as const }
|
||||
}
|
||||
|
||||
function qualificationStatusMeta(status: PlatformTenantQualification['status']) {
|
||||
if (status === 'APPROVED') return { label: '已通过', type: 'success' as const }
|
||||
if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const }
|
||||
if (status === 'SUPERSEDED') return { label: '已替换', type: 'info' as const }
|
||||
return { label: '待审核', type: 'warning' as const }
|
||||
}
|
||||
|
||||
async function loadPendingReviews() {
|
||||
const sequence = ++reviewLoadSequence
|
||||
@@ -435,65 +418,6 @@ function handleQualificationPreviewError(draft: QualificationDraft) {
|
||||
if (qualificationPreviewUrl.value === draft.previewUrl) qualificationPreviewOpen.value = false
|
||||
}
|
||||
|
||||
function detailQualificationName(item: PlatformTenantQualification) {
|
||||
try { return qualificationFileName(item.documentUrl) }
|
||||
catch { return `${qualifications.value.find((draft) => draft.type === item.qualificationType)?.label || '资质'}文件` }
|
||||
}
|
||||
|
||||
function clearDetailQualificationPreviews() {
|
||||
for (const preview of Object.values(detailQualificationPreviews.value)) {
|
||||
if (preview.url) URL.revokeObjectURL(preview.url)
|
||||
}
|
||||
detailQualificationPreviews.value = {}
|
||||
qualificationPreviewOpen.value = false
|
||||
}
|
||||
|
||||
async function loadDetailQualificationPreview(item: PlatformTenantQualification, showError = true) {
|
||||
const key = String(item.id)
|
||||
const preview = detailQualificationPreviews.value[key]
|
||||
const tenantId = detail.value?.id
|
||||
if (!preview || !tenantId || !item.documentUrl || preview.loading) return false
|
||||
preview.loading = true
|
||||
try {
|
||||
const blob = await downloadTenantQualification(tenantId, item.documentUrl)
|
||||
if (!blob.size) throw new Error('资质文件内容为空')
|
||||
if (!detailOpen.value || detailQualificationPreviews.value[key] !== preview) return false
|
||||
if (preview.url) URL.revokeObjectURL(preview.url)
|
||||
preview.url = URL.createObjectURL(blob)
|
||||
preview.kind = blob.type === 'application/pdf' || detailQualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
|
||||
preview.failed = false
|
||||
return true
|
||||
} catch (error) {
|
||||
preview.failed = true
|
||||
if (showError) ElMessage.error(getApiErrorMessage(error, '资质文件预览加载失败,请稍后重试'))
|
||||
return false
|
||||
} finally {
|
||||
if (detailQualificationPreviews.value[key] === preview) preview.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetailQualificationPreview(item: PlatformTenantQualification) {
|
||||
const preview = detailQualificationPreviews.value[String(item.id)]
|
||||
if (!preview) return
|
||||
if (preview.failed) {
|
||||
if (preview.url) URL.revokeObjectURL(preview.url)
|
||||
preview.url = ''
|
||||
preview.failed = false
|
||||
}
|
||||
if (!preview.url) await loadDetailQualificationPreview(item)
|
||||
if (!preview.url) return
|
||||
qualificationPreviewName.value = detailQualificationName(item)
|
||||
qualificationPreviewUrl.value = preview.url
|
||||
qualificationPreviewKind.value = preview.kind
|
||||
qualificationPreviewOpen.value = true
|
||||
}
|
||||
|
||||
function handleDetailQualificationPreviewError(item: PlatformTenantQualification) {
|
||||
const preview = detailQualificationPreviews.value[String(item.id)]
|
||||
if (!preview) return
|
||||
preview.failed = true
|
||||
if (qualificationPreviewUrl.value === preview.url) qualificationPreviewOpen.value = false
|
||||
}
|
||||
|
||||
async function uploadSelectedQualifications(tenantId: string) {
|
||||
const failures: string[] = []
|
||||
@@ -565,28 +489,8 @@ async function submitReview() {
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
|
||||
async function showDetail(row: PlatformTenantResource) {
|
||||
const sequence = ++detailLoadSequence
|
||||
const rowId = String(row.id)
|
||||
clearDetailQualificationPreviews()
|
||||
detail.value = row; detailQualifications.value = []; detailOpen.value = true; detailLoading.value = true
|
||||
try {
|
||||
const [tenant, records] = await Promise.all([getPlatformTenant(row.id), listTenantQualifications(row.id)])
|
||||
if (sequence !== detailLoadSequence || !detailOpen.value || String(detail.value?.id) !== rowId) return
|
||||
detail.value = tenant
|
||||
detailQualifications.value = records
|
||||
detailQualificationPreviews.value = Object.fromEntries(records.map((item) => {
|
||||
const kind = detailQualificationName(item).toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
|
||||
return [String(item.id), { url: '', kind, loading: false, failed: false } satisfies DetailQualificationPreview]
|
||||
}))
|
||||
for (const item of records) {
|
||||
if (detailQualificationPreviews.value[String(item.id)]?.kind === 'image') void loadDetailQualificationPreview(item, false)
|
||||
}
|
||||
} catch (error) {
|
||||
if (sequence === detailLoadSequence) ElMessage.error(getApiErrorMessage(error, '客户详情加载失败'))
|
||||
} finally {
|
||||
if (sequence === detailLoadSequence) detailLoading.value = false
|
||||
}
|
||||
function showDetail(row: PlatformTenantResource) {
|
||||
void router.push({ name: 'customer-detail', params: { tenantId: String(row.id) } })
|
||||
}
|
||||
async function toggleStatus(row: PlatformTenantResource) {
|
||||
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; const verb = next === 'ACTIVE' ? '启用' : '停用'
|
||||
@@ -627,7 +531,6 @@ async function submitDecision() {
|
||||
await rejectEnterpriseReview(review.id, decision.comment.trim())
|
||||
}
|
||||
decisionOpen.value = false
|
||||
detailOpen.value = false
|
||||
const successMessage = decision.type === 'reject'
|
||||
? '企业资料已驳回'
|
||||
: approved?.administratorCreated
|
||||
@@ -682,12 +585,6 @@ watch(editorOpen, (open) => {
|
||||
clearQualificationPreviews()
|
||||
}
|
||||
})
|
||||
watch(detailOpen, (open) => {
|
||||
if (!open) {
|
||||
detailLoadSequence += 1
|
||||
clearDetailQualificationPreviews()
|
||||
}
|
||||
})
|
||||
watch(
|
||||
() => [access.can('platform:customer:approve'), access.can('platform:customer:reject')],
|
||||
([canApprove, canReject]) => {
|
||||
@@ -695,8 +592,18 @@ watch(
|
||||
},
|
||||
)
|
||||
onBeforeUnmount(clearQualificationPreviews)
|
||||
onBeforeUnmount(clearDetailQualificationPreviews)
|
||||
onMounted(loadCustomers)
|
||||
onMounted(async () => {
|
||||
await loadCustomers()
|
||||
const editId = typeof route.query.edit === 'string' ? route.query.edit : ''
|
||||
if (!editId || !access.can('platform:customer:edit')) return
|
||||
const row = result.value.records.find((item) => String(item.id) === editId)
|
||||
if (row) await openEdit(row)
|
||||
else {
|
||||
try { await openEdit(await getPlatformTenant(editId)) }
|
||||
catch (error) { ElMessage.error(getApiErrorMessage(error, '客户资料加载失败')) }
|
||||
}
|
||||
void router.replace({ name: 'customers' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -778,55 +685,8 @@ onMounted(loadCustomers)
|
||||
<template #footer><el-button type="primary" @click="closeApprovalResult">我已保存,关闭</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="客户详情" size="620px" class="business-drawer">
|
||||
<div v-loading="detailLoading" class="customer-detail">
|
||||
<template v-if="detail">
|
||||
<div class="detail-title">
|
||||
<span><Building2 :size="22" /></span>
|
||||
<div><h3>{{ detail.tenantName }}</h3><p>{{ detail.tenantCode }}</p></div>
|
||||
<el-tag :type="statusMeta(detail.status).type" effect="light">{{ statusMeta(detail.status).label }}</el-tag>
|
||||
</div>
|
||||
|
||||
<section class="customer-detail-section" aria-labelledby="customer-profile-heading">
|
||||
<h4 id="customer-profile-heading">企业资料</h4>
|
||||
<dl class="customer-detail-grid">
|
||||
<div class="customer-detail-item is-wide"><dt>营业执照企业名称</dt><dd>{{ displayText(detail.legalName) }}</dd></div>
|
||||
<div class="customer-detail-item"><dt>法定代表人</dt><dd>{{ displayText(detail.legalRepresentative) }}</dd></div>
|
||||
<div class="customer-detail-item is-wide"><dt>统一社会信用代码</dt><dd>{{ displayText(detail.unifiedSocialCreditCode) }}</dd></div>
|
||||
<div class="customer-detail-item"><dt>联系人</dt><dd>{{ displayText(detail.contactName) }}</dd></div>
|
||||
<div class="customer-detail-item"><dt>联系电话</dt><dd>{{ displayText(detail.contactPhone) }}</dd></div>
|
||||
<div class="customer-detail-item is-wide"><dt>联系邮箱</dt><dd>{{ displayText(detail.contactEmail) }}</dd></div>
|
||||
<div class="customer-detail-item"><dt>客户套餐</dt><dd>{{ displayText(detail.servicePackage) }}</dd></div>
|
||||
<div class="customer-detail-item"><dt>账期</dt><dd>{{ detail.paymentTermDays ?? 0 }} 天</dd></div>
|
||||
<div class="customer-detail-item"><dt>销售人员</dt><dd>{{ displayText(detail.salesOwnerName) }}</dd></div>
|
||||
<div class="customer-detail-item"><dt>入驻时间</dt><dd>{{ formatDate(detail.createdAt) }}</dd></div>
|
||||
<div class="customer-detail-item is-wide"><dt>注册地址</dt><dd>{{ displayText(detail.registeredAddress) }}</dd></div>
|
||||
<div class="customer-detail-item is-wide"><dt>内部备注</dt><dd>{{ displayText(detail.internalRemark) }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="customer-detail-section detail-qualification-list" aria-labelledby="customer-qualification-heading">
|
||||
<div class="detail-section-heading"><h4 id="customer-qualification-heading">资质文件</h4><span>共 {{ detailQualifications.length }} 份</span></div>
|
||||
<div v-if="detailQualifications.length" class="detail-qualification-items">
|
||||
<button v-for="item in detailQualifications" :key="String(item.id)" type="button" class="detail-qualification-item" :disabled="detailQualificationPreviews[String(item.id)]?.loading" :aria-label="`查看${qualifications.find((draft) => draft.type === item.qualificationType)?.label || item.qualificationType}:${detailQualificationName(item)}`" @click="openDetailQualificationPreview(item)">
|
||||
<span class="detail-qualification-icon">
|
||||
<img v-if="detailQualificationPreviews[String(item.id)]?.url && detailQualificationPreviews[String(item.id)]?.kind === 'image' && !detailQualificationPreviews[String(item.id)]?.failed" :src="detailQualificationPreviews[String(item.id)]?.url" :alt="`${qualifications.find((draft) => draft.type === item.qualificationType)?.label || item.qualificationType}缩略图`" @error="handleDetailQualificationPreviewError(item)" />
|
||||
<FileText v-else :size="20" />
|
||||
<small v-if="detailQualificationPreviews[String(item.id)]?.kind === 'pdf'">PDF</small>
|
||||
</span>
|
||||
<span class="detail-qualification-copy"><strong>{{ qualifications.find((draft) => draft.type === item.qualificationType)?.label || item.qualificationType }}</strong><small class="detail-qualification-file-name">{{ detailQualificationName(item) }}</small><small>{{ item.validTo ? `有效期至 ${item.validTo}` : '长期有效或未设置有效期' }}</small></span>
|
||||
<span class="detail-qualification-meta"><el-tag :type="qualificationStatusMeta(item.status).type" size="small" effect="light">{{ qualificationStatusMeta(item.status).label }}</el-tag><span><Eye :size="14" />{{ detailQualificationPreviews[String(item.id)]?.loading ? '读取中' : (detailQualificationPreviews[String(item.id)]?.failed ? '重试' : '查看') }}</span></span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="detail-empty-qualification"><FileCheck2 :size="20" /><span>暂未上传资质文件</span></div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="detail && pendingReviewByTenantId.has(String(detail.id)) && access.can('platform:customer:reject')" type="danger" plain @click="openDecision(detail, 'reject')">驳回</el-button><el-button v-if="detail && pendingReviewByTenantId.has(String(detail.id)) && access.can('platform:customer:approve')" type="success" @click="openDecision(detail, 'approve')">审核通过</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="decisionOpen" :title="decision.type === 'approve' ? '确认审核通过' : '确认驳回企业资料'" width="min(520px, calc(100vw - 28px))" class="review-decision-dialog" :close-on-click-modal="false">
|
||||
<div v-if="selectedReview" class="decision-summary"><strong>{{ selectedReview.tenantName || detail?.tenantName || '当前客户' }}</strong><span>审核单 {{ selectedReview.id }}</span></div>
|
||||
<div v-if="selectedReview" class="decision-summary"><strong>{{ selectedReview.tenantName || '当前客户' }}</strong><span>审核单 {{ selectedReview.id }}</span></div>
|
||||
<el-form ref="decisionFormRef" :model="decision" :rules="decisionRules" label-position="top">
|
||||
<el-form-item prop="comment" :label="decision.type === 'approve' ? '审核说明' : '驳回原因'" required><el-input v-model="decision.comment" type="textarea" :rows="4" maxlength="300" show-word-limit :placeholder="decision.type === 'approve' ? '请填写审核通过说明' : '请明确说明需要企业修改的内容'" /></el-form-item>
|
||||
</el-form>
|
||||
@@ -843,7 +703,7 @@ onMounted(loadCustomers)
|
||||
.customers-table-actions { display:flex; flex-wrap:wrap; gap:8px; } .customers-table-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.table-actions { display:flex; flex-wrap:wrap; align-items:center; gap:2px 8px; } .table-actions :deep(.el-button) { margin-left:0; }
|
||||
.review-sync-error { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; padding:10px 18px; border-bottom:1px solid #fecdca; background:#fffafa; color:#b42318; font-size:12px; } .review-sync-error > div { display:flex; min-width:0; align-items:center; gap:8px; } .review-sync-error span { overflow-wrap:anywhere; }
|
||||
.customer-company-cell { display:flex; min-width:0; align-items:center; gap:10px; } .customer-company-cell > span,.detail-title > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:7px; background:#eef4ff; color:var(--cc-primary); } .customer-company-cell strong { overflow:hidden; text-overflow:ellipsis; }
|
||||
.customer-company-cell { display:flex; min-width:0; align-items:center; gap:10px; } .customer-company-cell > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:7px; background:#eef4ff; color:var(--cc-primary); } .customer-company-cell strong { overflow:hidden; text-overflow:ellipsis; }
|
||||
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:8px; } .capital-input :deep(.el-input-number),.capital-input :deep(.el-select) { width:100%; }
|
||||
.qualification-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } .qualification-heading h3 { margin-bottom:4px; } .qualification-heading p,.qualification-hint { margin:0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint { margin-top:12px; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); }
|
||||
.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:180px minmax(300px,1fr) 180px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; }
|
||||
@@ -864,42 +724,8 @@ onMounted(loadCustomers)
|
||||
.customer-editor :deep(.el-drawer__body) { min-height:0; overflow-y:auto; overscroll-behavior:contain; }
|
||||
.customer-editor :deep(.el-drawer__footer) { flex:0 0 auto; }
|
||||
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; } .customer-editor-footer > span { color:var(--cc-muted); font-size:12px; text-align:left; } .customer-editor-footer > div { display:flex; flex:0 0 auto; gap:8px; } .customer-editor-footer :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.customer-detail { min-height:200px; padding:20px 24px 24px; }
|
||||
.detail-title { display:grid; grid-template-columns:44px minmax(0,1fr) auto; align-items:center; gap:12px; margin-bottom:24px; padding:4px 0 20px; border-bottom:1px solid var(--cc-border); }
|
||||
.detail-title > span { width:44px; height:44px; border-radius:10px; }
|
||||
.detail-title h3 { margin:0; overflow:hidden; color:var(--cc-text); font-size:20px; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.detail-title p { margin:4px 0 0; color:var(--cc-muted); font-size:12px; letter-spacing:.02em; }
|
||||
.customer-detail-section + .customer-detail-section { margin-top:24px; }
|
||||
.customer-detail-section h4 { margin:0 0 12px; color:var(--cc-text); font-size:14px; }
|
||||
.customer-detail-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); overflow:hidden; margin:0; border:1px solid var(--cc-border); border-radius:10px; background:#fff; }
|
||||
.customer-detail-item { min-width:0; padding:13px 16px; border-bottom:1px solid var(--cc-border); }
|
||||
.customer-detail-item:nth-child(odd):not(.is-wide) { border-right:1px solid var(--cc-border); }
|
||||
.customer-detail-item.is-wide { grid-column:1 / -1; }
|
||||
.customer-detail-item:last-child { border-bottom:0; }
|
||||
.customer-detail-item dt { margin-bottom:5px; color:var(--cc-muted); font-size:12px; line-height:1.4; }
|
||||
.customer-detail-item dd { margin:0; color:var(--cc-text); font-size:14px; font-weight:600; line-height:1.55; overflow-wrap:anywhere; }
|
||||
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:12px; margin-bottom:18px; padding:12px 14px; border-radius:8px; background:#f8fafc; } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--cc-text); font-size:13px; } .decision-summary span { color:var(--cc-muted); font-size:11px; }
|
||||
.approval-result-warning { display:flex; align-items:flex-start; gap:10px; padding:12px 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong,.approval-result-warning span { display:block; } .approval-result-warning strong { font-size:14px; } .approval-result-warning span { margin-top:4px; font-size:12px; line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); border-radius:8px; } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:12px; padding:12px 14px; } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:12px; } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:10px; margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:14px; font-weight:700; }
|
||||
.detail-section-heading { display:flex; align-items:baseline; justify-content:space-between; gap:12px; }
|
||||
.detail-section-heading h4 { margin-bottom:12px; }
|
||||
.detail-section-heading span { color:var(--cc-muted); font-size:12px; }
|
||||
.detail-qualification-items { display:flex; flex-direction:column; gap:8px; }
|
||||
.detail-qualification-item { display:grid; width:100%; grid-template-columns:52px minmax(0,1fr) auto; align-items:center; gap:12px; padding:12px 14px; border:1px solid var(--cc-border); border-radius:8px; background:#fff; color:inherit; text-align:left; cursor:pointer; }
|
||||
.detail-qualification-item:hover { border-color:color-mix(in srgb,var(--cc-primary) 48%,var(--cc-border)); background:#fbfdff; }
|
||||
.detail-qualification-item:focus-visible { outline:2px solid color-mix(in srgb,var(--cc-primary) 42%,transparent); outline-offset:2px; }
|
||||
.detail-qualification-item:disabled { cursor:wait; opacity:.75; }
|
||||
.detail-qualification-icon { display:grid; width:52px; height:52px; overflow:hidden; place-items:center; border-radius:8px; background:#eef4ff; color:var(--cc-primary); }
|
||||
.detail-qualification-icon img { width:100%; height:100%; object-fit:contain; background:#fff; }
|
||||
.detail-qualification-icon small { font-size:9px; font-weight:700; }
|
||||
.detail-qualification-copy { min-width:0; }
|
||||
.detail-qualification-item strong,.detail-qualification-item small { display:block; }
|
||||
.detail-qualification-item strong { color:var(--cc-text); font-size:13px; }
|
||||
.detail-qualification-item small { margin-top:4px; color:var(--cc-muted); font-size:11px; line-height:1.4; }
|
||||
.detail-qualification-file-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.detail-qualification-meta { display:flex; align-items:flex-end; gap:8px; flex-direction:column; }
|
||||
.detail-qualification-meta > span { display:flex; align-items:center; gap:4px; color:var(--cc-primary); font-size:11px; font-weight:600; }
|
||||
.detail-empty-qualification { display:flex; min-height:88px; align-items:center; justify-content:center; gap:8px; border:1px dashed var(--cc-border); border-radius:8px; background:#fafbfc; color:var(--cc-muted); font-size:12px; }
|
||||
@media (max-width:720px) { .customers-error,.customers-table-header,.customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .customer-detail { padding:16px; } .customer-detail-grid { grid-template-columns:1fr; } .customer-detail-item,.customer-detail-item:nth-child(odd):not(.is-wide) { grid-column:1; border-right:0; } .customer-detail-item:last-child { border-bottom:0; } .decision-summary { align-items:flex-start; flex-direction:column; } }
|
||||
@media (max-width:480px) { .detail-qualification-item { grid-template-columns:44px minmax(0,1fr); } .detail-qualification-icon { width:44px; height:44px; } .detail-qualification-meta { grid-column:2; align-items:center; flex-direction:row; justify-content:space-between; } }
|
||||
@media (max-width:720px) { .customers-error,.customers-table-header,.customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; } }
|
||||
@media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,12 @@ export const customerRoutes = [
|
||||
component: () => import('@/modules/customers/pages/CustomersView.vue'),
|
||||
meta: { title: '客户管理', description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list', permissionAnyOf: ['platform:customer:review'] },
|
||||
},
|
||||
{
|
||||
path: 'customers/:tenantId',
|
||||
name: 'customer-detail',
|
||||
component: () => import('@/modules/customers/pages/CustomerDetailView.vue'),
|
||||
meta: { title: '客户详情', description: '查看企业资料、关联用户和业务记录', permission: 'platform:customer:view', showPageHeading: false },
|
||||
},
|
||||
{
|
||||
path: 'customers/reviews',
|
||||
name: 'enterprise-reviews',
|
||||
|
||||
@@ -50,6 +50,21 @@ describe('platform first-leg order API', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes string pagination fields returned by the gateway', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({
|
||||
code: '200',
|
||||
message: 'success',
|
||||
data: { records: [], page: '1', size: '20', total: '6', pages: '1' },
|
||||
})
|
||||
|
||||
await expect(listPlatformFirstLegOrders({ status: 'SUBMITTED', page: 1, size: 20 })).resolves.toMatchObject({
|
||||
page: 1,
|
||||
size: 20,
|
||||
total: 6,
|
||||
pages: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the platform lifecycle routes and target tenant query parameter', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { order: {} } })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
|
||||
@@ -105,6 +105,13 @@ export interface PlatformFirstLegOrderPageResource {
|
||||
pages: number
|
||||
}
|
||||
|
||||
type PlatformFirstLegOrderPagePayload = Omit<PlatformFirstLegOrderPageResource, 'page' | 'size' | 'total' | 'pages'> & {
|
||||
page: number | string
|
||||
size: number | string
|
||||
total: number | string
|
||||
pages: number | string
|
||||
}
|
||||
|
||||
export interface PlatformFirstLegOrderQuery {
|
||||
tenantId?: string
|
||||
keyword?: string
|
||||
@@ -127,8 +134,20 @@ async function postData<T>(path: string, body?: unknown, params?: Record<string,
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export function listPlatformFirstLegOrders(query: PlatformFirstLegOrderQuery) {
|
||||
return getData<PlatformFirstLegOrderPageResource>('/platform/v1/orders/first-leg', { ...query })
|
||||
function paginationNumber(value: number | string, fallback: number) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.trunc(parsed) : fallback
|
||||
}
|
||||
|
||||
export async function listPlatformFirstLegOrders(query: PlatformFirstLegOrderQuery) {
|
||||
const result = await getData<PlatformFirstLegOrderPagePayload>('/platform/v1/orders/first-leg', { ...query })
|
||||
return {
|
||||
...result,
|
||||
page: paginationNumber(result.page, query.page),
|
||||
size: paginationNumber(result.size, query.size),
|
||||
total: paginationNumber(result.total, 0),
|
||||
pages: paginationNumber(result.pages, 0),
|
||||
} satisfies PlatformFirstLegOrderPageResource
|
||||
}
|
||||
|
||||
export function getPlatformFirstLegOrder(tenantId: string, orderId: string) {
|
||||
|
||||
@@ -25,6 +25,15 @@ describe('PlatformFirstLegOrdersPanel review queue', () => {
|
||||
expect(source).toContain('await nextTick()')
|
||||
})
|
||||
|
||||
it('keeps the fixed action column compact by grouping secondary actions', () => {
|
||||
expect(source).toContain('class="table-actions order-row-actions"')
|
||||
expect(source).toContain('更多订单操作')
|
||||
expect(source).toContain('维护快递')
|
||||
expect(source).toContain('内部备注')
|
||||
expect(source).toContain('取消订单')
|
||||
expect(source).toContain("minWidth: 224, fixed: 'left'")
|
||||
})
|
||||
|
||||
it('passes the order tenant through detail and lifecycle actions', () => {
|
||||
expect(source).toContain('tenantId: String(order.tenantId)')
|
||||
expect(source).toContain('getPlatformFirstLegOrder(tenantId, orderId)')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { ArrowLeft, Eye, FilePenLine, PackageCheck } from '@lucide/vue'
|
||||
import { ArrowLeft, CircleX, Eye, FilePenLine, MoreHorizontal, PackageCheck } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
acceptPlatformFirstLegOrder,
|
||||
@@ -90,7 +90,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
])
|
||||
|
||||
const orderColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', minWidth: 200, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', minWidth: 224, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'orderInfo', label: '订单信息', minWidth: 210, slotName: 'orderInfo' },
|
||||
{ key: 'customer', label: '客户', minWidth: 190, showOverflowTooltip: true, slotName: 'customer' },
|
||||
{ key: 'transport', label: '运输方案', minWidth: 180, slotName: 'transport' },
|
||||
@@ -179,6 +179,20 @@ function canCancel(order: PlatformFirstLegOrderResource) {
|
||||
return can('platform:order:cancel') && ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED'].includes(order.status)
|
||||
}
|
||||
|
||||
function canMaintainExpress(order: PlatformFirstLegOrderResource) {
|
||||
return can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes(order.status)
|
||||
}
|
||||
|
||||
function hasSecondaryActions(order: PlatformFirstLegOrderResource) {
|
||||
return canCancel(order) || canMaintainExpress(order) || can('platform:order:note')
|
||||
}
|
||||
|
||||
function runSecondaryAction(command: string | number | object, order: PlatformFirstLegOrderResource) {
|
||||
if (command === 'express') openExpress(order)
|
||||
if (command === 'note') openNote(order)
|
||||
if (command === 'cancel') void cancelOrder(order)
|
||||
}
|
||||
|
||||
function actionLabel(order: PlatformFirstLegOrderResource) {
|
||||
if (order.status === 'SUBMITTED' && can('platform:order:approve')) return '审核通过'
|
||||
if (order.status === 'APPROVED' && can('platform:order:accept')) return '受理订单'
|
||||
@@ -505,7 +519,22 @@ onMounted(() => void loadTenants())
|
||||
empty-text="当前没有符合条件的头程订单"
|
||||
row-key="id"
|
||||
>
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button><el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)">{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button><el-button v-if="canCancel(row as PlatformFirstLegOrderResource)" link type="danger" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="cancelOrder(row as PlatformFirstLegOrderResource)">取消</el-button><el-button v-if="can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes((row as PlatformFirstLegOrderResource).status)" link type="primary" @click="openExpress(row as PlatformFirstLegOrderResource)"><PackageCheck :size="15" />快递</el-button><el-button v-if="can('platform:order:note')" link type="primary" @click="openNote(row as PlatformFirstLegOrderResource)"><FilePenLine :size="15" />备注</el-button></div></template>
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions order-row-actions">
|
||||
<el-button link type="primary" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)">{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button>
|
||||
<el-dropdown v-if="hasSecondaryActions(row as PlatformFirstLegOrderResource)" trigger="click" popper-class="first-leg-action-menu" :disabled="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @command="runSecondaryAction($event, row as PlatformFirstLegOrderResource)">
|
||||
<el-button link type="primary" aria-label="更多订单操作"><MoreHorizontal :size="17" />更多</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-if="canMaintainExpress(row as PlatformFirstLegOrderResource)" command="express"><PackageCheck :size="15" />维护快递</el-dropdown-item>
|
||||
<el-dropdown-item v-if="can('platform:order:note')" command="note"><FilePenLine :size="15" />内部备注</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canCancel(row as PlatformFirstLegOrderResource)" command="cancel" divided><CircleX :size="15" />取消订单</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<template #orderInfo="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).referenceId || '无客户参考号' }} · {{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></div></template>
|
||||
<template #customer="{ row }"><div class="primary-cell"><strong>{{ tenantName((row as PlatformFirstLegOrderResource).tenantId) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).customerName }} · {{ (row as PlatformFirstLegOrderResource).tenantId }}</span></div></template>
|
||||
<template #transport="{ row }"><div class="primary-cell"><strong>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).channelName || '未填写渠道' }}</span></div></template>
|
||||
@@ -538,6 +567,9 @@ onMounted(() => void loadTenants())
|
||||
.detail-page-header p { margin: 5px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.5; }
|
||||
.detail-table-scroll { overflow-x: auto; }
|
||||
.party-grid p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.order-row-actions { gap: 8px; white-space: nowrap; }
|
||||
.order-row-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
:global(.first-leg-action-menu .el-dropdown-menu__item) { min-height: 44px; gap: 8px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.detail-page-header { align-items: flex-start; flex-direction: column; padding: 16px; }
|
||||
|
||||
@@ -94,13 +94,13 @@ const managedUser: PlatformManagedUser = {
|
||||
displayName: '张伟',
|
||||
phone: '13800138000',
|
||||
email: 'zhangwei@example.com',
|
||||
tenantId: '10001',
|
||||
tenantName: '华远贸易',
|
||||
memberType: 'EMPLOYEE',
|
||||
memberNo: 'MEMBER-3000002',
|
||||
tenantId: '0',
|
||||
tenantName: '平台',
|
||||
memberType: 'INTERNAL',
|
||||
memberNo: 'PLATFORM-3000002',
|
||||
status: 'ACTIVE',
|
||||
roleIds: ['40001'],
|
||||
roleNames: ['租户管理员'],
|
||||
roleNames: ['运营管理员'],
|
||||
lastLoginAt: '2026-06-01T10:30:00',
|
||||
protectedAccount: false,
|
||||
}
|
||||
@@ -204,32 +204,37 @@ describe('platform IAM roles', () => {
|
||||
expect(http.put).toHaveBeenNthCalledWith(4, `/platform/v1/iam/internal-users/${internalUser.membershipId}/roles`, { roleIds: [4000001] })
|
||||
})
|
||||
|
||||
it('maintains platform and tenant users through the unified user contract', async () => {
|
||||
it('maintains paginated platform users through the unified user contract', async () => {
|
||||
const userOptions = {
|
||||
tenants: [{ id: '10001', name: '华远贸易', status: 'ACTIVE' }],
|
||||
roles: [{ id: '40001', tenantId: '10001', name: '租户管理员', status: 'ACTIVE' }],
|
||||
tenants: [{ id: '0', name: '平台', status: 'ACTIVE' }],
|
||||
roles: [{ id: '40001', tenantId: '0', name: '运营管理员', status: 'ACTIVE' }],
|
||||
}
|
||||
const create = {
|
||||
username: 'zhangwei', displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
|
||||
tenantId: '10001', roleIds: ['40001'], password: 'Password1', status: 'ACTIVE' as const,
|
||||
tenantId: '0', roleIds: ['40001'], password: 'Password1', status: 'ACTIVE' as const,
|
||||
}
|
||||
const update = {
|
||||
displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
|
||||
tenantId: '10001', roleIds: ['40001'],
|
||||
tenantId: '0', roleIds: ['40001'],
|
||||
}
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', message: 'success', data: [managedUser] })
|
||||
const userPage = { records: [managedUser], page: 2, size: 10, total: 11, pages: 2 }
|
||||
vi.mocked(http.get).mockResolvedValueOnce({
|
||||
code: '200', message: 'success', data: { ...userPage, page: '2', size: '10', total: '11', pages: '2' },
|
||||
})
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: userOptions })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: managedUser })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: managedUser })
|
||||
|
||||
await expect(listPlatformManagedUsers()).resolves.toEqual([managedUser])
|
||||
await expect(listPlatformManagedUsers({ page: 2, size: 10, keyword: '张伟', status: 'ACTIVE' })).resolves.toEqual(userPage)
|
||||
await expect(getPlatformUserOptions()).resolves.toEqual(userOptions)
|
||||
await expect(createPlatformManagedUser(create)).resolves.toEqual(managedUser)
|
||||
await expect(updatePlatformManagedUser(managedUser.membershipId, update)).resolves.toEqual(managedUser)
|
||||
await expect(changePlatformManagedUserStatus(managedUser.membershipId, 'DISABLED')).resolves.toEqual(managedUser)
|
||||
await expect(resetPlatformManagedUserPassword(managedUser.membershipId, 'ChangeMe123')).resolves.toEqual(managedUser)
|
||||
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/iam/users')
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/iam/users', {
|
||||
params: { page: 2, size: 10, keyword: '张伟', status: 'ACTIVE' },
|
||||
})
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/iam/users/options')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/users', create)
|
||||
expect(http.put).toHaveBeenNthCalledWith(1, `/platform/v1/iam/users/${managedUser.membershipId}`, update)
|
||||
|
||||
@@ -211,6 +211,21 @@ export interface PlatformManagedUser {
|
||||
protectedAccount: boolean
|
||||
}
|
||||
|
||||
export interface PlatformManagedUserPage {
|
||||
records: PlatformManagedUser[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface PlatformManagedUserQuery {
|
||||
page: number
|
||||
size: number
|
||||
keyword?: string
|
||||
status?: PlatformManagedUser['status']
|
||||
}
|
||||
|
||||
export interface PlatformUserOptions {
|
||||
tenants: Array<{ id: number | string; name: string; status: string }>
|
||||
roles: Array<{ id: number | string; tenantId: number | string; name: string; status: string }>
|
||||
@@ -422,8 +437,26 @@ export function assignPlatformInternalUserRoles(membershipId: number | string, r
|
||||
return putData<PlatformInternalUser>(`/platform/v1/iam/internal-users/${membershipId}/roles`, { roleIds })
|
||||
}
|
||||
|
||||
export function listPlatformManagedUsers() {
|
||||
return getData<PlatformManagedUser[]>('/platform/v1/iam/users')
|
||||
export async function listPlatformManagedUsers(query: PlatformManagedUserQuery) {
|
||||
const response = await http.get<ApiEnvelope<PlatformManagedUserPage>, ApiEnvelope<PlatformManagedUserPage>>(
|
||||
'/platform/v1/iam/users',
|
||||
{
|
||||
params: {
|
||||
page: query.page,
|
||||
size: query.size,
|
||||
...(query.keyword ? { keyword: query.keyword } : {}),
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
},
|
||||
},
|
||||
)
|
||||
const data = unwrapApiResponse(response)
|
||||
return {
|
||||
...data,
|
||||
page: Number(data.page),
|
||||
size: Number(data.size),
|
||||
total: Number(data.total),
|
||||
pages: Number(data.pages),
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlatformUserOptions() {
|
||||
|
||||
@@ -40,9 +40,9 @@ function createMemoryStorage(): Storage {
|
||||
|
||||
const user: PlatformManagedUser = {
|
||||
membershipId: '30', userId: '20', authUserId: '10', username: 'zhangwei', displayName: '张伟',
|
||||
phone: '13800138000', email: 'zhangwei@example.com', tenantId: '10001', tenantName: '华远贸易',
|
||||
memberType: 'EMPLOYEE', memberNo: 'MEMBER-30', status: 'ACTIVE', roleIds: ['40'],
|
||||
roleNames: ['租户管理员'], lastLoginAt: '2026-06-01T10:30:00',
|
||||
phone: '13800138000', email: 'zhangwei@example.com', tenantId: '0', tenantName: '平台',
|
||||
memberType: 'INTERNAL', memberNo: 'PLATFORM-30', status: 'ACTIVE', roleIds: ['40'],
|
||||
roleNames: ['运营管理员'], lastLoginAt: '2026-06-01T10:30:00',
|
||||
protectedAccount: false,
|
||||
}
|
||||
|
||||
@@ -69,39 +69,47 @@ async function mountView() {
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('InternalUsersView unified platform user management', () => {
|
||||
describe('InternalUsersView platform internal user management', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(listPlatformManagedUsers).mockReset().mockResolvedValue([user])
|
||||
vi.mocked(listPlatformManagedUsers).mockReset().mockResolvedValue({
|
||||
records: [user], page: 1, size: 10, total: 21, pages: 3,
|
||||
})
|
||||
vi.mocked(getPlatformUserOptions).mockReset().mockResolvedValue({
|
||||
tenants: [
|
||||
{ id: '0', name: '平台', status: 'ACTIVE' },
|
||||
{ id: '10001', name: '华远贸易', status: 'ACTIVE' },
|
||||
],
|
||||
roles: [{ id: '40', tenantId: '10001', name: '租户管理员', status: 'ACTIVE' }],
|
||||
roles: [
|
||||
{ id: '40', tenantId: '0', name: '运营管理员', status: 'ACTIVE' },
|
||||
{ id: '41', tenantId: '10001', name: '租户管理员', status: 'ACTIVE' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('loads and renders prototype fields from the unified API', async () => {
|
||||
it('loads a server page and only renders internal-user fields', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(listPlatformManagedUsers).toHaveBeenCalledOnce()
|
||||
expect(listPlatformManagedUsers).toHaveBeenCalledWith({ page: 1, size: 10 })
|
||||
expect(getPlatformUserOptions).toHaveBeenCalledOnce()
|
||||
expect(wrapper.text()).toContain('zhangwei')
|
||||
expect(wrapper.text()).toContain('张伟')
|
||||
expect(wrapper.text()).toContain('华远贸易')
|
||||
expect(wrapper.text()).toContain('租户管理员')
|
||||
expect(wrapper.text()).toContain('运营管理员')
|
||||
expect(wrapper.text()).toContain('2026-06-01 10:30')
|
||||
expect(wrapper.find('[aria-label="用户筛选"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="用户列表"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="内部用户列表"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('共 21 条')
|
||||
expect(wrapper.text()).toContain('查询')
|
||||
expect(wrapper.text()).toContain('重置')
|
||||
expect(wrapper.text()).not.toContain('认证用户 ID')
|
||||
expect(wrapper.text()).not.toContain('成员编号')
|
||||
expect(wrapper.text()).not.toContain('所属租户')
|
||||
expect(wrapper.text()).not.toContain('华远贸易')
|
||||
})
|
||||
|
||||
it('opens the complete prototype create form', async () => {
|
||||
it('opens an internal-user form without a tenant selector', async () => {
|
||||
const wrapper = await mountView()
|
||||
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增用户')
|
||||
await createButton?.trigger('click')
|
||||
@@ -111,11 +119,26 @@ describe('InternalUsersView unified platform user management', () => {
|
||||
expect(document.querySelector('input[placeholder="真实姓名"]')).not.toBeNull()
|
||||
expect(document.querySelector('input[placeholder="手机号码"]')).not.toBeNull()
|
||||
expect(document.querySelector('input[placeholder="邮箱地址"]')).not.toBeNull()
|
||||
expect(document.body.textContent).toContain('所属租户')
|
||||
expect(document.body.textContent).not.toContain('所属租户')
|
||||
expect(document.body.textContent).toContain('初始密码')
|
||||
expect(document.body.textContent).toContain('确认添加')
|
||||
})
|
||||
|
||||
it('requests server-side pages and filters instead of slicing locally', async () => {
|
||||
const wrapper = await mountView()
|
||||
await wrapper.find('button[aria-label="第 2 页"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformManagedUsers).toHaveBeenLastCalledWith({ page: 2, size: 10 })
|
||||
|
||||
await wrapper.find('input[aria-label="用户关键词"]').setValue(' 张伟 ')
|
||||
const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === '查询')
|
||||
await searchButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformManagedUsers).toHaveBeenLastCalledWith({ page: 1, size: 10, keyword: '张伟' })
|
||||
})
|
||||
|
||||
it('shows a recoverable API error without fake users', async () => {
|
||||
vi.mocked(listPlatformManagedUsers).mockRejectedValueOnce(new Error('Gateway unavailable'))
|
||||
const wrapper = await mountView()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Pencil, Plus } from '@lucide/vue'
|
||||
import {
|
||||
@@ -32,28 +32,20 @@ const editorOpen = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const editingUser = ref<PlatformManagedUser | null>(null)
|
||||
const rowActionId = ref('')
|
||||
let loadRequestId = 0
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
type UserFilters = QueryModel & { keyword: string; tenantId: string; status: string }
|
||||
const filters = ref<UserFilters>({ keyword: '', tenantId: '', status: '' })
|
||||
const total = ref(0)
|
||||
type UserFilters = QueryModel & { keyword: string; status: string }
|
||||
const filters = ref<UserFilters>({ keyword: '', status: '' })
|
||||
const appliedFilters = ref<UserFilters>({ ...filters.value })
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{
|
||||
key: 'keyword',
|
||||
type: 'input',
|
||||
placeholder: '用户名、姓名、手机号、租户或角色',
|
||||
placeholder: '姓名、手机号、邮箱或员工编号',
|
||||
props: { maxlength: 128, 'aria-label': '用户关键词' },
|
||||
},
|
||||
{
|
||||
key: 'tenantId',
|
||||
type: 'select',
|
||||
placeholder: '全部租户',
|
||||
props: { 'aria-label': '所属租户' },
|
||||
options: options.value.tenants.map((tenant) => ({
|
||||
label: tenant.name,
|
||||
value: String(tenant.id),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
type: 'select',
|
||||
@@ -71,7 +63,7 @@ const emptyForm = () => ({
|
||||
displayName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
tenantId: '' as string,
|
||||
tenantId: '0',
|
||||
roleIds: [] as string[],
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
@@ -95,11 +87,8 @@ useUnsavedWorkRegistration({
|
||||
requiredPermission: computed(() => editingUser.value ? 'platform:user:edit' : 'platform:user:create'),
|
||||
})
|
||||
|
||||
const enabledTenants = computed(() => options.value.tenants.filter((tenant) =>
|
||||
tenant.status === 'ACTIVE' || String(tenant.id) === form.tenantId,
|
||||
))
|
||||
const availableRoles = computed(() => options.value.roles.filter((role) =>
|
||||
String(role.tenantId) === form.tenantId
|
||||
String(role.tenantId) === '0'
|
||||
&& (role.status === 'ACTIVE' || form.roleIds.includes(String(role.id))),
|
||||
))
|
||||
|
||||
@@ -114,7 +103,6 @@ const formRules = computed<FormRules>(() => ({
|
||||
{ pattern: /^1\d{10}$/, message: '请输入正确的 11 位手机号', trigger: 'blur' },
|
||||
],
|
||||
email: editingUser.value && !canEdit.value ? [] : [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
|
||||
tenantId: editingUser.value && !canEdit.value ? [] : [{ required: true, message: '请选择所属租户', trigger: 'change' }],
|
||||
roleIds: editingUser.value && !canEdit.value ? [] : [{ type: 'array', required: true, min: 1, message: '请选择角色', trigger: 'change' }],
|
||||
password: editingUser.value && !form.resetPassword ? [] : [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
@@ -126,50 +114,71 @@ const formRules = computed<FormRules>(() => ({
|
||||
],
|
||||
}))
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const keyword = appliedFilters.value.keyword.trim().toLowerCase()
|
||||
return users.value.filter((item) => {
|
||||
const matchesKeyword = !keyword || [item.username, item.displayName, item.phone ?? '', item.email ?? '', item.tenantName, ...item.roleNames]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesKeyword
|
||||
&& (!appliedFilters.value.tenantId || String(item.tenantId) === appliedFilters.value.tenantId)
|
||||
&& (!appliedFilters.value.status || item.status === appliedFilters.value.status)
|
||||
})
|
||||
})
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
|
||||
const userColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
||||
{ prop: 'username', label: '用户名', minWidth: 145 },
|
||||
{ prop: 'displayName', label: '姓名', minWidth: 130 },
|
||||
{ prop: 'tenantName', label: '所属租户', minWidth: 190 },
|
||||
{ key: 'roles', label: '角色', minWidth: 180, slotName: 'roles' },
|
||||
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
|
||||
{ key: 'lastLoginAt', label: '最后登录', minWidth: 170, slotName: 'lastLoginAt' },
|
||||
]
|
||||
|
||||
function userQuery() {
|
||||
return {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
...(appliedFilters.value.keyword.trim() ? { keyword: appliedFilters.value.keyword.trim() } : {}),
|
||||
...(appliedFilters.value.status ? { status: appliedFilters.value.status as PlatformManagedUser['status'] } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
const requestId = ++loadRequestId
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const [userRecords, optionRecords] = await Promise.all([
|
||||
listPlatformManagedUsers(),
|
||||
getPlatformUserOptions(),
|
||||
])
|
||||
users.value = userRecords
|
||||
options.value = optionRecords
|
||||
const result = await listPlatformManagedUsers(userQuery())
|
||||
if (requestId !== loadRequestId) return
|
||||
users.value = result.records
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
if (requestId !== loadRequestId) return
|
||||
users.value = []
|
||||
total.value = 0
|
||||
loadError.value = getApiErrorDetails(error, '用户列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (requestId === loadRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInitialData() {
|
||||
const requestId = ++loadRequestId
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const [result, optionRecords] = await Promise.all([
|
||||
listPlatformManagedUsers(userQuery()),
|
||||
getPlatformUserOptions(),
|
||||
])
|
||||
options.value = {
|
||||
tenants: optionRecords.tenants.filter((tenant) => String(tenant.id) === '0'),
|
||||
roles: optionRecords.roles.filter((role) => String(role.tenantId) === '0'),
|
||||
}
|
||||
if (requestId !== loadRequestId) return
|
||||
users.value = result.records
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
if (requestId !== loadRequestId) return
|
||||
users.value = []
|
||||
total.value = 0
|
||||
loadError.value = getApiErrorDetails(error, '内部用户列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (requestId === loadRequestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, emptyForm())
|
||||
const defaultTenant = options.value.tenants.find((tenant) => tenant.status === 'ACTIVE')
|
||||
form.tenantId = defaultTenant ? String(defaultTenant.id) : ''
|
||||
editingUser.value = null
|
||||
editorOpen.value = true
|
||||
nextTick(() => formRef.value?.clearValidate())
|
||||
@@ -182,7 +191,7 @@ function openEdit(row: PlatformManagedUser) {
|
||||
displayName: row.displayName,
|
||||
phone: row.phone ?? '',
|
||||
email: row.email ?? '',
|
||||
tenantId: String(row.tenantId),
|
||||
tenantId: '0',
|
||||
roleIds: row.roleIds.map(String),
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
@@ -193,11 +202,6 @@ function openEdit(row: PlatformManagedUser) {
|
||||
nextTick(() => formRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
function handleTenantChange() {
|
||||
const validRoleIds = new Set(availableRoles.value.map((role) => String(role.id)))
|
||||
form.roleIds = form.roleIds.filter((roleId) => validRoleIds.has(roleId))
|
||||
}
|
||||
|
||||
function isCurrentUser(row: PlatformManagedUser | null) {
|
||||
if (!row) return false
|
||||
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
|
||||
@@ -218,7 +222,7 @@ async function saveUser() {
|
||||
displayName: form.displayName.trim(),
|
||||
phone: form.phone.trim(),
|
||||
email: form.email.trim(),
|
||||
tenantId: form.tenantId,
|
||||
tenantId: '0',
|
||||
roleIds: form.roleIds,
|
||||
password: form.password,
|
||||
status: form.status,
|
||||
@@ -231,7 +235,7 @@ async function saveUser() {
|
||||
displayName: form.displayName.trim(),
|
||||
phone: form.phone.trim(),
|
||||
email: form.email.trim(),
|
||||
tenantId: form.tenantId,
|
||||
tenantId: '0',
|
||||
roleIds: form.roleIds,
|
||||
})
|
||||
}
|
||||
@@ -280,10 +284,12 @@ function formatLogin(value: string | null) {
|
||||
|
||||
function applyFilters() {
|
||||
appliedFilters.value = { ...filters.value }
|
||||
page.value = 1
|
||||
if (page.value === 1) void loadUsers()
|
||||
else page.value = 1
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
watch([page, pageSize], () => void loadUsers())
|
||||
onMounted(loadInitialData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -291,7 +297,7 @@ onMounted(loadUsers)
|
||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
|
||||
<template #title>
|
||||
<div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div>
|
||||
<el-button link type="danger" @click="loadUsers">重新加载</el-button>
|
||||
<el-button link type="danger" @click="loadInitialData">重新加载</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
@@ -306,14 +312,14 @@ onMounted(loadUsers)
|
||||
<section class="surface-panel table-panel" :aria-busy="loading">
|
||||
<header class="panel-heading">
|
||||
<div>
|
||||
<h2>用户列表 <span class="secondary-text">共 {{ filteredRows.length }} 条</span></h2>
|
||||
<p>统一维护平台与企业租户账号、所属角色和使用状态。</p>
|
||||
<h2>内部用户列表 <span class="secondary-text">共 {{ total }} 条</span></h2>
|
||||
<p>维护平台内部员工账号、平台角色和使用状态。</p>
|
||||
</div>
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button>
|
||||
</header>
|
||||
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="userColumns"
|
||||
:loading="loading" :total="filteredRows.length" aria-label="用户列表" row-key="membershipId" empty-text="没有符合筛选条件的用户">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns"
|
||||
:loading="loading" :total="total" aria-label="内部用户列表" row-key="membershipId" empty-text="没有符合筛选条件的内部用户">
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button v-if="canEdit || canChangeStatus || canResetPassword" link type="primary" @click="openEdit(row as PlatformManagedUser)">
|
||||
@@ -354,11 +360,6 @@ onMounted(loadUsers)
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" :disabled="Boolean(editingUser) && !canEdit" placeholder="邮箱地址" inputmode="email" maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属租户" prop="tenantId" required>
|
||||
<el-select v-model="form.tenantId" :disabled="Boolean(editingUser) && (!canEdit || !canGrantRoles || editingUser?.protectedAccount)" placeholder="选择所属租户" @change="handleTenantChange">
|
||||
<el-option v-for="tenant in enabledTenants" :key="tenant.id" :label="tenant.name" :value="String(tenant.id)" :disabled="tenant.status !== 'ACTIVE'" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="roleIds" required>
|
||||
<el-select v-model="form.roleIds" multiple collapse-tags collapse-tags-tooltip
|
||||
:disabled="Boolean(editingUser) && (!canEdit || !canGrantRoles || editingUser?.protectedAccount)" placeholder="选择角色">
|
||||
|
||||
Reference in New Issue
Block a user