优化
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, FileText, Pencil } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
@@ -17,13 +17,31 @@ const route = useRoute(); const router = useRouter(); const access = useAccessPr
|
||||
const canView = computed(() => access.can('platform:supplier:list'))
|
||||
const supplierId = computed(() => typeof route.params.id === 'string' ? route.params.id : '')
|
||||
const supplier = ref<Supplier | null>(null); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const licenseOpen = ref(false)
|
||||
let loadSequence = 0
|
||||
const items = computed(() => supplier.value ? [
|
||||
['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['国内/国外', supplier.value.scope ? scopeLabels[supplier.value.scope] : '—'], ['地区', supplier.value.region],
|
||||
['法人代表', supplier.value.legalRepresentative], ['联系人', supplier.value.contactName], ['联系电话', supplier.value.phone], ['电子邮箱', supplier.value.email || '—'],
|
||||
['营业执照号', supplier.value.businessLicenseNo || '—'], ['结算周期', settlementLabels[supplier.value.settlementPeriod]], ['公司地址', supplier.value.address || '—'], ['备注', supplier.value.remark || '—'],
|
||||
] : [])
|
||||
async function load() { if (!canView.value) return; loading.value = true; error.value = null; try { supplier.value = await getSupplier(supplierId.value) } catch (cause) { error.value = getApiErrorDetails(cause, '供应商详情加载失败') } finally { loading.value = false } }
|
||||
onMounted(load)
|
||||
async function load() {
|
||||
const requestedId = supplierId.value
|
||||
const sequence = ++loadSequence
|
||||
if (!canView.value || !requestedId) { supplier.value = null; loading.value = false; error.value = null; return }
|
||||
supplier.value = null
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await getSupplier(requestedId)
|
||||
if (sequence !== loadSequence || requestedId !== supplierId.value) return
|
||||
supplier.value = result
|
||||
} catch (cause) {
|
||||
if (sequence !== loadSequence || requestedId !== supplierId.value) return
|
||||
error.value = getApiErrorDetails(cause, '供应商详情加载失败')
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
}
|
||||
}
|
||||
watch(supplierId, () => { void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ArrowLeft, FileText, Save } from '@lucide/vue'
|
||||
@@ -40,14 +40,25 @@ function optional(value: string | null) { return value?.trim() || null }
|
||||
function payload(): SaveSupplierRequest {
|
||||
return { ...form, name: form.name.trim(), scope: form.category === 'BOOKING' ? form.scope : null, region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) }
|
||||
}
|
||||
let loadSequence = 0
|
||||
function resetForm() { Object.assign(form, initial()) }
|
||||
async function load() {
|
||||
if (!editing.value || !access.can(requiredPermission.value)) { captureBaseline(); return }
|
||||
loading.value = true; loadError.value = null
|
||||
const requestedId = supplierId.value
|
||||
const sequence = ++loadSequence
|
||||
resetForm()
|
||||
loadError.value = null
|
||||
submitError.value = null
|
||||
saving.value = false
|
||||
if (!requestedId || !access.can(requiredPermission.value)) { loading.value = false; captureBaseline(); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getSupplier(supplierId.value)
|
||||
const data = await getSupplier(requestedId)
|
||||
if (sequence !== loadSequence || requestedId !== supplierId.value) return
|
||||
Object.assign(form, { name: data.name, category: data.category, scope: data.scope, region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark })
|
||||
captureBaseline()
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '供应商详情加载失败') } finally { loading.value = false }
|
||||
} catch (error) {
|
||||
if (sequence === loadSequence && requestedId === supplierId.value) loadError.value = getApiErrorDetails(error, '供应商详情加载失败')
|
||||
} finally { if (sequence === loadSequence) loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!access.can(requiredPermission.value)) return
|
||||
@@ -63,7 +74,7 @@ async function save() {
|
||||
}
|
||||
|
||||
watch(() => form.category, (category) => { if (category === 'BOOKING' && !form.scope) form.scope = 'DOMESTIC'; if (category !== 'BOOKING') form.scope = null })
|
||||
onMounted(load)
|
||||
watch(supplierId, () => { void load() }, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -109,5 +120,7 @@ onMounted(load)
|
||||
.supplier-form-panel { padding: 22px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }.span-2 { grid-column: 1 / -1; }
|
||||
.form-grid :deep(.el-select) { width: 100%; }.file-placeholder { display: flex; width: 100%; min-height: 96px; align-items: center; justify-content: center; flex-direction: column; gap: 6px; padding: 16px; border: 1px dashed #d9d9d9; border-radius: 6px; background: #fafafa; color: var(--cc-muted); text-align: center; }.file-placeholder small { max-width: 420px; line-height: 1.5; }
|
||||
.form-actions { display: flex; justify-content: center; gap: 14px; padding: 18px 0 0; border-top: 1px solid var(--cc-border); }.form-actions :deep(.el-button) { min-width: 112px; margin-left: 0; }
|
||||
@media (min-width: 1600px) { .form-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
|
||||
@media (min-width: 1280px) and (max-width: 1599px) { .form-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width: 720px) { .supplier-form-panel { padding: 16px; } .form-grid { grid-template-columns: 1fr; }.span-2 { grid-column: auto; }.form-page-header { align-items: stretch; flex-direction: column; }.form-actions { flex-direction: column; }.form-actions :deep(.el-button) { width: 100%; min-height: 44px; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { reactive, nextTick } from 'vue'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import ElementPlus from 'element-plus'
|
||||
import type { Component } from 'vue'
|
||||
import type { Supplier } from '@/modules/suppliers/types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
route: null as null | { params: { id: string } },
|
||||
push: vi.fn(), getSupplier: vi.fn(), updateSupplier: vi.fn(),
|
||||
can: vi.fn((permission: string) => permission === 'platform:supplier:list' || permission === 'platform:supplier:edit' || permission === 'platform:supplier:create'),
|
||||
}))
|
||||
const routeState = reactive({ params: { id: 'A' } })
|
||||
mocks.route = routeState
|
||||
let SupplierFormView: Component
|
||||
let SupplierDetailView: Component
|
||||
|
||||
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: mocks.can }) }))
|
||||
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
|
||||
vi.mock('@/modules/suppliers/api/platformSuppliers', () => ({ getSupplier: mocks.getSupplier, updateSupplier: mocks.updateSupplier, createSupplier: vi.fn() }))
|
||||
|
||||
function supplier(id: string, name: string): Supplier {
|
||||
return { id, tenantId: '0', name, category: 'BOOKING', scope: 'DOMESTIC', region: '宁波', legalRepresentative: '法人', contactName: '联系人', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1 }
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((next) => { resolve = next })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
const stubs = {
|
||||
AdminPage: { template: '<main><slot /></main>' },
|
||||
DetailPageLayout: { template: '<article><slot name="header"/><slot /></article>' },
|
||||
FormSection: { template: '<section><slot /></section>' },
|
||||
ApiErrorMeta: { template: '<span />' },
|
||||
LoadErrorState: { template: '<div class="load-error"><slot name="details" /></div>' },
|
||||
SupplierLicenseDialog: { template: '<div />' },
|
||||
}
|
||||
|
||||
describe('supplier route reuse', () => {
|
||||
beforeAll(async () => {
|
||||
SupplierFormView = (await import('@/modules/suppliers/pages/SupplierFormView.vue')).default
|
||||
SupplierDetailView = (await import('@/modules/suppliers/pages/SupplierDetailView.vue')).default
|
||||
})
|
||||
beforeEach(() => {
|
||||
routeState.params.id = 'A'
|
||||
mocks.push.mockReset(); mocks.getSupplier.mockReset(); mocks.updateSupplier.mockReset().mockResolvedValue(null)
|
||||
mocks.can.mockClear()
|
||||
})
|
||||
|
||||
it('reloads form A to B, ignores late A, and submits B', async () => {
|
||||
const requestA = deferred<Supplier>(); const requestB = deferred<Supplier>()
|
||||
mocks.getSupplier.mockImplementation((id: string) => id === 'A' ? requestA.promise : requestB.promise)
|
||||
const wrapper = mount(SupplierFormView, { global: { plugins: [ElementPlus], stubs } })
|
||||
expect(mocks.getSupplier).toHaveBeenCalledWith('A')
|
||||
|
||||
routeState.params.id = 'B'
|
||||
await nextTick()
|
||||
expect(mocks.getSupplier).toHaveBeenLastCalledWith('B')
|
||||
requestB.resolve(supplier('B', '供应商B')); await flushPromises()
|
||||
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('供应商B')
|
||||
requestA.resolve(supplier('A', '供应商A')); await flushPromises()
|
||||
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('供应商B')
|
||||
expect((wrapper.find('input').element as HTMLInputElement).value).not.toBe('供应商A')
|
||||
|
||||
const saveButton = wrapper.findAll('button').find((button) => button.text().includes('保存'))
|
||||
expect(saveButton).toBeDefined()
|
||||
await saveButton!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(mocks.updateSupplier).toHaveBeenCalledWith('B', expect.objectContaining({ name: '供应商B' }))
|
||||
expect(mocks.push).toHaveBeenCalledWith({ name: 'supplier-list' })
|
||||
})
|
||||
|
||||
it('reloads detail A to B when the route is reused', async () => {
|
||||
const requestA = deferred<Supplier>(); const requestB = deferred<Supplier>()
|
||||
mocks.getSupplier.mockImplementation((id: string) => id === 'A' ? requestA.promise : requestB.promise)
|
||||
const wrapper = mount(SupplierDetailView, { global: { plugins: [ElementPlus], stubs } })
|
||||
routeState.params.id = 'B'; await nextTick()
|
||||
requestB.resolve(supplier('B', '供应商B')); await flushPromises()
|
||||
expect(wrapper.text()).toContain('供应商B')
|
||||
requestA.resolve(supplier('A', '供应商A')); await flushPromises()
|
||||
expect(wrapper.text()).toContain('供应商B')
|
||||
expect(wrapper.text()).not.toContain('供应商A')
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,14 @@ describe('supplier formal pages', () => {
|
||||
expect(source).toContain("access.can('platform:supplier:edit')")
|
||||
expect(source).toContain("access.can('platform:supplier:status')")
|
||||
expect(source).toContain("access.can('platform:supplier:list')")
|
||||
expect(source).toContain('<el-button size="small" plain type="primary" @click="router.push')
|
||||
})
|
||||
|
||||
it('matches the prototype statistics, category tabs and search sequence', () => {
|
||||
expect(source).toContain('class="supplier-list-top"')
|
||||
expect(source).toContain('class="supplier-query-panel"')
|
||||
expect(source).toContain('class="supplier-query-toolbar"')
|
||||
expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px')
|
||||
})
|
||||
|
||||
it('uses independent routes for list, detail, create and edit', () => {
|
||||
@@ -34,4 +42,11 @@ describe('supplier formal pages', () => {
|
||||
expect(formSource).toContain('<ApiErrorMeta')
|
||||
expect(detailSource).toContain('<ApiErrorMeta')
|
||||
})
|
||||
|
||||
it('reloads detail data when the reused route id changes and protects the newer request', () => {
|
||||
expect(detailSource).toContain("watch(supplierId, () => { void load() }, { immediate: true })")
|
||||
expect(detailSource).toContain('const requestedId = supplierId.value')
|
||||
expect(detailSource).toContain('if (sequence !== loadSequence || requestedId !== supplierId.value) return')
|
||||
expect(detailSource).toContain("params: { id: supplier.id }")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -115,15 +115,20 @@ onMounted(load)
|
||||
<template>
|
||||
<AdminPage>
|
||||
<ListPageLayout aria-label="供应商列表">
|
||||
<template #metrics>
|
||||
<div class="supplier-metrics" aria-label="供应商分类统计">
|
||||
<button v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)">
|
||||
<span>{{ item.label }}</span><strong>{{ item.value }}<small>家</small></strong>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar>
|
||||
<QueryToolbar v-model="filters" ariaLabel="供应商筛选" :fields="queryFields" @search="search" />
|
||||
<div class="supplier-list-top">
|
||||
<div class="supplier-metrics" aria-label="供应商分类统计">
|
||||
<button v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)">
|
||||
<span>{{ item.label }}</span><strong>{{ item.value }}<small>家</small></strong>
|
||||
</button>
|
||||
</div>
|
||||
<div class="supplier-query-panel">
|
||||
<nav class="supplier-tabs" aria-label="供应商分类">
|
||||
<button v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)">{{ categoryLabels[item.category] }}</button>
|
||||
</nav>
|
||||
<QueryToolbar v-model="filters" class="supplier-query-toolbar" ariaLabel="供应商筛选" :fields="queryFields" @search="search" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #state>
|
||||
<LoadErrorState v-if="!canView" title="暂无供应商查看权限" message="请联系管理员开通供应商查看权限。" :show-retry="false" />
|
||||
@@ -132,24 +137,21 @@ onMounted(load)
|
||||
</LoadErrorState>
|
||||
</template>
|
||||
<section v-if="canView" class="surface-panel table-panel">
|
||||
<nav class="supplier-tabs" aria-label="供应商分类">
|
||||
<button v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)">{{ categoryLabels[item.category] }}</button>
|
||||
</nav>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" empty-text="暂无供应商数据" preference-key="supplier-list">
|
||||
<template #toolbar-actions>
|
||||
<el-button v-if="access.can('platform:supplier:create')" type="primary" @click="router.push({ name: 'supplier-create' })"><Plus :size="16" />新增供应商</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button link type="primary" @click="router.push({ name: 'supplier-detail', params: { id: row.id } })"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="access.can('platform:supplier:edit')" link type="primary" @click="router.push({ name: 'supplier-edit', params: { id: row.id } })"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="access.can('platform:supplier:status')" link :type="row.status === 'ACTIVE' ? 'danger' : 'success'" :loading="actionId === row.id" @click="toggleStatus(supplierRow(row))"><Power :size="15" />{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
|
||||
<el-button size="small" plain type="primary" @click="router.push({ name: 'supplier-detail', params: { id: row.id } })"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="access.can('platform:supplier:edit')" size="small" plain type="primary" @click="router.push({ name: 'supplier-edit', params: { id: row.id } })"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="access.can('platform:supplier:status')" size="small" plain :type="row.status === 'ACTIVE' ? 'danger' : 'success'" :loading="actionId === row.id" @click="toggleStatus(supplierRow(row))"><Power :size="15" />{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #name="{ row }"><strong class="supplier-name">{{ row.name }}</strong></template>
|
||||
<template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template>
|
||||
<template #scope="{ row }">{{ row.scope ? scopeLabels[row.scope as SupplierScope] : '—' }}</template>
|
||||
<template #license="{ row }"><el-button link type="primary" @click="showLicense(supplierRow(row))"><FileText :size="15" />查看</el-button></template>
|
||||
<template #license="{ row }"><el-button size="small" plain type="primary" @click="showLicense(supplierRow(row))"><FileText :size="15" />查看</el-button></template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabels[row.status as SupplierStatus]" /></template>
|
||||
<template #settlement="{ row }">{{ settlementLabels[supplierRow(row).settlementPeriod] }}</template>
|
||||
</DataTable>
|
||||
@@ -160,13 +162,8 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.supplier-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.supplier-metrics button { min-width: 0; padding: 18px 20px; border: 0; border-right: 1px solid var(--cc-border); background: #fff; color: var(--cc-text); text-align: left; cursor: pointer; }
|
||||
.supplier-metrics button:last-child { border-right: 0; }.supplier-metrics button.active { box-shadow: inset 0 3px 0 var(--el-color-primary); background: #f8fbff; }
|
||||
.supplier-metrics span { display: block; margin-bottom: 7px; color: var(--cc-muted); font-size: 12px; }.supplier-metrics strong { font-size: 25px; }.supplier-metrics small { margin-left: 3px; color: var(--cc-muted); font-size: 12px; }
|
||||
.supplier-tabs { display: flex; gap: 6px; padding: 14px 14px 0; overflow-x: auto; border-bottom: 1px solid var(--cc-border); }
|
||||
.supplier-tabs button { min-width: 76px; padding: 7px 20px; border: 0; border-radius: 6px 6px 0 0; background: #f5f5f5; color: #555; cursor: pointer; }.supplier-tabs button.active { background: var(--el-color-primary); color: #fff; font-weight: 650; }
|
||||
.supplier-list-top { display: grid; gap: 16px; }.supplier-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }.supplier-metrics button { min-width: 0; padding: 12px 14px; border: 1px solid #e7edf5; border-radius: 8px; background: #f8fafc; color: var(--cc-text); text-align: left; cursor: pointer; }.supplier-metrics button.active { border-color: var(--cc-primary); background: #f7f8ff; box-shadow: inset 0 3px 0 var(--cc-primary); }.supplier-metrics span { display: block; margin-bottom: 6px; color: #64748b; font-size: 12px; }.supplier-metrics strong { color: #1e293b; font-size: 20px; }.supplier-metrics small { margin-left: 3px; color: #64748b; font-size: 12px; }.supplier-query-panel { overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.supplier-query-toolbar { border: 0; }.supplier-tabs { display: flex; gap: 6px; padding: 10px 14px; overflow-x: auto; border-bottom: 1px solid var(--cc-border); background: #fafbfc; }.supplier-tabs button { min-width: 76px; padding: 7px 20px; border: 0; border-radius: 6px; background: #f1f3f5; color: #555; cursor: pointer; }.supplier-tabs button.active { background: var(--cc-primary); color: #fff; font-weight: 650; }
|
||||
.row-actions { display: flex; align-items: center; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 8px; }.supplier-name { overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) { .supplier-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .supplier-metrics button:nth-child(2) { border-right: 0; } .supplier-metrics button:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); } }
|
||||
@media (max-width: 380px) { .supplier-metrics { grid-template-columns: 1fr; } .supplier-metrics button { border-right: 0; border-bottom: 1px solid var(--cc-border); } .supplier-metrics button:last-child { border-bottom: 0; } }
|
||||
@media (max-width: 720px) { .supplier-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 380px) { .supplier-metrics { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user