style(admin): unify dialog layouts and dense readonly presentation

This commit is contained in:
2026-10-20 10:11:31 +08:00
parent 7ff7c9914f
commit 3267679758
214 changed files with 10931 additions and 2654 deletions
@@ -0,0 +1,166 @@
import ElementPlus, { ElMessageBox } 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 { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import AppDialog from '@/shared/components/AppDialog.vue'
import SupplierLicenseDialog from './SupplierLicenseDialog.vue'
import SuppliersView from '../pages/SuppliersView.vue'
import SupplierDetailView from '../pages/SupplierDetailView.vue'
import { changeSupplierStatus, getSupplier, getSupplierStats, listSuppliers } from '../api/platformSuppliers'
import type { Supplier } from '../types'
const { routerPush, routeControl } = vi.hoisted(() => ({ routerPush: vi.fn(), routeControl: { setId: (_id: string) => {} } }))
vi.mock('vue-router', async importOriginal => ({
...await importOriginal<typeof import('vue-router')>(),
...await (async () => {
const { reactive } = await import('vue')
const route = reactive({ params: { id: '' } })
routeControl.setId = id => { route.params.id = id }
return { useRoute: () => route }
})(),
useRouter: () => ({ push: routerPush }),
}))
vi.mock('../api/platformSuppliers', () => ({ getSupplier: vi.fn(), getSupplierStats: vi.fn(), listSuppliers: vi.fn(), changeSupplierStatus: vi.fn() }))
const privateId = '9007199254740993123'
let record: Supplier
function supplier(): Supplier {
return { id: privateId, tenantId: '0', name: '供应商测试公司', category: 'BOOKING', truckingScope: null, carrierCodes: ['MATSON'], region: '宁波', legalRepresentative: '测试法人', contactName: '联系人', phone: '13800000000', email: null, address: '供应商测试地址', businessLicenseNo: '91440300MA5ABCDE12', licenseFileId: 'private-file-id', status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 2 }
}
function rowButton(text: string) {
const found = Array.from(document.querySelectorAll<HTMLButtonElement>('.el-table__body button')).find(button => button.textContent?.trim() === text)
expect(found, text).toBeDefined()
return found!
}
function messageBoxButton(text: string) {
const found = Array.from(document.querySelectorAll<HTMLButtonElement>('.el-message-box button')).find(button => button.textContent?.trim() === text)
expect(found, text).toBeDefined()
return found!
}
async function openList() {
const wrapper = mount(SuppliersView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
return wrapper
}
enableAutoUnmount(afterEach)
afterEach(() => { ElMessageBox.close() })
beforeEach(() => {
vi.resetAllMocks()
document.body.innerHTML = ''
setActivePinia(createPinia())
const auth = useAuthStore()
auth.token = 'test-session'
auth.permissions = ['platform:supplier:list', 'platform:supplier:edit', 'platform:supplier:create', 'platform:supplier:status']
useAccessPreviewStore().initializeAfterLogin()
record = supplier()
routeControl.setId(privateId)
vi.mocked(getSupplier).mockImplementation(async () => record)
vi.mocked(getSupplierStats).mockResolvedValue({ booking: 1, customs: 0, trucking: 0, certificate: 0 })
vi.mocked(listSuppliers).mockImplementation(async () => ({ records: [record], page: 1, size: 20, total: 1, pages: 1 }))
vi.mocked(changeSupplierStatus).mockImplementation(async (_id, status) => { record = { ...record, status }; return null })
})
describe('supplier shared dialogs', () => {
it('shows all linked license facts with a truthful image limitation and no synthetic certificate', async () => {
const name = '供应商完整长名称'.repeat(12)
const wrapper = mount(SupplierLicenseDialog, { attachTo: document.body, props: { modelValue: true, supplier: { ...record, name } }, global: { plugins: [ElementPlus] } })
await flushPromises()
const dialog = wrapper.findComponent(AppDialog)
expect(dialog.props()).toMatchObject({ title: `营业执照查看 · ${name}`, width: 'min(560px, calc(100vw - 24px))', showConfirm: false, cancelText: '关闭' })
const facts = document.querySelector('[aria-label="营业执照信息"] table')!
expect(facts.querySelectorAll('tr')).toHaveLength(4)
expect(facts.textContent).toContain(name)
expect(facts.textContent).toContain('测试法人')
expect(facts.textContent).toContain(record.businessLicenseNo)
expect(facts.textContent).toContain('已关联')
expect(facts.textContent).not.toContain(privateId)
expect(facts.textContent).not.toContain('private-file-id')
expect(document.querySelector('.el-dialog [role="alert"]')?.textContent).toContain('暂无法展示原始影像')
expect(document.querySelector('.el-dialog img, .el-dialog iframe')).toBeNull()
expect(document.querySelector('.license-card')).toBeNull()
expect(document.querySelector('.el-dialog')?.textContent).not.toContain('副本')
})
it('keeps missing facts explicit and handles an absent supplier without an ID fallback', async () => {
const wrapper = mount(SupplierLicenseDialog, { attachTo: document.body, props: { modelValue: true, supplier: { ...record, name: '', legalRepresentative: '', businessLicenseNo: null, licenseFileId: null } }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findComponent(AppDialog).props('title')).toBe('营业执照查看')
expect(document.querySelector('[aria-label="营业执照信息"] table')?.textContent).toContain('未上传')
expect(document.querySelectorAll('.license-info-table td')[2]?.textContent).toBe('—')
expect(document.querySelector('.el-dialog [role="alert"]')).toBeNull()
await wrapper.setProps({ supplier: null }); await flushPromises()
expect(document.querySelector('.el-dialog')?.textContent).toContain('暂无营业执照资料')
expect(document.querySelector('.license-info-table')).toBeNull()
})
it('opens the list license entry and closes through its original footer', async () => {
const wrapper = await openList()
rowButton('查看').click(); await flushPromises()
const dialog = wrapper.findComponent(AppDialog)
expect(dialog.props('modelValue')).toBe(true)
expect(document.querySelector('.license-info-table')?.textContent).toContain(record.businessLicenseNo)
document.querySelector<HTMLButtonElement>('.el-dialog__footer button')!.click(); await flushPromises()
expect(dialog.props('modelValue')).toBe(false)
expect(changeSupplierStatus).not.toHaveBeenCalled()
})
it('closes an existing license preview when the detail route changes', async () => {
vi.mocked(getSupplier).mockImplementation(async id => id === privateId ? record : { ...record, id, name: '另一供应商' })
const wrapper = mount(SupplierDetailView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
const preview = wrapper.findAll('button').find(button => button.text().includes('查看营业执照'))!
await preview.trigger('click'); await flushPromises()
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(true)
routeControl.setId('another-supplier'); await flushPromises()
expect(getSupplier).toHaveBeenLastCalledWith('another-supplier')
expect(wrapper.findComponent(AppDialog).props('modelValue')).toBe(false)
expect(wrapper.get('.density-detail-title').text()).toBe('另一供应商')
})
it.each(['ACTIVE', 'INACTIVE'] as const)('identifies the %s status confirmation and sends the original status request only after confirmation', async status => {
record = { ...record, status }
const verb = status === 'ACTIVE' ? '停用' : '启用'
const next = status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
await openList()
rowButton(verb).click(); await flushPromises()
expect(document.querySelector('.el-message-box__title')?.textContent).toBe(`${verb}供应商 · 供应商测试公司`)
expect(document.querySelector('.el-message-box')?.classList.contains('admin-density-confirm')).toBe(true)
expect(changeSupplierStatus).not.toHaveBeenCalled()
messageBoxButton(`确认${verb}`).click(); await flushPromises()
expect(changeSupplierStatus).toHaveBeenCalledWith(privateId, next)
expect(listSuppliers).toHaveBeenCalledTimes(2)
})
it('cancels a status change without sending a write request', async () => {
await openList()
rowButton('停用').click(); await flushPromises()
messageBoxButton('取消').click(); await flushPromises()
expect(changeSupplierStatus).not.toHaveBeenCalled()
expect(listSuppliers).toHaveBeenCalledTimes(1)
})
it('keeps status actions hidden when the current account has only read permission', async () => {
useAuthStore().permissions = ['platform:supplier:list']
useAccessPreviewStore().initializeAfterLogin()
await openList()
const labels = Array.from(document.querySelectorAll<HTMLButtonElement>('.el-table__body button')).map(button => button.textContent?.trim())
expect(labels).not.toContain('停用')
expect(labels).not.toContain('启用')
expect(labels).not.toContain('编辑')
expect(labels).toContain('查看')
expect(changeSupplierStatus).not.toHaveBeenCalled()
})
it('retains the list and resets action loading if the status update fails', async () => {
vi.mocked(changeSupplierStatus).mockRejectedValueOnce(new Error('network'))
await openList()
rowButton('停用').click(); await flushPromises()
messageBoxButton('确认停用').click(); await flushPromises()
expect(changeSupplierStatus).toHaveBeenCalledWith(privateId, 'INACTIVE')
expect(rowButton('停用').classList.contains('is-loading')).toBe(false)
expect(document.body.textContent).toContain('停用供应商失败')
expect(listSuppliers).toHaveBeenCalledTimes(1)
})
})
@@ -1,38 +1,35 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { Supplier } from '@/modules/suppliers/types'
defineProps<{ supplier: Supplier | null }>()
const props = defineProps<{ supplier: Supplier | null }>()
const open = defineModel<boolean>({ default: false })
const title = computed(() => {
const name = props.supplier?.name.trim()
return name ? `营业执照查看 · ${name}` : '营业执照查看'
})
</script>
<template>
<AppDialog class="admin-density-dialog" v-model="open" :title="`营业执照查看${supplier ? ` - ${supplier.name}` : ''}`" width="min(560px, calc(100vw - 24px))" :show-confirm="false" cancel-text="关闭">
<div v-if="supplier" class="license-dialog">
<p class="license-dialog__supplier">供应商:<strong>{{ supplier.name }}</strong></p>
<section class="license-card" aria-label="营业执照信息">
<h2>营业执照</h2><p class="license-card__copy">(副本)</p>
<dl>
<div><dt>名称:</dt><dd>{{ supplier.name }}</dd></div>
<div><dt>法定代表人:</dt><dd>{{ supplier.legalRepresentative }}</dd></div>
<div><dt>统一社会信用代码:</dt><dd>{{ supplier.businessLicenseNo || '—' }}</dd></div>
<div><dt>资质文件:</dt><dd>{{ supplier.licenseFileId ? '已关联' : '未上传' }}</dd></div>
</dl>
<AppDialog class="admin-density-dialog" v-model="open" :title="title" width="min(560px, calc(100vw - 24px))" :show-confirm="false" cancel-text="关闭">
<div v-if="supplier" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="营业执照信息">
<h3 class="density-dialog-section__title">营业执照资料</h3>
<table class="density-table license-info-table"><tbody>
<tr><th scope="row">名称</th><td>{{ supplier.name || '—' }}</td></tr>
<tr><th scope="row">法定代表人</th><td>{{ supplier.legalRepresentative || '—' }}</td></tr>
<tr><th scope="row">统一社会信用代码</th><td>{{ supplier.businessLicenseNo || '—' }}</td></tr>
<tr><th scope="row">资质文件</th><td>{{ supplier.licenseFileId ? '已关联' : '未上传' }}</td></tr>
</tbody></table>
</section>
<el-alert v-if="supplier.licenseFileId" class="license-dialog__notice" type="info" :closable="false" show-icon title="资质文件已保留;平台公共供应商文件读取接口尚未提供,暂无法展示原始影像。" />
<el-alert v-if="supplier.licenseFileId" type="info" :closable="false" show-icon title="已关联资质文件,暂无法展示原始影像。" />
</div>
<el-empty v-else description="暂无营业执照资料" />
</AppDialog>
</template>
<style scoped>
.license-dialog__supplier { margin: 0 0 var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-body); }
.license-card { max-width: 460px; margin: 0 auto; padding: var(--admin-space-7) var(--admin-space-7); border: 2px solid #c0392b; border-radius: var(--admin-radius); background: #fff; box-shadow: 0 4px 16px rgba(192,57,43,.12); }
.license-card h2 { margin: 0; color: #c0392b; font-size: 22px; letter-spacing: 6px; text-align: center; }
.license-card__copy { margin: 2px 0 var(--admin-space-6); color: #c0392b; font-size: var(--admin-font-caption); text-align: center; }
.license-card dl { margin: 0; }
.license-card dl div { display: grid; grid-template-columns: 126px minmax(0, 1fr); margin-bottom: var(--admin-space-4); font-size: var(--admin-font-body); }
.license-card dt { color: #444; font-weight: 700; }.license-card dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.license-dialog__notice { margin-top: var(--admin-space-6); }
@media (max-width: 480px) { .license-card { padding: 20px 16px; } .license-card dl div { grid-template-columns: 1fr; gap: 3px; } .license-card h2 { font-size: 19px; } }
.license-info-table th { width:140px; }
</style>
@@ -26,6 +26,7 @@ const items = computed(() => supplier.value ? [
['营业执照号', supplier.value.businessLicenseNo || '—'], ['结算周期', settlementLabels[supplier.value.settlementPeriod]], ['公司地址', supplier.value.address || '—'], ['备注', supplier.value.remark || '—'],
] : [])
async function load() {
licenseOpen.value = false
const requestedId = supplierId.value
const sequence = ++loadSequence
if (!canView.value || !requestedId) { supplier.value = null; loading.value = false; error.value = null; return }
@@ -99,8 +99,9 @@ function supplierRow(row: object) { return row as Supplier }
async function toggleStatus(row: Supplier) {
const next: SupplierStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
const verb = next === 'ACTIVE' ? '启用' : '停用'
const supplierName = row.name.trim()
try {
await ElMessageBox.confirm(`确认${verb}供应商“${row.name}”吗?`, `${verb}供应商`, { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
await ElMessageBox.confirm(`确认${verb}供应商“${row.name}”吗?`, supplierName ? `${verb}供应商 · ${supplierName}` : `${verb}供应商`, { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
actionId.value = row.id
await changeSupplierStatus(row.id, next)
ElMessage.success(`供应商已${verb}`)