Files
admin-web/src/modules/downloads/pages/DownloadCenterView.vue
T

395 lines
20 KiB
Vue
Raw Normal View History

2026-08-03 18:15:41 +08:00
<script setup lang="ts">
2026-09-15 09:58:15 +08:00
import '@/styles/adminDensity.css'
2026-08-03 18:15:41 +08:00
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
2026-08-03 18:49:18 +08:00
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
2026-08-03 18:15:41 +08:00
import {
downloadPlatformExportTask,
2026-08-04 09:05:19 +08:00
getPlatformExportTask,
2026-08-03 18:15:41 +08:00
listPlatformExportTasks,
retryPlatformExportTask,
type ExportTaskStatus,
type PlatformExportTask,
2026-08-03 18:49:18 +08:00
} from '@/modules/downloads/api/platformExportTasks'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
2026-08-10 16:24:54 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
2026-08-13 11:57:44 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
2026-08-03 18:49:18 +08:00
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
2026-08-10 16:24:54 +08:00
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
2026-08-03 18:49:18 +08:00
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
2026-08-03 18:15:41 +08:00
type DownloadFilters = QueryModel & {
exportType: string
status: ExportTaskStatus | ''
requestedRange: string[]
}
2026-08-17 17:49:10 +08:00
const exportTypeMeta: Record<string, { label: string; retryPermission: string }> = {
2026-10-08 17:08:03 +08:00
LAST_MILE_ORDER_LIST: { label: '尾程订单', retryPermission: 'platform:overseas-warehouse:last-mile-order:export' },
OVERSEAS_INVENTORY_BALANCE: { label: '库存余额', retryPermission: 'platform:overseas-warehouse:inventory:export' },
OVERSEAS_INVENTORY_LEDGER: { label: '进销存记录', retryPermission: 'platform:overseas-warehouse:inventory:export' },
OVERSEAS_QUOTE_LIST: { label: '海外报价', retryPermission: 'platform:overseas-master-data:quote:export' },
MONTHLY_EXCHANGE_RATE_LIST: { label: '月度汇率', retryPermission: 'platform:overseas-master-data:exchange-rate:export' },
VEHICLE_LIST: { label: '车辆', retryPermission: 'platform:overseas-master-data:vehicle:export' },
FLEET_QUOTE_LIST: { label: '车队报价', retryPermission: 'platform:fleet:quote:export' },
TRIP_OPERATIONS_LIST: { label: '出车记录', retryPermission: 'platform:fleet:operations:export' },
SELFOP_INVENTORY_TURNOVER: { label: '库存周转', retryPermission: 'platform:self-operated-analytics:export' },
2026-10-08 17:08:03 +08:00
FINANCE_RECEIVABLE_STATISTICS: { label: '应收统计', retryPermission: 'platform:finance:receivable-statistics:export' },
LOADING_LIST_BUY_DECLARATION_ITEMS: { label: '买单报关明细', retryPermission: 'platform:firstleg:customs:manage' },
2026-08-17 17:49:10 +08:00
CUSTOMER_LIST: { label: '客户列表', retryPermission: 'platform:customer:export' },
2026-09-01 21:09:39 +08:00
DOMESTIC_WAREHOUSE_ORDER_LIST: { label: '国内仓订单列表', retryPermission: 'platform:order:export' },
2026-08-17 17:49:10 +08:00
FINANCE_CUSTOMER_ACCOUNT_LIST: { label: '客户账户', retryPermission: 'platform:finance:customer-account:export' },
FINANCE_CUSTOMER_ACCOUNT_LEDGER: { label: '客户账户流水', retryPermission: 'platform:finance:customer-account:export' },
FINANCE_FEE_SOURCE_LIST: { label: '客户应收应付费用来源', retryPermission: 'platform:finance:customer-arap:export' },
FINANCE_RECONCILIATION_STATEMENT_LIST: { label: '客户对账单', retryPermission: 'platform:finance:customer-statement:export' },
FINANCE_RECEIVABLE_SETTLEMENT_LIST: { label: '应收结算单列表', retryPermission: 'platform:finance:settlement-ar:export' },
FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: { label: '应收结算单详情', retryPermission: 'platform:finance:settlement-ar:export' },
FINANCE_PAYABLE_SETTLEMENT_LIST: { label: '应付结算单列表', retryPermission: 'platform:finance:settlement-ap:export' },
FINANCE_PAYABLE_SETTLEMENT_DETAIL: { label: '应付结算单详情', retryPermission: 'platform:finance:settlement-ap:export' },
}
2026-08-03 18:15:41 +08:00
const access = useAccessPreviewStore()
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const downloadingId = ref('')
const retryingId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
2026-08-04 09:05:19 +08:00
const syncWarning = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detailTask = ref<PlatformExportTask | null>(null)
2026-08-03 18:15:41 +08:00
const result = ref({ records: [] as PlatformExportTask[], total: 0, pages: 0 })
const filters = ref<DownloadFilters>({ exportType: '', status: '', requestedRange: [] })
let refreshTimer: number | undefined
2026-08-03 18:49:18 +08:00
let disposed = false
2026-08-04 09:05:19 +08:00
let loadRequestId = 0
let detailRequestId = 0
2026-08-03 18:15:41 +08:00
const queryFields: QueryField[] = [
2026-08-06 19:03:13 +08:00
{ key: 'exportType', type: 'select', label: '任务类型', placeholder: '全部任务类型', options: [
2026-08-17 17:49:10 +08:00
...Object.entries(exportTypeMeta).map(([value, meta]) => ({ label: meta.label, value })),
2026-08-03 18:15:41 +08:00
] },
2026-08-06 19:03:13 +08:00
{ key: 'status', type: 'select', label: '任务状态', placeholder: '全部任务状态', options: [
2026-08-03 18:15:41 +08:00
{ label: '等待处理', value: 'PENDING' }, { label: '处理中', value: 'RUNNING' },
{ label: '已完成', value: 'SUCCEEDED' }, { label: '失败', value: 'FAILED' },
{ label: '已过期', value: 'EXPIRED' },
] },
2026-08-06 19:03:13 +08:00
{ key: 'requestedRange', type: 'daterange', label: '申请日期', placeholder: '请选择申请日期' },
2026-08-03 18:15:41 +08:00
]
const columns: DataTableColumn[] = [
2026-08-04 09:05:19 +08:00
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
2026-08-06 19:03:13 +08:00
{ key: 'taskName', prop: 'taskName', label: '任务名称', minWidth: 180, showOverflowTooltip: true },
2026-08-03 18:15:41 +08:00
{ key: 'type', label: '任务类型', width: 120, slotName: 'type' },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'progress', label: '数据量', width: 120, slotName: 'progress' },
2026-08-17 19:52:04 +08:00
{ key: 'format', prop: 'format', label: '格式', width: 80 },
2026-08-03 18:15:41 +08:00
{ key: 'requester', label: '申请人', minWidth: 130, slotName: 'requester' },
{ key: 'requestedAt', label: '申请时间', width: 170, slotName: 'requestedAt' },
2026-08-17 19:52:04 +08:00
{ key: 'completedAt', label: '完成时间', width: 170, slotName: 'completedAt' },
{ key: 'expiresAt', label: '过期时间', width: 170, slotName: 'expiresAt' },
2026-08-03 18:15:41 +08:00
]
const hasActiveTasks = computed(() => result.value.records.some((task) => ['PENDING', 'RUNNING'].includes(task.status)))
function clearRefreshTimer() {
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
refreshTimer = undefined
}
function scheduleRefresh() {
clearRefreshTimer()
2026-08-04 09:05:19 +08:00
if (!disposed && hasActiveTasks.value) refreshTimer = window.setTimeout(() => { void loadTasks(true) }, 5000)
2026-08-03 18:15:41 +08:00
}
async function loadTasks(background = false) {
2026-08-04 09:05:19 +08:00
const requestId = ++loadRequestId
2026-08-03 18:15:41 +08:00
clearRefreshTimer()
if (!background) loading.value = true
2026-08-04 09:05:19 +08:00
if (!background) loadError.value = null
2026-08-03 18:15:41 +08:00
try {
const range = filters.value.requestedRange
const response = await listPlatformExportTasks({
page: page.value,
size: pageSize.value,
exportType: filters.value.exportType || undefined,
status: filters.value.status || undefined,
requestedFrom: range?.[0] || undefined,
requestedTo: range?.[1] || undefined,
})
2026-08-04 09:05:19 +08:00
if (disposed || requestId !== loadRequestId) return
2026-08-03 18:15:41 +08:00
result.value = { records: response.records, total: response.total, pages: response.pages }
2026-08-04 09:05:19 +08:00
loadError.value = null
syncWarning.value = null
if (detailOpen.value && detailTask.value) {
const freshDetail = response.records.find((task) => task.id === detailTask.value?.id)
if (freshDetail) detailTask.value = freshDetail
}
2026-08-03 18:15:41 +08:00
} catch (error) {
2026-08-04 09:05:19 +08:00
if (disposed || requestId !== loadRequestId) return
const details = getApiErrorDetails(error, '下载任务加载失败')
if (background && result.value.records.length > 0) syncWarning.value = details
else loadError.value = details
2026-08-03 18:15:41 +08:00
} finally {
2026-08-04 09:05:19 +08:00
if (!disposed && requestId === loadRequestId) {
if (!background) loading.value = false
scheduleRefresh()
}
2026-08-03 18:15:41 +08:00
}
}
function applyFilters() {
if (page.value === 1) void loadTasks()
else page.value = 1
}
2026-08-04 09:05:19 +08:00
async function openDetail(task: PlatformExportTask) {
if (!task?.id) return
const requestId = ++detailRequestId
detailTask.value = task
detailError.value = null
detailOpen.value = true
detailLoading.value = true
try {
const response = await getPlatformExportTask(task.id)
if (disposed || requestId !== detailRequestId) return
detailTask.value = response
} catch (error) {
if (disposed || requestId !== detailRequestId) return
detailError.value = getApiErrorDetails(error, '下载任务详情加载失败')
} finally {
if (!disposed && requestId === detailRequestId) detailLoading.value = false
}
}
function closeDetail() {
detailRequestId += 1
detailTask.value = null
detailError.value = null
detailLoading.value = false
}
2026-08-13 11:57:44 +08:00
function downloadDetailTask() {
if (detailTask.value?.status === 'SUCCEEDED') void downloadTask(detailTask.value)
}
2026-08-03 18:15:41 +08:00
async function downloadTask(task: PlatformExportTask) {
downloadingId.value = task.id
try {
const blob = await downloadPlatformExportTask(task.id)
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
2026-08-04 09:05:19 +08:00
try {
anchor.href = url
anchor.download = safeFileName(task.fileName || `${task.taskName}.xlsx`)
document.body.append(anchor)
anchor.click()
} finally {
anchor.remove()
window.setTimeout(() => URL.revokeObjectURL(url), 0)
}
2026-08-03 18:15:41 +08:00
} catch (error) {
2026-08-04 09:05:19 +08:00
const details = getApiErrorDetails(error, '文件下载失败,请刷新任务状态后重试')
const trace = [details.code, details.requestId && `requestId: ${details.requestId}`, details.traceId && `traceId: ${details.traceId}`]
.filter(Boolean)
.join(',')
ElMessage.error(trace ? `${details.message}(${trace})` : details.message)
2026-08-03 18:15:41 +08:00
} finally {
downloadingId.value = ''
}
}
async function retryTask(task: PlatformExportTask) {
retryingId.value = task.id
try {
await retryPlatformExportTask(task.id)
ElMessage.success('导出任务已重新提交')
await loadTasks()
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '任务重试失败'))
} finally {
retryingId.value = ''
}
}
function safeFileName(value: string) {
const safe = value.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim()
return safe || '导出文件.xlsx'
}
2026-08-17 17:49:10 +08:00
function typeLabel(type: string) { return exportTypeMeta[type]?.label || type }
2026-08-03 18:15:41 +08:00
function statusMeta(status: ExportTaskStatus) {
const values = {
PENDING: { label: '等待处理', type: 'info' as const },
RUNNING: { label: '处理中', type: 'warning' as const },
SUCCEEDED: { label: '已完成', type: 'success' as const },
FAILED: { label: '失败', type: 'danger' as const },
EXPIRED: { label: '已过期', type: 'info' as const },
}
2026-08-04 09:05:19 +08:00
return values[status] ?? { label: status || '未知状态', type: 'info' as const }
2026-08-03 18:15:41 +08:00
}
function taskStatusMeta(task: PlatformExportTask) {
if (task.status === 'PENDING' && task.attemptCount > 0) {
return { label: '等待重试', type: 'warning' as const }
}
return statusMeta(task.status)
}
2026-08-03 18:15:41 +08:00
function formatDate(value: string | null) {
if (!value) return '—'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false })
}
function formatSize(bytes: number | null) {
if (bytes === null) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function canRetry(task: PlatformExportTask) {
2026-08-17 17:49:10 +08:00
const permission = exportTypeMeta[task.exportType]?.retryPermission
const retryableStatus = task.status === 'FAILED' || (task.status === 'PENDING' && task.attemptCount > 0)
return retryableStatus && permission !== undefined && access.can(permission)
}
function retryLabel(task: PlatformExportTask) {
return task.status === 'PENDING' ? '立即重试' : '重试'
2026-08-03 18:15:41 +08:00
}
watch([page, pageSize], () => { void loadTasks() })
onMounted(loadTasks)
2026-08-03 18:49:18 +08:00
onBeforeUnmount(() => {
disposed = true
2026-08-04 09:05:19 +08:00
loadRequestId += 1
detailRequestId += 1
2026-08-03 18:49:18 +08:00
clearRefreshTimer()
})
2026-08-03 18:15:41 +08:00
</script>
<template>
2026-09-15 09:58:15 +08:00
<AdminPage class="admin-density">
2026-08-03 18:15:41 +08:00
<div class="business-page download-center-page">
2026-08-10 16:24:54 +08:00
<ListPageLayout ariaLabel="下载任务列表">
2026-08-03 18:15:41 +08:00
2026-09-30 14:01:58 +08:00
<section :aria-busy="loading"><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="columns" preference-key="downloads-table" :loading="loading" :total="result.total" row-key="id" empty-text="暂无符合筛选条件的下载任务" aria-label="下载任务列表">
<template #toolbar-start><h1>下载中心</h1></template>
<template #filters>
<QueryToolbar v-model="filters" ariaLabel="下载任务筛选" :fields="queryFields" preference-key="downloads-query" @search="applyFilters" />
</template>
<template #notices><template v-if="loadError">
2026-08-10 16:24:54 +08:00
<section class="surface-panel download-error" role="alert">
<div><strong>下载任务暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div>
<el-button type="primary" :loading="loading" @click="loadTasks()">重新加载</el-button>
</section>
2026-09-30 14:01:58 +08:00
</template></template>
<template #before-results><div v-if="syncWarning" class="download-sync-warning" role="status">
2026-08-04 09:05:19 +08:00
<span>自动刷新暂时失败,正在保留当前任务并继续重试:{{ syncWarning.message }}</span>
<ApiErrorMeta :details="syncWarning" />
2026-09-30 14:01:58 +08:00
</div></template>
2026-08-10 16:24:54 +08:00
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个任务</span></template>
2026-09-30 14:01:58 +08:00
<template #actions="{ row }"><div class="table-actions density-actions row-actions admin-text-actions">
2026-09-15 09:58:15 +08:00
<el-button data-tone="neutral" class="density-action" v-if="(row as PlatformExportTask)?.id" @click="openDetail(row as PlatformExportTask)">详情</el-button>
<el-button data-tone="neutral" class="density-action" v-if="(row as PlatformExportTask).status === 'SUCCEEDED'" :loading="downloadingId === (row as PlatformExportTask).id" @click="downloadTask(row as PlatformExportTask)">下载</el-button>
<el-button data-tone="neutral" class="density-action" v-if="canRetry(row as PlatformExportTask)" :loading="retryingId === (row as PlatformExportTask).id" @click="retryTask(row as PlatformExportTask)">{{ retryLabel(row as PlatformExportTask) }}</el-button>
2026-08-03 18:15:41 +08:00
</div></template>
<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>
2026-08-03 18:15:41 +08:00
<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 || '—' }}</template>
2026-08-03 18:15:41 +08:00
<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>
2026-09-30 14:01:58 +08:00
</DataTable></section>
2026-08-10 16:24:54 +08:00
</ListPageLayout>
2026-08-04 09:05:19 +08:00
2026-09-15 09:58:15 +08:00
<AppDialog class="admin-density-dialog"
2026-08-04 09:05:19 +08:00
v-model="detailOpen"
:title="detailTask?.taskNo?.trim() ? `任务详情 · ${detailTask.taskNo.trim()}` : '任务详情'"
2026-08-13 11:57:44 +08:00
width="min(720px, calc(100vw - 28px))"
fullscreenable
2026-08-04 09:05:19 +08:00
:loading="detailLoading"
2026-08-13 11:57:44 +08:00
:show-confirm="false"
2026-08-04 09:05:19 +08:00
destroy-on-close
@closed="closeDetail"
>
<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>
2026-09-15 09:58:15 +08:00
<dl class="task-detail-grid density-fields">
<div><dt>任务编号</dt><dd>{{ detailTask.taskNo || '—' }}</dd></div>
<div><dt>申请人</dt><dd>{{ detailTask.requestedUsername || '—' }}</dd></div>
2026-08-04 09:05:19 +08:00
<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>
<div><dt>执行次数</dt><dd>{{ detailTask.attemptCount }} / {{ detailTask.maxAttempts }}</dd></div>
<div><dt>下次重试</dt><dd>{{ detailTask.status === 'PENDING' && detailTask.attemptCount > 0 ? formatDate(detailTask.nextRetryAt) : '—' }}</dd></div>
2026-08-04 09:05:19 +08:00
<div><dt>过期时间</dt><dd>{{ formatDate(detailTask.expiresAt) }}</dd></div>
<div><dt>处理数据</dt><dd>{{ detailTask.processedRows }}<template v-if="detailTask.totalRows !== null"> / {{ detailTask.totalRows }}</template> 行</dd></div>
<div><dt>文件大小</dt><dd>{{ formatSize(detailTask.sizeBytes) || '—' }}</dd></div>
<div class="is-wide"><dt>文件名称</dt><dd>{{ detailTask.fileName || '—' }}</dd></div>
</dl>
</section>
<el-alert v-if="detailTask.failureMessage" class="task-failure" role="status" title="失败原因" :description="detailTask.failureMessage" type="error" :closable="false" show-icon />
2026-08-04 09:05:19 +08:00
</div>
2026-08-13 11:57:44 +08:00
<template #footer="{ close }">
<div class="density-actions density-actions--footer">
2026-08-13 11:57:44 +08:00
<el-button @click="close">关闭</el-button>
<el-button
v-if="!detailLoading && !detailError && detailTask && canRetry(detailTask)"
:loading="retryingId === detailTask.id"
@click="retryTask(detailTask)"
>{{ retryLabel(detailTask) }}</el-button>
2026-08-13 11:57:44 +08:00
<el-button
type="primary"
:loading="downloadingId === detailTask?.id"
:disabled="detailLoading || Boolean(detailError) || detailTask?.status !== 'SUCCEEDED'"
2026-08-13 11:57:44 +08:00
@click="downloadDetailTask"
>下载文件</el-button>
</div>
</template>
</AppDialog>
2026-08-03 18:15:41 +08:00
</div>
2026-08-10 16:24:54 +08:00
</AdminPage>
2026-08-03 18:15:41 +08:00
</template>
<style scoped>
2026-09-15 09:58:15 +08:00
.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; }
2026-08-03 18:15:41 +08:00
.download-center-page { min-width: 0; }
2026-09-15 09:58:15 +08:00
.download-error { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6); }
.download-error { padding: var(--admin-space-6); }
.download-error strong { color: var(--cc-danger); font-size: var(--admin-font-body); }
.download-error p { margin: 5px 0; color: var(--cc-text); font-size: var(--admin-font-body); }
.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); }
2026-08-03 18:49:18 +08:00
.failure-cell { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.failure-message { color: var(--cc-danger); }
2026-09-15 09:58:15 +08:00
small { display: block; margin-top: 3px; color: var(--cc-muted); font-size: var(--admin-font-caption); }
2026-08-03 18:15:41 +08:00
@media (max-width: 720px) {
2026-08-06 20:14:56 +08:00
.download-error, .download-sync-warning { align-items: stretch; flex-direction: column; }
.table-actions :deep(.el-button) { min-height: 44px; }
2026-08-04 09:05:19 +08:00
.table-actions :deep(.el-button) { padding-right: 8px; padding-left: 8px; }
}
@media (max-width: 480px) {
.task-detail-grid { grid-template-columns: 1fr; }
.task-detail-grid .is-wide { grid-column: 1; }
2026-08-03 18:15:41 +08:00
}
</style>