|
|
@@ -0,0 +1,1247 @@
|
|
|
+<template>
|
|
|
+ <div class="p-4">
|
|
|
+ <NCard title="DataDownload 任务管理">
|
|
|
+ <div class="mb-4 flex flex-nowrap gap-3 w-full items-end">
|
|
|
+ <div class="shrink-0 w-28">
|
|
|
+ <div class="text-xs text-gray-500 mb-1">任务范围</div>
|
|
|
+ <NSelect v-model:value="scope" :options="scopeOptions" />
|
|
|
+ </div>
|
|
|
+ <div class="shrink-0 w-28">
|
|
|
+ <div class="text-xs text-gray-500 mb-1">任务状态</div>
|
|
|
+ <NSelect
|
|
|
+ v-model:value="statusFilter"
|
|
|
+ :options="statusOptions"
|
|
|
+ placeholder="任务状态"
|
|
|
+ clearable
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="flex-1 max-w-52 min-w-20">
|
|
|
+ <div class="text-xs text-gray-500 mb-1">SN</div>
|
|
|
+ <NInput
|
|
|
+ v-model:value="snFilter"
|
|
|
+ placeholder="按 SN 查询"
|
|
|
+ clearable
|
|
|
+ @keyup.enter="applyFilters"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="flex-1 min-w-20">
|
|
|
+ <div class="text-xs text-gray-500 mb-1">描述</div>
|
|
|
+ <NInput
|
|
|
+ v-model:value="description"
|
|
|
+ placeholder="按描述查询"
|
|
|
+ clearable
|
|
|
+ @keyup.enter="applyFilters"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <NButton type="primary" :loading="loading" class="shrink-0" @click="applyFilters"
|
|
|
+ >查询</NButton
|
|
|
+ >
|
|
|
+ <NButton type="success" class="shrink-0" @click="showCreate = true">创建任务</NButton>
|
|
|
+ </div>
|
|
|
+ <NDataTable
|
|
|
+ :columns="columns"
|
|
|
+ :data="tasks"
|
|
|
+ :loading="loading"
|
|
|
+ :row-key="(row: DataDownloadTask) => row.id"
|
|
|
+ :scroll-x="1380"
|
|
|
+ />
|
|
|
+ <div class="mt-4 flex justify-end">
|
|
|
+ <NPagination
|
|
|
+ v-model:page="pageNum"
|
|
|
+ v-model:page-size="pageSize"
|
|
|
+ :item-count="total"
|
|
|
+ show-size-picker
|
|
|
+ :page-sizes="[20, 50, 100]"
|
|
|
+ @update:page="onPageChange"
|
|
|
+ @update:page-size="onPageSizeChange"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </NCard>
|
|
|
+
|
|
|
+ <NModal
|
|
|
+ :show="showDetail"
|
|
|
+ preset="card"
|
|
|
+ title="任务目录与明细"
|
|
|
+ :style="{ width: 'min(1080px, calc(100vw - 32px))' }"
|
|
|
+ @update:show="onDetailShowChange"
|
|
|
+ >
|
|
|
+ <div v-if="detail" class="task-detail">
|
|
|
+ <div class="task-detail-overview">
|
|
|
+ <div class="task-detail-heading">
|
|
|
+ <div class="task-detail-heading-main">
|
|
|
+ <div class="task-detail-eyebrow">任务概览</div>
|
|
|
+ <h3>{{ detail.task.description || '未命名任务' }}</h3>
|
|
|
+ <div class="task-detail-meta">
|
|
|
+ <span>{{ detail.task.ownerName || '未知创建人' }}</span>
|
|
|
+ <span>{{ formatDateRange(detail.task) }}</span>
|
|
|
+ <span>创建于 {{ formatDateTime(detail.task.createdAt) }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <NTag :type="statusTagType(detail.task.status)" size="small" round>
|
|
|
+ {{ statusText(detail.task.status) }}
|
|
|
+ </NTag>
|
|
|
+ </div>
|
|
|
+ <div class="task-detail-stats">
|
|
|
+ <div class="task-detail-stat">
|
|
|
+ <span>站点</span><strong>{{ detail.task.sns.length }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="task-detail-stat">
|
|
|
+ <span>已生成事项</span><strong>{{ progressCounts.total }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="task-detail-stat is-success">
|
|
|
+ <span>成功</span><strong>{{ detail.task.succeeded }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="task-detail-stat is-error">
|
|
|
+ <span>失败</span><strong>{{ detail.task.failed }}</strong>
|
|
|
+ </div>
|
|
|
+ <div class="task-detail-stat">
|
|
|
+ <span>无数据</span><strong>{{ detail.task.empty }}</strong>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="task-detail-progress">
|
|
|
+ <div class="task-detail-progress-heading">
|
|
|
+ <strong
|
|
|
+ >已结束 {{ progressCounts.finished }} / 当前已生成
|
|
|
+ {{ progressCounts.total }} 项</strong
|
|
|
+ >
|
|
|
+ <span>{{ progressHint }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="task-detail-progress-track" role="img" :aria-label="progressDescription">
|
|
|
+ <span
|
|
|
+ v-for="segment in progressSegments"
|
|
|
+ :key="segment.key"
|
|
|
+ :class="`progress-segment-${segment.key}`"
|
|
|
+ :style="{ width: `${segment.percent}%` }"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="task-detail-progress-legend">
|
|
|
+ <span v-for="segment in progressLegend" :key="segment.key">
|
|
|
+ <i :class="`progress-dot-${segment.key}`" />{{ segment.label }} {{ segment.count }}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <NTabs v-model:value="detailTab" type="line" animated class="task-detail-tabs">
|
|
|
+ <NTabPane name="progress" :tab="`执行进度 (${progressCounts.total})`">
|
|
|
+ <div class="task-detail-section-heading task-detail-progress-title">
|
|
|
+ <div>
|
|
|
+ <h4>工作单元</h4>
|
|
|
+ <p>
|
|
|
+ {{
|
|
|
+ detailRefreshError
|
|
|
+ ? '刷新失败,当前显示上次获取的状态。'
|
|
|
+ : '查看当前已生成的全部事项;排队顺序不代表执行顺序。'
|
|
|
+ }}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <NButton size="small" :loading="detailRefreshing" @click="refreshDetail(true)"
|
|
|
+ >刷新</NButton
|
|
|
+ >
|
|
|
+ </div>
|
|
|
+ <div class="task-detail-filters">
|
|
|
+ <NButton
|
|
|
+ v-for="filter in progressFilters"
|
|
|
+ :key="filter.value"
|
|
|
+ size="small"
|
|
|
+ :type="progressFilter === filter.value ? 'primary' : 'default'"
|
|
|
+ :secondary="progressFilter === filter.value"
|
|
|
+ :quaternary="progressFilter !== filter.value"
|
|
|
+ @click="progressFilter = filter.value"
|
|
|
+ >
|
|
|
+ {{ filter.label }} {{ filter.count }}
|
|
|
+ </NButton>
|
|
|
+ </div>
|
|
|
+ <NDataTable
|
|
|
+ :columns="progressColumns"
|
|
|
+ :data="visibleProgressItems"
|
|
|
+ :row-key="(row: DataDownloadTaskDetail['items'][number]) => row.id"
|
|
|
+ :scroll-x="1000"
|
|
|
+ :pagination="{
|
|
|
+ page: progressPage,
|
|
|
+ pageSize: progressPageSize,
|
|
|
+ showSizePicker: true,
|
|
|
+ pageSizes: [20, 50, 100],
|
|
|
+ }"
|
|
|
+ size="small"
|
|
|
+ striped
|
|
|
+ @update:page="progressPage = $event"
|
|
|
+ @update:page-size="setProgressPageSize"
|
|
|
+ />
|
|
|
+ </NTabPane>
|
|
|
+ <NTabPane name="documents" :tab="`任务目录 (${visibleDocumentCount})`">
|
|
|
+ <div class="task-detail-section-heading">
|
|
|
+ <div>
|
|
|
+ <h4>任务目录</h4>
|
|
|
+ <p>按站点、数据类型和日期查看已生成的文档。</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <NDataTable
|
|
|
+ :columns="documentColumns"
|
|
|
+ :data="documentTree"
|
|
|
+ :row-key="(row: DocumentTreeRow) => row.key"
|
|
|
+ :scroll-x="640"
|
|
|
+ default-expand-all
|
|
|
+ size="small"
|
|
|
+ striped
|
|
|
+ />
|
|
|
+ </NTabPane>
|
|
|
+ <NTabPane name="faultFiles" :tab="`录波文件 (${faultFileRows.length})`">
|
|
|
+ <div class="task-detail-section-heading">
|
|
|
+ <div>
|
|
|
+ <h4>FaultRecord 文件</h4>
|
|
|
+ <p>查看并下载已获取的故障录波原始文件。</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <NDataTable
|
|
|
+ :columns="faultFileColumns"
|
|
|
+ :data="faultFileRows"
|
|
|
+ :row-key="(row: FaultFileRow) => row.id"
|
|
|
+ :scroll-x="700"
|
|
|
+ size="small"
|
|
|
+ striped
|
|
|
+ />
|
|
|
+ </NTabPane>
|
|
|
+ <NTabPane name="failures" :tab="`失败项 (${failedItems.length})`">
|
|
|
+ <div class="task-detail-section-heading">
|
|
|
+ <div>
|
|
|
+ <h4>失败项</h4>
|
|
|
+ <p>查看执行失败或部分成功的工作单元及原因。</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <NDataTable
|
|
|
+ :columns="failureColumns"
|
|
|
+ :data="failedItems"
|
|
|
+ :scroll-x="600"
|
|
|
+ size="small"
|
|
|
+ striped
|
|
|
+ />
|
|
|
+ </NTabPane>
|
|
|
+ <NTabPane name="fileFailures" :tab="`文件请求失败 (${failedFileAttempts.length})`">
|
|
|
+ <div class="task-detail-section-heading">
|
|
|
+ <div>
|
|
|
+ <h4>文件请求失败</h4>
|
|
|
+ <p>查看失败阶段、尝试次数和具体原因。</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <NDataTable
|
|
|
+ :columns="fileFailureColumns"
|
|
|
+ :data="failedFileAttempts"
|
|
|
+ :scroll-x="780"
|
|
|
+ size="small"
|
|
|
+ striped
|
|
|
+ />
|
|
|
+ </NTabPane>
|
|
|
+ </NTabs>
|
|
|
+ </div>
|
|
|
+ </NModal>
|
|
|
+
|
|
|
+ <NModal
|
|
|
+ v-model:show="showCreate"
|
|
|
+ preset="card"
|
|
|
+ title="创建 DataDownload 任务"
|
|
|
+ class="max-w-3xl"
|
|
|
+ >
|
|
|
+ <NForm label-placement="left" label-width="110">
|
|
|
+ <NFormItem label="站点 SN">
|
|
|
+ <div class="w-full">
|
|
|
+ <NInput
|
|
|
+ v-model:value="form.snText"
|
|
|
+ type="textarea"
|
|
|
+ :rows="5"
|
|
|
+ placeholder="使用空格、逗号或换行分隔多个 SN"
|
|
|
+ /><input class="mt-2" type="file" accept=".txt,text/plain" @change="readSnFile" />
|
|
|
+ <div class="text-xs text-gray-500 mt-1">
|
|
|
+ UTF-8 TXT,每行一个 SN;允许 BOM,不支持表头和注释。
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </NFormItem>
|
|
|
+ <NFormItem label="描述"><NInput v-model:value="form.description" /></NFormItem>
|
|
|
+ <NFormItem label="日期范围"
|
|
|
+ ><NDatePicker
|
|
|
+ v-model:formatted-value="dates"
|
|
|
+ type="daterange"
|
|
|
+ value-format="yyyy-MM-dd"
|
|
|
+ clearable
|
|
|
+ /></NFormItem>
|
|
|
+ <NFormItem label="数据类型">
|
|
|
+ <NSpace vertical>
|
|
|
+ <NSpace
|
|
|
+ ><NCheckbox v-model:checked="form.options.powerFlow">功率流图</NCheckbox
|
|
|
+ ><NCheckbox v-model:checked="form.options.eventAlarm">事件&告警</NCheckbox
|
|
|
+ ><NCheckbox v-model:checked="form.options.alarmSnapshot">告警快照</NCheckbox
|
|
|
+ ><NCheckbox v-model:checked="form.options.faultRecord">故障录波</NCheckbox></NSpace
|
|
|
+ >
|
|
|
+ <NSpace
|
|
|
+ ><NCheckbox v-model:checked="form.options.updateLog"
|
|
|
+ >升级记录(当前全量,不受日期范围限制)</NCheckbox
|
|
|
+ ></NSpace
|
|
|
+ >
|
|
|
+ <NSelect
|
|
|
+ v-model:value="form.options.mqttLogs"
|
|
|
+ multiple
|
|
|
+ filterable
|
|
|
+ clearable
|
|
|
+ :options="mqttLogOptions"
|
|
|
+ placeholder="MQTT 日志类型(可搜索多选)"
|
|
|
+ />
|
|
|
+ <NSelect
|
|
|
+ v-model:value="form.options.emsLogs"
|
|
|
+ multiple
|
|
|
+ filterable
|
|
|
+ clearable
|
|
|
+ :options="emsLogOptions"
|
|
|
+ placeholder="EMS 日志类型(可搜索多选)"
|
|
|
+ />
|
|
|
+ </NSpace>
|
|
|
+ </NFormItem>
|
|
|
+ </NForm>
|
|
|
+ <template #footer
|
|
|
+ ><div class="flex gap-2 justify-end">
|
|
|
+ <NButton @click="showCreate = false">取消</NButton
|
|
|
+ ><NButton type="primary" :loading="creating" @click="submit">创建</NButton>
|
|
|
+ </div></template
|
|
|
+ >
|
|
|
+ </NModal>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="tsx">
|
|
|
+import { NButton, NDropdown, NTag, type DataTableColumns, type SelectOption } from 'naive-ui'
|
|
|
+import dayjs from 'dayjs'
|
|
|
+import mqttLogNameList from '@/assets/data/mqttLogNameList.json'
|
|
|
+import { useUserStore } from '@/stores/user'
|
|
|
+import {
|
|
|
+ cancelDataDownload,
|
|
|
+ createDataDownload,
|
|
|
+ createDataDownloadPackage,
|
|
|
+ deleteDataDownload,
|
|
|
+ downloadDataDownloadTaskDocument,
|
|
|
+ downloadDataDownloadTaskFile,
|
|
|
+ getDataDownloadTask,
|
|
|
+ getDataDownloadTasks,
|
|
|
+ retryDataDownload,
|
|
|
+} from '@/service/dataDownload'
|
|
|
+import type {
|
|
|
+ DataDownloadFaultRecordEntry,
|
|
|
+ DataDownloadFileAttempt,
|
|
|
+ DataDownloadTask,
|
|
|
+ DataDownloadTaskDetail,
|
|
|
+ DataDownloadTaskInput,
|
|
|
+} from '@/service/dataDownload/interface'
|
|
|
+
|
|
|
+defineOptions({ name: 'DataDownloadTasks' })
|
|
|
+const BOM_PATTERN = /^\uFEFF/
|
|
|
+const LINE_BREAK_PATTERN = /\r?\n/
|
|
|
+const message = useMessage()
|
|
|
+const dialog = useDialog()
|
|
|
+const userStore = useUserStore()
|
|
|
+const loading = ref(false)
|
|
|
+const creating = ref(false)
|
|
|
+const showCreate = ref(false)
|
|
|
+const showDetail = ref(false)
|
|
|
+const detail = ref<DataDownloadTaskDetail>()
|
|
|
+const detailTab = ref('progress')
|
|
|
+type ProgressFilter = 'all' | 'running' | 'pending' | 'finished'
|
|
|
+const progressFilter = ref<ProgressFilter>('all')
|
|
|
+const progressPage = ref(1)
|
|
|
+const progressPageSize = ref(20)
|
|
|
+const detailRefreshing = ref(false)
|
|
|
+const detailRefreshError = ref(false)
|
|
|
+const scope = ref<'mine' | 'all'>('mine')
|
|
|
+const description = ref('')
|
|
|
+const snFilter = ref('')
|
|
|
+const statusFilter = ref<string | null>(null)
|
|
|
+const pageNum = ref(1)
|
|
|
+const pageSize = ref(20)
|
|
|
+const total = ref(0)
|
|
|
+const tasks = ref<DataDownloadTask[]>([])
|
|
|
+const dates = ref<[string, string] | null>([
|
|
|
+ dayjs().format('YYYY-MM-DD'),
|
|
|
+ dayjs().format('YYYY-MM-DD'),
|
|
|
+])
|
|
|
+const mqttLogOptions = Object.entries(mqttLogNameList.cloudLog.logType).map<SelectOption>(
|
|
|
+ ([key, value]) => ({ label: `${key} - ${value}`, value: key }),
|
|
|
+)
|
|
|
+const emsLogOptions: SelectOption[] = [
|
|
|
+ { label: 'IBG运行日志', value: 'EmsRunLog' },
|
|
|
+ { label: '电池日志', value: 'EmsBattLog' },
|
|
|
+ { label: '设备系统资源监控日志', value: 'EmsMonitorLog' },
|
|
|
+ { label: '4G日志', value: 'Ems4gLog' },
|
|
|
+ { label: 'AWS日志', value: 'EmsAwsLog' },
|
|
|
+ { label: '主进程日志', value: 'EmsMainLog' },
|
|
|
+]
|
|
|
+const scopeOptions = computed(() => [
|
|
|
+ { label: '我的任务', value: 'mine' },
|
|
|
+ ...(userStore.user.permissions.isAdmin ? [{ label: '所有任务', value: 'all' }] : []),
|
|
|
+])
|
|
|
+const statusLabels: Record<string, string> = {
|
|
|
+ pending: '等待中',
|
|
|
+ running: '执行中',
|
|
|
+ success: '已完成',
|
|
|
+ skipped: '已跳过',
|
|
|
+ cancelling: '取消中',
|
|
|
+ cancelled: '已取消',
|
|
|
+ complete: '已完成',
|
|
|
+ partial_success: '部分成功',
|
|
|
+ failed: '失败',
|
|
|
+ error: '错误',
|
|
|
+}
|
|
|
+const statusText = (status: string) => statusLabels[status] || status
|
|
|
+const statusTagType = (status: string) =>
|
|
|
+ status === 'complete' || status === 'success'
|
|
|
+ ? 'success'
|
|
|
+ : status === 'failed' || status === 'error'
|
|
|
+ ? 'error'
|
|
|
+ : status === 'cancelled'
|
|
|
+ ? 'default'
|
|
|
+ : 'warning'
|
|
|
+const formatDateTime = (value?: string) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-')
|
|
|
+const formatDateRange = (task: DataDownloadTask) =>
|
|
|
+ `${task.startDate || '-'} ~ ${task.endDate || '-'}`
|
|
|
+const formatFileSize = (bytes: number) => {
|
|
|
+ if (!Number.isFinite(bytes) || bytes < 0) return '-'
|
|
|
+ if (bytes < 1024) return `${bytes} B`
|
|
|
+ const units = ['KB', 'MB', 'GB', 'TB']
|
|
|
+ const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)) - 1, units.length - 1)
|
|
|
+ return `${(bytes / 1024 ** (index + 1)).toFixed(1)} ${units[index]}`
|
|
|
+}
|
|
|
+// 任务筛选只使用任务状态;success 和 skipped 属于工作单元状态。
|
|
|
+const taskStatuses = [
|
|
|
+ 'pending',
|
|
|
+ 'running',
|
|
|
+ 'cancelling',
|
|
|
+ 'cancelled',
|
|
|
+ 'complete',
|
|
|
+ 'partial_success',
|
|
|
+ 'failed',
|
|
|
+ 'error',
|
|
|
+]
|
|
|
+const statusOptions = taskStatuses.map((value) => ({
|
|
|
+ label: statusLabels[value],
|
|
|
+ value,
|
|
|
+}))
|
|
|
+const form = reactive<DataDownloadTaskInput>({
|
|
|
+ snText: '',
|
|
|
+ description: '',
|
|
|
+ startDate: '',
|
|
|
+ endDate: '',
|
|
|
+ options: {
|
|
|
+ powerFlow: false,
|
|
|
+ mqttLogs: [],
|
|
|
+ eventAlarm: false,
|
|
|
+ alarmSnapshot: false,
|
|
|
+ emsLogs: [],
|
|
|
+ faultRecord: false,
|
|
|
+ updateLog: false,
|
|
|
+ },
|
|
|
+})
|
|
|
+
|
|
|
+const active = (status: string) => ['pending', 'running', 'cancelling'].includes(status)
|
|
|
+const terminal = (status: string) => !active(status)
|
|
|
+const columns: DataTableColumns<DataDownloadTask> = [
|
|
|
+ { title: '描述', key: 'description', minWidth: 160 },
|
|
|
+ { title: 'SN', key: 'sns', minWidth: 240, render: (row) => row.sns.join(', ') },
|
|
|
+ { title: 'Owner', key: 'ownerName', width: 130 },
|
|
|
+ {
|
|
|
+ title: '日期',
|
|
|
+ key: 'date',
|
|
|
+ width: 240,
|
|
|
+ render: (row) => `${row.startDate || '-'} ~ ${row.endDate || '-'}`,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '状态',
|
|
|
+ key: 'status',
|
|
|
+ width: 130,
|
|
|
+ render: (row) => (
|
|
|
+ <NTag
|
|
|
+ type={
|
|
|
+ row.status === 'complete'
|
|
|
+ ? 'success'
|
|
|
+ : row.status === 'failed' || row.status === 'error'
|
|
|
+ ? 'error'
|
|
|
+ : 'warning'
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {statusText(row.status)}
|
|
|
+ </NTag>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '进度',
|
|
|
+ key: 'progress',
|
|
|
+ width: 150,
|
|
|
+ render: (row) => `${row.succeeded}/${row.total}(空 ${row.empty})`,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '创建时间',
|
|
|
+ key: 'createdAt',
|
|
|
+ width: 180,
|
|
|
+ render: (row) => dayjs(row.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '操作',
|
|
|
+ key: 'actions',
|
|
|
+ width: 150,
|
|
|
+ fixed: 'right',
|
|
|
+ render: (row) => (
|
|
|
+ <div class="flex gap-2">
|
|
|
+ <NButton size="small" onClick={() => openDetail(row)}>
|
|
|
+ 详情
|
|
|
+ </NButton>
|
|
|
+ <NDropdown
|
|
|
+ trigger="click"
|
|
|
+ options={rowMenuOptions(row)}
|
|
|
+ onSelect={(key: string) => onRowMenuSelect(key, row)}
|
|
|
+ >
|
|
|
+ <NButton size="small">更多</NButton>
|
|
|
+ </NDropdown>
|
|
|
+ </div>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+]
|
|
|
+
|
|
|
+function rowMenuOptions(row: DataDownloadTask) {
|
|
|
+ return [
|
|
|
+ { label: '打包', key: 'pack', disabled: row.succeeded < 1 },
|
|
|
+ { label: '取消', key: 'cancel', disabled: !active(row.status) },
|
|
|
+ {
|
|
|
+ label: '重试',
|
|
|
+ key: 'retry',
|
|
|
+ disabled: !['failed', 'partial_success', 'error'].includes(row.status),
|
|
|
+ },
|
|
|
+ { label: '删除', key: 'remove', disabled: !terminal(row.status) },
|
|
|
+ ]
|
|
|
+}
|
|
|
+function onRowMenuSelect(key: string, row: DataDownloadTask) {
|
|
|
+ if (key === 'pack') pack(row)
|
|
|
+ else if (key === 'cancel') cancel(row)
|
|
|
+ else if (key === 'retry') retry(row)
|
|
|
+ else if (key === 'remove') remove(row)
|
|
|
+}
|
|
|
+
|
|
|
+interface DetailRow {
|
|
|
+ documentId: string
|
|
|
+ logicalPath: string
|
|
|
+ empty: boolean
|
|
|
+ kind?: string
|
|
|
+ hasBinary?: boolean
|
|
|
+}
|
|
|
+// 文档目录树节点:分组节点(SN / 类型 / 设备)只有 name + children;
|
|
|
+// 叶子节点携带原始 DetailRow 信息(documentId 等),用于展示类型与下载。
|
|
|
+interface DocumentTreeRow {
|
|
|
+ key: string
|
|
|
+ name: string
|
|
|
+ leaf?: DetailRow
|
|
|
+ children?: DocumentTreeRow[]
|
|
|
+}
|
|
|
+const detailRows = computed<DetailRow[]>(() =>
|
|
|
+ (detail.value?.documents || []).map((link) => {
|
|
|
+ const document = detail.value?.catalog.find((item) => item.id === link.documentId)
|
|
|
+ return { ...link, ...(document || {}) }
|
|
|
+ }),
|
|
|
+)
|
|
|
+const visibleDocumentCount = computed(
|
|
|
+ () =>
|
|
|
+ detailRows.value.filter((row) => !row.empty && row.logicalPath?.split('/').some(Boolean))
|
|
|
+ .length,
|
|
|
+)
|
|
|
+const failedItems = computed(() =>
|
|
|
+ (detail.value?.items || []).filter(
|
|
|
+ (item) => item != null && ['failed', 'partial_success'].includes(item.status),
|
|
|
+ ),
|
|
|
+)
|
|
|
+const failedFileAttempts = computed(() =>
|
|
|
+ (detail.value?.fileAttempts || []).filter((item) => item != null && item.status === 'failed'),
|
|
|
+)
|
|
|
+type ProgressItem = DataDownloadTaskDetail['items'][number]
|
|
|
+const progressGroup = (status: string): Exclude<ProgressFilter, 'all'> | 'other' => {
|
|
|
+ if (status === 'pending') return 'pending'
|
|
|
+ if (status === 'running' || status === 'cancelling') return 'running'
|
|
|
+ if (
|
|
|
+ ['success', 'complete', 'skipped', 'partial_success', 'failed', 'error', 'cancelled'].includes(
|
|
|
+ status,
|
|
|
+ )
|
|
|
+ ) {
|
|
|
+ return 'finished'
|
|
|
+ }
|
|
|
+ return 'other'
|
|
|
+}
|
|
|
+const progressCounts = computed(() => {
|
|
|
+ const counts = {
|
|
|
+ total: 0,
|
|
|
+ pending: 0,
|
|
|
+ running: 0,
|
|
|
+ success: 0,
|
|
|
+ partial: 0,
|
|
|
+ failed: 0,
|
|
|
+ cancelled: 0,
|
|
|
+ other: 0,
|
|
|
+ finished: 0,
|
|
|
+ }
|
|
|
+ for (const item of detail.value?.items || []) {
|
|
|
+ if (!item) continue
|
|
|
+ counts.total++
|
|
|
+ if (item.status === 'pending') counts.pending++
|
|
|
+ else if (item.status === 'running' || item.status === 'cancelling') counts.running++
|
|
|
+ else if (['success', 'complete', 'skipped'].includes(item.status)) counts.success++
|
|
|
+ else if (item.status === 'partial_success') counts.partial++
|
|
|
+ else if (item.status === 'failed' || item.status === 'error') counts.failed++
|
|
|
+ else if (item.status === 'cancelled') counts.cancelled++
|
|
|
+ else counts.other++
|
|
|
+ }
|
|
|
+ counts.finished = counts.success + counts.partial + counts.failed + counts.cancelled
|
|
|
+ return counts
|
|
|
+})
|
|
|
+const progressSegments = computed(() => {
|
|
|
+ const counts = progressCounts.value
|
|
|
+ return [
|
|
|
+ { key: 'success', label: '完成', count: counts.success },
|
|
|
+ { key: 'partial', label: '部分成功', count: counts.partial },
|
|
|
+ { key: 'failed', label: '失败', count: counts.failed },
|
|
|
+ { key: 'running', label: '进行中', count: counts.running },
|
|
|
+ { key: 'pending', label: '排队中', count: counts.pending },
|
|
|
+ { key: 'cancelled', label: '已取消', count: counts.cancelled },
|
|
|
+ { key: 'other', label: '其他', count: counts.other },
|
|
|
+ ].map((segment) => ({
|
|
|
+ ...segment,
|
|
|
+ percent: counts.total ? (segment.count / counts.total) * 100 : 0,
|
|
|
+ }))
|
|
|
+})
|
|
|
+const progressLegend = computed(() =>
|
|
|
+ progressSegments.value.filter(
|
|
|
+ (segment) => segment.count > 0 || segment.key === 'running' || segment.key === 'pending',
|
|
|
+ ),
|
|
|
+)
|
|
|
+const progressHint = computed(() => {
|
|
|
+ const status = detail.value?.task.status
|
|
|
+ if (status === 'pending') return '等待生成任务清单'
|
|
|
+ if (status === 'cancelling') return '任务取消中'
|
|
|
+ if (status === 'running') {
|
|
|
+ return progressCounts.value.total === 0 ? '正在生成任务清单' : '各项并行执行,清单可能继续增加'
|
|
|
+ }
|
|
|
+ return '任务已结束'
|
|
|
+})
|
|
|
+const progressDescription = computed(
|
|
|
+ () =>
|
|
|
+ `当前已生成 ${progressCounts.value.total} 项,${progressSegments.value.map((segment) => `${segment.label} ${segment.count} 项`).join(',')}`,
|
|
|
+)
|
|
|
+const progressFilters = computed<Array<{ value: ProgressFilter; label: string; count: number }>>(
|
|
|
+ () => [
|
|
|
+ { value: 'all', label: '全部', count: progressCounts.value.total },
|
|
|
+ { value: 'running', label: '进行中', count: progressCounts.value.running },
|
|
|
+ { value: 'pending', label: '排队中', count: progressCounts.value.pending },
|
|
|
+ { value: 'finished', label: '已结束', count: progressCounts.value.finished },
|
|
|
+ ],
|
|
|
+)
|
|
|
+const visibleProgressItems = computed(() =>
|
|
|
+ (detail.value?.items || [])
|
|
|
+ .filter(
|
|
|
+ (item): item is ProgressItem =>
|
|
|
+ item != null &&
|
|
|
+ (progressFilter.value === 'all' || progressGroup(item.status) === progressFilter.value),
|
|
|
+ )
|
|
|
+ .slice()
|
|
|
+ .sort((a, b) => {
|
|
|
+ const order = { running: 0, pending: 1, other: 2, finished: 3 }
|
|
|
+ return order[progressGroup(a.status)] - order[progressGroup(b.status)]
|
|
|
+ }),
|
|
|
+)
|
|
|
+watch(progressFilter, () => {
|
|
|
+ progressPage.value = 1
|
|
|
+})
|
|
|
+watch(
|
|
|
+ () => visibleProgressItems.value.length,
|
|
|
+ (count) => {
|
|
|
+ progressPage.value = Math.min(
|
|
|
+ progressPage.value,
|
|
|
+ Math.max(1, Math.ceil(count / progressPageSize.value)),
|
|
|
+ )
|
|
|
+ },
|
|
|
+)
|
|
|
+function setProgressPageSize(value: number) {
|
|
|
+ progressPageSize.value = value
|
|
|
+ progressPage.value = 1
|
|
|
+}
|
|
|
+const kindLabels: Record<string, string> = {
|
|
|
+ power_flow: '功率流图',
|
|
|
+ mqtt_log: 'MQTT 日志',
|
|
|
+ event_alarm: '事件与告警',
|
|
|
+ alarm_snapshot: '告警快照',
|
|
|
+ ems_log: 'EMS 日志',
|
|
|
+ fault_record: '故障录波',
|
|
|
+ update_log: '升级记录',
|
|
|
+ site_validation: '站点校验',
|
|
|
+}
|
|
|
+const emsLogLabelByValue = Object.fromEntries(
|
|
|
+ emsLogOptions.map((option) => [String(option.value), String(option.label)]),
|
|
|
+)
|
|
|
+const progressItemSubtitle = (item: ProgressItem) => {
|
|
|
+ if (item.kind === 'fault_record' && item.deviceSn) {
|
|
|
+ return `设备 ${item.deviceSn}${item.deviceType ? ` · ${item.deviceType}` : ''}`
|
|
|
+ }
|
|
|
+ if (item.kind === 'ems_log' && item.subtype) {
|
|
|
+ return emsLogLabelByValue[item.subtype] || item.subtype
|
|
|
+ }
|
|
|
+ return item.subtype || ''
|
|
|
+}
|
|
|
+const stageLabels: Record<string, string> = {
|
|
|
+ waiting: '等待资源',
|
|
|
+ validation: '校验站点',
|
|
|
+ fetch: '获取数据',
|
|
|
+ planning: '规划中',
|
|
|
+ enqueue: '入队',
|
|
|
+ complete: '完成',
|
|
|
+ cancelled: '已取消',
|
|
|
+}
|
|
|
+const progressColumns: DataTableColumns<ProgressItem> = [
|
|
|
+ { title: '站点 SN', key: 'siteSn', width: 170, ellipsis: { tooltip: true } },
|
|
|
+ {
|
|
|
+ title: '事项',
|
|
|
+ key: 'kind',
|
|
|
+ minWidth: 190,
|
|
|
+ render: (row) => (
|
|
|
+ <div>
|
|
|
+ <div>{kindLabels[row.kind] || row.kind}</div>
|
|
|
+ {progressItemSubtitle(row) && (
|
|
|
+ <div class="progress-subtype">{progressItemSubtitle(row)}</div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ { title: '日期', key: 'date', width: 120, render: (row) => row.date || '—' },
|
|
|
+ {
|
|
|
+ title: '状态',
|
|
|
+ key: 'status',
|
|
|
+ width: 110,
|
|
|
+ render: (row) => (
|
|
|
+ <NTag size="small" type={statusTagType(row.status)}>
|
|
|
+ {statusText(row.status)}
|
|
|
+ </NTag>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ { title: '阶段', key: 'stage', width: 115, render: (row) => stageLabels[row.stage || ''] || '—' },
|
|
|
+ {
|
|
|
+ title: '最近更新',
|
|
|
+ key: 'updatedAt',
|
|
|
+ width: 160,
|
|
|
+ render: (row) => formatDateTime(row.updatedAt),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '原因',
|
|
|
+ key: 'failureReason',
|
|
|
+ minWidth: 180,
|
|
|
+ ellipsis: { tooltip: true },
|
|
|
+ render: (row) => row.failureReason || '—',
|
|
|
+ },
|
|
|
+]
|
|
|
+// 按 logicalPath 的天然层级(SN / 类型 / [设备] / 日期)聚合成树;过滤空文档。
|
|
|
+const documentTree = computed<DocumentTreeRow[]>(() => {
|
|
|
+ const roots: DocumentTreeRow[] = []
|
|
|
+ for (const row of detailRows.value) {
|
|
|
+ if (row.empty) continue // 空文档不在目录树中显示
|
|
|
+ const segments = (row.logicalPath || '').split('/').filter(Boolean)
|
|
|
+ if (segments.length === 0) continue
|
|
|
+ let level = roots
|
|
|
+ let keyPrefix = ''
|
|
|
+ segments.forEach((segment, index) => {
|
|
|
+ keyPrefix = keyPrefix ? `${keyPrefix}/${segment}` : segment
|
|
|
+ const isLeaf = index === segments.length - 1
|
|
|
+ let node = level.find((item) => item.key === keyPrefix)
|
|
|
+ if (!node) {
|
|
|
+ node = { key: keyPrefix, name: segment }
|
|
|
+ if (!isLeaf) node.children = []
|
|
|
+ level.push(node)
|
|
|
+ }
|
|
|
+ if (isLeaf) {
|
|
|
+ node.leaf = row
|
|
|
+ } else {
|
|
|
+ if (!node.children) node.children = []
|
|
|
+ level = node.children
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return roots
|
|
|
+})
|
|
|
+const documentColumns: DataTableColumns<DocumentTreeRow> = [
|
|
|
+ {
|
|
|
+ title: '名称',
|
|
|
+ key: 'name',
|
|
|
+ minWidth: 360,
|
|
|
+ render: (row) => (
|
|
|
+ <span class={row.leaf ? undefined : 'tree-folder-name'} title={row.name}>
|
|
|
+ {row.name}
|
|
|
+ </span>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ { title: '类型', key: 'kind', width: 140, render: (row) => row.leaf?.kind ?? '—' },
|
|
|
+ {
|
|
|
+ title: '操作',
|
|
|
+ key: 'action',
|
|
|
+ width: 100,
|
|
|
+ render: (row) => {
|
|
|
+ const leaf = row.leaf
|
|
|
+ if (!leaf) return null
|
|
|
+ return (
|
|
|
+ <NButton
|
|
|
+ size="small"
|
|
|
+ disabled={leaf.empty || leaf.kind === 'fault_record'}
|
|
|
+ type="primary"
|
|
|
+ quaternary
|
|
|
+ onClick={() =>
|
|
|
+ detail.value &&
|
|
|
+ downloadDataDownloadTaskDocument(
|
|
|
+ detail.value.task.id,
|
|
|
+ leaf.documentId,
|
|
|
+ leaf.hasBinary ? undefined : leaf.kind === 'power_flow' ? 'html' : 'xlsx',
|
|
|
+ )
|
|
|
+ }
|
|
|
+ >
|
|
|
+ 下载
|
|
|
+ </NButton>
|
|
|
+ )
|
|
|
+ },
|
|
|
+ },
|
|
|
+]
|
|
|
+const failureColumns: DataTableColumns<DataDownloadTaskDetail['items'][number]> = [
|
|
|
+ { title: '工作单元', key: 'itemKey' },
|
|
|
+ { title: '失败原因', key: 'failureReason' },
|
|
|
+]
|
|
|
+interface FaultFileRow {
|
|
|
+ id: string
|
|
|
+ documentId: string
|
|
|
+ fileName: string
|
|
|
+ size: number
|
|
|
+ lastSuccessfulAt: string
|
|
|
+}
|
|
|
+const faultFileRows = computed<FaultFileRow[]>(() =>
|
|
|
+ Object.values(detail.value?.faultFiles || {})
|
|
|
+ .filter((entries): entries is DataDownloadFaultRecordEntry[] => Array.isArray(entries))
|
|
|
+ .flat()
|
|
|
+ .filter((entry): entry is DataDownloadFaultRecordEntry => entry != null),
|
|
|
+)
|
|
|
+const faultFileColumns: DataTableColumns<FaultFileRow> = [
|
|
|
+ { title: '文件名', key: 'fileName', minWidth: 300, ellipsis: { tooltip: true } },
|
|
|
+ { title: '大小', key: 'size', width: 120, render: (row) => formatFileSize(row.size) },
|
|
|
+ {
|
|
|
+ title: '最后成功时间',
|
|
|
+ key: 'lastSuccessfulAt',
|
|
|
+ width: 180,
|
|
|
+ render: (row) => dayjs(row.lastSuccessfulAt).format('YYYY-MM-DD HH:mm:ss'),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '操作',
|
|
|
+ key: 'action',
|
|
|
+ width: 100,
|
|
|
+ render: (row) => (
|
|
|
+ <NButton
|
|
|
+ size="small"
|
|
|
+ type="primary"
|
|
|
+ quaternary
|
|
|
+ onClick={() => detail.value && downloadDataDownloadTaskFile(detail.value.task.id, row.id)}
|
|
|
+ >
|
|
|
+ 下载
|
|
|
+ </NButton>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+]
|
|
|
+const fileFailureColumns: DataTableColumns<DataDownloadFileAttempt> = [
|
|
|
+ {
|
|
|
+ title: '文件',
|
|
|
+ key: 'fileName',
|
|
|
+ minWidth: 180,
|
|
|
+ render: (row) => row.fileName || row.sourceFileId,
|
|
|
+ },
|
|
|
+ { title: '阶段', key: 'failureStage', width: 100 },
|
|
|
+ { title: '尝试次数', key: 'attemptCount', width: 100 },
|
|
|
+ { title: '失败原因', key: 'failureReason', minWidth: 260 },
|
|
|
+]
|
|
|
+
|
|
|
+// 刷新间隔(毫秒)。默认 30s;创建/重试任务后重置为 5s,之后每次刷新 +5s 递增,封顶 30s。
|
|
|
+const REFRESH_MIN = 5000
|
|
|
+const REFRESH_MAX = 30000
|
|
|
+const REFRESH_STEP = 5000
|
|
|
+let refreshInterval = REFRESH_MAX
|
|
|
+// 创建/重试任务时调用:重置刷新间隔,使轮询从 5s 开始递增。
|
|
|
+function resetRefreshInterval() {
|
|
|
+ refreshInterval = REFRESH_MIN
|
|
|
+}
|
|
|
+
|
|
|
+let latestLoadId = 0
|
|
|
+function applyFilters() {
|
|
|
+ pageNum.value = 1
|
|
|
+ void load()
|
|
|
+}
|
|
|
+function onPageChange(value: number) {
|
|
|
+ pageNum.value = value
|
|
|
+ void load()
|
|
|
+}
|
|
|
+function onPageSizeChange(value: number) {
|
|
|
+ pageSize.value = value
|
|
|
+ applyFilters()
|
|
|
+}
|
|
|
+watch([scope, statusFilter], applyFilters)
|
|
|
+
|
|
|
+async function load() {
|
|
|
+ const loadId = ++latestLoadId
|
|
|
+ loading.value = true
|
|
|
+ try {
|
|
|
+ const data = await getDataDownloadTasks(
|
|
|
+ pageNum.value,
|
|
|
+ pageSize.value,
|
|
|
+ scope.value,
|
|
|
+ description.value.trim(),
|
|
|
+ snFilter.value.trim(),
|
|
|
+ statusFilter.value || '',
|
|
|
+ )
|
|
|
+ if (loadId !== latestLoadId) return
|
|
|
+ tasks.value = (data.items || []).filter((item): item is DataDownloadTask => item != null)
|
|
|
+ total.value = data.total || 0
|
|
|
+ } finally {
|
|
|
+ if (loadId === latestLoadId) loading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+async function submit() {
|
|
|
+ if (!form.snText.trim()) return message.warning('请输入至少一个 SN')
|
|
|
+ form.startDate = dates.value?.[0] || ''
|
|
|
+ form.endDate = dates.value?.[1] || ''
|
|
|
+ creating.value = true
|
|
|
+ try {
|
|
|
+ await createDataDownload(form)
|
|
|
+ message.success('任务已创建')
|
|
|
+ showCreate.value = false
|
|
|
+ resetRefreshInterval()
|
|
|
+ await load()
|
|
|
+ } catch (error) {
|
|
|
+ message.error(String(error))
|
|
|
+ } finally {
|
|
|
+ creating.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+async function readSnFile(event: Event) {
|
|
|
+ const input = event.target as HTMLInputElement
|
|
|
+ const file = input.files?.[0]
|
|
|
+ if (!file) return
|
|
|
+ const text = (await file.text()).replace(BOM_PATTERN, '')
|
|
|
+ const values = text
|
|
|
+ .split(LINE_BREAK_PATTERN)
|
|
|
+ .map((value) => value.trim())
|
|
|
+ .filter(Boolean)
|
|
|
+ form.snText = [form.snText, ...values].filter(Boolean).join('\n')
|
|
|
+ input.value = ''
|
|
|
+}
|
|
|
+async function cancel(row: DataDownloadTask) {
|
|
|
+ await cancelDataDownload(row.id)
|
|
|
+ message.success('已请求取消')
|
|
|
+ await load()
|
|
|
+}
|
|
|
+async function retry(row: DataDownloadTask) {
|
|
|
+ await retryDataDownload(row.id)
|
|
|
+ message.success('失败项已重新入队')
|
|
|
+ resetRefreshInterval()
|
|
|
+ await load()
|
|
|
+}
|
|
|
+async function pack(row: DataDownloadTask) {
|
|
|
+ const job = await createDataDownloadPackage([row.id])
|
|
|
+ message.success(`打包任务已创建:${job.filename}`)
|
|
|
+}
|
|
|
+async function openDetail(row: DataDownloadTask) {
|
|
|
+ try {
|
|
|
+ detail.value = await getDataDownloadTask(row.id)
|
|
|
+ detailTab.value = 'progress'
|
|
|
+ progressFilter.value = 'all'
|
|
|
+ progressPage.value = 1
|
|
|
+ detailRefreshError.value = false
|
|
|
+ showDetail.value = true
|
|
|
+ scheduleDetailRefresh()
|
|
|
+ } catch (error) {
|
|
|
+ message.error(`获取任务明细失败:${String(error)}`)
|
|
|
+ }
|
|
|
+}
|
|
|
+function onDetailShowChange(value: boolean) {
|
|
|
+ showDetail.value = value
|
|
|
+ if (!value) {
|
|
|
+ window.clearTimeout(detailRefreshTimer)
|
|
|
+ detail.value = undefined
|
|
|
+ }
|
|
|
+}
|
|
|
+let detailRefreshTimer: number | undefined
|
|
|
+function scheduleDetailRefresh() {
|
|
|
+ window.clearTimeout(detailRefreshTimer)
|
|
|
+ if (!showDetail.value || !detail.value || !active(detail.value.task.status)) return
|
|
|
+ detailRefreshTimer = window.setTimeout(() => void refreshDetail(), 30_000)
|
|
|
+}
|
|
|
+async function refreshDetail(manual = false) {
|
|
|
+ const taskID = detail.value?.task.id
|
|
|
+ if (!showDetail.value || !taskID || detailRefreshing.value) return
|
|
|
+ window.clearTimeout(detailRefreshTimer)
|
|
|
+ detailRefreshing.value = true
|
|
|
+ try {
|
|
|
+ const updated = await getDataDownloadTask(taskID)
|
|
|
+ if (showDetail.value && detail.value?.task.id === taskID) {
|
|
|
+ detail.value = updated
|
|
|
+ detailRefreshError.value = false
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ detailRefreshError.value = true
|
|
|
+ if (manual) message.error(`刷新任务明细失败:${String(error)}`)
|
|
|
+ } finally {
|
|
|
+ detailRefreshing.value = false
|
|
|
+ scheduleDetailRefresh()
|
|
|
+ }
|
|
|
+}
|
|
|
+function remove(row: DataDownloadTask) {
|
|
|
+ dialog.warning({
|
|
|
+ title: '确认删除',
|
|
|
+ content: `删除任务“${row.description || row.id}”?共享文档不会被删除。`,
|
|
|
+ positiveText: '删除',
|
|
|
+ negativeText: '取消',
|
|
|
+ onPositiveClick: async () => {
|
|
|
+ await deleteDataDownload(row.id)
|
|
|
+ message.success('已删除')
|
|
|
+ await load()
|
|
|
+ },
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(load)
|
|
|
+let timer: number | undefined
|
|
|
+watch(
|
|
|
+ tasks,
|
|
|
+ (value) => {
|
|
|
+ window.clearTimeout(timer)
|
|
|
+ if (value.some((row) => active(row.status))) {
|
|
|
+ timer = window.setTimeout(load, refreshInterval)
|
|
|
+ // 按刷新次数递增,封顶 30s
|
|
|
+ refreshInterval = Math.min(refreshInterval + REFRESH_STEP, REFRESH_MAX)
|
|
|
+ } else {
|
|
|
+ // 无活跃任务,停止轮询并恢复默认间隔
|
|
|
+ refreshInterval = REFRESH_MAX
|
|
|
+ }
|
|
|
+ },
|
|
|
+ { deep: true },
|
|
|
+)
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ window.clearTimeout(timer)
|
|
|
+ window.clearTimeout(detailRefreshTimer)
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.task-detail {
|
|
|
+ max-height: min(72vh, 780px);
|
|
|
+ overflow-y: auto;
|
|
|
+ padding-right: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-overview {
|
|
|
+ padding: 20px 22px;
|
|
|
+ border: 1px solid color-mix(in srgb, currentColor 10%, transparent);
|
|
|
+ border-radius: 12px;
|
|
|
+ background: color-mix(in srgb, currentColor 3%, transparent);
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-heading {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-heading-main {
|
|
|
+ min-width: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-eyebrow {
|
|
|
+ margin-bottom: 6px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #888;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-heading h3 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+ line-height: 1.4;
|
|
|
+ overflow-wrap: anywhere;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-meta {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 4px 18px;
|
|
|
+ margin-top: 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #888;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-stats {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
|
+ gap: 10px;
|
|
|
+ margin-top: 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-stat {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 10px 12px;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: color-mix(in srgb, currentColor 5%, transparent);
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-stat span {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #888;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-stat strong {
|
|
|
+ font-size: 20px;
|
|
|
+ font-weight: 600;
|
|
|
+ line-height: 1.2;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-stat.is-success strong {
|
|
|
+ color: #18a058;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-stat.is-error strong {
|
|
|
+ color: #d03050;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-progress {
|
|
|
+ margin-top: 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-progress-heading,
|
|
|
+.task-detail-progress-title {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-progress-heading strong {
|
|
|
+ font-size: 13px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-progress-heading > span {
|
|
|
+ font-size: 12px;
|
|
|
+ color: #888;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-progress-track {
|
|
|
+ display: flex;
|
|
|
+ height: 10px;
|
|
|
+ margin-top: 10px;
|
|
|
+ overflow: hidden;
|
|
|
+ border-radius: 999px;
|
|
|
+ background: color-mix(in srgb, currentColor 10%, transparent);
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-progress-track span {
|
|
|
+ display: block;
|
|
|
+ height: 100%;
|
|
|
+ flex: none;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-progress-legend {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 6px 16px;
|
|
|
+ margin-top: 10px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #888;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-progress-legend > span {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 5px;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-progress-legend i {
|
|
|
+ display: inline-block;
|
|
|
+ width: 7px;
|
|
|
+ height: 7px;
|
|
|
+ border-radius: 50%;
|
|
|
+}
|
|
|
+
|
|
|
+.progress-segment-success,
|
|
|
+.progress-dot-success {
|
|
|
+ background: #18a058;
|
|
|
+}
|
|
|
+.progress-segment-partial,
|
|
|
+.progress-dot-partial {
|
|
|
+ background: #f0a020;
|
|
|
+}
|
|
|
+.progress-segment-failed,
|
|
|
+.progress-dot-failed {
|
|
|
+ background: #d03050;
|
|
|
+}
|
|
|
+.progress-segment-running,
|
|
|
+.progress-dot-running {
|
|
|
+ background: #2080f0;
|
|
|
+}
|
|
|
+.progress-segment-pending,
|
|
|
+.progress-dot-pending {
|
|
|
+ background: #a0a4ad;
|
|
|
+}
|
|
|
+.progress-segment-cancelled,
|
|
|
+.progress-dot-cancelled {
|
|
|
+ background: #626670;
|
|
|
+}
|
|
|
+.progress-segment-other,
|
|
|
+.progress-dot-other {
|
|
|
+ background: #8a6fc4;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-tabs {
|
|
|
+ margin-top: 18px;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-section-heading {
|
|
|
+ margin: 6px 0 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-section-heading h4 {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-section-heading p {
|
|
|
+ margin: 4px 0 0;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #888;
|
|
|
+}
|
|
|
+
|
|
|
+.task-detail-filters {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 6px;
|
|
|
+ margin-bottom: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+.progress-subtype {
|
|
|
+ margin-top: 2px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #888;
|
|
|
+}
|
|
|
+
|
|
|
+.tree-folder-name {
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 640px) {
|
|
|
+ .task-detail-overview {
|
|
|
+ padding: 16px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .task-detail-stats {
|
|
|
+ grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|