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
+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>