feat: add OpenList upload support and upload tasks monitoring page

Backend:
- Add OpenListRecordArtifactUploader implementing AList-compatible API upload
- Add Uploading status to RecordArtifactUploadStatus enum
- Add MarkUploadStarted() to RecordResult entity for upload progress tracking
- Add ListUploadStatus API endpoint with pagination and status filtering
- Add UploadTaskItemDto and UploadTaskListResponse models
- Add upload segment count stats (uploaded/failed/uploading) to session DTO
- Add OpenListUploadSettingsDto and upload target type OpenList

Frontend:
- Add UploadTasksView page with route /upload-tasks
- Add upload status labels and UploadTaskItem types
- Refactor MainLayout navigation and clean up main.css
- Polish DashboardView, MetricCard, StatusBadge, RightDrawer components
- Update SettingsView to support OpenList upload configuration

Build:
- Add frontend/Dockerfile.arm64 for ARM64 frontend image
- Update build-arm64-image.sh script

Other:
- Add segment_completed_openlist.sh trigger script
- Add prototype/ directory with UI mockups
- Add frontend .dockerignore refinements

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 21:14:50 +08:00
co-authored by Claude Fable 5
parent f8abae0a7a
commit 9df174bb0b
44 changed files with 3962 additions and 1827 deletions
-1
View File
@@ -1,3 +1,2 @@
node_modules
dist
npm-debug.log
+10
View File
@@ -0,0 +1,10 @@
ARG NGINX_IMAGE=nginx:1.27-alpine
FROM ${NGINX_IMAGE}
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
File diff suppressed because it is too large Load Diff
+12 -63
View File
@@ -8,78 +8,27 @@ withDefaults(
description?: string;
icon?: Component | null;
}>(),
{
description: "",
icon: null
}
{ description: "", icon: null }
);
</script>
<template>
<article class="metric-card">
<div class="metric-card__header">
<span class="metric-card__label">{{ label }}</span>
<span v-if="icon" class="metric-card__icon">
<el-icon><component :is="icon" /></el-icon>
<article class="stat-card">
<div class="stat-card__top" style="display:flex;align-items:center;justify-content:space-between">
<span class="stat-card__label">{{ label }}</span>
<span v-if="icon" class="stat-card__ic">
<el-icon :size="18"><component :is="icon" /></el-icon>
</span>
</div>
<div class="metric-card__value">{{ value }}</div>
<div v-if="description" class="metric-card__description">{{ description }}</div>
<div class="stat-card__value">{{ value }}</div>
<div v-if="description" class="stat-card__hint">{{ description }}</div>
</article>
</template>
<style scoped>
.metric-card {
display: grid;
gap: 12px;
min-height: 164px;
padding: 20px;
border-radius: 16px;
border: 1px solid var(--border-subtle);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
var(--surface);
box-shadow: var(--shadow-soft);
}
.metric-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.metric-card__label {
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.metric-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 12px;
background: rgba(37, 99, 235, 0.1);
color: var(--accent);
font-size: 18px;
}
.metric-card__value {
color: var(--text-primary);
font-size: clamp(28px, 2vw, 40px);
font-weight: 760;
letter-spacing: -0.06em;
line-height: 1;
}
.metric-card__description {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
.stat-card__top { display: flex; align-items: center; justify-content: space-between; }
.stat-card__ic {
width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center;
background: var(--accent-soft); color: var(--accent);
}
</style>
+1 -1
View File
@@ -59,7 +59,7 @@ const visible = computed({
display: flex;
min-height: 100%;
flex-direction: column;
background: linear-gradient(180deg, var(--surface-raised), var(--surface));
background: var(--surface);
}
.right-drawer__header {
+12 -12
View File
@@ -238,21 +238,21 @@ const displayLabel = computed(() => {
}
.status-badge--blue {
background: rgba(37, 99, 235, 0.1);
border-color: rgba(37, 99, 235, 0.16);
color: #2563eb;
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
}
.status-badge--yellow {
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.18);
color: #b45309;
background: var(--warning-soft);
border-color: color-mix(in srgb, var(--warning) 22%, transparent);
color: var(--warning);
}
.status-badge--red {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.16);
color: #dc2626;
background: var(--danger-soft);
border-color: color-mix(in srgb, var(--danger) 20%, transparent);
color: var(--danger);
}
.status-badge--orange {
@@ -262,9 +262,9 @@ const displayLabel = computed(() => {
}
.status-badge--indigo {
background: rgba(99, 102, 241, 0.12);
border-color: rgba(99, 102, 241, 0.18);
color: #4f46e5;
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 22%, transparent);
color: var(--accent);
}
:global(html[data-theme="dark"]) .status-badge--gray {
+6
View File
@@ -7,6 +7,7 @@ const DashboardView = () => import("@/views/DashboardView.vue");
const LiveRoomsView = () => import("@/views/LiveRoomsView.vue");
const RecordTasksView = () => import("@/views/RecordTasksView.vue");
const TranscodeTasksView = () => import("@/views/TranscodeTasksView.vue");
const UploadTasksView = () => import("@/views/UploadTasksView.vue");
const RecordTaskDetailView = () => import("@/views/RecordTaskDetailView.vue");
const RecordSessionDetailView = () => import("@/views/RecordSessionDetailView.vue");
const MediaBrowserView = () => import("@/views/MediaBrowserView.vue");
@@ -48,6 +49,11 @@ const router = createRouter({
name: "transcode-tasks",
component: TranscodeTasksView
},
{
path: "upload-tasks",
name: "upload-tasks",
component: UploadTasksView
},
{
path: "media-browser",
name: "media-browser",
File diff suppressed because it is too large Load Diff
+38 -2
View File
@@ -141,6 +141,7 @@ export interface RecordTask {
startedAt?: string;
endedAt?: string;
durationSeconds?: number;
uploadStatus?: number;
postProcessStage?: string;
postProcessProgressPercent?: number;
postProcessDetail?: string;
@@ -184,6 +185,9 @@ export interface RecordSession {
endedAt?: string;
totalFileSizeBytes: number;
totalDanmakuMessageCount: number;
uploadedSegmentCount: number;
failedUploadSegmentCount: number;
uploadingSegmentCount: number;
tasks: RecordTask[];
}
@@ -262,6 +266,36 @@ export interface RecordArtifactUploadBatchResult {
items: RecordArtifactUploadItemResult[];
}
export interface UploadTaskItem {
recordTaskId: string;
recordSessionId: string;
liveRoomId: string;
liveRoomTitle: string;
platform: number;
roomId: string;
segmentIndex: number;
outputFormat: string;
filePath?: string;
fileSizeBytes?: number;
danmakuFilePath?: string;
uploadStatus: number;
lastUploadProvider?: string;
remoteVideoPath?: string;
remoteDanmakuPath?: string;
lastUploadedAt?: string;
uploadErrorMessage?: string;
deletedLocalFilesAfterUpload: boolean;
createdAt: string;
}
export interface UploadTaskListResponse {
items: UploadTaskItem[];
totalCount: number;
notUploadedCount: number;
succeededCount: number;
failedCount: number;
}
export interface ManualSegmentCompletedTriggerResult {
recordTaskId: string;
success: boolean;
@@ -771,13 +805,15 @@ export const platformLabelMap: Record<number, string> = {
export const uploadTargetLabelMap: Record<number, string> = {
0: "不上传",
1: "WebDAV",
2: "S3"
2: "S3",
3: "OpenList"
};
export const uploadStatusLabelMap: Record<number, string> = {
0: "未上传",
1: "已上传",
2: "上传失败"
2: "上传失败",
3: "上传中"
};
export interface DanmakuEvent {
+66 -99
View File
@@ -32,7 +32,7 @@ function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function sessionStatusTagType(status: number) {
function sessionStatusTagType(status: number): "" | "success" | "warning" | "danger" | "info" {
if (status === 2) return "success";
if (status === 5) return "danger";
if (status === 4 || status === 6) return "info";
@@ -73,12 +73,13 @@ onMounted(loadData);
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">系统概览</div>
<h1 class="page-title">仪表盘</h1>
<p class="page-subtitle">系统运行状态一览包含直播间录制会话弹幕和存储概况</p>
</div>
<el-space class="header-actions">
<div class="header-actions">
<el-button @click="loadData" :loading="loading">刷新</el-button>
</el-space>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
@@ -86,28 +87,23 @@ onMounted(loadData);
<el-skeleton v-if="loading && !data" animated :rows="6" />
<template v-else-if="data">
<!-- KPI Cards -->
<!-- KPI -->
<div class="stats-grid">
<MetricCard label="正在录制" :value="data.activeRecordingCount">
<template #icon><el-icon :size="18"><VideoCamera /></el-icon></template>
</MetricCard>
<MetricCard label="直播间" :value="`${data.liveRoomCount} / ${data.offlineRoomCount}`" :description="`共 ${data.totalRoomCount} 个直播间`">
<MetricCard label="直播间" :value="`${data.liveRoomCount} / ${data.offlineRoomCount}`" description="共接入">
<template #icon><el-icon :size="18"><House /></el-icon></template>
</MetricCard>
<MetricCard label="今日录制时长" :value="formatDuration(data.todayRecordingSeconds)">
<template #icon><el-icon :size="18"><Clock /></el-icon></template>
</MetricCard>
<MetricCard label="今日数据量" :value="formatDataSize(data.todayDataBytes)">
<template #icon><el-icon :size="18"><DataAnalysis /></el-icon></template>
</MetricCard>
<MetricCard label="今日弹幕" :value="data.todayDanmakuCount.toLocaleString()">
<template #icon><el-icon :size="18"><Bell /></el-icon></template>
</MetricCard>
<MetricCard
label="24h 异常"
:value="data.recentErrorCount"
@@ -117,69 +113,64 @@ onMounted(loadData);
</MetricCard>
</div>
<!-- Storage Status -->
<el-card class="surface-card" shadow="never">
<h3 class="section-title">存储状态</h3>
<p class="section-subtitle">录制输出路径的磁盘剩余空间和当前录制保护阈值</p>
<div class="storage-bar-row">
<div class="storage-bar-card">
<span class="storage-bar-card__label">水位线</span>
<el-tag :type="storageTierTagType()" size="large">
{{ storageTierLabel() }}
</el-tag>
</div>
<div class="storage-bar-card">
<span class="storage-bar-card__label">使用率</span>
<span class="storage-bar-card__value">{{ data.storageStatus.usagePercent.toFixed(1) }}%</span>
</div>
<div class="storage-bar-card">
<span class="storage-bar-card__label">可用空间</span>
<span class="storage-bar-card__value">{{ formatDataSize(data.storageStatus.availableBytes) }}</span>
</div>
<div class="storage-bar-card">
<span class="storage-bar-card__label">说明</span>
<span class="storage-bar-card__value storage-bar-card__value--message">{{ data.storageStatus.message || "-" }}</span>
</div>
</div>
</el-card>
<!-- Queue Status -->
<!-- storage + queue -->
<el-row :gutter="18">
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">存储状态</h3>
<p class="section-subtitle">录制输出路径的磁盘剩余空间和当前录制保护阈值</p>
<div style="display:flex;align-items:center;gap:28px;margin-top:18px">
<div class="ring-wrap" :style="{ background: `conic-gradient(var(--success) ${Number(data.storageStatus.usagePercent.toFixed(1))}%, var(--surface-hover) 0)` }">
<div class="ring-inner">
<div class="ring-num">{{ data.storageStatus.usagePercent.toFixed(1) }}%</div>
<div class="ring-cap">已使用</div>
</div>
</div>
<div style="flex:1;display:grid;gap:14px">
<div>
<div style="font-size:12px;color:var(--text-muted)">可用空间</div>
<div style="font-size:18px;font-weight:700;font-variant-numeric:tabular-nums">
{{ formatDataSize(data.storageStatus.availableBytes) }}
<span style="font-size:13px;font-weight:500;color:var(--text-muted)">/ 总计</span>
</div>
</div>
<div style="display:flex;gap:24px">
<div><div style="font-size:12px;color:var(--text-muted)">水位线</div><el-tag :type="storageTierTagType()" size="small">{{ storageTierLabel() }}</el-tag></div>
<div><div style="font-size:12px;color:var(--text-muted)">说明</div><div style="font-size:13px;font-weight:500;color:var(--text-secondary)">{{ data.storageStatus.message || "-" }}</div></div>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">处理队列</h3>
<p class="section-subtitle">待转码和待上传的文件积压情况</p>
<div class="storage-bar-row">
<div class="storage-bar-card">
<span class="storage-bar-card__label">待转码</span>
<span class="storage-bar-card__value" :style="{ color: data.pendingTranscodeCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">
{{ data.pendingTranscodeCount }}
</span>
<div style="display:grid;gap:14px;margin-top:18px">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--surface-muted)">
<div><div style="font-weight:600;font-size:13.5px">待转码</div><div style="font-size:12px;color:var(--text-muted)">FFmpeg 后处理队列</div></div>
<div :style="{ fontSize: '26px', fontWeight: 800, fontVariantNumeric: 'tabular-nums', color: data.pendingTranscodeCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">{{ data.pendingTranscodeCount }}</div>
</div>
<div class="storage-bar-card">
<span class="storage-bar-card__label">待上传</span>
<span class="storage-bar-card__value" :style="{ color: data.pendingUploadCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">
{{ data.pendingUploadCount }}
</span>
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--surface-muted)">
<div><div style="font-weight:600;font-size:13.5px">待上传</div><div style="font-size:12px;color:var(--text-muted)">WebDAV / S3 / OpenList</div></div>
<div :style="{ fontSize: '26px', fontWeight: 800, fontVariantNumeric: 'tabular-nums', color: data.pendingUploadCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">{{ data.pendingUploadCount }}</div>
</div>
<div class="storage-bar-card">
<span class="storage-bar-card__label">积压数据量</span>
<span class="storage-bar-card__value" :style="{ color: data.queuedDataBytes > 0 ? 'var(--warning)' : 'var(--text-primary)' }">
{{ formatDataSize(data.queuedDataBytes) }}
</span>
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border:1px solid var(--border-subtle);border-radius:var(--radius-sm);background:var(--surface-muted)">
<div><div style="font-weight:600;font-size:13.5px">积压数据量</div><div style="font-size:12px;color:var(--text-muted)">等待归档的本地文件总量</div></div>
<div style="font-size:20px;font-weight:700;font-variant-numeric:tabular-nums">{{ formatDataSize(data.queuedDataBytes) }}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- Recent Sessions + Top Rooms -->
<!-- recent sessions + top rooms -->
<el-row :gutter="18">
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">最近会话</h3>
<p class="section-subtitle">最近创建的录制会话点击可跳转至详情</p>
<div class="table-scroll-shell">
<div class="table-scroll-shell" style="margin-top:14px">
<el-table :data="data.recentSessions" class="premium-table" size="small">
<el-table-column label="直播间" min-width="140" prop="liveRoomTitle" />
<el-table-column label="状态" width="90">
@@ -202,12 +193,11 @@ onMounted(loadData);
</div>
</el-card>
</el-col>
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">今日热门直播间</h3>
<p class="section-subtitle">今日录制时长最长的直播间Top 5</p>
<div class="table-scroll-shell">
<div class="table-scroll-shell" style="margin-top:14px">
<el-table :data="data.topRooms" class="premium-table" size="small">
<el-table-column label="直播间" min-width="130">
<template #default="{ row }">{{ row.title || row.anchorName || "-" }}</template>
@@ -232,50 +222,27 @@ onMounted(loadData);
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
.page-stack { display: grid; gap: var(--page-gap); }
.header-actions { align-self: center; }
.page-error-alert { border-radius: var(--radius-md); }
.ring-wrap {
--p: 32;
width: 92px; height: 92px; flex-shrink: 0;
border-radius: 99px; display: grid; place-items: center;
}
.header-actions {
align-self: center;
}
.page-error-alert {
border-radius: 14px;
}
.storage-bar-row {
display: flex;
flex-wrap: wrap;
gap: 20px 40px;
}
.storage-bar-card {
display: flex;
flex-direction: column;
gap: 6px;
}
.storage-bar-card__label {
font-size: 12px;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 600;
}
.storage-bar-card__value {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.storage-bar-card__value--message {
font-size: 14px;
font-weight: 400;
color: var(--text-secondary);
.ring-inner {
width: 70px; height: 70px; border-radius: 99px;
background: var(--surface); display: grid; place-items: center; text-align: center;
}
.ring-num { font-size: 20px; font-weight: 800; line-height: 1; }
.ring-cap { font-size: 10.5px; color: var(--text-muted); margin-top: 2px; }
@media (max-width: 768px) {
.header-actions {
width: 100%;
justify-content: stretch;
}
.header-actions :deep(.el-button) {
flex: 1 1 0;
margin: 0;
}
.header-actions { width: 100%; }
.header-actions :deep(.el-button) { flex: 1; }
.ring-wrap { width: 68px; height: 68px; }
.ring-inner { width: 50px; height: 50px; }
.ring-num { font-size: 16px; }
}
</style>
+38 -9
View File
@@ -29,7 +29,8 @@ import {
outputFormatLabelMap,
saveModeLabelMap,
sessionStatusLabelMap,
taskStatusLabelMap
taskStatusLabelMap,
uploadStatusLabelMap
} from "@/types";
const router = useRouter();
@@ -1043,7 +1044,15 @@ onBeforeUnmount(() => {
<strong class="monospace">#{{ task.segmentIndex }}</strong>
<div class="cell-subtitle">{{ formatDate(task.startedAt || task.createdAt) }}</div>
</div>
<StatusBadge :label="taskStatusLabelMap[task.status]" :status="task.status" context="task" />
<div class="session-task-card__badges">
<StatusBadge :label="taskStatusLabelMap[task.status]" :status="task.status" context="task" />
<StatusBadge
v-if="task.uploadStatus !== undefined && task.uploadStatus !== null"
:label="uploadStatusLabelMap[task.uploadStatus] ?? String(task.uploadStatus)"
:status="task.uploadStatus"
context="upload"
/>
</div>
</div>
<div v-if="hasPostProcess(task)" class="session-task-card__progress">
@@ -1135,6 +1144,9 @@ onBeforeUnmount(() => {
<span>{{ saveModeLabelMap[session.saveMode] }}</span>
<span>{{ outputFormatLabelMap[session.outputFormat] }}</span>
<span>分片 {{ session.segmentCount }}</span>
<span v-if="session.uploadedSegmentCount > 0 || session.failedUploadSegmentCount > 0 || session.uploadingSegmentCount > 0">
上传 {{ session.uploadedSegmentCount }}/{{ session.segmentCount }}
</span>
<span>{{ formatFileSize(session.totalFileSizeBytes) }}</span>
</div>
</div>
@@ -1221,13 +1233,25 @@ onBeforeUnmount(() => {
</template>
</el-table-column>
<el-table-column label="上传" width="110">
<template #default="{ row }">
<StatusBadge
v-if="row.uploadStatus !== undefined && row.uploadStatus !== null"
:label="uploadStatusLabelMap[row.uploadStatus] ?? String(row.uploadStatus)"
:status="row.uploadStatus"
context="upload"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="清晰度" width="120">
<template #default="{ row }">
<span>{{ formatQualityLabel(row.preferredQuality) }}</span>
</template>
</el-table-column>
<el-table-column label="输出文件" min-width="320">
<el-table-column label="输出文件" min-width="280">
<template #default="{ row }">
<div class="monospace path-text">{{ row.outputFilePath || "-" }}</div>
</template>
@@ -1420,13 +1444,11 @@ onBeforeUnmount(() => {
.record-feature-card {
display: grid;
gap: 10px;
padding: 18px;
border-radius: 16px;
padding: 20px;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
var(--surface);
box-shadow: var(--shadow-soft);
background: var(--surface);
box-shadow: var(--shadow-sm);
}
.record-feature-card__eyebrow {
@@ -1673,6 +1695,13 @@ onBeforeUnmount(() => {
gap: 12px;
}
.session-task-card__badges {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.session-task-card__progress {
display: grid;
gap: 8px;
+8 -9
View File
@@ -2119,7 +2119,7 @@ watch(
width: 56px;
height: 56px;
border-radius: 999px;
background: linear-gradient(180deg, #2563eb 0%, #4338ca 100%);
background: var(--accent);
color: #ffffff;
font-size: 20px;
font-weight: 800;
@@ -2214,7 +2214,7 @@ watch(
border: 1px solid var(--border-subtle);
border-bottom: 0;
border-radius: 12px 12px 0 0;
background: linear-gradient(180deg, var(--surface-raised), var(--surface-muted));
background: var(--surface-muted);
box-shadow: var(--shadow-soft);
}
@@ -2255,7 +2255,7 @@ watch(
border: 1px solid var(--border-subtle);
border-top: 0;
border-radius: 0 0 12px 12px;
background: linear-gradient(180deg, var(--surface-raised), var(--surface));
background: var(--surface);
box-shadow: var(--shadow-soft);
}
@@ -2303,7 +2303,7 @@ watch(
gap: 10px;
padding: 16px 18px;
border-radius: 12px;
background: linear-gradient(180deg, var(--surface-muted), var(--surface));
background: var(--surface);
border: 1px solid var(--border-subtle);
}
@@ -2420,7 +2420,7 @@ watch(
padding: 18px 18px 4px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: linear-gradient(180deg, var(--surface-muted), var(--surface));
background: var(--surface);
}
.template-section__header {
@@ -2526,11 +2526,10 @@ watch(
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
border-radius: 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border-base);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)),
var(--surface-raised);
background: var(--surface-raised);
box-shadow: var(--shadow-lg);
box-shadow: var(--shadow-float);
backdrop-filter: blur(18px);
pointer-events: auto;
+466
View File
@@ -0,0 +1,466 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Bell, CircleCheck, CircleClose, UploadFilled } from "@element-plus/icons-vue";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import type { RecordArtifactUploadItemResult, UploadTaskItem, UploadTaskListResponse } from "@/types";
import {
platformLabelMap,
uploadStatusLabelMap
} from "@/types";
const router = useRouter();
const loading = ref(false);
const loadError = ref("");
const items = ref<UploadTaskItem[]>([]);
const totalCount = ref(0);
const notUploadedCount = ref(0);
const succeededCount = ref(0);
const failedCount = ref(0);
const uploadStatusFilter = ref<number | null>(null);
const currentPage = ref(1);
const pageSize = 50;
const uploadingTaskId = ref<string | null>(null);
const retryingFailed = ref(false);
const uploadingAllPending = ref(false);
const filterOptions = [
{ label: "全部", value: null as number | null },
{ label: "待上传", value: 0 },
{ label: "已上传", value: 1 },
{ label: "上传失败", value: 2 }
];
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize)));
async function loadUploadStatus() {
loading.value = true;
loadError.value = "";
try {
const params: Record<string, string | number> = {
skip: (currentPage.value - 1) * pageSize,
take: pageSize
};
if (uploadStatusFilter.value !== null) {
params.uploadStatus = uploadStatusFilter.value;
}
const { data } = await apiClient.get<UploadTaskListResponse>("/record-tasks/upload-status", { params });
items.value = data.items;
totalCount.value = data.totalCount;
notUploadedCount.value = data.notUploadedCount;
succeededCount.value = data.succeededCount;
failedCount.value = data.failedCount;
} catch (error) {
loadError.value = getApiErrorMessage(error, "上传任务列表加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
function handleFilterChange() {
currentPage.value = 1;
loadUploadStatus();
}
function handlePageChange(page: number) {
currentPage.value = page;
loadUploadStatus();
}
async function uploadTask(task: UploadTaskItem) {
uploadingTaskId.value = task.recordTaskId;
try {
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(`/record-tasks/${task.recordTaskId}/upload`);
ElMessage[data.success ? "success" : "warning"](data.message);
await loadUploadStatus();
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "上传失败,请稍后重试。"));
} finally {
uploadingTaskId.value = null;
}
}
async function retryAllFailed() {
const failedItems = items.value.filter(item => item.uploadStatus === 2);
if (failedItems.length === 0) {
ElMessage.info("当前没有上传失败的任务。");
return;
}
retryingFailed.value = true;
let successCount = 0;
let failCount = 0;
try {
for (const item of failedItems) {
try {
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(`/record-tasks/${item.recordTaskId}/upload`);
if (data.success) {
successCount++;
} else {
failCount++;
}
} catch {
failCount++;
}
}
ElMessage[successCount > 0 ? "success" : "warning"](
`重试完成:成功 ${successCount},失败 ${failCount}`
);
await loadUploadStatus();
} finally {
retryingFailed.value = false;
}
}
async function uploadAllPending() {
const pendingItems = items.value.filter(item => item.uploadStatus === 0);
if (pendingItems.length === 0) {
ElMessage.info("当前没有待上传的任务。");
return;
}
uploadingAllPending.value = true;
let successCount = 0;
let failCount = 0;
try {
for (const item of pendingItems) {
try {
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(`/record-tasks/${item.recordTaskId}/upload`);
if (data.success) {
successCount++;
} else {
failCount++;
}
} catch {
failCount++;
}
}
ElMessage[successCount > 0 ? "success" : "warning"](
`批量上传完成:成功 ${successCount},失败 ${failCount}`
);
await loadUploadStatus();
} finally {
uploadingAllPending.value = false;
}
}
function openDetail(task: UploadTaskItem) {
router.push({ name: "record-task-detail", params: { id: task.recordTaskId } });
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function formatFileSize(bytes?: number) {
if (typeof bytes !== "number" || Number.isNaN(bytes)) {
return "-";
}
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
if (bytes < 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
onMounted(loadUploadStatus);
</script>
<template>
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">录制工作台</div>
<h1 class="page-title">上传任务</h1>
<p class="page-subtitle">
查看所有录制分片的上传状态支持按状态筛选单任务上传及批量重试失败任务
</p>
</div>
<div class="page-toolbar">
<el-button @click="router.push({ name: 'record-tasks' })">录制任务</el-button>
<el-button @click="loadUploadStatus">刷新列表</el-button>
</div>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid">
<MetricCard label="总计" :value="totalCount" description="所有有录制结果的分片" :icon="Bell" />
<MetricCard label="待上传" :value="notUploadedCount" description="尚未上传的分片" :icon="UploadFilled" />
<MetricCard label="已上传" :value="succeededCount" description="上传成功的分片" :icon="CircleCheck" />
<MetricCard label="上传失败" :value="failedCount" description="上传失败的分片" :icon="CircleClose" />
</div>
<el-card class="surface-card upload-card" shadow="never">
<div class="toolbar-row">
<div class="toolbar-row__filter">
<span class="filter-label">上传状态</span>
<el-radio-group
v-model="uploadStatusFilter"
size="small"
@change="handleFilterChange"
>
<el-radio-button
v-for="opt in filterOptions"
:key="String(opt.value)"
:value="opt.value"
>
{{ opt.label }}
</el-radio-button>
</el-radio-group>
</div>
<div class="toolbar-row__actions">
<el-button
type="primary"
plain
:loading="uploadingAllPending"
@click="uploadAllPending"
>
上传本页待上传
</el-button>
<el-button
type="warning"
plain
:loading="retryingFailed"
@click="retryAllFailed"
>
重试本页失败
</el-button>
</div>
</div>
<EmptyState
v-if="!loading && items.length === 0"
title="暂无上传任务"
description="当前筛选条件下没有可展示的上传记录。"
action-text="刷新列表"
@action="loadUploadStatus"
/>
<template v-else>
<div class="table-scroll-shell">
<el-table
:data="items"
v-loading="loading"
class="premium-table upload-table"
table-layout="auto"
row-key="recordTaskId"
>
<el-table-column label="直播间" min-width="160">
<template #default="{ row }">
<div>
<div class="cell-primary">{{ row.liveRoomTitle }}</div>
<div class="cell-subtitle monospace">{{ row.roomId }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="平台" width="100">
<template #default="{ row }">
{{ platformLabelMap[row.platform] ?? "-" }}
</template>
</el-table-column>
<el-table-column label="分片" width="80">
<template #default="{ row }">
<span class="monospace">#{{ row.segmentIndex }}</span>
</template>
</el-table-column>
<el-table-column label="文件" min-width="260">
<template #default="{ row }">
<div class="monospace path-text">{{ row.filePath || "-" }}</div>
<div class="cell-subtitle">{{ formatFileSize(row.fileSizeBytes) }}</div>
</template>
</el-table-column>
<el-table-column label="上传状态" width="120">
<template #default="{ row }">
<StatusBadge
:label="uploadStatusLabelMap[row.uploadStatus]"
:status="row.uploadStatus"
context="upload"
/>
</template>
</el-table-column>
<el-table-column label="上传方式" width="100">
<template #default="{ row }">
{{ row.lastUploadProvider || "-" }}
</template>
</el-table-column>
<el-table-column label="远端路径" min-width="220">
<template #default="{ row }">
<div class="monospace path-text">{{ row.remoteVideoPath || "-" }}</div>
</template>
</el-table-column>
<el-table-column label="上传时间" width="170">
<template #default="{ row }">
{{ formatDate(row.lastUploadedAt) }}
</template>
</el-table-column>
<el-table-column label="上传错误" min-width="160">
<template #default="{ row }">
<span class="error-text">{{ row.uploadErrorMessage || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<div class="task-actions-cell">
<el-button size="small" @click="openDetail(row)">详情</el-button>
<el-button
v-if="row.uploadStatus !== 1"
size="small"
type="primary"
:loading="uploadingTaskId === row.recordTaskId"
@click="uploadTask(row)"
>
{{ row.uploadStatus === 2 ? "重试" : "上传" }}
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="totalPages > 1" class="pagination-row">
<el-pagination
background
layout="prev, pager, next"
:page-size="pageSize"
:total="totalCount"
:current-page="currentPage"
@current-change="handlePageChange"
/>
</div>
</template>
</el-card>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.page-error-alert {
border-radius: 14px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
@media (max-width: 960px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
.upload-card :deep(.el-card__body) {
padding-top: 18px;
}
.toolbar-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-subtle);
flex-wrap: wrap;
}
.toolbar-row__filter {
display: flex;
align-items: center;
gap: 12px;
}
.filter-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
white-space: nowrap;
}
.toolbar-row__actions {
display: flex;
align-items: center;
gap: 10px;
}
.table-scroll-shell {
overflow-x: auto;
}
.upload-table {
min-width: 1200px;
border-radius: 12px;
}
.pagination-row {
display: flex;
justify-content: center;
padding-top: 18px;
}
.path-text {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.6;
}
.error-text {
font-size: 12px;
color: var(--danger);
line-height: 1.5;
}
.task-actions-cell {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
white-space: nowrap;
}
.task-actions-cell :deep(.el-button) {
margin: 0;
}
</style>