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:
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+308
-870
File diff suppressed because it is too large
Load Diff
+38
-2
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user