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
@@ -1,4 +1,4 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElDialog } 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'
@@ -12,6 +12,8 @@ import {
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import DownloadCenterView from './DownloadCenterView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { ApiResponseError } from '@/shared/api/http'
vi.mock('@/modules/downloads/api/platformExportTasks', () => ({
downloadPlatformExportTask: vi.fn(),
@@ -58,6 +60,21 @@ describe('DownloadCenterView', () => {
vi.mocked(retryPlatformExportTask).mockResolvedValue({ ...task, status: 'PENDING' })
})
const dialogButton = (label: string) => {
const found = Array.from(document.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')).find(button => button.textContent?.trim() === label)
expect(found, label).toBeDefined(); return found!
}
const factValue = (label: string) => {
const term = Array.from(document.querySelectorAll('[aria-label="下载任务资料"] dt')).find(node => node.textContent === label)
expect(term, label).toBeDefined(); return term!.nextElementSibling!.textContent
}
async function openTask(value: PlatformExportTask = task) {
vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [value], total: 1, page: 1, size: 20, pages: 1 })
const wrapper = mount(DownloadCenterView, { attachTo: document.body, global: { plugins: [ElementPlus] } }); await flushPromises()
const open = () => Array.from(document.querySelectorAll<HTMLButtonElement>('.el-table__body .table-actions button')).find(button => button.textContent?.trim() === '详情')!.click()
open(); await flushPromises(); return { wrapper, open }
}
it('lists completed tasks and downloads the backend-generated artifact', async () => {
const pinia = setActivePinia(createPinia())
const auth = useAuthStore()
@@ -290,4 +307,99 @@ describe('DownloadCenterView', () => {
await flushPromises()
expect(document.body.textContent).toContain('12 / 12 行')
})
it('keeps long facts and zero values complete through fullscreen and never uses a requester ID fallback', async () => {
const value: PlatformExportTask = { ...task, id: '9223372036854775807', requestedBy: '9223372036854775806', requestedUsername: null, taskNo: 'EXP-FULL-001', taskName: '完整导出任务名称'.repeat(25), fileName: '完整导出文件名称'.repeat(25) + '.xlsx', processedRows: 0, totalRows: 0, sizeBytes: 0 }
vi.mocked(getPlatformExportTask).mockResolvedValue(value)
const { wrapper } = await openTask(value)
const dialog = wrapper.findComponent(AppDialog)
expect(dialog.props()).toMatchObject({ title: '任务详情 · EXP-FULL-001', width: 'min(720px, calc(100vw - 28px))', fullscreenable: true, showConfirm: false })
expect(document.querySelector('[aria-label="下载任务资料"]')?.textContent).toContain(value.taskName)
expect(factValue('文件名称')).toBe(value.fileName)
expect(factValue('申请人')).toBe('—')
expect(factValue('处理数据')).toBe('0 / 0 行')
expect(factValue('文件大小')).toBe('0 B')
expect(document.body.textContent).not.toContain(value.requestedBy)
expect(document.body.textContent).not.toContain(value.id)
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click(); await flushPromises()
expect(dialog.findComponent(ElDialog).props('fullscreen')).toBe(true)
expect(factValue('文件名称')).toBe(value.fileName)
dialogButton('下载文件').click(); await flushPromises()
expect(downloadPlatformExportTask).toHaveBeenCalledWith(value.id)
})
it.each([
['RUNNING', '处理中', false], ['PENDING', '等待重试', true], ['EXPIRED', '已过期', false],
] as const)('shows %s facts and original retry/download eligibility', async (status,label,retry) => {
const value: PlatformExportTask = { ...task, status, attemptCount: 2, processedRows: 3, totalRows: null, sizeBytes: null, fileName: null, nextRetryAt: '2026-10-20T12:00:00' }
vi.mocked(getPlatformExportTask).mockResolvedValue(value)
await openTask(value)
expect(document.querySelector('.task-summary-heading')?.textContent).toContain(label)
expect(factValue('处理数据')).toBe('3 行'); expect(factValue('文件大小')).toBe('—'); expect(factValue('文件名称')).toBe('—')
expect(dialogButton('下载文件').disabled).toBe(true)
if (retry) {
expect(factValue('下次重试')).not.toBe('—')
dialogButton('立即重试').click(); await flushPromises()
expect(retryPlatformExportTask).toHaveBeenCalledWith(value.id)
} else {
expect(document.querySelector('.el-dialog__footer')?.textContent).not.toContain('重试')
expect(factValue('下次重试')).toBe('—')
}
})
it('preserves the full failure reason when retry fails and refreshes the same task after retry succeeds', async () => {
const failed: PlatformExportTask = { ...task, status: 'FAILED', failureMessage: '完整失败原因'.repeat(90) }
vi.mocked(getPlatformExportTask).mockResolvedValue(failed)
vi.mocked(retryPlatformExportTask).mockRejectedValueOnce(new Error('重试失败')).mockResolvedValueOnce({ ...failed, status: 'PENDING' })
await openTask(failed)
expect(document.querySelector('.task-failure')?.textContent).toContain(failed.failureMessage)
dialogButton('重试').click(); await flushPromises()
expect(document.querySelector('.task-failure')?.textContent).toContain(failed.failureMessage)
expect(dialogButton('重试').disabled).toBe(false)
vi.mocked(listPlatformExportTasks).mockResolvedValue({ records: [{ ...failed, status: 'PENDING', attemptCount: 0, failureMessage: null }], total: 1, page: 1, size: 20, pages: 1 })
dialogButton('重试').click(); await flushPromises()
expect(retryPlatformExportTask).toHaveBeenCalledTimes(2)
expect(document.querySelector('.task-summary-heading')?.textContent).toContain('等待处理')
expect(document.querySelector('.task-failure')).toBeNull()
expect(dialogButton('下载文件').disabled).toBe(true)
})
it('shows a compact detail error with diagnostics and restores actions only after reloading', async () => {
vi.mocked(getPlatformExportTask).mockRejectedValueOnce(new ApiResponseError('详情读取失败', 'A0500', 500, null, 'req-download-detail')).mockResolvedValueOnce(task)
await openTask()
expect(document.querySelector('.el-dialog .load-error-state')?.textContent).toContain('req-download-detail')
expect(document.querySelector('.el-dialog .load-error-state')?.classList.contains('is-compact')).toBe(true)
expect(dialogButton('下载文件').disabled).toBe(true)
const reload = Array.from(document.querySelectorAll<HTMLButtonElement>('.el-dialog button')).find(button => button.textContent?.trim() === '重新加载')!
reload.click(); await flushPromises()
expect(getPlatformExportTask).toHaveBeenCalledTimes(2)
expect(document.querySelector('.el-dialog .load-error-state')).toBeNull()
expect(dialogButton('下载文件').disabled).toBe(false)
})
it('keeps actions disabled while detail is loading and ignores an old response after close and reopen', async () => {
let release!: (value: PlatformExportTask) => void
vi.mocked(getPlatformExportTask).mockImplementationOnce(() => new Promise(resolve => { release = resolve })).mockResolvedValueOnce({ ...task, taskNo: 'EXP-NEW', taskName: '最新任务详情' })
const { wrapper, open } = await openTask()
expect(wrapper.findComponent(AppDialog).props('loading')).toBe(true)
expect(dialogButton('下载文件').disabled).toBe(true)
dialogButton('关闭').click(); await flushPromises()
wrapper.findComponent(AppDialog).vm.$emit('closed'); await flushPromises()
open(); await flushPromises()
expect(wrapper.findComponent(AppDialog).props('title')).toBe('任务详情 · EXP-NEW')
release({ ...task, taskName: '过期响应' }); await flushPromises()
expect(document.querySelector('.task-summary-heading')?.textContent).toContain('最新任务详情')
expect(document.querySelector('.task-summary-heading')?.textContent).not.toContain('过期响应')
expect(wrapper.findComponent(AppDialog).props('loading')).toBe(false)
})
it('shows a missing business number as — without exposing the internal task ID', async () => {
const value: PlatformExportTask = { ...task, id: '9223372036854775807', taskNo: '' }
vi.mocked(getPlatformExportTask).mockResolvedValue(value)
const { wrapper } = await openTask(value)
expect(wrapper.findComponent(AppDialog).props('title')).toBe('任务详情')
expect(factValue('任务编号')).toBe('—')
expect(document.querySelector('.el-dialog')?.textContent).not.toContain(value.id)
})
})
@@ -16,6 +16,7 @@ import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -306,7 +307,7 @@ onBeforeUnmount(() => {
<template #type="{ row }">{{ typeLabel((row as PlatformExportTask).exportType) }}</template>
<template #status="{ row }"><el-tag :type="taskStatusMeta(row as PlatformExportTask).type" effect="light">{{ taskStatusMeta(row as PlatformExportTask).label }}</el-tag></template>
<template #progress="{ row }"><span>{{ (row as PlatformExportTask).processedRows }}<template v-if="(row as PlatformExportTask).totalRows !== null"> / {{ (row as PlatformExportTask).totalRows }}</template></span><small v-if="(row as PlatformExportTask).sizeBytes !== null">{{ formatSize((row as PlatformExportTask).sizeBytes) }}</small></template>
<template #requester="{ row }">{{ (row as PlatformExportTask).requestedUsername || (row as PlatformExportTask).requestedBy }}</template>
<template #requester="{ row }">{{ (row as PlatformExportTask).requestedUsername || '—' }}</template>
<template #requestedAt="{ row }">{{ formatDate((row as PlatformExportTask).requestedAt) }}</template>
<template #completedAt="{ row }">{{ formatDate((row as PlatformExportTask).completedAt) }}</template>
<template #expiresAt="{ row }">{{ formatDate((row as PlatformExportTask).expiresAt) }}</template>
@@ -315,30 +316,24 @@ onBeforeUnmount(() => {
<AppDialog class="admin-density-dialog"
v-model="detailOpen"
:title="detailTask ? `任务详情 · ${detailTask.taskNo}` : '任务详情'"
:title="detailTask?.taskNo?.trim() ? `任务详情 · ${detailTask.taskNo.trim()}` : '任务详情'"
width="min(720px, calc(100vw - 28px))"
fullscreenable
:loading="detailLoading"
:show-confirm="false"
destroy-on-close
@closed="closeDetail"
>
<section v-if="detailError" class="detail-load-error" role="alert">
<strong>任务详情暂时无法加载</strong>
<p>{{ detailError.message }}</p>
<ApiErrorMeta :details="detailError" />
<el-button type="primary" @click="detailTask && openDetail(detailTask)">重新加载</el-button>
</section>
<div v-else-if="detailTask" class="task-detail">
<div class="task-detail-summary">
<div>
<h3>{{ detailTask.taskName }}</h3>
<p>{{ typeLabel(detailTask.exportType) }} · {{ detailTask.format }}</p>
</div>
<el-tag :type="taskStatusMeta(detailTask).type" effect="light">{{ taskStatusMeta(detailTask).label }}</el-tag>
</div>
<LoadErrorState v-if="detailError" compact title="任务详情暂时无法加载" :message="detailError.message" :loading="detailLoading" @retry="detailTask && openDetail(detailTask)">
<template #details><ApiErrorMeta :details="detailError" /></template>
</LoadErrorState>
<div v-else-if="detailTask" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="summary" aria-label="下载任务资料">
<h3 class="density-dialog-section__title task-summary-heading"><span>{{ detailTask.taskName || '—' }}</span><el-tag :type="taskStatusMeta(detailTask).type" effect="light">{{ taskStatusMeta(detailTask).label }}</el-tag></h3>
<p class="density-dialog-note">{{ typeLabel(detailTask.exportType) }} · {{ detailTask.format }}</p>
<dl class="task-detail-grid density-fields">
<div><dt>任务编号</dt><dd>{{ detailTask.taskNo }}</dd></div>
<div><dt>申请人</dt><dd>{{ detailTask.requestedUsername || detailTask.requestedBy }}</dd></div>
<div><dt>任务编号</dt><dd>{{ detailTask.taskNo || '—' }}</dd></div>
<div><dt>申请人</dt><dd>{{ detailTask.requestedUsername || '—' }}</dd></div>
<div><dt>申请时间</dt><dd>{{ formatDate(detailTask.requestedAt) }}</dd></div>
<div><dt>开始时间</dt><dd>{{ formatDate(detailTask.startedAt) }}</dd></div>
<div><dt>完成时间</dt><dd>{{ formatDate(detailTask.completedAt) }}</dd></div>
@@ -349,23 +344,21 @@ onBeforeUnmount(() => {
<div><dt>文件大小</dt><dd>{{ formatSize(detailTask.sizeBytes) || '—' }}</dd></div>
<div class="is-wide"><dt>文件名称</dt><dd>{{ detailTask.fileName || '—' }}</dd></div>
</dl>
<section v-if="detailTask.failureMessage" class="task-failure" role="status">
<strong>失败原因</strong>
<p>{{ detailTask.failureMessage }}</p>
</section>
<el-alert v-if="detailTask.failureMessage" class="task-failure" role="status" title="失败原因" :description="detailTask.failureMessage" type="error" :closable="false" show-icon />
</div>
<template #footer="{ close }">
<div class="app-dialog__footer">
<div class="density-actions density-actions--footer">
<el-button @click="close">关闭</el-button>
<el-button
v-if="detailTask && canRetry(detailTask)"
v-if="!detailLoading && !detailError && detailTask && canRetry(detailTask)"
:loading="retryingId === detailTask.id"
@click="retryTask(detailTask)"
>{{ retryLabel(detailTask) }}</el-button>
<el-button
type="primary"
:loading="downloadingId === detailTask?.id"
:disabled="detailTask?.status !== 'SUCCEEDED'"
:disabled="detailLoading || Boolean(detailError) || detailTask?.status !== 'SUCCEEDED'"
@click="downloadDetailTask"
>下载文件</el-button>
</div>
@@ -378,6 +371,8 @@ onBeforeUnmount(() => {
<style scoped>
.task-detail-grid.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.task-detail-grid .is-wide { grid-column: 1 / -1; }
.task-summary-heading { justify-content: space-between; }
.task-summary-heading > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.download-center-page { min-width: 0; }
.download-error { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6); }
.download-error { padding: var(--admin-space-6); }
@@ -386,17 +381,6 @@ onBeforeUnmount(() => {
.download-sync-warning { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); padding: var(--admin-space-4) var(--admin-space-6); border-bottom: 1px solid #fedf89; background: #fffaeb; color: #93370d; font-size: var(--admin-font-caption); }
.failure-cell { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.failure-message { color: var(--cc-danger); }
.detail-load-error { display: flex; min-height: 220px; align-items: flex-start; justify-content: center; flex-direction: column; }
.detail-load-error strong { color: var(--cc-danger); font-size: var(--admin-font-section); }
.detail-load-error p { margin: var(--admin-space-2) 0; color: var(--cc-text); font-size: var(--admin-font-body); }
.detail-load-error :deep(.el-button) { margin-top: var(--admin-space-6); }
.task-detail { min-width: 0; }
.task-detail-summary { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--admin-space-6); padding-bottom: var(--admin-space-6); border-bottom: 1px solid var(--cc-border); }
.task-detail-summary h3 { margin: 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-section); }
.task-detail-summary p { margin: var(--admin-space-2) 0 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.task-failure { margin-top: var(--admin-space-6); padding: var(--admin-space-5); border: 1px solid color-mix(in srgb, var(--cc-danger) 24%, var(--cc-border)); border-radius: var(--admin-radius); background: color-mix(in srgb, var(--cc-danger) 5%, #fff); }
.task-failure strong { color: var(--cc-danger); font-size: var(--admin-font-body); }
.task-failure p { margin: var(--admin-space-2) 0 0; overflow-wrap: anywhere; color: var(--cc-text); font-size: var(--admin-font-body); line-height: 1.6; }
small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: var(--admin-font-caption); }
@media (max-width: 720px) {
.download-error, .download-sync-warning { align-items: stretch; flex-direction: column; }
@@ -404,9 +388,7 @@ small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: var(
.table-actions :deep(.el-button) { padding-right: 8px; padding-left: 8px; }
}
@media (max-width: 480px) {
.task-detail-summary { flex-direction: column; }
.task-detail-grid { grid-template-columns: 1fr; }
.task-detail-grid > div:nth-child(odd):not(.is-wide) { border-right: 0; }
.task-detail-grid .is-wide { grid-column: 1; }
}
</style>