style(admin): unify dialog layouts and dense readonly presentation
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user