优化
This commit is contained in:
@@ -92,6 +92,25 @@ describe('AppLayout', () => {
|
||||
expect(menuButton.attributes('aria-expanded')).toBe('true')
|
||||
})
|
||||
|
||||
it('closes the mobile menu when returning to the desktop breakpoint and removes the listener', async () => {
|
||||
mockViewport(true)
|
||||
const { wrapper } = await mountLayout()
|
||||
const menuButton = wrapper.get('.shell-menu-button')
|
||||
const mediaQuery = vi.mocked(window.matchMedia).mock.results[0]?.value as MediaQueryList
|
||||
const changeHandler = vi.mocked(mediaQuery.addEventListener).mock.calls[0]?.[1] as ((event: MediaQueryListEvent) => void)
|
||||
|
||||
await menuButton.trigger('click')
|
||||
expect(menuButton.attributes('aria-expanded')).toBe('true')
|
||||
|
||||
changeHandler({ matches: false } as MediaQueryListEvent)
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(menuButton.attributes('aria-label')).toBe('收起导航')
|
||||
expect(menuButton.attributes('aria-expanded')).toBe('true')
|
||||
|
||||
wrapper.unmount()
|
||||
expect(mediaQuery.removeEventListener).toHaveBeenCalledWith('change', changeHandler)
|
||||
})
|
||||
|
||||
it('shows the authenticated identity without a role preview switcher', async () => {
|
||||
const { wrapper } = await mountLayout()
|
||||
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
|
||||
|
||||
@@ -11,19 +11,20 @@ describe('platform audit api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('queries the selected customer and preserves long ids as strings', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: '2082813911786024961', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }], page: 1, size: 20, total: 1, pages: 1 } })
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ id: '2082813911786024961', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }], page: 1, size: 20, total: 1, pages: 1 } })
|
||||
|
||||
const result = await listPlatformAuditLogs({ accountId: '8', page: 1, size: 20 })
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs', { params: { accountId: '8', page: 1, size: 20 } })
|
||||
expect(result.records[0].id).toBe('2082813911786024961')
|
||||
expect(result.records[0].clientIp).toBe('127.0.0.1')
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.pages).toBe(1)
|
||||
})
|
||||
|
||||
it('preserves the server total when the current page has fewer records', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: {
|
||||
records: [{ id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }],
|
||||
records: [{ id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }],
|
||||
page: 1, size: 20, total: 13, pages: 1,
|
||||
} })
|
||||
|
||||
@@ -50,7 +51,7 @@ describe('platform audit api', () => {
|
||||
})
|
||||
|
||||
it('loads one audit detail', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00', httpMethod: 'PUT', requestUri: '/tenants/8', clientIp: '127.0.0.1', durationMs: 12, beforeData: '{}', afterData: '{"name":"new"}' } })
|
||||
vi.mocked(http.get).mockResolvedValue({ data: { id: '1', accountId: '8', operatorTenantId: '0', userId: '9', resourceId: '8', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', description: '编辑客户', username: 'admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: null, traceId: null, serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00', httpMethod: 'PUT', requestUri: '/tenants/8', durationMs: 12, beforeData: '{}', afterData: '{"name":"new"}' } })
|
||||
|
||||
const result = await getPlatformAuditLog('1')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/audit/logs/1')
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface PlatformAuditLogListItem {
|
||||
username: string | null
|
||||
appCode: string | null
|
||||
clientType: string | null
|
||||
clientIp: string | null
|
||||
requestId: string | null
|
||||
traceId: string | null
|
||||
serviceName: string | null
|
||||
|
||||
@@ -10,7 +10,7 @@ import CustomerAuditLogsPanel from './CustomerAuditLogsPanel.vue'
|
||||
|
||||
vi.mock('../api/platformAudit', () => ({ getPlatformAuditLog: vi.fn(), listPlatformAuditLogs: vi.fn() }))
|
||||
|
||||
const row = { id: '1', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', resourceId: '8', description: '编辑客户', accountId: '8', operatorTenantId: '0', userId: '9', username: 'platform_admin', appCode: 'ADMIN', clientType: 'WEB', requestId: 'req', traceId: 'trace', serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }
|
||||
const row = { id: '1', eventId: 'evt', eventType: 'PLATFORM_TENANT_UPDATE', resourceType: 'TENANT', resourceId: '8', description: '编辑客户', accountId: '8', operatorTenantId: '0', userId: '9', username: 'platform_admin', appCode: 'ADMIN', clientType: 'WEB', clientIp: '127.0.0.1', requestId: 'req', traceId: 'trace', serviceName: 'iam-service', success: true, errorCode: null, errorMessage: null, occurredAt: '2026-08-13T01:00:00Z', createdAt: '2026-08-13T09:00:00' }
|
||||
|
||||
function mountPanel(permissions: string[]) {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
@@ -26,6 +26,7 @@ describe('CustomerAuditLogsPanel', () => {
|
||||
const wrapper = mountPanel(['platform:audit:list']); await flushPromises()
|
||||
expect(listPlatformAuditLogs).toHaveBeenCalledWith({ accountId: '8', page: 1, size: 20 })
|
||||
expect(wrapper.text()).toContain('编辑客户')
|
||||
expect(wrapper.text()).toContain('127.0.0.1')
|
||||
})
|
||||
|
||||
it('shows the server total instead of the current page row count', async () => {
|
||||
|
||||
@@ -36,6 +36,7 @@ const queryFields: QueryField[] = [
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'description', label: '操作内容', minWidth: 190, slotName: 'description' },
|
||||
{ key: 'clientIp', label: 'IP 地址', width: 140, slotName: 'clientIp' },
|
||||
{ key: 'eventType', prop: 'eventType', label: '事件类型', minWidth: 220 },
|
||||
{ key: 'username', label: '操作人', width: 140, slotName: 'username' },
|
||||
{ key: 'serviceName', label: '来源服务', width: 150, slotName: 'serviceName' },
|
||||
@@ -132,6 +133,7 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
<DataTable v-else v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" aria-label="客户操作日志列表" empty-text="当前客户暂无操作日志" preference-key="customer-audit-logs" border stripe>
|
||||
<template #actions="{ row }"><el-button link type="primary" :aria-label="`查看操作日志${(row as PlatformAuditLogListItem).id}详情`" @click="openDetail(row as PlatformAuditLogListItem)"><Eye :size="15" />详情</el-button></template>
|
||||
<template #description="{ row }">{{ display((row as PlatformAuditLogListItem).description) }}</template>
|
||||
<template #clientIp="{ row }">{{ display((row as PlatformAuditLogListItem).clientIp) }}</template>
|
||||
<template #username="{ row }">{{ display((row as PlatformAuditLogListItem).username) }}</template>
|
||||
<template #serviceName="{ row }">{{ display((row as PlatformAuditLogListItem).serviceName) }}</template>
|
||||
<template #success="{ row }"><el-tag :type="(row as PlatformAuditLogListItem).success ? 'success' : 'danger'">{{ (row as PlatformAuditLogListItem).success ? '成功' : '失败' }}</el-tag></template>
|
||||
|
||||
@@ -113,6 +113,7 @@ function statusMeta(status: PlatformTenantStatus) {
|
||||
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 }
|
||||
if (status === 'ARCHIVED') return { label: '已归档', type: 'info' as const }
|
||||
return { label: '已停用', type: 'info' as const }
|
||||
}
|
||||
function qualificationLabel(type: string) {
|
||||
@@ -373,7 +374,7 @@ watch(orderPage, (page, previousPage) => {
|
||||
<template #actions>
|
||||
<div v-if="customer" class="header-actions">
|
||||
<el-tag :type="statusMeta(customer.status).type" effect="light" round>{{ statusMeta(customer.status).label }}</el-tag>
|
||||
<el-button v-if="access.can('platform:customer:edit')" @click="goEdit"><Pencil :size="16" />编辑信息</el-button>
|
||||
<el-button v-if="access.can('platform:customer:edit') && customer.status !== 'ARCHIVED'" @click="goEdit"><Pencil :size="16" />编辑信息</el-button>
|
||||
<el-button v-if="access.can('platform:customer:status') && ['ACTIVE', 'DISABLED'].includes(customer.status)" :type="customer.status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusChanging" @click="changeStatus"><Power :size="16" />{{ customer.status === 'ACTIVE' ? '停用账号' : '启用账号' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -529,7 +529,7 @@ describe('CustomersView', () => {
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = [...auth.permissions, 'platform:customer:delete']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'DISABLED' })
|
||||
vi.mocked(archivePlatformTenant).mockResolvedValue({ ...fullTenant, status: 'ARCHIVED' })
|
||||
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
@@ -578,6 +578,24 @@ describe('CustomersView', () => {
|
||||
expect(document.body.textContent).toContain('代登录原因')
|
||||
})
|
||||
|
||||
it('treats archived customers as read-only and terminal', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = [...auth.permissions, 'platform:customer:edit', 'platform:customer:status', 'platform:customer:delete']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({
|
||||
records: [{ ...fullTenant, status: 'ARCHIVED' }], page: 1, size: 10, total: 1, pages: 1,
|
||||
})
|
||||
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('已归档')
|
||||
expect(tableButtonByText('编辑')).toBeUndefined()
|
||||
expect(tableButtonByText('启用')).toBeUndefined()
|
||||
expect(tableButtonByText('停用')).toBeUndefined()
|
||||
expect(tableButtonByText('删除')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders all permitted actions directly for multiple customers', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.permissions = [...auth.permissions, 'platform:customer:reset-password', 'platform:customer:impersonate', 'platform:customer:status', 'platform:customer:delete']
|
||||
|
||||
@@ -131,7 +131,7 @@ const queryFields: QueryField[] = [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', options: [
|
||||
{ label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' },
|
||||
{ label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' },
|
||||
{ label: '已停用', value: 'DISABLED' },
|
||||
{ label: '已停用', value: 'DISABLED' }, { label: '已归档', value: 'ARCHIVED' },
|
||||
] },
|
||||
]
|
||||
|
||||
@@ -271,6 +271,7 @@ function statusMeta(status: PlatformTenantStatus) {
|
||||
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 }
|
||||
if (status === 'ARCHIVED') return { label: '已归档', type: 'info' as const }
|
||||
return { label: '已停用', type: 'info' as const }
|
||||
}
|
||||
|
||||
@@ -644,8 +645,8 @@ function canImpersonate(row: PlatformTenantResource) {
|
||||
function canChangeStatus(row: PlatformTenantResource) {
|
||||
return access.can('platform:customer:status') && ['ACTIVE', 'DISABLED'].includes(row.status)
|
||||
}
|
||||
function canArchive() {
|
||||
return access.can('platform:customer:delete')
|
||||
function canArchive(row: PlatformTenantResource) {
|
||||
return access.can('platform:customer:delete') && ['PROFILE_REQUIRED', 'REJECTED'].includes(row.status)
|
||||
}
|
||||
type CustomerRowAction = 'reset-password' | 'impersonate' | 'status' | 'delete'
|
||||
const customerRowActionIcons: Record<CustomerRowAction, Component> = {
|
||||
@@ -664,7 +665,7 @@ function customerSecondaryActions(row: PlatformTenantResource): CustomerRowActio
|
||||
...(canResetPassword(row) ? ['reset-password' as const] : []),
|
||||
...(canImpersonate(row) ? ['impersonate' as const] : []),
|
||||
...(canChangeStatus(row) ? ['status' as const] : []),
|
||||
...(canArchive() ? ['delete' as const] : []),
|
||||
...(canArchive(row) ? ['delete' as const] : []),
|
||||
]
|
||||
}
|
||||
function customerActionLabel(row: PlatformTenantResource, action: CustomerRowAction) {
|
||||
@@ -819,7 +820,7 @@ async function submitArchive() {
|
||||
archiveError.value = null
|
||||
try {
|
||||
await archivePlatformTenant(archiveTenant.value.id)
|
||||
ElMessage.success('客户已删除,历史数据已保留且账号已停用')
|
||||
ElMessage.success('客户已归档,历史数据已保留且账号已停用')
|
||||
archiveDialogOpen.value = false
|
||||
archiveTenant.value = null
|
||||
archiveConfirmCode.value = ''
|
||||
@@ -1016,7 +1017,7 @@ onMounted(() => { void loadCustomers() })
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button v-if="access.can('platform:customer:list')" class="customer-action-button customer-action-detail" size="small" @click="showDetail(row as PlatformTenantResource)"><Eye :size="14" />详情</el-button>
|
||||
<el-button v-if="access.can('platform:customer:edit')" class="customer-action-button customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="14" />编辑</el-button>
|
||||
<el-button v-if="access.can('platform:customer:edit') && (row as PlatformTenantResource).status !== 'ARCHIVED'" class="customer-action-button customer-action-edit" size="small" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="14" />编辑</el-button>
|
||||
<el-button v-if="hasReviewAction(row as PlatformTenantResource)" class="customer-action-button customer-action-review" size="small" :disabled="reviewSubmitting" @click="openDecision(row as PlatformTenantResource, access.can('platform:customer:approve') ? 'approve' : 'reject')"><ShieldCheck :size="14" />审核</el-button>
|
||||
<el-button v-for="action in customerSecondaryActions(row as PlatformTenantResource)" :key="action" :class="[`customer-action-button`, `customer-action-${action}`, { 'is-enable': action === 'status' && (row as PlatformTenantResource).status !== 'ACTIVE', 'is-disable': action === 'status' && (row as PlatformTenantResource).status === 'ACTIVE' }]" size="small" :disabled="customerActionDisabled(action)" @click="handleMoreAction(row as PlatformTenantResource, action)"><component :is="customerRowActionIcons[action]" :size="14" />{{ customerActionLabel(row as PlatformTenantResource, action) }}</el-button>
|
||||
</div>
|
||||
|
||||
@@ -296,7 +296,7 @@ describe('platform IAM roles', () => {
|
||||
|
||||
it('supports owner password reset, logical archive, and audited impersonation contracts', async () => {
|
||||
const password = { newPassword: 'NewPassword#2026', forceChangePassword: true }
|
||||
const archivedTenant = { id: '190000000000000099', tenantCode: 'TEN99', tenantName: '测试客户', status: 'DISABLED' }
|
||||
const archivedTenant = { id: '190000000000000099', tenantCode: 'TEN99', tenantName: '测试客户', status: 'ARCHIVED' }
|
||||
const impersonation = { code: 'ticket-code', ticketId: 'ticket-id', expiresIn: 60 }
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
vi.mocked(http.post)
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface EnterpriseReview {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type PlatformTenantStatus = 'PENDING' | 'ACTIVE' | 'REJECTED' | 'INACTIVE' | string
|
||||
export type PlatformTenantStatus = 'PROFILE_REQUIRED' | 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED' | 'ARCHIVED' | string
|
||||
|
||||
export interface PlatformTenantResource {
|
||||
id: number | string
|
||||
|
||||
@@ -32,6 +32,7 @@ const statusLabels: Record<string, string> = {
|
||||
REJECTED: '已驳回',
|
||||
DISABLED: '已停用',
|
||||
INACTIVE: '已停用',
|
||||
ARCHIVED: '已归档',
|
||||
}
|
||||
|
||||
function tenantLabel(tenant: PlatformTenantResource) {
|
||||
|
||||
@@ -172,7 +172,7 @@ describe('PartnersView real API integration', () => {
|
||||
|
||||
it('updates filters locally and only requests after search', async () => {
|
||||
const wrapper = await mountView()
|
||||
const keywordInput = wrapper.find('input[placeholder="单位名称、编码、联系人或电话"]')
|
||||
const keywordInput = wrapper.find('input[placeholder="请输入往来单位名称"]')
|
||||
|
||||
await keywordInput.setValue('深圳')
|
||||
await flushPromises()
|
||||
|
||||
@@ -61,7 +61,7 @@ const filters = ref<PartnerFilters>({
|
||||
})
|
||||
const appliedFilters = ref<PartnerFilters>({ ...filters.value })
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '往来单位关键词', placeholder: '单位名称、编码、联系人或电话' },
|
||||
{ key: 'keyword', type: 'input', label: '往来单位名称', placeholder: '请输入往来单位名称' },
|
||||
{
|
||||
key: 'type',
|
||||
type: 'select',
|
||||
|
||||
@@ -131,7 +131,7 @@ describe('ExpressCompaniesView query and table migration', () => {
|
||||
|
||||
it('does not request with edited filters until search is submitted', async () => {
|
||||
const wrapper = await mountView()
|
||||
await wrapper.find('input[placeholder="快递公司名称或编码"]').setValue('顺丰')
|
||||
await wrapper.find('input[placeholder="请输入快递公司名称"]').setValue('顺丰')
|
||||
await flushPromises()
|
||||
expect(listPlatformExpressCompanies).toHaveBeenCalledTimes(1)
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ type ExpressCompanyFilters = QueryModel & {
|
||||
const filters = ref<ExpressCompanyFilters>({ keyword: '', status: '' })
|
||||
const appliedFilters = ref<ExpressCompanyFilters>({ ...filters.value })
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'keyword', type: 'input', label: '快递公司关键词', placeholder: '快递公司名称或编码', props: { maxlength: 128, 'aria-label': '快递公司关键词' } },
|
||||
{ key: 'keyword', type: 'input', label: '快递公司名称', placeholder: '请输入快递公司名称', props: { maxlength: 128, 'aria-label': '快递公司名称' } },
|
||||
{
|
||||
key: 'status',
|
||||
type: 'select',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
cancelTask,
|
||||
changeTemplateStatus,
|
||||
createNotification,
|
||||
getTask,
|
||||
listTasks,
|
||||
listTemplates,
|
||||
retryTask,
|
||||
@@ -18,6 +19,7 @@ vi.mock('@/shared/api/http', () => ({
|
||||
const envelope = <T>(data: T) => ({ code: '200', data })
|
||||
const template = { id: 12, templateCode: 'ORDER_SENT', templateName: '订单已发货', category: 'ORDER', channel: 'IN_APP' as const, titleTemplate: '已发货', bodyTemplate: '正文', variableWhitelist: [], status: 'DRAFT' as const, version: 1 }
|
||||
const task = { id: 13, taskNo: 'NT-13', category: 'ORDER', title: '已发货', status: 'CREATED' as const, recipientCount: 2, scheduledAt: null, sentAt: null, duplicated: false }
|
||||
const taskDetail = { id: 13, taskNo: 'NT-13', category: 'ORDER', title: '已发货', status: 'DEAD' as const, recipientCount: 1, attemptCount: 3, scheduledAt: null, sentAt: null, lastError: 'gateway timeout', recipients: [{ id: 21, displayName: '客户成员', appCode: 'MERCHANT', status: 'FAILED', failureReason: 'gateway timeout', deliveredAt: null }] }
|
||||
|
||||
describe('notificationCenter API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
@@ -51,4 +53,13 @@ describe('notificationCenter API', () => {
|
||||
expect(http.put).toHaveBeenNthCalledWith(1, '/platform/v1/notification-tasks/13/cancel')
|
||||
expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/notification-tasks/13/retry')
|
||||
})
|
||||
|
||||
it('loads task failure evidence and normalizes nested long ids', async () => {
|
||||
vi.mocked(http.get).mockResolvedValueOnce(envelope(taskDetail))
|
||||
const result = await getTask('13')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/notification-tasks/13')
|
||||
expect(result.id).toBe('13')
|
||||
expect(result.recipients[0]?.id).toBe('21')
|
||||
expect(result.lastError).toBe('gateway timeout')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { CreateNotificationRequest, CreateTemplateRequest, NotificationTask, NotificationTaskStatus, NotificationTemplate, PageResult, TemplateStatus, UpdateTemplateRequest } from '../types'
|
||||
import type { CreateNotificationRequest, CreateTemplateRequest, NotificationTask, NotificationTaskDetail, NotificationTaskStatus, NotificationTemplate, PageResult, TemplateStatus, UpdateTemplateRequest } from '../types'
|
||||
|
||||
type RawId = string | number
|
||||
type RawPage<T> = Omit<PageResult<T>, 'page' | 'size' | 'total' | 'pages'> & { page: RawId; size: RawId; total: RawId; pages: RawId }
|
||||
type RawTemplate = Omit<NotificationTemplate, 'id'> & { id: RawId }
|
||||
type RawTask = Omit<NotificationTask, 'id'> & { id: RawId }
|
||||
type RawTaskDetail = Omit<NotificationTaskDetail, 'id' | 'recipients'> & { id: RawId; recipients: Array<Omit<NotificationTaskDetail['recipients'][number], 'id'> & { id: RawId }> }
|
||||
|
||||
const notificationPath = '/platform/v1/notifications'
|
||||
const templatePath = '/platform/v1/notification-templates'
|
||||
@@ -13,6 +14,7 @@ const id = (value: RawId) => String(value)
|
||||
const page = <T>(value: RawPage<T>): PageResult<T> => ({ ...value, page: Number(value.page), size: Number(value.size), total: Number(value.total), pages: Number(value.pages) })
|
||||
const template = (value: RawTemplate): NotificationTemplate => ({ ...value, id: id(value.id) })
|
||||
const task = (value: RawTask): NotificationTask => ({ ...value, id: id(value.id) })
|
||||
const taskDetail = (value: RawTaskDetail): NotificationTaskDetail => ({ ...value, id: id(value.id), recipients: value.recipients.map((recipient) => ({ ...recipient, id: id(recipient.id) })) })
|
||||
|
||||
export async function createNotification(payload: CreateNotificationRequest) { const response = await http.post<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(notificationPath, payload); return task(unwrapApiResponse(response)) }
|
||||
export async function listTemplates(query: { page: number; size: number; templateCode?: string; category?: string; title?: string; status?: TemplateStatus }) { const response = await http.get<ApiEnvelope<RawPage<RawTemplate>>, ApiEnvelope<RawPage<RawTemplate>>>(templatePath, { params: query }); const data = unwrapApiResponse(response); return page({ ...data, records: data.records.map(template) }) }
|
||||
@@ -20,5 +22,6 @@ export async function createTemplate(payload: CreateTemplateRequest) { const res
|
||||
export async function updateTemplate(templateId: string, payload: UpdateTemplateRequest) { const response = await http.put<ApiEnvelope<RawTemplate>, ApiEnvelope<RawTemplate>>(`${templatePath}/${encodeURIComponent(templateId)}`, payload); return template(unwrapApiResponse(response)) }
|
||||
export async function changeTemplateStatus(templateId: string, targetStatus: Exclude<TemplateStatus, 'DRAFT'>, version: number) { const response = await http.put<ApiEnvelope<RawTemplate>, ApiEnvelope<RawTemplate>>(`${templatePath}/${encodeURIComponent(templateId)}/status`, { targetStatus, version }); return template(unwrapApiResponse(response)) }
|
||||
export async function listTasks(query: { page: number; size: number; taskNo?: string; category?: string; title?: string; status?: NotificationTaskStatus }) { const response = await http.get<ApiEnvelope<RawPage<RawTask>>, ApiEnvelope<RawPage<RawTask>>>(taskPath, { params: query }); const data = unwrapApiResponse(response); return page({ ...data, records: data.records.map(task) }) }
|
||||
export async function getTask(taskId: string) { const response = await http.get<ApiEnvelope<RawTaskDetail>, ApiEnvelope<RawTaskDetail>>(`${taskPath}/${encodeURIComponent(taskId)}`); return taskDetail(unwrapApiResponse(response)) }
|
||||
export async function cancelTask(taskId: string) { const response = await http.put<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(`${taskPath}/${encodeURIComponent(taskId)}/cancel`); return task(unwrapApiResponse(response)) }
|
||||
export async function retryTask(taskId: string) { const response = await http.put<ApiEnvelope<RawTask>, ApiEnvelope<RawTask>>(`${taskPath}/${encodeURIComponent(taskId)}/retry`); return task(unwrapApiResponse(response)) }
|
||||
|
||||
@@ -38,6 +38,15 @@ describe('notification center contract UI', () => {
|
||||
expect(templates).toContain("status==='DRAFT'?'激活':'禁用'")
|
||||
})
|
||||
|
||||
it('exposes read-only task delivery evidence behind the task list contract', () => {
|
||||
const management = source('./pages/MessageManagementView.vue')
|
||||
expect(management).toContain('getTask(row.id)')
|
||||
expect(management).toContain('消息任务详情')
|
||||
expect(management).toContain('任务失败原因')
|
||||
expect(management).toContain('收件人投递明细')
|
||||
expect(management).toContain('failureReason')
|
||||
})
|
||||
|
||||
it('uses the prototype composer width and exposes the real send command', () => {
|
||||
const wrapper = mount(MessageComposerDialog, {
|
||||
props: { modelValue: true, templates: [] },
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { Plus, RotateCcw, XCircle } from '@lucide/vue'
|
||||
import { Eye, Plus, RotateCcw, XCircle } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import DataTable,{type DataTableColumn} from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import MessageComposerDialog from '../components/MessageComposerDialog.vue'; import { cancelTask, listTasks, listTemplates, retryTask } from '../api/notificationCenter'; import type { NotificationTask, NotificationTaskStatus, NotificationTemplate } from '../types'; import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
const access=useAccessPreviewStore(),page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),composerOpen=ref(false),templates=ref<NotificationTemplate[]>([]),result=ref({records:[] as NotificationTask[],total:0,pages:0}),filters=ref<QueryModel>({category:'',status:'',keyword:''})
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'; import AppDrawer from '@/shared/components/AppDrawer.vue'; import DataTable,{type DataTableColumn} from '@/shared/components/DataTable.vue'; import ListPageLayout from '@/shared/components/ListPageLayout.vue'; import QueryToolbar,{type QueryField,type QueryModel} from '@/shared/components/QueryToolbar.vue'; import LoadErrorState from '@/shared/components/LoadErrorState.vue'; import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import MessageComposerDialog from '../components/MessageComposerDialog.vue'; import { cancelTask, getTask, listTasks, listTemplates, retryTask } from '../api/notificationCenter'; import type { NotificationTask, NotificationTaskDetail, NotificationTaskStatus, NotificationTemplate } from '../types'; import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'; import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
const access=useAccessPreviewStore(),page=ref(1),pageSize=ref(20),loading=ref(false),actionId=ref(''),error=ref<ApiErrorDetails|null>(null),composerOpen=ref(false),detailOpen=ref(false),detailLoading=ref(false),detailError=ref<ApiErrorDetails|null>(null),detail=ref<NotificationTaskDetail|null>(null),templates=ref<NotificationTemplate[]>([]),result=ref({records:[] as NotificationTask[],total:0,pages:0}),filters=ref<QueryModel>({category:'',status:'',keyword:''})
|
||||
const queryFields:QueryField[]=[{key:'category',type:'input',label:'业务分类',placeholder:'业务分类'},{key:'status',type:'select',label:'任务状态',placeholder:'全部状态',options:['CREATED','SCHEDULED','DISPATCHING','RETRYING','SENT','DEAD','CANCELLED'].map(value=>({label:statusLabel(value as NotificationTaskStatus),value}))},{key:'keyword',type:'input',label:'任务号或标题',placeholder:'任务号/消息标题'}]
|
||||
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:180,fixed:'left',slotName:'actions'},{key:'taskNo',prop:'taskNo',label:'任务号',minWidth:190},{key:'title',prop:'title',label:'标题',minWidth:220},{key:'category',prop:'category',label:'业务分类',width:130},{key:'recipientCount',prop:'recipientCount',label:'接收人数',width:100},{key:'scheduledAt',label:'定时发送',width:170,slotName:'scheduledAt'},{key:'sentAt',label:'发送时间',width:170,slotName:'sentAt'},{key:'status',label:'状态',width:110,slotName:'status'}]
|
||||
const columns:DataTableColumn[]=[{key:'actions',label:'操作',width:240,fixed:'left',slotName:'actions'},{key:'taskNo',prop:'taskNo',label:'任务号',minWidth:190},{key:'title',prop:'title',label:'标题',minWidth:220},{key:'category',prop:'category',label:'业务分类',width:130},{key:'recipientCount',prop:'recipientCount',label:'接收人数',width:100},{key:'scheduledAt',label:'定时发送',width:170,slotName:'scheduledAt'},{key:'sentAt',label:'发送时间',width:170,slotName:'sentAt'},{key:'status',label:'状态',width:110,slotName:'status'}]
|
||||
function statusLabel(v:NotificationTaskStatus){return {CREATED:'已创建',SCHEDULED:'待定时发送',DISPATCHING:'发送中',RETRYING:'重试中',SENT:'已发送',DEAD:'发送失败',CANCELLED:'已取消'}[v]}
|
||||
function formatDate(v:string|null){return v?new Date(v).toLocaleString('zh-CN',{hour12:false}):'—'}
|
||||
async function load(){loading.value=true;error.value=null;try{const keyword=String(filters.value.keyword||'').trim();const data=await listTasks({page:page.value,size:pageSize.value,category:String(filters.value.category||'').trim()||undefined,status:(filters.value.status||undefined) as NotificationTaskStatus|undefined,...(keyword.startsWith('NT')?{taskNo:keyword}:{title:keyword||undefined})});result.value={records:data.records,total:data.total,pages:data.pages}}catch(e){error.value=getApiErrorDetails(e,'消息任务加载失败')}finally{loading.value=false}}
|
||||
async function loadTemplates(){if(!access.can('platform:notification:create'))return;try{const data=await listTemplates({page:1,size:200,status:'ACTIVE'});templates.value=data.records}catch(e){ElMessage.error(getApiErrorMessage(e,'可用模板加载失败'))}}
|
||||
async function openDetail(row:NotificationTask){detailOpen.value=true;detailLoading.value=true;detailError.value=null;detail.value=null;try{detail.value=await getTask(row.id)}catch(e){detailError.value=getApiErrorDetails(e,'消息任务详情加载失败')}finally{detailLoading.value=false}}
|
||||
function search(){page.value===1?void load():page.value=1}
|
||||
async function action(row:NotificationTask,type:'cancel'|'retry'){try{await ElMessageBox.confirm(type==='cancel'?'确定取消该消息任务吗?':'确定重试该消息任务吗?',type==='cancel'?'取消任务':'重试任务',{type:'warning'});actionId.value=row.id;if(type==='cancel')await cancelTask(row.id);else await retryTask(row.id);ElMessage.success(type==='cancel'?'任务已取消':'任务已提交重试');await load()}catch(e){if(e!=='cancel'&&e!=='close')ElMessage.error(getApiErrorMessage(e,type==='cancel'?'取消失败':'重试失败'))}finally{actionId.value=''}}
|
||||
watch([page,pageSize],load);onMounted(()=>{void load();void loadTemplates()})
|
||||
</script>
|
||||
<template><AdminPage><ListPageLayout aria-label="消息管理列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="消息任务筛选" :fields="queryFields" preference-key="notifications-messages-query" @search="search" /></template><template v-if="error" #state><LoadErrorState title="消息任务加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-messages-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息任务"><template #toolbar-actions><el-button v-if="access.can('platform:notification:create')" type="primary" :disabled="!templates.length" @click="composerOpen=true"><Plus :size="16"/>新建消息</el-button></template><template #pagination-left><span>共 {{ result.total }} 个任务</span></template><template #actions="{row}"><div class="table-actions"><el-button v-if="access.can('platform:notification:task:cancel')&&['CREATED','SCHEDULED'].includes((row as NotificationTask).status)" link type="danger" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'cancel')"><XCircle :size="15"/>取消</el-button><el-button v-if="access.can('platform:notification:task:retry')&&['RETRYING','DEAD'].includes((row as NotificationTask).status)" link type="primary" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'retry')"><RotateCcw :size="15"/>重试</el-button></div></template><template #scheduledAt="{row}">{{formatDate((row as NotificationTask).scheduledAt)}}</template><template #sentAt="{row}">{{formatDate((row as NotificationTask).sentAt)}}</template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTask).status)}}</el-tag></template></DataTable></section></ListPageLayout><MessageComposerDialog v-model="composerOpen" :templates="templates" @success="load"/></AdminPage></template>
|
||||
<style scoped>.table-actions{display:flex;min-height:32px;align-items:center;gap:8px}.table-actions :deep(.el-button+.el-button){margin-left:0}</style>
|
||||
<template><AdminPage><ListPageLayout aria-label="消息管理列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="消息任务筛选" :fields="queryFields" preference-key="notifications-messages-query" @search="search" /></template><template v-if="error" #state><LoadErrorState title="消息任务加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="notifications-messages-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无消息任务"><template #toolbar-actions><el-button v-if="access.can('platform:notification:create')" type="primary" :disabled="!templates.length" @click="composerOpen=true"><Plus :size="16"/>新建消息</el-button></template><template #pagination-left><span>共 {{ result.total }} 个任务</span></template><template #actions="{row}"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as NotificationTask)"><Eye :size="15"/>查看</el-button><el-button v-if="access.can('platform:notification:task:cancel')&&['CREATED','SCHEDULED'].includes((row as NotificationTask).status)" link type="danger" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'cancel')"><XCircle :size="15"/>取消</el-button><el-button v-if="access.can('platform:notification:task:retry')&&['RETRYING','DEAD'].includes((row as NotificationTask).status)" link type="primary" :loading="actionId===(row as NotificationTask).id" @click="action(row as NotificationTask,'retry')"><RotateCcw :size="15"/>重试</el-button></div></template><template #scheduledAt="{row}">{{formatDate((row as NotificationTask).scheduledAt)}}</template><template #sentAt="{row}">{{formatDate((row as NotificationTask).sentAt)}}</template><template #status="{row}"><el-tag>{{statusLabel((row as NotificationTask).status)}}</el-tag></template></DataTable></section></ListPageLayout><MessageComposerDialog v-model="composerOpen" :templates="templates" @success="load"/><AppDrawer v-model="detailOpen" title="消息任务详情" size="min(820px, 94vw)" :loading="detailLoading" :show-footer="false" destroy-on-close><LoadErrorState v-if="detailError" title="消息任务详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState><template v-else-if="detail"><dl class="detail-grid"><div><dt>任务号</dt><dd>{{detail.taskNo}}</dd></div><div><dt>状态</dt><dd>{{statusLabel(detail.status)}}</dd></div><div><dt>业务分类</dt><dd>{{detail.category}}</dd></div><div><dt>接收人数</dt><dd>{{detail.recipientCount}}</dd></div><div><dt>尝试次数</dt><dd>{{detail.attemptCount}}</dd></div><div><dt>发送时间</dt><dd>{{formatDate(detail.sentAt)}}</dd></div></dl><section v-if="detail.lastError" class="failure-panel" role="alert"><strong>任务失败原因</strong><p>{{detail.lastError}}</p></section><h3 class="recipient-heading">收件人投递明细</h3><el-table :data="detail.recipients" row-key="id" empty-text="暂无收件人明细" size="small"><el-table-column prop="displayName" label="收件人" min-width="150"/><el-table-column prop="appCode" label="应用端" width="110"/><el-table-column prop="status" label="投递状态" width="120"/><el-table-column label="送达时间" width="180"><template #default="{row}">{{formatDate(row.deliveredAt)}}</template></el-table-column><el-table-column prop="failureReason" label="失败原因" min-width="220"><template #default="{row}">{{row.failureReason||'—'}}</template></el-table-column></el-table></template></AppDrawer></AdminPage></template>
|
||||
<style scoped>.table-actions{display:flex;min-height:32px;align-items:center;gap:8px}.table-actions :deep(.el-button+.el-button){margin-left:0}.detail-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:0 0 20px}.detail-grid div{min-width:0;padding:12px;border:1px solid var(--border-color,#e5e7eb);border-radius:6px}.detail-grid dt{color:var(--text-secondary,#64748b);font-size:12px}.detail-grid dd{margin:6px 0 0;overflow-wrap:anywhere;color:var(--text-primary,#1f2937);font-weight:600}.failure-panel{margin-bottom:20px;padding:12px;border:1px solid #f2b8b5;border-radius:6px;background:#fff7f6;color:#9f2d27}.failure-panel p{margin:6px 0 0;white-space:pre-wrap;overflow-wrap:anywhere}.recipient-heading{margin:0 0 12px;font-size:15px}@media(max-width:720px){.detail-grid{grid-template-columns:1fr}}</style>
|
||||
|
||||
@@ -5,6 +5,8 @@ export type RecipientType = 'USER' | 'MEMBERSHIP' | 'TENANT_ROLE' | 'TENANT_ALL'
|
||||
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
export interface NotificationTemplate { id: string; templateCode: string; templateName: string; category: string; channel: 'IN_APP'; titleTemplate: string; bodyTemplate: string; variableWhitelist: string[]; status: TemplateStatus; version: number }
|
||||
export interface NotificationTask { id: string; taskNo: string; category: string; title: string; status: NotificationTaskStatus; recipientCount: number; scheduledAt: string | null; sentAt: string | null; duplicated: boolean }
|
||||
export interface NotificationTaskRecipient { id: string; displayName: string; appCode: string; status: string; failureReason: string | null; deliveredAt: string | null }
|
||||
export interface NotificationTaskDetail extends Omit<NotificationTask, 'duplicated'> { attemptCount: number; lastError: string | null; recipients: NotificationTaskRecipient[] }
|
||||
|
||||
export interface CreateNotificationRequest { tenantId: string; templateCode: string; recipientType: RecipientType; recipientIds?: string[]; roleCode?: string; variables: Record<string, string>; scheduledAt: string | null; idempotencyKey: string; sourceEventId?: string | null }
|
||||
export interface CreateTemplateRequest { templateCode: string; templateName: string; category: string; titleTemplate: string; bodyTemplate: string; variableWhitelist: string[] }
|
||||
|
||||
@@ -5,6 +5,12 @@ import { describe, expect, it } from 'vitest'
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/workflows/components/SmartLoadingWorkbench.vue'), 'utf8')
|
||||
|
||||
describe('SmartLoadingWorkbench business actions', () => {
|
||||
it('allows preload push before the real container number is available', () => {
|
||||
expect(source).toContain(':disabled="!canPush || !editable || !plan.items.length || Boolean(action)"')
|
||||
expect(source).not.toContain('!container?.containerNo')
|
||||
expect(source).toContain('真实集装箱号可在订舱放舱后补录')
|
||||
})
|
||||
|
||||
it('uses semantic outlined buttons and keeps narrow action cells usable', () => {
|
||||
expect(source).toContain('size="small" plain type="primary"')
|
||||
expect(source).toContain('size="small" plain type="danger"')
|
||||
|
||||
@@ -280,7 +280,7 @@ onMounted(() => { void load() })
|
||||
<el-button circle :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button>
|
||||
<el-button v-if="!plan" type="primary" :disabled="!canCreate || loading" :loading="action === 'create'" @click="createPlan"><Plus :size="15" />创建装柜草稿</el-button>
|
||||
<el-button v-if="plan" :disabled="!canAssign || !editable || Boolean(action)" :loading="action === 'recommend'" @click="recommend"><Sparkles :size="15" />生成智能建议</el-button>
|
||||
<el-button v-if="plan" type="primary" :disabled="!canPush || !editable || !plan.items.length || !container?.containerNo || Boolean(action)" @click="pushOpen = true"><Check :size="15" />推送预装</el-button>
|
||||
<el-button v-if="plan" type="primary" :disabled="!canPush || !editable || !plan.items.length || Boolean(action)" @click="pushOpen = true"><Check :size="15" />推送预装</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -353,7 +353,7 @@ onMounted(() => { void load() })
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model="pushOpen" title="推送预装清单" confirm-type="primary" confirm-text="确认推送" :confirm-loading="action === 'push'" @confirm="confirmPush">
|
||||
<p>推送后装柜计划将进入装柜中,当前草稿明细不可再增删调整。请确认柜号和货物分配无误。</p>
|
||||
<p>推送后当前草稿明细不可再增删调整。真实集装箱号可在订舱放舱后补录,现场 PDA 任务将在条件齐备后开放。</p>
|
||||
<ApiErrorMeta v-if="actionError" :details="actionError" />
|
||||
</AppDialog>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import AppDialog from './AppDialog.vue'
|
||||
|
||||
const ElDialogStub = defineComponent({
|
||||
@@ -62,4 +64,12 @@ describe('AppDialog', () => {
|
||||
const wrapper = mountDialog({ showFooter: false })
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the dialog chrome fixed while long content scrolls in the body', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/AppDialog.vue'), 'utf8')
|
||||
|
||||
expect(source).toContain(':global(.app-dialog.el-dialog) { display: flex;')
|
||||
expect(source).toContain(':global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto;')
|
||||
expect(source).toContain('max-height: calc(100svh - 20px)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -88,8 +88,11 @@ function closeDialog() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:global(.app-dialog.el-dialog) { display: flex; max-height: min(90vh, 900px); flex-direction: column; overflow: hidden; }
|
||||
:global(.app-dialog .el-dialog__header), :global(.app-dialog .el-dialog__footer) { flex: 0 0 auto; }
|
||||
:global(.app-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.app-dialog__body { min-height: 0; }
|
||||
.app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.app-dialog__footer :deep(.el-button) { margin-left: 0; }
|
||||
@media (max-width: 720px) { .app-dialog__footer :deep(.el-button) { min-height: 44px; } }
|
||||
@media (max-width: 720px) { :global(.app-dialog.el-dialog) { max-height: calc(100svh - 20px); margin-block: 10px !important; } .app-dialog__footer :deep(.el-button) { min-height: 44px; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import AppDrawer from './AppDrawer.vue'
|
||||
|
||||
const ElDrawerStub = defineComponent({
|
||||
props: {
|
||||
modelValue: Boolean,
|
||||
showClose: Boolean,
|
||||
closeOnClickModal: Boolean,
|
||||
closeOnPressEscape: Boolean,
|
||||
},
|
||||
template: '<section data-testid="drawer"><slot name="header" /><slot /><slot name="footer" /></section>',
|
||||
})
|
||||
|
||||
const ElButtonStub = defineComponent({
|
||||
props: { disabled: Boolean, loading: Boolean },
|
||||
emits: ['click'],
|
||||
template: '<button type="button" :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
|
||||
})
|
||||
|
||||
function mountDrawer(props: Record<string, unknown> = {}) {
|
||||
return mount(AppDrawer, {
|
||||
props: { modelValue: true, title: '编辑资料', ...props },
|
||||
slots: { default: '<p>抽屉内容</p>' },
|
||||
global: {
|
||||
stubs: { ElDrawer: ElDrawerStub, ElButton: ElButtonStub },
|
||||
directives: { loading: {} },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('AppDrawer', () => {
|
||||
it('provides default cancel and confirm actions', async () => {
|
||||
const wrapper = mountDrawer()
|
||||
const buttons = wrapper.findAll('button')
|
||||
|
||||
expect(buttons.map((button) => button.text())).toEqual(['取消', '确定'])
|
||||
await buttons[1].trigger('click')
|
||||
expect(wrapper.emitted('confirm')).toHaveLength(1)
|
||||
|
||||
await buttons[0].trigger('click')
|
||||
expect(wrapper.emitted('cancel')).toHaveLength(1)
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
|
||||
})
|
||||
|
||||
it('prevents accidental closing while confirmation is submitting', () => {
|
||||
const wrapper = mountDrawer({ confirmLoading: true })
|
||||
const drawer = wrapper.getComponent(ElDrawerStub)
|
||||
const cancelButton = wrapper.findAll('button')[0]
|
||||
|
||||
expect(drawer.props('showClose')).toBe(false)
|
||||
expect(drawer.props('closeOnClickModal')).toBe(false)
|
||||
expect(drawer.props('closeOnPressEscape')).toBe(false)
|
||||
expect(cancelButton.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the drawer chrome fixed while long content scrolls in the body', () => {
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/shared/components/AppDrawer.vue'), 'utf8')
|
||||
|
||||
expect(source).toContain(':global(.app-drawer .el-drawer__body) { min-height: 0; overflow-y: auto;')
|
||||
expect(source).toContain('max-width: 94vw')
|
||||
})
|
||||
})
|
||||
@@ -88,6 +88,8 @@ function closeDrawer() {
|
||||
|
||||
<style scoped>
|
||||
:global(.app-drawer) { max-width: calc(100vw - 20px); }
|
||||
:global(.app-drawer .el-drawer__header), :global(.app-drawer .el-drawer__footer) { flex: 0 0 auto; }
|
||||
:global(.app-drawer .el-drawer__body) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.app-drawer__body { min-height: 0; }
|
||||
.app-drawer__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.app-drawer__footer :deep(.el-button) { margin-left: 0; }
|
||||
|
||||
Reference in New Issue
Block a user