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:
@@ -1,3 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
+305
-867
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 -->
|
||||
<!-- 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 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 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 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 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 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 -->
|
||||
<el-row :gutter="18">
|
||||
</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>
|
||||
<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>
|
||||
@@ -0,0 +1,44 @@
|
||||
# Live Recorder · 高保真原型
|
||||
|
||||
商业级 SaaS 风格 UI 重设计原型(参考 Ant Design Pro / 飞书 / 云控制台 / Linear)。
|
||||
纯静态 HTML + CSS + 少量 JS,**无需构建**,直接用浏览器打开即可浏览。
|
||||
|
||||
## 如何查看
|
||||
|
||||
直接双击打开 `login.html` 或 `index.html`,通过左侧导航在各页之间跳转。
|
||||
|
||||
> 建议用本地静态服务器以获得最佳体验(可选):
|
||||
> `npx serve prototype` 或 VSCode Live Server。
|
||||
|
||||
## 页面清单
|
||||
|
||||
| 文件 | 页面 | 说明 |
|
||||
|---|---|---|
|
||||
| `login.html` | 登录 | 品牌左栏 + 表单右栏 |
|
||||
| `index.html` | 仪表盘 | KPI + 存储水位 + 队列 + 最近会话/热门房间 |
|
||||
| `live-rooms.html` | 直播间控制台 | 收敛表格 + 分段筛选 + 详情抽屉(**重点重构**) |
|
||||
| `record-tasks.html` | 录制任务 | 实时态 + 折叠会话卡 + 嵌套分片表 |
|
||||
| `session-detail.html` | 会话详情 | 时间轴 + 弹幕热力 + 回放播放器 |
|
||||
| `record-task-detail.html` | 分片详情 | 预览播放器 + 描述列表 + 日志 |
|
||||
| `transcode-tasks.html` | 转码任务 | 进度条 + 分段筛选 |
|
||||
| `upload-tasks.html` | 上传任务 | 上传状态 + 批量重传 |
|
||||
| `media-browser.html` | 文件库 | 面包屑目录浏览 |
|
||||
| `daily-reviews.html` | 回顾日报 | 维度表 + 高光会话 + 高光时刻 |
|
||||
| `logs.html` | 系统日志 | 多维筛选 + 实体下钻 |
|
||||
| `recovery.html` | 恢复中心 | 存储守护 + 可恢复房间/收尾 |
|
||||
| `settings.html` | 系统设置 | 左锚点 + 分组卡片 + 吸底保存栏 |
|
||||
|
||||
## 交互说明
|
||||
|
||||
- **主题切换**:顶栏月亮/太阳图标,切换深/浅色(localStorage 记忆)。
|
||||
- **侧栏折叠**:顶栏菜单图标。
|
||||
- **详情抽屉**:直播间页点行尾「⋯」打开右侧抽屉。
|
||||
- **折叠会话**:录制任务页点会话头部展开/收起分片表。
|
||||
- **设置锚点**:设置页左侧锚点定位到对应分组。
|
||||
|
||||
## 资源
|
||||
|
||||
- `assets/design-system.css` — 设计系统(tokens + 全部组件样式,含暗色/密度)。
|
||||
- `assets/shell.js` — 应用外壳注入(侧栏/顶栏/主题/折叠/抽屉)。
|
||||
|
||||
所有页面共享同一套设计系统与外壳,与第四阶段《设计规范》一致。
|
||||
@@ -0,0 +1,701 @@
|
||||
/* =============================================================
|
||||
Live Recorder · Design System v2 (high-fidelity prototype)
|
||||
商业级 SaaS · 深色侧栏 + 层次投影 + 玻璃磨砂 + 微动画
|
||||
参考 Linear / Vercel / 飞书 / 云控制台 / Ant Design Pro
|
||||
============================================================= */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--font-sans: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", Menlo, monospace;
|
||||
|
||||
/* neutrals — 冷蓝灰统一基调 */
|
||||
--bg-base: #eef1f6;
|
||||
--bg-subtle: #e7ebf2;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f4f7fb;
|
||||
--surface-hover: #eef2f8;
|
||||
--border-subtle: #e3e7ef;
|
||||
--border-base: #d2d8e3;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #45526a;
|
||||
--text-muted: #6a7689;
|
||||
--text-soft: #97a1b3;
|
||||
|
||||
/* sidebar (light — subtle grey, blends with page) */
|
||||
--side-bg: #f3f5f9;
|
||||
--side-bg-2: #f3f5f9;
|
||||
--side-border: #e3e7ef;
|
||||
--side-title: #7a8699;
|
||||
--side-item: #44526b;
|
||||
--side-item-hover-bg: rgba(26, 86, 219, 0.07);
|
||||
--side-item-hover: #1e40af;
|
||||
--side-active-bg: rgba(26, 86, 219, 0.10);
|
||||
--side-active: #1a56db;
|
||||
|
||||
/* brand */
|
||||
--accent: #1a56db;
|
||||
--accent-strong: #1e40af;
|
||||
--accent-soft: rgba(26, 86, 219, 0.12);
|
||||
--accent-soft-2: rgba(26, 86, 219, 0.07);
|
||||
|
||||
/* semantic */
|
||||
--success: #16a34a;
|
||||
--success-soft: rgba(22, 163, 74, 0.12);
|
||||
--warning: #d97706;
|
||||
--warning-soft: rgba(217, 119, 6, 0.13);
|
||||
--danger: #dc2626;
|
||||
--danger-soft: rgba(220, 38, 38, 0.11);
|
||||
--info: #2563eb;
|
||||
--info-soft: rgba(37, 99, 235, 0.11);
|
||||
--purple: #7c3aed;
|
||||
--purple-soft: rgba(124, 58, 237, 0.11);
|
||||
|
||||
/* elevation — 真正可见的投影 */
|
||||
--shadow-xs: 0 1px 3px rgba(16, 24, 40, 0.09), 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
--shadow-sm: 0 2px 8px rgba(16, 24, 40, 0.10), 0 1px 3px rgba(16, 24, 40, 0.06);
|
||||
--shadow-md: 0 8px 20px rgba(16, 24, 40, 0.12), 0 2px 6px rgba(16, 24, 40, 0.07);
|
||||
--shadow-lg: 0 16px 40px rgba(16, 24, 40, 0.16), 0 4px 10px rgba(16, 24, 40, 0.08);
|
||||
--shadow-float: 0 24px 56px rgba(16, 24, 40, 0.22);
|
||||
|
||||
/* radius */
|
||||
--radius-xs: 7px;
|
||||
--radius-sm: 9px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
|
||||
/* layout */
|
||||
--sidebar-w: 244px;
|
||||
--sidebar-w-collapsed: 72px;
|
||||
--topbar-h: 62px;
|
||||
--page-max: 1320px;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg-base: #0a1120;
|
||||
--bg-subtle: #0f172a;
|
||||
--surface: #121c30;
|
||||
--surface-muted: #16223899;
|
||||
--surface-hover: #1b2942;
|
||||
--border-subtle: #1f2c44;
|
||||
--border-base: #2c3a55;
|
||||
--text-primary: #e9eefb;
|
||||
--text-secondary: #b6c4dd;
|
||||
--text-muted: #8295b3;
|
||||
--text-soft: #647a9a;
|
||||
|
||||
--side-bg: #0c1424;
|
||||
--side-bg-2: #0a111e;
|
||||
--side-border: #1a2740;
|
||||
--side-title: #4a607d;
|
||||
--side-item: #8295b3;
|
||||
--side-item-hover-bg: rgba(255, 255, 255, 0.05);
|
||||
--side-item-hover: #d4def0;
|
||||
--side-active-bg: rgba(99, 102, 241, 0.32);
|
||||
--side-active: #93c5fd;
|
||||
|
||||
--accent: #60a5fa;
|
||||
--accent-strong: #3b82f6;
|
||||
--accent-soft: rgba(96, 165, 250, 0.18);
|
||||
--accent-soft-2: rgba(96, 165, 250, 0.10);
|
||||
--success: #34d399;
|
||||
--success-soft: rgba(52, 211, 153, 0.15);
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: rgba(251, 191, 36, 0.15);
|
||||
--danger: #f87171;
|
||||
--danger-soft: rgba(248, 113, 113, 0.15);
|
||||
--info: #5b9bff;
|
||||
--info-soft: rgba(91, 155, 255, 0.15);
|
||||
--purple: #a78bfa;
|
||||
--purple-soft: rgba(167, 139, 250, 0.15);
|
||||
|
||||
--shadow-xs: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.45);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.6);
|
||||
--shadow-float: 0 24px 56px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* — dark mode component overrides (brand / health) — */
|
||||
html[data-theme="dark"] .brand__mark { background: #1a2438; box-shadow: 0 2px 8px rgba(0,0,0,.5); }
|
||||
html[data-theme="dark"] .brand__name { color: #eaf0fb; }
|
||||
html[data-theme="dark"] .brand__sub { color: #647a9a; }
|
||||
html[data-theme="dark"] .health { background: rgba(52,211,153,.12); border-color: rgba(52,211,153,.22); }
|
||||
html[data-theme="dark"] .health__dot { background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,.22); }
|
||||
html[data-theme="dark"] .health__txt { color: #6ee7b7; }
|
||||
|
||||
html[data-density="compact"] { --topbar-h: 56px; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: "kern" 1, "liga" 1, "calt" 1;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
h1, h2, h3, h4, p { margin: 0; }
|
||||
::selection { background: var(--accent-soft); }
|
||||
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--border-base); border-radius: 99px; border: 3px solid transparent; background-clip: content-box; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--text-soft); background-clip: content-box; }
|
||||
|
||||
/* ============================ Icons ============================ */
|
||||
svg.ic { width: 18px; height: 18px; flex-shrink: 0; display: inline-block; vertical-align: middle; }
|
||||
.empty__ic svg.ic { width: 24px; height: 24px; }
|
||||
|
||||
/* ============================ Motion ============================ */
|
||||
@keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
/* ============================ App Shell ============================ */
|
||||
.app-shell { display: flex; min-height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
position: fixed; inset: 0 auto 0 0; z-index: 30;
|
||||
width: var(--sidebar-w);
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--side-bg);
|
||||
border-right: 1px solid var(--side-border);
|
||||
transition: width .2s ease;
|
||||
}
|
||||
.sidebar.collapsed { width: var(--sidebar-w-collapsed); }
|
||||
|
||||
.brand {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
height: var(--topbar-h); padding: 0 18px; flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--side-border);
|
||||
}
|
||||
.brand__mark {
|
||||
width: 36px; height: 36px; flex-shrink: 0; border-radius: 8px;
|
||||
display: grid; place-items: center; color: #fff;
|
||||
background: #1a56db;
|
||||
box-shadow: 0 2px 8px rgba(26, 86, 219, 0.25);
|
||||
}
|
||||
.brand__name { font-weight: 800; font-size: 16px; letter-spacing: -.02em; color: var(--text-primary); white-space: nowrap; }
|
||||
.brand__sub { font-size: 11px; color: var(--text-muted); white-space: nowrap; margin-top: 1px; }
|
||||
.sidebar.collapsed .brand__copy { display: none; }
|
||||
|
||||
.nav { flex: 1; overflow-y: auto; padding: 14px 12px 8px; }
|
||||
.nav__group { margin-bottom: 16px; }
|
||||
.nav__title {
|
||||
padding: 6px 10px; font-size: 11px; font-weight: 700; letter-spacing: .09em;
|
||||
text-transform: uppercase; color: var(--side-title);
|
||||
}
|
||||
.sidebar.collapsed .nav__title { font-size: 0; padding: 6px 0; text-align: center; }
|
||||
.sidebar.collapsed .nav__title::after { content: "•"; font-size: 13px; }
|
||||
|
||||
.nav__item {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
padding: 9px 11px; margin-bottom: 2px; border-radius: 6px;
|
||||
color: var(--side-item); font-weight: 600; font-size: 14px;
|
||||
position: relative; transition: background .15s, color .15s; white-space: nowrap;
|
||||
}
|
||||
.nav__item:hover { background: var(--side-item-hover-bg); color: var(--side-item-hover); }
|
||||
.nav__item.active { background: var(--side-active-bg); color: var(--side-active); }
|
||||
.nav__item.active::before {
|
||||
content: ""; position: absolute; left: -12px; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--side-active);
|
||||
}
|
||||
.nav__item .ic { width: 18px; height: 18px; opacity: .92; }
|
||||
.nav__badge { margin-left: auto; font-size: 11px; font-weight: 700;
|
||||
background: var(--danger); color: #fff; border-radius: 99px; padding: 1px 6px; min-width: 18px; text-align: center; }
|
||||
.sidebar.collapsed .nav__item { justify-content: center; padding: 9px 0; }
|
||||
.sidebar.collapsed .nav__label, .sidebar.collapsed .nav__badge { display: none; }
|
||||
|
||||
.sidebar__foot { padding: 12px; border-top: 1px solid var(--side-border); }
|
||||
.health {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px;
|
||||
background: rgba(22, 163, 74, 0.10); border: 1px solid rgba(22, 163, 74, 0.20);
|
||||
}
|
||||
.health__dot { width: 8px; height: 8px; border-radius: 99px; background: #16a34a; box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.18); }
|
||||
.health__txt { font-size: 12.5px; font-weight: 600; color: #15803d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sidebar.collapsed .health__txt { display: none; }
|
||||
.sidebar.collapsed .health { justify-content: center; padding: 10px 0; }
|
||||
@media (max-width: 768px) { .sidebar .health { min-width: 0; } .sidebar .health__txt { min-width: 0; flex: 1; } }
|
||||
|
||||
/* main column */
|
||||
.main-col { flex: 1; margin-left: var(--sidebar-w); min-width: 0; transition: margin .2s ease; }
|
||||
.sidebar.collapsed ~ .main-col { margin-left: var(--sidebar-w-collapsed); }
|
||||
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
height: var(--topbar-h); display: flex; align-items: center; gap: 14px;
|
||||
padding: 0 28px; background: color-mix(in srgb, var(--surface) 70%, transparent);
|
||||
backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4);
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
html[data-theme="dark"] .topbar { border-bottom-color: rgba(255, 255, 255, 0.06); }
|
||||
.icon-btn {
|
||||
width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center;
|
||||
border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent;
|
||||
color: var(--text-secondary); position: relative; transition: background .15s, color .15s;
|
||||
}
|
||||
.icon-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.icon-btn .dot { position: absolute; top: 7px; right: 8px; width: 7px; height: 7px;
|
||||
border-radius: 99px; background: var(--danger); border: 1.5px solid var(--surface); }
|
||||
|
||||
.breadcrumb { display: flex; align-items: center; gap: 7px; font-size: 13.5px; color: var(--text-muted); min-width: 0; }
|
||||
.breadcrumb b { color: var(--text-primary); font-weight: 700; }
|
||||
.breadcrumb .sep { color: var(--text-soft); }
|
||||
|
||||
.searchbar {
|
||||
margin-left: 8px; flex: 1; max-width: 440px; display: flex; align-items: center; gap: 9px;
|
||||
height: 38px; padding: 0 12px; border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted); border: 1px solid var(--border-subtle); color: var(--text-muted);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.searchbar:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.searchbar input { flex: 1; border: 0; background: transparent; outline: none; color: var(--text-primary); font-size: 13.5px; }
|
||||
.searchbar input::placeholder { color: var(--text-soft); }
|
||||
.searchbar kbd { font-family: var(--font-sans); font-size: 11px; color: var(--text-muted);
|
||||
background: var(--surface); border: 1px solid var(--border-base); border-radius: 5px; padding: 1px 6px; }
|
||||
.topbar__right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.user-chip {
|
||||
display: flex; align-items: center; gap: 9px; padding: 4px 10px 4px 4px;
|
||||
border: 1px solid var(--border-subtle); border-radius: 99px; background: var(--surface);
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.user-chip:hover { background: var(--surface-hover); border-color: var(--border-base); }
|
||||
.avatar {
|
||||
width: 30px; height: 30px; flex-shrink: 0; border-radius: 99px; display: grid; place-items: center;
|
||||
color: #fff; font-weight: 700; font-size: 13px; background: #1a56db;
|
||||
}
|
||||
.user-chip__name { font-size: 13px; font-weight: 700; }
|
||||
.user-chip__meta { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
/* page */
|
||||
.page { max-width: var(--page-max); margin: 0 auto; padding: 28px 32px; display: flex; flex-direction: column; gap: 22px; }
|
||||
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; animation: fadeUp .3s ease both; }
|
||||
.page-head__kicker { font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--accent); margin-bottom: 7px; }
|
||||
.page-head h1 { font-size: 28px; font-weight: 800; letter-spacing: -.025em; line-height: 1.2; }
|
||||
.page-head p { margin-top: 8px; color: var(--text-muted); font-size: 13.5px; max-width: 720px; line-height: 1.6; }
|
||||
.page-head__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
/* ============================ Buttons ============================ */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
height: 38px; padding: 0 15px; border-radius: var(--radius-sm); font-size: 13.5px; font-weight: 700;
|
||||
border: 1px solid var(--border-base); background: var(--surface); color: var(--text-secondary);
|
||||
transition: all .15s ease; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: var(--text-soft); color: var(--text-primary); background: var(--surface); box-shadow: var(--shadow-xs); }
|
||||
.btn .ic { width: 16px; height: 16px; }
|
||||
.btn--primary {
|
||||
background: #1a56db;
|
||||
border-color: #1e40af; color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(26, 86, 219, 0.4), 0 4px 14px rgba(26, 86, 219, 0.2);
|
||||
}
|
||||
.btn--primary:hover { color: #fff; background: #1e40af; border-color: #1e40af; transform: translateY(-1px); box-shadow: 0 2px 5px rgba(26, 86, 219, 0.45), 0 6px 18px rgba(26, 86, 219, 0.28); }
|
||||
.btn--primary:active { transform: translateY(0); }
|
||||
.btn--ghost { border-color: transparent; background: transparent; }
|
||||
.btn--ghost:hover { background: var(--surface-hover); box-shadow: none; }
|
||||
.btn--danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, transparent); background: transparent; }
|
||||
.btn--danger:hover { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); box-shadow: none; }
|
||||
.btn--sm { height: 30px; padding: 0 11px; font-size: 12.5px; border-radius: var(--radius-xs); }
|
||||
.btn--link { border: 0; background: transparent; color: var(--accent); padding: 0 6px; height: auto; font-weight: 600; }
|
||||
.btn--link:hover { background: transparent; color: var(--accent-strong); text-decoration: underline; box-shadow: none; }
|
||||
.btn[disabled] { opacity: .45; pointer-events: none; }
|
||||
|
||||
/* ============================ Cards ============================ */
|
||||
.card { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); transition: box-shadow .18s ease; animation: fadeUp .3s ease both; }
|
||||
.card:hover { box-shadow: var(--shadow-md); }
|
||||
.card__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 24px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.card__title { font-size: 16px; font-weight: 700; letter-spacing: -.01em; }
|
||||
.card__sub { font-size: 12.5px; color: var(--text-muted); margin-top: 3px; font-weight: 500; }
|
||||
.card__body { padding: 24px; }
|
||||
.card__body--flush { padding: 0; }
|
||||
|
||||
/* stat card */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
|
||||
.stat {
|
||||
background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md);
|
||||
padding: 20px; display: flex; flex-direction: column; gap: 11px; box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow .18s, transform .18s; position: relative; overflow: hidden;
|
||||
animation: fadeUp .35s ease both;
|
||||
}
|
||||
.stat:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
|
||||
.stat:nth-child(2) { animation-delay: .05s; }
|
||||
.stat:nth-child(3) { animation-delay: .10s; }
|
||||
.stat:nth-child(4) { animation-delay: .15s; }
|
||||
.stat:nth-child(5) { animation-delay: .20s; }
|
||||
.stat:nth-child(6) { animation-delay: .25s; }
|
||||
.stat__top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.stat__label { font-size: 12.5px; font-weight: 600; color: var(--text-muted); }
|
||||
.stat__ic { width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent); }
|
||||
.stat__ic.is-green { background: var(--success-soft); color: var(--success); }
|
||||
.stat__ic.is-amber { background: var(--warning-soft); color: var(--warning); }
|
||||
.stat__ic.is-red { background: var(--danger-soft); color: var(--danger); }
|
||||
.stat__ic.is-purple { background: var(--purple-soft); color: var(--purple); }
|
||||
.stat__value { font-size: 31px; font-weight: 800; letter-spacing: -.04em; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.stat__value small { font-size: 15px; font-weight: 600; color: var(--text-muted); letter-spacing: 0; }
|
||||
.stat__foot { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-muted); font-weight: 500; }
|
||||
.trend { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: 12px; }
|
||||
.trend.up { color: var(--success); }
|
||||
.trend.down { color: var(--danger); }
|
||||
|
||||
/* ============================ Badges / Tags ============================ */
|
||||
.badge { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--text-secondary); }
|
||||
.badge .pip { width: 7px; height: 7px; border-radius: 99px; background: var(--text-soft); }
|
||||
.badge.is-success { color: var(--success); } .badge.is-success .pip { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); }
|
||||
.badge.is-danger { color: var(--danger); } .badge.is-danger .pip { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
|
||||
.badge.is-warning { color: var(--warning); } .badge.is-warning .pip { background: var(--warning); box-shadow: 0 0 0 3px var(--warning-soft); }
|
||||
.badge.is-info { color: var(--info); } .badge.is-info .pip { background: var(--info); box-shadow: 0 0 0 3px var(--info-soft); }
|
||||
.badge.live .pip { animation: pulse 1.4s infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
|
||||
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px;
|
||||
border-radius: var(--radius-xs); font-size: 12px; font-weight: 600;
|
||||
background: var(--surface-muted); color: var(--text-secondary); border: 1px solid var(--border-subtle);
|
||||
}
|
||||
.tag.is-blue { background: var(--info-soft); color: var(--info); border-color: transparent; }
|
||||
.tag.is-green { background: var(--success-soft); color: var(--success); border-color: transparent; }
|
||||
.tag.is-amber { background: var(--warning-soft); color: var(--warning); border-color: transparent; }
|
||||
.tag.is-red { background: var(--danger-soft); color: var(--danger); border-color: transparent; }
|
||||
.tag.is-purple { background: var(--purple-soft); color: var(--purple); border-color: transparent; }
|
||||
|
||||
/* ============================ Table ============================ */
|
||||
.table-wrap { width: 100%; overflow-x: auto; }
|
||||
table.tbl { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||
.tbl thead th {
|
||||
position: sticky; top: 0; text-align: left; font-size: 12px; font-weight: 700; letter-spacing: .02em;
|
||||
color: var(--text-muted); text-transform: uppercase; padding: 12px 20px;
|
||||
background: #eef2f8; border-bottom: 2px solid var(--border-base); white-space: nowrap;
|
||||
}
|
||||
html[data-theme="dark"] .tbl thead th { background: #16223a; }
|
||||
.tbl tbody td { padding: 14px 20px; border-bottom: 1px solid var(--border-subtle); vertical-align: middle; }
|
||||
.tbl tbody tr:last-child td { border-bottom: 0; }
|
||||
.tbl tbody tr { transition: background .12s; }
|
||||
.tbl tbody tr:nth-child(even) { background: rgba(15, 23, 42, 0.018); }
|
||||
html[data-theme="dark"] .tbl tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.02); }
|
||||
.tbl tbody tr:hover { background: var(--surface-hover); }
|
||||
.tbl .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.cell-title { font-weight: 700; color: var(--text-primary); }
|
||||
.cell-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; font-weight: 500; }
|
||||
.cell-mono { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.row-actions { display: flex; align-items: center; gap: 4px; justify-content: flex-end; opacity: 0; transition: opacity .15s; }
|
||||
.tbl tbody tr:hover .row-actions { opacity: 1; }
|
||||
@media (hover: none) { .row-actions { opacity: 1; } }
|
||||
|
||||
.checkbox { width: 16px; height: 16px; border: 1.5px solid var(--border-base); border-radius: 4px; display: inline-block; vertical-align: middle; background: var(--surface); }
|
||||
.checkbox.checked { background: var(--accent); border-color: var(--accent); position: relative; }
|
||||
.checkbox.checked::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||
|
||||
.identity { display: flex; align-items: center; gap: 11px; }
|
||||
.identity .avatar { width: 40px; height: 40px; border-radius: 11px; font-size: 15px; }
|
||||
.meta-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; font-size: 12px; color: var(--text-muted); }
|
||||
.meta-row .dot-sep { width: 3px; height: 3px; border-radius: 99px; background: var(--text-soft); }
|
||||
|
||||
/* ============================ Toolbar / filters ============================ */
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 16px 24px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.toolbar__spacer { flex: 1; }
|
||||
.segmented { display: inline-flex; padding: 3px; gap: 2px; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); }
|
||||
.segmented button { border: 0; background: transparent; padding: 6px 13px; border-radius: 6px; font-size: 13px; font-weight: 600; color: var(--text-muted); transition: all .15s; }
|
||||
.segmented button.active { background: var(--surface); color: var(--accent); box-shadow: var(--shadow-xs); }
|
||||
.segmented button:hover:not(.active) { color: var(--text-primary); }
|
||||
.field {
|
||||
display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 12px;
|
||||
background: var(--surface); border: 1px solid var(--border-base); border-radius: var(--radius-sm);
|
||||
font-size: 13px; color: var(--text-secondary);
|
||||
}
|
||||
.field input, .field select { border: 0; background: transparent; outline: none; color: var(--text-primary); font-size: 13px; font-family: inherit; }
|
||||
.field--search { color: var(--text-muted); min-width: 220px; }
|
||||
.field--search input { flex: 1; }
|
||||
.sel-count { font-size: 13px; color: var(--text-muted); font-weight: 600; }
|
||||
.sel-count b { color: var(--accent); }
|
||||
|
||||
/* ============================ Progress ============================ */
|
||||
.bar { height: 8px; border-radius: 99px; background: var(--surface-hover); overflow: hidden; }
|
||||
.bar > span { display: block; height: 100%; border-radius: 99px; background: #1a56db; }
|
||||
.bar > span.is-green { background: #16a34a; }
|
||||
.bar > span.is-amber { background: #d97706; }
|
||||
.bar > span.is-red { background: #dc2626; }
|
||||
|
||||
.ring { --p: 32; width: 92px; height: 92px; border-radius: 99px; display: grid; place-items: center;
|
||||
background: conic-gradient(var(--success) calc(var(--p)*1%), var(--surface-hover) 0); }
|
||||
.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; font-variant-numeric: tabular-nums; }
|
||||
.ring__cap { font-size: 10.5px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
/* ============================ Drawer ============================ */
|
||||
.scrim { position: fixed; inset: 0; background: rgba(15,23,42,.5); backdrop-filter: blur(2px); z-index: 40; opacity: 0; pointer-events: none; transition: opacity .2s; }
|
||||
.scrim.open { opacity: 1; pointer-events: auto; }
|
||||
.drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0; width: 448px; max-width: 92vw; z-index: 41;
|
||||
background: var(--surface); border-left: 1px solid var(--border-subtle); box-shadow: var(--shadow-float);
|
||||
transform: translateX(100%); transition: transform .26s cubic-bezier(.32,.72,0,1);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.drawer.open { transform: translateX(0); }
|
||||
.drawer__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.drawer__title { font-size: 16px; font-weight: 700; }
|
||||
.drawer__sub { font-size: 12.5px; color: var(--text-muted); margin-top: 3px; }
|
||||
.drawer__body { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.drawer__foot { padding: 14px 20px; border-top: 1px solid var(--border-subtle); display: flex; gap: 8px; justify-content: flex-end; }
|
||||
|
||||
.desc { display: grid; gap: 1px; background: var(--border-subtle); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.desc__row { display: grid; grid-template-columns: 120px 1fr; gap: 1px; }
|
||||
.desc__row dt { background: var(--surface-muted); padding: 10px 12px; font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
|
||||
.desc__row dd { background: var(--surface); padding: 10px 12px; margin: 0; font-size: 13px; color: var(--text-primary); }
|
||||
|
||||
/* ============================ Forms ============================ */
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px 20px; }
|
||||
.form-item { display: flex; flex-direction: column; gap: 7px; }
|
||||
.form-item.full { grid-column: 1 / -1; }
|
||||
.form-item label { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
|
||||
.form-item label .req { color: var(--danger); margin-left: 3px; }
|
||||
.form-item .hint { font-size: 12px; color: var(--text-muted); font-weight: 500; }
|
||||
.input, .select-box, .textarea {
|
||||
height: 38px; padding: 0 12px; border: 1px solid var(--border-base); border-radius: var(--radius-sm);
|
||||
background: var(--surface); color: var(--text-primary); font-size: 13.5px; font-family: inherit; outline: none;
|
||||
transition: border-color .15s, box-shadow .15s; width: 100%;
|
||||
}
|
||||
.input:focus, .select-box:focus, .textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.textarea { height: auto; padding: 10px 12px; resize: vertical; min-height: 84px; line-height: 1.6; }
|
||||
select.select-box { appearance: none; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236a7689' stroke-width='2'><path d='M6 9l6 6 6-6'/></svg>"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; }
|
||||
|
||||
.switch { width: 40px; height: 22px; border-radius: 99px; background: var(--border-base); position: relative; transition: background .18s; flex-shrink: 0; }
|
||||
.switch.on { background: var(--accent); }
|
||||
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 99px; background: #fff; box-shadow: var(--shadow-sm); transition: left .18s; }
|
||||
.switch.on::after { left: 20px; }
|
||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--surface-muted); }
|
||||
.toggle-row__t { font-size: 13.5px; font-weight: 600; }
|
||||
.toggle-row__h { font-size: 12px; color: var(--text-muted); margin-top: 2px; font-weight: 500; }
|
||||
|
||||
.anchor-layout { display: grid; grid-template-columns: 200px 1fr; gap: 24px; align-items: start; }
|
||||
.anchor { position: sticky; top: calc(var(--topbar-h) + 24px); display: flex; flex-direction: column; gap: 2px; }
|
||||
.anchor a { padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13.5px; font-weight: 600; color: var(--text-muted); border-left: 2px solid transparent; transition: all .15s; }
|
||||
.anchor a:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.anchor a.active { background: var(--accent-soft); color: var(--accent); border-left-color: var(--accent); }
|
||||
|
||||
.savebar {
|
||||
position: sticky; bottom: 16px; margin-top: 4px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
padding: 13px 18px; border-radius: var(--radius-md); background: var(--surface);
|
||||
border: 1px solid var(--border-base); box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.savebar__t { font-size: 13px; color: var(--text-muted); }
|
||||
.savebar__t b { color: var(--warning); }
|
||||
|
||||
/* ============================ Misc ============================ */
|
||||
.alert { display: flex; gap: 11px; padding: 13px 16px; border-radius: var(--radius-sm); font-size: 13px; align-items: flex-start; }
|
||||
.alert .ic { width: 18px; height: 18px; flex-shrink: 0; margin-top: 1px; }
|
||||
.alert--info { background: var(--info-soft); color: var(--info); }
|
||||
.alert--warn { background: var(--warning-soft); color: var(--warning); }
|
||||
.alert--error { background: var(--danger-soft); color: var(--danger); }
|
||||
.alert--success { background: var(--success-soft); color: var(--success); }
|
||||
.alert b { font-weight: 700; }
|
||||
.alert__body { color: var(--text-secondary); }
|
||||
.alert__body b { color: var(--text-primary); }
|
||||
|
||||
.empty { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 48px 20px; text-align: center; color: var(--text-muted); }
|
||||
.empty__ic { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; background: var(--surface-muted); color: var(--text-soft); }
|
||||
.empty__t { font-size: 14px; font-weight: 700; color: var(--text-secondary); }
|
||||
.empty__d { font-size: 12.5px; }
|
||||
|
||||
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 24px; border-top: 1px solid var(--border-subtle); font-size: 13px; color: var(--text-muted); }
|
||||
.pagination__pages { display: flex; align-items: center; gap: 4px; }
|
||||
.pg { min-width: 32px; height: 32px; padding: 0 8px; display: grid; place-items: center; border-radius: var(--radius-xs); border: 1px solid var(--border-subtle); background: var(--surface); font-size: 13px; font-weight: 600; color: var(--text-secondary); transition: all .15s; }
|
||||
.pg.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.pg:hover:not(.active) { border-color: var(--text-soft); }
|
||||
|
||||
.divider { height: 1px; background: var(--border-subtle); border: 0; margin: 0; }
|
||||
.section-label { font-size: 13px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px; margin: 4px 0; }
|
||||
.section-label::before { content: ""; width: 3px; height: 14px; border-radius: 2px; background: var(--accent); }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.grid-12-7-5 { display: grid; grid-template-columns: 7fr 5fr; gap: 20px; }
|
||||
@media (max-width: 1100px) { .grid-2, .grid-12-7-5 { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ≤980px — hide search, tighten page */
|
||||
@media (max-width: 980px) { .searchbar { display: none; } .page { padding: 18px 16px; } }
|
||||
|
||||
/* ≤768px — mobile */
|
||||
@media (max-width: 768px) {
|
||||
:root { --sidebar-w: 0px; --sidebar-w-collapsed: 0px; --topbar-h: 56px; }
|
||||
.sidebar { top: 0; height: 100dvh; transform: translateX(-100%); transition: transform .24s cubic-bezier(.4,0,.2,1); }
|
||||
.sidebar.mobile-open { transform: translateX(0); width: 280px; z-index: 50; top: 0; }
|
||||
.sidebar:not(.mobile-open) .brand { display: none; }
|
||||
.sidebar:not(.mobile-open) .sidebar__foot { display: none; }
|
||||
.sidebar:not(.mobile-open) .nav { padding-top: 0; }
|
||||
.sidebar.mobile-open .brand { height: var(--topbar-h); }
|
||||
.sidebar.mobile-open ~ .scrim { opacity: 1; pointer-events: auto; }
|
||||
.main-col { margin-left: 0 !important; }
|
||||
|
||||
/* page spacing */
|
||||
.page { padding: 12px 12px; gap: 12px; max-width: 100%; }
|
||||
.page-head h1 { font-size: 20px; }
|
||||
.page-head { flex-direction: column; gap: 10px; }
|
||||
.page-head p { font-size: 12.5px; max-width: 100%; word-break: break-word; }
|
||||
.page-head__actions { width: 100%; flex-wrap: wrap; }
|
||||
.page-head__actions .btn { flex: 1; justify-content: center; min-width: 0; }
|
||||
|
||||
/* KPI cards */
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.stat { padding: 12px; gap: 8px; min-width: 0; }
|
||||
.stat__value { font-size: 22px; word-break: break-word; }
|
||||
.stat__value small { font-size: 13px; }
|
||||
.stat__label { font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.stat__foot { font-size: 11px; flex-wrap: wrap; }
|
||||
.stat__ic { width: 28px; height: 28px; border-radius: 6px; }
|
||||
|
||||
/* topbar */
|
||||
.topbar { padding: 0 10px; gap: 6px; min-width: 0; flex-wrap: nowrap; }
|
||||
.topbar > * { min-width: 0; }
|
||||
.topbar__right { gap: 2px; flex-shrink: 0; }
|
||||
.breadcrumb { font-size: 11.5px; min-width: 0; max-width: 130px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; flex-shrink: 1; }
|
||||
.icon-btn { width: 30px; height: 30px; flex-shrink: 0; }
|
||||
.user-chip__copy { display: none; }
|
||||
.user-chip { padding: 2px 6px 2px 2px; }
|
||||
.user-chip .avatar { width: 24px; height: 24px; font-size: 11px; }
|
||||
.user-chip__name { font-size: 11px; }
|
||||
.searchbar { display: none; }
|
||||
|
||||
/* toolbar */
|
||||
.toolbar { padding: 10px 12px; gap: 8px; flex-wrap: wrap; }
|
||||
.toolbar .segmented { width: 100%; }
|
||||
.toolbar .segmented button { flex: 1; text-align: center; font-size: 11.5px; padding: 5px 6px; }
|
||||
.toolbar .field { height: 32px; font-size: 12px; }
|
||||
.toolbar .field--search { min-width: 140px; }
|
||||
.sel-count { font-size: 11px; }
|
||||
|
||||
/* table — force scroll + prevent overflow */
|
||||
.table-wrap { -webkit-overflow-scrolling: touch; }
|
||||
.tbl thead th { padding: 8px 8px; font-size: 10.5px; white-space: nowrap; }
|
||||
.tbl tbody td { padding: 9px 8px; font-size: 12px; line-height: 1.45; }
|
||||
.tbl thead th[style*="width"], .tbl tbody td[style*="width"] { min-width: auto !important; }
|
||||
.cell-title { font-size: 12.5px; word-break: break-word; }
|
||||
.cell-sub { font-size: 11px; }
|
||||
.cell-mono { font-size: 11px; word-break: break-all; }
|
||||
.meta-row { flex-wrap: wrap; gap: 4px; font-size: 11px; }
|
||||
.identity .avatar { width: 32px; height: 32px; border-radius: 8px; font-size: 13px; }
|
||||
|
||||
/* table actions — compact */
|
||||
.row-actions { opacity: 1 !important; flex-wrap: wrap; gap: 2px; }
|
||||
.row-actions .btn--sm { height: 26px; padding: 0 7px; font-size: 11px; }
|
||||
.row-actions .btn--link { padding: 0 4px; font-size: 11px; }
|
||||
|
||||
/* card */
|
||||
.card__head { padding: 12px 14px; gap: 8px; flex-wrap: wrap; }
|
||||
.card__title { font-size: 14px; }
|
||||
.card__body { padding: 14px; }
|
||||
.card__body .session__head + .session__body .tbl tbody td { font-size: 11px; padding: 7px 6px; }
|
||||
|
||||
/* stat card in card context */
|
||||
.card .stat-grid { grid-template-columns: repeat(2, 1fr); gap: 6px; }
|
||||
.card .stat { padding: 10px; }
|
||||
.card .stat .stat__value { font-size: 18px; }
|
||||
|
||||
/* anchor (settings) */
|
||||
.anchor-layout { grid-template-columns: 1fr; }
|
||||
.anchor { flex-direction: row; overflow-x: auto; gap: 2px; position: static; padding-bottom: 4px; }
|
||||
.anchor a { border-left: none; border-bottom: 2px solid transparent; padding: 7px 9px; white-space: nowrap; font-size: 11.5px; }
|
||||
.anchor a.active { border-left: none; border-bottom-color: var(--accent); }
|
||||
|
||||
/* forms */
|
||||
.form-grid { grid-template-columns: 1fr; gap: 12px 14px; }
|
||||
.form-item { gap: 5px; }
|
||||
.toggle-row { padding: 10px 12px; gap: 10px; }
|
||||
.toggle-row__t { font-size: 12.5px; }
|
||||
.toggle-row__h { font-size: 11px; }
|
||||
.desc__row { grid-template-columns: 90px 1fr; }
|
||||
.desc__row dt, .desc__row dd { padding: 7px 8px; font-size: 11.5px; }
|
||||
|
||||
/* drawer */
|
||||
.drawer { width: 100vw; max-width: 100vw; }
|
||||
.drawer__head { padding: 14px; }
|
||||
.drawer__body { padding: 14px; gap: 12px; }
|
||||
.drawer__foot { padding: 10px 14px; }
|
||||
|
||||
/* pagination */
|
||||
.pagination { flex-direction: column; align-items: stretch; gap: 8px; padding: 10px 14px; }
|
||||
|
||||
/* session collapse */
|
||||
.session__head { flex-wrap: wrap; padding: 10px 12px; gap: 8px; }
|
||||
.session__metrics { gap: 12px; margin-left: 0; width: 100%; justify-content: space-between; }
|
||||
.session__metrics .m b { font-size: 13px; }
|
||||
.session__metrics .m span { font-size: 10px; }
|
||||
|
||||
/* misc */
|
||||
.ring { width: 68px; height: 68px; }
|
||||
.ring__inner { width: 50px; height: 50px; }
|
||||
.ring__num { font-size: 16px; }
|
||||
.chip { font-size: 11px; padding: 3px 8px; }
|
||||
.badge { font-size: 11.5px; }
|
||||
.tag { font-size: 11px; height: 20px; padding: 0 6px; }
|
||||
.savebar { flex-direction: column; align-items: stretch; gap: 10px; padding: 10px 14px; }
|
||||
.savebar__t { font-size: 12px; }
|
||||
.alert { font-size: 12px; padding: 10px 12px; }
|
||||
.empty { padding: 32px 16px; }
|
||||
|
||||
/* grid/pair always stack */
|
||||
.grid-2, .grid-12-7-5 { grid-template-columns: 1fr; gap: 12px; }
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
}
|
||||
|
||||
/* very narrow (≤400px) — KPI goes 1-col, even more compact */
|
||||
@media (max-width: 400px) {
|
||||
.stat-grid { grid-template-columns: 1fr; }
|
||||
.page-head__actions { flex-direction: column; }
|
||||
.tbl thead th { font-size: 10px; padding: 6px 5px; }
|
||||
.tbl tbody td { font-size: 11px; padding: 7px 5px; }
|
||||
}
|
||||
|
||||
/* mobile hamburger */
|
||||
.mobile-hamburger { display: none; }
|
||||
@media (max-width: 768px) { .mobile-hamburger { display: grid; } }
|
||||
|
||||
/* mobile sidebar scrim (below drawer scrim 40) */
|
||||
#mobile-scrim { z-index: 39; }
|
||||
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 99px; background: var(--surface-muted); border: 1px solid var(--border-subtle); font-size: 12px; color: var(--text-secondary); font-weight: 600; }
|
||||
|
||||
.timeline-track { position: relative; height: 56px; border-radius: var(--radius-sm); background: var(--surface-muted); overflow: hidden; border: 1px solid var(--border-subtle); }
|
||||
.seg-block { position: absolute; top: 8px; bottom: 8px; border-radius: 5px; background: var(--accent); opacity: .9; }
|
||||
.seg-block.rec { background: #dc2626; }
|
||||
.heat-row { display: flex; gap: 2px; align-items: flex-end; height: 60px; }
|
||||
.heat-bar { flex: 1; border-radius: 3px 3px 0 0; background: var(--accent); opacity: .85; min-height: 3px; }
|
||||
|
||||
.player { aspect-ratio: 16/9; border-radius: var(--radius-md); background: #0f172a; display: grid; place-items: center; color: #cbd5e1; position: relative; overflow: hidden; border: 1px solid var(--border-base); }
|
||||
.player__play { width: 56px; height: 56px; border-radius: 99px; background: rgba(255,255,255,.16); display: grid; place-items: center; backdrop-filter: blur(4px); cursor: pointer; transition: transform .15s, background .15s; }
|
||||
.player__play:hover { transform: scale(1.08); background: rgba(255,255,255,.24); }
|
||||
.player__bar { position: absolute; left: 16px; right: 16px; bottom: 14px; display: flex; align-items: center; gap: 10px; color: #fff; font-size: 12px; }
|
||||
.player__bar .bar { flex: 1; background: rgba(255,255,255,.2); }
|
||||
.player__bar .bar > span { background: #fff; width: 38%; }
|
||||
.danmaku { position: absolute; font-size: 13px; color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.6); white-space: nowrap; font-weight: 600; }
|
||||
|
||||
@media (max-width: 980px) { .searchbar { display: none; } .page { padding: 18px 16px; } }
|
||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; } }
|
||||
|
||||
/* ============================ Skeleton ============================ */
|
||||
@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
|
||||
.skel {
|
||||
background: linear-gradient(90deg, var(--border-subtle) 25%, var(--surface-muted) 37%, var(--border-subtle) 63%);
|
||||
background-size: 800px 100%;
|
||||
animation: shimmer 1.6s ease-in-out infinite;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.skel--text { height: 14px; width: 100%; }
|
||||
.skel--text.short { width: 60%; }
|
||||
.skel--title { height: 18px; width: 50%; }
|
||||
.skel--stat-value { height: 34px; width: 50%; margin-bottom: 8px; }
|
||||
.skel--stat-label { height: 12px; width: 80%; }
|
||||
.skel--btn { height: 38px; width: 100px; border-radius: var(--radius-sm); }
|
||||
.skel--avatar { width: 40px; height: 40px; border-radius: 99px; flex-shrink: 0; }
|
||||
.skel--pill { height: 22px; width: 56px; border-radius: 99px; }
|
||||
.skel--row { display: flex; align-items: center; gap: 16px; padding: 14px 20px; }
|
||||
.skel--row + .skel--row { border-top: 1px solid var(--border-subtle); }
|
||||
.skel-card { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); overflow: hidden; }
|
||||
.skel-card__body { padding: 24px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.skel-stat { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; box-shadow: var(--shadow-sm); display: flex; flex-direction: column; gap: 12px; }
|
||||
Vendored
+169
@@ -0,0 +1,169 @@
|
||||
/* =============================================================
|
||||
App Shell injector — sidebar + topbar, theme + collapse.
|
||||
Each page: <body data-page="dashboard" data-crumb="监控录制 / 直播间">
|
||||
Content lives in <div id="view"> ... </div>
|
||||
============================================================= */
|
||||
(function () {
|
||||
// ---- inline icon set (lucide-style, 24x24, stroke) ----
|
||||
const I = {
|
||||
gauge: '<path d="M12 14l4-4M4 14a8 8 0 1116 0"/><circle cx="12" cy="14" r="1"/>',
|
||||
home: '<path d="M3 10l9-7 9 7v9a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M9 21V12h6v9"/>',
|
||||
video: '<rect x="2" y="5" width="14" height="14" rx="2"/><path d="M16 9l6-3v12l-6-3"/>',
|
||||
refresh: '<path d="M21 12a9 9 0 11-3-6.7L21 8"/><path d="M21 3v5h-5"/>',
|
||||
convert: '<path d="M4 7h13l-3-3M20 17H7l3 3"/>',
|
||||
upload: '<path d="M12 16V4M6 10l6-6 6 6"/><path d="M4 20h16"/>',
|
||||
folder: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>',
|
||||
report: '<path d="M14 3v4a1 1 0 001 1h4"/><path d="M5 3h9l5 5v11a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z"/><path d="M8 13h8M8 17h5"/>',
|
||||
logs: '<path d="M4 5h16M4 12h16M4 19h10"/>',
|
||||
settings: '<circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 00-.1-1l2-1.6-2-3.4-2.4 1a7 7 0 00-1.7-1L14.5 2h-5l-.3 2.4a7 7 0 00-1.7 1l-2.4-1-2 3.4L3 11a7 7 0 000 2l-2 1.6 2 3.4 2.4-1a7 7 0 001.7 1L9.5 22h5l.3-2.4a7 7 0 001.7-1l2.4 1 2-3.4-2-1.6a7 7 0 00.1-1z"/>',
|
||||
search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/>',
|
||||
bell: '<path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 01-3.4 0"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M19 5l-1.5 1.5M6.5 17.5L5 19"/>',
|
||||
moon: '<path d="M21 12.8A9 9 0 1111.2 3 7 7 0 0021 12.8z"/>',
|
||||
menu: '<path d="M3 6h18M3 12h18M3 18h18"/>',
|
||||
chevron: '<path d="M9 18l6-6-6-6"/>',
|
||||
recovery: '<path d="M12 3a9 9 0 100 18 9 9 0 000-18z"/><path d="M12 7v5l3 2"/>',
|
||||
};
|
||||
function svg(name, cls) {
|
||||
return '<svg class="' + (cls || 'ic') + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">' + (I[name] || '') + '</svg>';
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ title: '总览', items: [
|
||||
{ id: 'dashboard', label: '仪表盘', icon: 'gauge', href: 'index.html' },
|
||||
]},
|
||||
{ title: '监控录制', items: [
|
||||
{ id: 'live-rooms', label: '直播间', icon: 'home', href: 'live-rooms.html' },
|
||||
{ id: 'record-tasks', label: '录制任务', icon: 'video', href: 'record-tasks.html', badge: '3' },
|
||||
{ id: 'recovery', label: '恢复中心', icon: 'recovery', href: 'recovery.html' },
|
||||
]},
|
||||
{ title: '媒资归档', items: [
|
||||
{ id: 'transcode-tasks', label: '转码任务', icon: 'convert', href: 'transcode-tasks.html' },
|
||||
{ id: 'upload-tasks', label: '上传任务', icon: 'upload', href: 'upload-tasks.html' },
|
||||
{ id: 'media-browser', label: '文件库', icon: 'folder', href: 'media-browser.html' },
|
||||
]},
|
||||
{ title: '分析', items: [
|
||||
{ id: 'daily-reviews', label: '回顾日报', icon: 'report', href: 'daily-reviews.html' },
|
||||
{ id: 'logs', label: '系统日志', icon: 'logs', href: 'logs.html' },
|
||||
]},
|
||||
{ title: '系统', items: [
|
||||
{ id: 'settings', label: '系统设置', icon: 'settings', href: 'settings.html' },
|
||||
]},
|
||||
];
|
||||
|
||||
const page = document.body.dataset.page || 'dashboard';
|
||||
const crumb = document.body.dataset.crumb || '';
|
||||
|
||||
// ---- sidebar ----
|
||||
let navHtml = '';
|
||||
NAV.forEach(g => {
|
||||
navHtml += '<div class="nav__group"><div class="nav__title">' + g.title + '</div>';
|
||||
g.items.forEach(it => {
|
||||
const active = it.id === page ? ' active' : '';
|
||||
navHtml += '<a class="nav__item' + active + '" href="' + it.href + '">' +
|
||||
svg(it.icon) +
|
||||
'<span class="nav__label">' + it.label + '</span>' +
|
||||
(it.badge ? '<span class="nav__badge">' + it.badge + '</span>' : '') +
|
||||
'</a>';
|
||||
});
|
||||
navHtml += '</div>';
|
||||
});
|
||||
|
||||
const sidebar = document.createElement('aside');
|
||||
sidebar.className = 'sidebar';
|
||||
const LOGO =
|
||||
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none">' +
|
||||
'<rect x="1.5" y="6" width="14" height="12" rx="2.4" stroke="#fff" stroke-width="1.8"/>' +
|
||||
'<path d="M15.5 10.2 21 7.2c.5-.3 1.1.06 1.1.64v8.32c0 .58-.6.94-1.1.64l-5.5-3" stroke="#fff" stroke-width="1.8" stroke-linejoin="round"/>' +
|
||||
'<circle cx="6" cy="12" r="2.6" stroke="#fff" stroke-width="1.6"/>' +
|
||||
'<circle cx="11.4" cy="9.2" r="1.5" fill="#ef4444"/>' +
|
||||
'</svg>';
|
||||
sidebar.innerHTML =
|
||||
'<div class="brand">' +
|
||||
'<div class="brand__mark">' + LOGO + '</div>' +
|
||||
'<div class="brand__copy"><div class="brand__name">Live Recorder</div><div class="brand__sub">直播监控录制平台</div></div>' +
|
||||
'</div>' +
|
||||
'<nav class="nav">' + navHtml + '</nav>' +
|
||||
'<div class="sidebar__foot"><div class="health"><span class="health__dot"></span><span class="health__txt">系统状态正常</span></div></div>';
|
||||
|
||||
// ---- topbar ----
|
||||
const crumbHtml = crumb.split('/').map((s, i, a) =>
|
||||
(i === a.length - 1 ? '<b>' + s.trim() + '</b>' : '<span>' + s.trim() + '</span>')
|
||||
).join('<span class="sep">/</span>');
|
||||
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = 'topbar';
|
||||
topbar.innerHTML =
|
||||
'<button class="icon-btn" id="collapseBtn" title="导航菜单">' + svg('menu') + '</button>' +
|
||||
'<div class="breadcrumb">' + crumbHtml + '</div>' +
|
||||
'<label class="searchbar">' + svg('search') +
|
||||
'<input placeholder="搜索直播间、主播、Room ID、录制文件…"><kbd>⌘K</kbd></label>' +
|
||||
'<div class="topbar__right">' +
|
||||
'<button class="icon-btn" id="themeBtn" title="切换主题">' + svg('moon') + '</button>' +
|
||||
'<button class="icon-btn" title="通知">' + svg('bell') + '<span class="dot"></span></button>' +
|
||||
'<button class="user-chip"><span class="avatar">管</span>' +
|
||||
'<span class="user-chip__copy"><div class="user-chip__name">管理员</div><div class="user-chip__meta">在线</div></span>' +
|
||||
svg('chevron') + '</button>' +
|
||||
'</div>';
|
||||
|
||||
// ---- assemble ----
|
||||
const view = document.getElementById('view');
|
||||
const shell = document.createElement('div');
|
||||
shell.className = 'app-shell';
|
||||
const mainCol = document.createElement('div');
|
||||
mainCol.className = 'main-col';
|
||||
mainCol.appendChild(topbar);
|
||||
if (view) mainCol.appendChild(view);
|
||||
shell.appendChild(sidebar);
|
||||
shell.appendChild(mainCol);
|
||||
document.body.insertBefore(shell, document.body.firstChild);
|
||||
|
||||
// mobile scrim (closes sidebar on tap)
|
||||
const scrim = document.createElement('div');
|
||||
scrim.className = 'scrim';
|
||||
scrim.id = 'mobile-scrim';
|
||||
document.body.appendChild(scrim);
|
||||
|
||||
function closeMobileSidebar() { sidebar.classList.remove('mobile-open'); scrim.classList.remove('open'); }
|
||||
|
||||
// ---- interactions ----
|
||||
const collapseBtn = document.getElementById('collapseBtn');
|
||||
collapseBtn.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
sidebar.classList.toggle('mobile-open');
|
||||
scrim.classList.toggle('open');
|
||||
} else {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
}
|
||||
});
|
||||
scrim.addEventListener('click', closeMobileSidebar);
|
||||
|
||||
// close mobile sidebar on resize back to desktop
|
||||
window.addEventListener('resize', () => { if (window.innerWidth > 768) closeMobileSidebar(); });
|
||||
const themeBtn = document.getElementById('themeBtn');
|
||||
function applyTheme(t) {
|
||||
document.documentElement.dataset.theme = t;
|
||||
themeBtn.innerHTML = svg(t === 'dark' ? 'sun' : 'moon');
|
||||
try { localStorage.setItem('lr-theme', t); } catch (e) {}
|
||||
}
|
||||
themeBtn.addEventListener('click', () => applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'));
|
||||
let saved = 'light';
|
||||
try { saved = localStorage.getItem('lr-theme') || 'light'; } catch (e) {}
|
||||
applyTheme(saved);
|
||||
|
||||
// generic drawer open/close hooks: [data-drawer-open="id"] and .drawer .scrim
|
||||
document.addEventListener('click', e => {
|
||||
const opener = e.target.closest('[data-drawer-open]');
|
||||
if (opener) {
|
||||
const d = document.getElementById(opener.dataset.drawerOpen);
|
||||
const s = document.getElementById(opener.dataset.drawerOpen + '-scrim');
|
||||
if (d) d.classList.add('open');
|
||||
if (s) s.classList.add('open');
|
||||
}
|
||||
const closer = e.target.closest('[data-drawer-close]');
|
||||
if (closer) {
|
||||
document.querySelectorAll('.drawer.open').forEach(d => d.classList.remove('open'));
|
||||
document.querySelectorAll('.scrim.open').forEach(s => s.classList.remove('open'));
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>回顾日报 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="daily-reviews" data-crumb="分析 / 回顾日报">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div><div class="page-head__kicker">回顾分析</div><h1>回顾日报</h1><p>按天聚合录制活跃度、Top 房间、高光会话与弹幕峰值时刻,支持推送到通知渠道。</p></div>
|
||||
<div class="page-head__actions"><span class="field"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" style="color:var(--text-muted)"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M3 10h18M8 2v4M16 2v4"/></svg><input value="2026-06-25" style="width:100px"></span><button class="btn">刷新日报</button><button class="btn btn--primary">推送日报</button></div>
|
||||
</div>
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(130px,1fr))">
|
||||
<div class="stat"><div class="stat__label">活跃房间</div><div class="stat__value">12</div></div>
|
||||
<div class="stat"><div class="stat__label">会话</div><div class="stat__value">28</div></div>
|
||||
<div class="stat"><div class="stat__label">分片</div><div class="stat__value">142</div></div>
|
||||
<div class="stat"><div class="stat__label">时长</div><div class="stat__value">38h</div></div>
|
||||
<div class="stat"><div class="stat__label">弹幕</div><div class="stat__value">86k</div></div>
|
||||
<div class="stat"><div class="stat__label">警告 / 错误</div><div class="stat__value">5 <small>/ 1</small></div></div>
|
||||
</div>
|
||||
<div class="grid-12-7-5">
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">房间维度</div></div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>房间</th><th class="num">会话</th><th class="num">分片</th><th class="num">时长</th><th class="num">弹幕</th><th style="text-align:right"></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><div class="cell-title">熊宇一</div><div class="meta-row"><span class="tag is-red">抖音</span></div></td><td class="num">4</td><td class="num">22</td><td class="num">3h12m</td><td class="num">18,420</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">会话</a></td></tr>
|
||||
<tr><td><div class="cell-title">游戏老王</div><div class="meta-row"><span class="tag is-blue">B站</span></div></td><td class="num">2</td><td class="num">16</td><td class="num">2h40m</td><td class="num">21,003</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">会话</a></td></tr>
|
||||
<tr><td><div class="cell-title">音乐电台</div><div class="meta-row"><span class="tag is-amber">虎牙</span></div></td><td class="num">3</td><td class="num">15</td><td class="num">1h55m</td><td class="num">12,880</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">会话</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">高光会话</div></div>
|
||||
<div class="card__body" style="display:grid;gap:12px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">⏱ 最长会话</div><div class="toggle-row__h">熊宇一 · 才艺专场 · 3h12m</div></div><a class="btn btn--sm" href="session-detail.html">查看</a></div>
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">🔥 最热会话</div><div class="toggle-row__h">游戏老王 · 巅峰赛 · 21,003 弹幕</div></div><a class="btn btn--sm" href="session-detail.html">查看</a></div>
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">⚠ 异常会话</div><div class="toggle-row__h">深夜美食档 · 录制失败 ×1</div></div><a class="btn btn--sm" href="session-detail.html">查看</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card__head"><div><div class="card__title">高光时刻</div><div class="card__sub">弹幕峰值时间段(每 5 分钟桶)</div></div></div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>房间</th><th>时间段</th><th class="num">弹幕峰值</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><div class="cell-title">游戏老王</div></td><td class="cell-mono">21:35 – 21:40</td><td class="num">1,204</td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">查看分片</a></td></tr>
|
||||
<tr><td><div class="cell-title">熊宇一</div></td><td class="cell-mono">10:50 – 10:55</td><td class="num">980</td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">查看分片</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>仪表盘 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="dashboard" data-crumb="总览 / 仪表盘">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">系统概览</div>
|
||||
<h1>仪表盘</h1>
|
||||
<p>系统运行状态一览:直播间、录制会话、弹幕吞吐与存储队列概况。</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<span class="field"><select class="select-box" style="border:0;height:auto"><option>今日</option><option>近 7 日</option><option>近 30 日</option></select></span>
|
||||
<button class="btn"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M21 12a9 9 0 11-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI -->
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<div class="stat__top"><span class="stat__label">正在录制</span><span class="stat__ic is-red"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><rect x="2" y="5" width="14" height="14" rx="2"/><path d="M16 9l6-3v12l-6-3"/></svg></span></div>
|
||||
<div class="stat__value">3</div>
|
||||
<div class="stat__foot"><span class="badge live is-danger"><span class="pip"></span>录制通道活跃</span></div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__top"><span class="stat__label">直播间</span><span class="stat__ic"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M3 10l9-7 9 7v9a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg></span></div>
|
||||
<div class="stat__value">12 <small>/ 28 离线</small></div>
|
||||
<div class="stat__foot">共接入 40 个直播间</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__top"><span class="stat__label">今日录制时长</span><span class="stat__ic is-purple"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></span></div>
|
||||
<div class="stat__value">6h 20m</div>
|
||||
<div class="stat__foot"><span class="trend up">▲ 12%</span> 较昨日</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__top"><span class="stat__label">今日数据量</span><span class="stat__ic is-green"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M3 3v18h18"/><path d="M7 14l3-3 3 3 5-6"/></svg></span></div>
|
||||
<div class="stat__value">48.2 <small>GB</small></div>
|
||||
<div class="stat__foot">平均码率 4.2 Mbps</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__top"><span class="stat__label">今日弹幕</span><span class="stat__ic is-amber"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg></span></div>
|
||||
<div class="stat__value">12,304</div>
|
||||
<div class="stat__foot"><span class="trend up">▲ 8%</span> 较昨日</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__top"><span class="stat__label">24h 异常</span><span class="stat__ic is-green"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="9"/></svg></span></div>
|
||||
<div class="stat__value">0</div>
|
||||
<div class="stat__foot"><span class="badge is-success"><span class="pip"></span>系统运行正常</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- storage + queue -->
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card__head"><div><div class="card__title">存储水位</div><div class="card__sub">录制输出路径剩余空间与保护阈值</div></div><span class="tag is-green">正常</span></div>
|
||||
<div class="card__body" style="display:flex;align-items:center;gap:28px">
|
||||
<div class="ring" style="--p:32"><div class="ring__inner"><div><div class="ring__num">32%</div><div class="ring__cap">已使用</div></div></div></div>
|
||||
<div style="flex:1;display:grid;gap:14px">
|
||||
<div><div class="cell-sub">可用空间</div><div style="font-size:18px;font-weight:700;font-variant-numeric:tabular-nums">412 GB <span style="font-size:13px;font-weight:500;color:var(--text-muted)">/ 600 GB</span></div></div>
|
||||
<div class="bar"><span class="is-green" style="width:32%"></span></div>
|
||||
<div style="display:flex;gap:24px">
|
||||
<div><div class="cell-sub">挂载路径</div><div class="cell-mono">/records</div></div>
|
||||
<div><div class="cell-sub">暂停阈值</div><div style="font-weight:650">1 GB</div></div>
|
||||
<div><div class="cell-sub">恢复阈值</div><div style="font-weight:650">4 GB</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card__head"><div><div class="card__title">处理队列</div><div class="card__sub">待转码与待上传的文件积压</div></div><a class="btn--link" href="transcode-tasks.html">去处理 →</a></div>
|
||||
<div class="card__body" style="display:grid;gap:14px">
|
||||
<div class="toggle-row" style="background:var(--warning-soft);border-color:transparent">
|
||||
<div><div class="toggle-row__t">待转码</div><div class="toggle-row__h">FFmpeg 后处理队列</div></div>
|
||||
<div style="font-size:26px;font-weight:760;color:var(--warning);font-variant-numeric:tabular-nums">2</div>
|
||||
</div>
|
||||
<div class="toggle-row" style="background:var(--warning-soft);border-color:transparent">
|
||||
<div><div class="toggle-row__t">待上传</div><div class="toggle-row__h">WebDAV / S3 / OpenList</div></div>
|
||||
<div style="font-size:26px;font-weight:760;color:var(--warning);font-variant-numeric:tabular-nums">5</div>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<div><div class="toggle-row__t">积压数据量</div><div class="toggle-row__h">等待归档的本地文件总量</div></div>
|
||||
<div style="font-size:26px;font-weight:760;font-variant-numeric:tabular-nums">18.4 <span style="font-size:14px;color:var(--text-muted)">GB</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- recent sessions + top rooms -->
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">最近会话</div><a class="btn--link" href="record-tasks.html">全部 →</a></div>
|
||||
<div class="card__body--flush">
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>直播间</th><th>状态</th><th class="num">分片</th><th>开始时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="cell-title">熊宇一 · 才艺专场</td><td><span class="badge live is-danger"><span class="pip"></span>录制中</span></td><td class="num">3</td><td class="cell-mono">10:24:05</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">游戏老王 · 巅峰赛</td><td><span class="badge is-info"><span class="pip"></span>处理中</span></td><td class="num">8</td><td class="cell-mono">09:12:40</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">音乐电台 LIVE</td><td><span class="badge"><span class="pip"></span>已完成</span></td><td class="num">5</td><td class="cell-mono">02:00:11</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">深夜美食档</td><td><span class="badge is-danger"><span class="pip"></span>失败</span></td><td class="num">1</td><td class="cell-mono">01:31:55</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">查看</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">今日热门直播间</div><span class="chip">Top 5 · 按时长</span></div>
|
||||
<div class="card__body--flush">
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>直播间</th><th>平台</th><th class="num">会话</th><th class="num">时长</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="cell-title">熊宇一</td><td><span class="tag is-red">抖音</span></td><td class="num">4</td><td class="num">3h 12m</td><td class="row-actions"><a class="btn btn--link" href="live-rooms.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">游戏老王</td><td><span class="tag is-blue">B站</span></td><td class="num">2</td><td class="num">2h 40m</td><td class="row-actions"><a class="btn btn--link" href="live-rooms.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">音乐电台</td><td><span class="tag is-amber">虎牙</span></td><td class="num">3</td><td class="num">1h 55m</td><td class="row-actions"><a class="btn btn--link" href="live-rooms.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">户外阿强</td><td><span class="tag is-purple">快手</span></td><td class="num">1</td><td class="num">1h 08m</td><td class="row-actions"><a class="btn btn--link" href="live-rooms.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">英文角 Talk</td><td><span class="tag is-blue">YouTube</span></td><td class="num">1</td><td class="num">52m</td><td class="row-actions"><a class="btn btn--link" href="live-rooms.html">查看</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,196 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>直播间 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="live-rooms" data-crumb="监控录制 / 直播间">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">直播监控</div>
|
||||
<h1>直播间控制台</h1>
|
||||
<p>聚合直播状态、自动开录决策与单房间录制配置,面向开播检测、异常巡检与手动介入。</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<button class="btn">刷新状态</button>
|
||||
<button class="btn">批量导入</button>
|
||||
<button class="btn">导出列表</button>
|
||||
<button class="btn btn--primary"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>新增直播间</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI -->
|
||||
<div class="stat-grid">
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">在线直播间</span><span class="stat__ic is-green"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M3 10l9-7 9 7v9a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg></span></div><div class="stat__value">12</div><div class="stat__foot">最近巡检识别为开播中</div></div>
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">录制中任务</span><span class="stat__ic is-red"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><rect x="2" y="5" width="14" height="14" rx="2"/><path d="M16 9l6-3v12l-6-3"/></svg></span></div><div class="stat__value">3</div><div class="stat__foot">当前处于录制状态</div></div>
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">启用房间</span><span class="stat__ic"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="9"/></svg></span></div><div class="stat__value">34</div><div class="stat__foot">参与自动检测与录制</div></div>
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">重点监控</span><span class="stat__ic is-amber"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M12 2l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 15.4 6.8 18l1-5.8L3.5 8.2l5.9-.9z"/></svg></span></div><div class="stat__value">6</div><div class="stat__foot">优先巡检与关注</div></div>
|
||||
</div>
|
||||
|
||||
<!-- table card -->
|
||||
<div class="card">
|
||||
<!-- toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="segmented">
|
||||
<button class="active">全部 40</button>
|
||||
<button>在线 12</button>
|
||||
<button>录制中 3</button>
|
||||
<button>已停用 6</button>
|
||||
</div>
|
||||
<span class="field"><select class="select-box" style="border:0;height:auto;padding:0;background-position:right center"><option>全部平台</option><option>抖音</option><option>B站</option><option>虎牙</option><option>快手</option></select></span>
|
||||
<label class="field field--search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="房间 / 主播 / Room ID"></label>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<span class="sel-count">已选 <b>2</b> 项</span>
|
||||
<button class="btn btn--sm">批量启用</button>
|
||||
<button class="btn btn--sm">批量停用</button>
|
||||
<button class="btn btn--sm btn--danger">批量删除</button>
|
||||
<button class="btn btn--sm" title="列设置"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>
|
||||
</div>
|
||||
|
||||
<!-- table -->
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr>
|
||||
<th style="width:40px"><span class="checkbox"></span></th>
|
||||
<th>直播间</th>
|
||||
<th style="width:110px">直播状态</th>
|
||||
<th style="width:110px">录制状态</th>
|
||||
<th>最近事件</th>
|
||||
<th style="width:170px">房间配置</th>
|
||||
<th style="width:80px">开关</th>
|
||||
<th style="width:150px;text-align:right">操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="checkbox checked"></span></td>
|
||||
<td><div class="identity"><span class="avatar" style="background:#dc2626">熊</span><div>
|
||||
<div class="cell-title">熊宇一 · 才艺专场 <span class="tag is-amber" style="height:18px">重点</span></div>
|
||||
<div class="meta-row"><span class="tag is-red">抖音</span><span class="cell-mono">845878323112</span></div>
|
||||
</div></div></td>
|
||||
<td><span class="badge is-success"><span class="pip"></span>开播中</span></td>
|
||||
<td><span class="badge live is-danger"><span class="pip"></span>录制中</span></td>
|
||||
<td><div><span class="tag is-green" style="height:18px">自动开录 ✓</span> <span class="cell-mono">10:24:05</span></div><div class="cell-sub">检测到开播,已启动录制任务</div></td>
|
||||
<td><div class="cell-sub" style="line-height:1.8">原画 · MP4 · 分段<br>弹幕开 · 30min</div></td>
|
||||
<td><span class="switch on"></span></td>
|
||||
<td class="row-actions"><button class="btn btn--sm btn--primary">录制中</button><button class="btn btn--sm" data-drawer-open="room-drawer">⋯</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="checkbox checked"></span></td>
|
||||
<td><div class="identity"><span class="avatar" style="background:#2563eb">王</span><div>
|
||||
<div class="cell-title">游戏老王 · 巅峰赛</div>
|
||||
<div class="meta-row"><span class="tag is-blue">B站</span><span class="cell-mono">123456</span></div>
|
||||
</div></div></td>
|
||||
<td><span class="badge is-success"><span class="pip"></span>开播中</span></td>
|
||||
<td><span class="badge is-info"><span class="pip"></span>处理中</span></td>
|
||||
<td><div><span class="tag is-green" style="height:18px">自动开录 ✓</span> <span class="cell-mono">09:12:40</span></div><div class="cell-sub">分片收尾中(MP4 finalize)</div></td>
|
||||
<td><div class="cell-sub" style="line-height:1.8">超清 · TS · 分段<br>弹幕开 · 60min</div></td>
|
||||
<td><span class="switch on"></span></td>
|
||||
<td class="row-actions"><button class="btn btn--sm btn--primary">录制</button><button class="btn btn--sm" data-drawer-open="room-drawer">⋯</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="checkbox"></span></td>
|
||||
<td><div class="identity"><span class="avatar" style="background:#d97706">音</span><div>
|
||||
<div class="cell-title">音乐电台 LIVE</div>
|
||||
<div class="meta-row"><span class="tag is-amber">虎牙</span><span class="cell-mono">998877</span></div>
|
||||
</div></div></td>
|
||||
<td><span class="badge"><span class="pip"></span>未开播</span></td>
|
||||
<td><span class="badge"><span class="pip"></span>未录制</span></td>
|
||||
<td><div><span class="tag" style="height:18px">已禁用</span> <span class="cell-mono">08:00:00</span></div><div class="cell-sub">房间未开播,跳过自动开录</div></td>
|
||||
<td><div class="cell-sub" style="line-height:1.8">高清 · MP4 · 单文件<br>弹幕关 · —</div></td>
|
||||
<td><span class="switch on"></span></td>
|
||||
<td class="row-actions"><button class="btn btn--sm btn--primary">录制</button><button class="btn btn--sm" data-drawer-open="room-drawer">⋯</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="checkbox"></span></td>
|
||||
<td><div class="identity"><span class="avatar" style="background:#7c3aed">强</span><div>
|
||||
<div class="cell-title">户外阿强 <span class="tag" style="height:18px">置顶</span></div>
|
||||
<div class="meta-row"><span class="tag is-purple">快手</span><span class="cell-mono">556677</span></div>
|
||||
</div></div></td>
|
||||
<td><span class="badge"><span class="pip"></span>未开播</span></td>
|
||||
<td><span class="badge"><span class="pip"></span>未录制</span></td>
|
||||
<td><div><span class="tag is-amber" style="height:18px">存储不足</span> <span class="cell-mono">07:45:00</span></div><div class="cell-sub">磁盘低于暂停阈值,跳过开录</div></td>
|
||||
<td><div class="cell-sub" style="line-height:1.8">原画 · MP4 · 分段<br>弹幕开 · 30min</div></td>
|
||||
<td><span class="switch"></span></td>
|
||||
<td class="row-actions"><button class="btn btn--sm btn--primary" disabled>录制</button><button class="btn btn--sm" data-drawer-open="room-drawer">⋯</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="checkbox"></span></td>
|
||||
<td><div class="identity"><span class="avatar">T</span><div>
|
||||
<div class="cell-title">英文角 Talk</div>
|
||||
<div class="meta-row"><span class="tag is-blue">YouTube</span><span class="cell-mono">dQw4w9</span></div>
|
||||
</div></div></td>
|
||||
<td><span class="badge is-warning"><span class="pip"></span>未知</span></td>
|
||||
<td><span class="badge"><span class="pip"></span>未录制</span></td>
|
||||
<td><div><span class="tag is-red" style="height:18px">轮询失败</span> <span class="cell-mono">06:30:00</span></div><div class="cell-sub">远端请求超时,将自动重试</div></td>
|
||||
<td><div class="cell-sub" style="line-height:1.8">高清 · MP4 · 单文件<br>弹幕关 · —</div></td>
|
||||
<td><span class="switch on"></span></td>
|
||||
<td class="row-actions"><button class="btn btn--sm btn--primary">录制</button><button class="btn btn--sm" data-drawer-open="room-drawer">⋯</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
<!-- pagination -->
|
||||
<div class="pagination">
|
||||
<span>共 40 个直播间</span>
|
||||
<div class="pagination__pages">
|
||||
<span class="pg">‹</span><span class="pg active">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg">›</span>
|
||||
<span class="field" style="height:32px;margin-left:8px"><select class="select-box" style="border:0;height:auto;padding:0"><option>20 / 页</option><option>50 / 页</option></select></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- detail drawer -->
|
||||
<div class="scrim" id="room-drawer-scrim" data-drawer-close></div>
|
||||
<aside class="drawer" id="room-drawer">
|
||||
<div class="drawer__head">
|
||||
<div style="display:flex;gap:12px;align-items:center">
|
||||
<span class="avatar" style="width:46px;height:46px;border-radius:13px;font-size:17px;background:#dc2626">熊</span>
|
||||
<div><div class="drawer__title">熊宇一 · 才艺专场</div><div class="drawer__sub">抖音 · 845878323112</div></div>
|
||||
</div>
|
||||
<button class="icon-btn" data-drawer-close><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<div class="drawer__body">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<span class="badge is-success"><span class="pip"></span>开播中</span>
|
||||
<span class="badge live is-danger"><span class="pip"></span>录制中</span>
|
||||
<span class="tag is-red">抖音</span>
|
||||
<span class="tag is-amber">重点监控</span>
|
||||
</div>
|
||||
|
||||
<!-- mini tabs -->
|
||||
<div class="segmented" style="width:100%">
|
||||
<button class="active" style="flex:1">概览</button>
|
||||
<button style="flex:1">配置</button>
|
||||
<button style="flex:1">最近事件</button>
|
||||
</div>
|
||||
|
||||
<dl class="desc">
|
||||
<div class="desc__row"><dt>直播间名称</dt><dd>熊宇一 · 才艺专场</dd></div>
|
||||
<div class="desc__row"><dt>主播</dt><dd>熊宇一</dd></div>
|
||||
<div class="desc__row"><dt>平台 · Room</dt><dd>抖音 · 845878323112</dd></div>
|
||||
<div class="desc__row"><dt>直播状态</dt><dd>开播中</dd></div>
|
||||
<div class="desc__row"><dt>录制状态</dt><dd>录制中(会话 #3 · 已 1h 02m)</dd></div>
|
||||
<div class="desc__row"><dt>录制时长</dt><dd>1h 02m</dd></div>
|
||||
<div class="desc__row"><dt>最近事件</dt><dd>检测到开播,已启动录制任务</dd></div>
|
||||
<div class="desc__row"><dt>最近巡检</dt><dd>2026-06-25 10:24:05</dd></div>
|
||||
<div class="desc__row"><dt>房间配置</dt><dd>原画 · MP4 · 分段 30min · 弹幕开</dd></div>
|
||||
<div class="desc__row"><dt>检测间隔</dt><dd>跟随全局(30 秒)</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="alert alert--info"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="12" cy="12" r="9"/><path d="M12 8h.01M11 12h1v4h1"/></svg><div class="alert__body">自动开录复用现有后端策略;<b>单房间配置优先于全局设置</b>。</div></div>
|
||||
</div>
|
||||
<div class="drawer__foot">
|
||||
<button class="btn" data-drawer-close>关闭</button>
|
||||
<button class="btn">配置</button>
|
||||
<button class="btn btn--primary">▶ 开始录制</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body>
|
||||
<div style="min-height:100vh;display:grid;grid-template-columns:1.1fr 0.9fr">
|
||||
<!-- brand panel -->
|
||||
<div style="background:#1e3a8a;color:#fff;padding:56px;display:flex;flex-direction:column;justify-content:space-between" class="login-hero">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div class="brand__mark" style="width:42px;height:42px;background:rgba(255,255,255,.12);box-shadow:none">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="1.5" y="6" width="14" height="12" rx="2.4" stroke="#fff" stroke-width="1.8"/>
|
||||
<path d="M15.5 10.2 21 7.2c.5-.3 1.1.06 1.1.64v8.32c0 .58-.6.94-1.1.64l-5.5-3" stroke="#fff" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<circle cx="6" cy="12" r="2.6" stroke="#fff" stroke-width="1.6"/>
|
||||
<circle cx="11.4" cy="9.2" r="1.5" fill="#ef4444"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div><div style="font-weight:750;font-size:17px">Live Recorder</div><div style="font-size:12px;opacity:.8">直播监控录制平台</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 style="font-size:34px;font-weight:760;letter-spacing:-.03em;line-height:1.25">监控 · 录制 · 归档<br>一体化直播采集平台</h1>
|
||||
<p style="margin-top:18px;opacity:.85;font-size:15px;line-height:1.8;max-width:420px">支持抖音、B站、虎牙、快手、YouTube 等多平台开播检测、自动录制、弹幕采集、转码与云端归档。</p>
|
||||
<div style="display:flex;gap:10px;margin-top:24px;flex-wrap:wrap">
|
||||
<span class="chip" style="background:rgba(255,255,255,.12);border-color:rgba(255,255,255,.2);color:#fff">自动开录</span>
|
||||
<span class="chip" style="background:rgba(255,255,255,.12);border-color:rgba(255,255,255,.2);color:#fff">弹幕回放</span>
|
||||
<span class="chip" style="background:rgba(255,255,255,.12);border-color:rgba(255,255,255,.2);color:#fff">存储守护</span>
|
||||
<span class="chip" style="background:rgba(255,255,255,.12);border-color:rgba(255,255,255,.2);color:#fff">云端归档</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:12px;opacity:.6">© 2026 Live Recorder</div>
|
||||
</div>
|
||||
<!-- form panel -->
|
||||
<div style="display:grid;place-items:center;padding:40px">
|
||||
<div style="width:100%;max-width:360px">
|
||||
<h2 style="font-size:24px;font-weight:750">登录</h2>
|
||||
<p style="color:var(--text-muted);margin-top:8px;margin-bottom:28px">欢迎回来,请使用管理员账号登录控制台。</p>
|
||||
<div style="display:grid;gap:18px">
|
||||
<div class="form-item"><label>用户名</label><input class="input" value="admin"></div>
|
||||
<div class="form-item"><label>密码</label><input class="input" type="password" value="123456"></div>
|
||||
<a class="btn btn--primary" href="index.html" style="height:42px;font-size:14px">登录</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>@media(max-width:860px){.login-hero{display:none!important}body>div{grid-template-columns:1fr!important}}</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>系统日志 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="logs" data-crumb="分析 / 系统日志">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">排障中心</div>
|
||||
<h1>系统日志</h1>
|
||||
<p>贯穿巡检、录制、转码、上传的全链路日志,支持按级别、分类与关键词过滤并下钻关联实体。</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<button class="btn">刷新</button>
|
||||
<button class="btn">导出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<span class="field"><select class="select-box" style="border:0;height:auto;padding:0"><option>全部级别</option><option>错误</option><option>警告</option><option>信息</option><option>跟踪</option></select></span>
|
||||
<span class="field"><select class="select-box" style="border:0;height:auto;padding:0"><option>全部分类</option><option>录制</option><option>巡检</option><option>转码</option><option>上传</option></select></span>
|
||||
<span class="field"><select class="select-box" style="border:0;height:auto;padding:0"><option>近 24 小时</option><option>近 7 天</option><option>自定义</option></select></span>
|
||||
<label class="field field--search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="搜索消息关键词"></label>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--sm btn--primary">应用筛选</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:160px">时间</th><th style="width:90px">级别</th><th style="width:90px">分类</th><th>消息</th><th style="width:180px">关联实体</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="cell-mono">10:24:05</td><td><span class="tag is-red">错误</span></td><td>录制</td><td>FFmpeg 进程异常退出,退出码 1:Connection reset by peer<div class="cell-sub">▸ 展开堆栈详情</div></td><td><a class="btn btn--link" href="session-detail.html">会话↗</a><a class="btn btn--link" href="record-task-detail.html">分片↗</a></td></tr>
|
||||
<tr><td class="cell-mono">10:23:50</td><td><span class="tag is-amber">警告</span></td><td>巡检</td><td>抖音房间 845878 轮询超时,将在 8s 后重试(第 2 次)</td><td><a class="btn btn--link" href="live-rooms.html">房间↗</a></td></tr>
|
||||
<tr><td class="cell-mono">10:24:05</td><td><span class="tag is-blue">信息</span></td><td>录制</td><td>检测到开播,已启动录制任务(会话 #3)</td><td><a class="btn btn--link" href="session-detail.html">会话↗</a></td></tr>
|
||||
<tr><td class="cell-mono">10:18:11</td><td><span class="tag is-blue">信息</span></td><td>上传</td><td>分片 #1 已上传至 OpenList:/archive/2026/06/25/熊宇一/…</td><td><a class="btn btn--link" href="upload-tasks.html">上传↗</a></td></tr>
|
||||
<tr><td class="cell-mono">10:15:02</td><td><span class="tag is-amber">警告</span></td><td>存储</td><td>磁盘可用空间 980MB 低于暂停阈值 1024MB,已暂停 2 个录制任务</td><td><a class="btn btn--link" href="recovery.html">恢复↗</a></td></tr>
|
||||
<tr><td class="cell-mono">10:12:40</td><td><span class="tag is-green">跟踪</span></td><td>转码</td><td>分片 #4 转码完成,耗时 42s,输出 410MB</td><td><a class="btn btn--link" href="transcode-tasks.html">转码↗</a></td></tr>
|
||||
<tr><td class="cell-mono">10:08:33</td><td><span class="tag is-blue">信息</span></td><td>巡检</td><td>本轮巡检完成:40 个房间,开播 12,耗时 3.2s</td><td><span class="cell-sub">—</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
<div class="pagination">
|
||||
<span>共 1,284 条</span>
|
||||
<div class="pagination__pages"><span class="pg">‹</span><span class="pg active">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg">…</span><span class="pg">65</span><span class="pg">›</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>文件库 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="media-browser" data-crumb="媒资归档 / 文件库">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div><div class="page-head__kicker">文件浏览</div><h1>文件库</h1><p>浏览录制输出目录,预览视频、查看弹幕 XML,或对源文件发起转码。</p></div>
|
||||
<div class="page-head__actions"><button class="btn">刷新目录</button></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<div class="breadcrumb" style="font-size:14px"><span>records</span><span class="sep">/</span><span>抖音</span><span class="sep">/</span><span>2026</span><span class="sep">/</span><span>06</span><span class="sep">/</span><b>25</b></div>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<label class="field field--search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="筛选当前目录"></label>
|
||||
</div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>名称</th><th style="width:100px">类型</th><th class="num" style="width:120px">大小</th><th style="width:160px">修改时间</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><div style="display:flex;align-items:center;gap:10px"><span style="color:var(--warning)">📁</span><span class="cell-title">熊宇一</span></div></td><td><span class="tag">目录</span></td><td class="num">—</td><td class="cell-mono">10:24</td><td class="row-actions"><button class="btn btn--sm">进入目录</button></td></tr>
|
||||
<tr><td><div style="display:flex;align-items:center;gap:10px"><span>🎬</span><span class="cell-title">103505_原画_熊宇一_才艺_01.mp4</span></div></td><td><span class="tag is-blue">视频</span></td><td class="num">420 MB</td><td class="cell-mono">10:54</td><td class="row-actions"><button class="btn btn--sm">预览</button><button class="btn btn--sm">转码</button></td></tr>
|
||||
<tr><td><div style="display:flex;align-items:center;gap:10px"><span>📄</span><span class="cell-title">103505_原画_熊宇一_才艺_01.xml</span></div></td><td><span class="tag is-purple">弹幕</span></td><td class="num">1.2 MB</td><td class="cell-mono">10:54</td><td class="row-actions"><button class="btn btn--sm">查看 XML</button></td></tr>
|
||||
<tr><td><div style="display:flex;align-items:center;gap:10px"><span>🎬</span><span class="cell-title">105405_原画_熊宇一_才艺_02.mp4</span></div></td><td><span class="tag is-blue">视频</span></td><td class="num">440 MB</td><td class="cell-mono">11:24</td><td class="row-actions"><button class="btn btn--sm">预览</button><button class="btn btn--sm">转码</button></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>分片详情 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="record-tasks" data-crumb="监控录制 / 录制任务 / 会话 #3 / 分片 #1">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div><div class="page-head__kicker">分片任务详情</div><h1>分片 #1 · 熊宇一 才艺专场</h1><p>抖音 · 845878323112 · 会话 #3 · 10:24:05 – 10:54:05</p></div>
|
||||
<div class="page-head__actions"><a class="btn" href="session-detail.html">返回会话</a><button class="btn">刷新</button><button class="btn btn--primary">上传文件</button></div>
|
||||
</div>
|
||||
<div class="grid-12-7-5">
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">视频预览</div><span class="tag is-green">已完成</span></div>
|
||||
<div class="card__body">
|
||||
<div class="player">
|
||||
<div class="player__play"><svg width="22" height="22" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg></div>
|
||||
<div class="player__bar"><span>00:00</span><div class="bar"><span></span></div><span>30:00</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">分片信息</div></div>
|
||||
<div class="card__body">
|
||||
<dl class="desc">
|
||||
<div class="desc__row"><dt>状态</dt><dd>已完成</dd></div>
|
||||
<div class="desc__row"><dt>时长</dt><dd>30m 00s</dd></div>
|
||||
<div class="desc__row"><dt>文件大小</dt><dd>420 MB</dd></div>
|
||||
<div class="desc__row"><dt>画质 / 格式</dt><dd>原画 · MP4</dd></div>
|
||||
<div class="desc__row"><dt>分辨率</dt><dd>1920 × 1080 · 30fps</dd></div>
|
||||
<div class="desc__row"><dt>编码</dt><dd>H.264 / AAC</dd></div>
|
||||
<div class="desc__row"><dt>弹幕数</dt><dd>2,940</dd></div>
|
||||
<div class="desc__row"><dt>上传状态</dt><dd>已上传 · OpenList</dd></div>
|
||||
<div class="desc__row"><dt>文件路径</dt><dd class="cell-mono" style="word-break:break-all">/records/抖音/2026/06/25/熊宇一/103505_…_01.mp4</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">分片日志</div></div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:160px">时间</th><th style="width:90px">级别</th><th>消息</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="cell-mono">10:54:05</td><td><span class="tag is-green">跟踪</span></td><td>MP4 finalize 成功,moov 写入完成</td></tr>
|
||||
<tr><td class="cell-mono">10:54:02</td><td><span class="tag is-blue">信息</span></td><td>分片达到分段时长,开始收尾</td></tr>
|
||||
<tr><td class="cell-mono">10:24:05</td><td><span class="tag is-blue">信息</span></td><td>分片开始录制,拉流成功(origin)</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>录制任务 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
<style>
|
||||
.session { border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); overflow:hidden; }
|
||||
.session + .session { margin-top:14px; }
|
||||
.session__head { display:flex; align-items:center; gap:14px; padding:16px 18px; cursor:pointer; }
|
||||
.session__head:hover { background:var(--surface-hover); }
|
||||
.session__chev { transition:transform .2s; color:var(--text-soft); }
|
||||
.session.open .session__chev { transform:rotate(90deg); }
|
||||
.session__body { border-top:1px solid var(--border-subtle); display:none; }
|
||||
.session.open .session__body { display:block; }
|
||||
.session__metrics { display:flex; gap:22px; margin-left:auto; }
|
||||
.session__metrics .m { text-align:right; }
|
||||
.session__metrics .m b { font-size:15px; font-weight:700; font-variant-numeric:tabular-nums; }
|
||||
.session__metrics .m span { display:block; font-size:11px; color:var(--text-muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="record-tasks" data-crumb="监控录制 / 录制任务">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">录制工作台</div>
|
||||
<h1>录制任务</h1>
|
||||
<p>按会话聚合录制分片,支持实时刷新、停止录制、上传归档与条件化清理。</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<span class="badge is-success" style="margin-right:6px"><span class="pip"></span>实时已连接</span>
|
||||
<button class="btn">刷新</button>
|
||||
<button class="btn">清理 ▾</button>
|
||||
<button class="btn btn--danger">条件删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(160px,1fr))">
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">活动会话</span></div><div class="stat__value">3</div><div class="stat__foot">正在录制 / 处理中</div></div>
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">总分片</span></div><div class="stat__value">142</div><div class="stat__foot">全部会话累计</div></div>
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">总弹幕</span></div><div class="stat__value">86,420</div><div class="stat__foot">已采集消息数</div></div>
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">待上传分片</span></div><div class="stat__value" style="color:var(--warning)">5</div><div class="stat__foot">等待归档</div></div>
|
||||
</div>
|
||||
|
||||
<!-- cleanup progress (running) -->
|
||||
<div class="card"><div class="card__body" style="display:flex;align-items:center;gap:16px;padding:16px 20px">
|
||||
<span class="badge is-info"><span class="pip"></span>清理进行中</span>
|
||||
<div style="flex:1"><div class="bar"><span style="width:30%"></span></div></div>
|
||||
<span class="cell-sub">已处理 <b style="color:var(--text-primary)">12 / 40</b> 会话 · 删除文件 8 · 释放 6.2 GB</span>
|
||||
<button class="btn btn--sm">查看详情</button>
|
||||
</div></div>
|
||||
|
||||
<!-- toolbar -->
|
||||
<div class="card"><div class="toolbar">
|
||||
<div class="segmented">
|
||||
<button class="active">全部</button><button>进行中</button><button>已完成</button><button>失败</button>
|
||||
</div>
|
||||
<label class="field field--search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="搜索直播间 / Room ID"></label>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<span class="sel-count">已选 <b>0</b> 项</span>
|
||||
<button class="btn btn--sm">停止</button>
|
||||
<button class="btn btn--sm">上传</button>
|
||||
<button class="btn btn--sm btn--danger">删除</button>
|
||||
</div>
|
||||
|
||||
<div class="card__body" style="background:var(--bg-base)">
|
||||
<!-- session 1 -->
|
||||
<div class="session open">
|
||||
<div class="session__head" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="checkbox"></span>
|
||||
<svg class="ic session__chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
<span class="avatar" style="background:#dc2626">熊</span>
|
||||
<div>
|
||||
<div class="cell-title">熊宇一 · 才艺专场 <span class="badge live is-danger" style="margin-left:6px"><span class="pip"></span>录制中</span></div>
|
||||
<div class="meta-row"><span class="tag is-red">抖音</span><span class="cell-mono">845878323112</span><span class="dot-sep"></span>会话 #3 · 开始 10:24</div>
|
||||
</div>
|
||||
<div class="session__metrics">
|
||||
<div class="m"><b>3</b><span>分片</span></div>
|
||||
<div class="m"><b>1.2 GB</b><span>数据量</span></div>
|
||||
<div class="m"><b>8,420</b><span>弹幕</span></div>
|
||||
</div>
|
||||
<div class="row-actions" style="margin-left:18px" onclick="event.stopPropagation()">
|
||||
<button class="btn btn--sm btn--danger">停止</button>
|
||||
<button class="btn btn--sm">上传</button>
|
||||
<a class="btn btn--sm" href="session-detail.html">详情</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session__body">
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:40px"></th><th>分片</th><th>状态</th><th>时间段</th><th class="num">时长</th><th class="num">大小</th><th>上传</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="checkbox"></span></td><td class="cell-title">#1</td><td><span class="badge"><span class="pip"></span>已完成</span></td><td class="cell-mono">10:24:05 – 10:54:05</td><td class="num">30m</td><td class="num">420 MB</td><td><span class="tag is-green">已上传</span></td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">详情</a></td></tr>
|
||||
<tr><td><span class="checkbox"></span></td><td class="cell-title">#2</td><td><span class="badge"><span class="pip"></span>已完成</span></td><td class="cell-mono">10:54:05 – 11:24:05</td><td class="num">30m</td><td class="num">440 MB</td><td><span class="tag is-amber">未上传</span></td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">详情</a><button class="btn btn--link">上传</button></td></tr>
|
||||
<tr><td><span class="checkbox"></span></td><td class="cell-title">#3</td><td><span class="badge live is-danger"><span class="pip"></span>录制中</span></td><td class="cell-mono">11:24:05 – …</td><td class="num">进行中</td><td class="num">340 MB</td><td><span class="tag">—</span></td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">详情</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- session 2 -->
|
||||
<div class="session">
|
||||
<div class="session__head" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="checkbox"></span>
|
||||
<svg class="ic session__chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
<span class="avatar" style="background:#2563eb">王</span>
|
||||
<div>
|
||||
<div class="cell-title">游戏老王 · 巅峰赛 <span class="badge is-info" style="margin-left:6px"><span class="pip"></span>处理中</span></div>
|
||||
<div class="meta-row"><span class="tag is-blue">B站</span><span class="cell-mono">123456</span><span class="dot-sep"></span>会话 #7 · 开始 09:12</div>
|
||||
</div>
|
||||
<div class="session__metrics">
|
||||
<div class="m"><b>8</b><span>分片</span></div>
|
||||
<div class="m"><b>3.4 GB</b><span>数据量</span></div>
|
||||
<div class="m"><b>21,003</b><span>弹幕</span></div>
|
||||
</div>
|
||||
<div class="row-actions" style="margin-left:18px" onclick="event.stopPropagation()">
|
||||
<button class="btn btn--sm">上传</button>
|
||||
<a class="btn btn--sm" href="session-detail.html">详情</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- session 3 -->
|
||||
<div class="session">
|
||||
<div class="session__head" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="checkbox"></span>
|
||||
<svg class="ic session__chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
<span class="avatar" style="background:#d97706">音</span>
|
||||
<div>
|
||||
<div class="cell-title">音乐电台 LIVE <span class="badge" style="margin-left:6px"><span class="pip"></span>已完成</span></div>
|
||||
<div class="meta-row"><span class="tag is-amber">虎牙</span><span class="cell-mono">998877</span><span class="dot-sep"></span>会话 #5 · 02:00 – 03:55</div>
|
||||
</div>
|
||||
<div class="session__metrics">
|
||||
<div class="m"><b>5</b><span>分片</span></div>
|
||||
<div class="m"><b>2.1 GB</b><span>数据量</span></div>
|
||||
<div class="m"><b>12,880</b><span>弹幕</span></div>
|
||||
</div>
|
||||
<div class="row-actions" style="margin-left:18px" onclick="event.stopPropagation()">
|
||||
<button class="btn btn--sm">上传</button>
|
||||
<a class="btn btn--sm" href="session-detail.html">详情</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>恢复中心 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="recovery" data-crumb="监控录制 / 恢复中心">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div><div class="page-head__kicker">恢复处理</div><h1>恢复中心</h1><p>存储守护状态、可恢复直播间与未完成 MP4 收尾的一键恢复。</p></div>
|
||||
<div class="page-head__actions"><button class="btn">刷新总览</button></div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert--success" style="padding:16px 18px"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="9"/></svg><div class="alert__body"><b>存储守护正常</b> · 可用 412 GB,需求 1 GB,检测路径 <span class="cell-mono">/records</span></div></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card__head"><div><div class="card__title">可恢复直播间</div><div class="card__sub">因异常中断、可重新触发自动开录的房间</div></div><div style="display:flex;gap:8px"><button class="btn btn--sm">恢复选中</button><button class="btn btn--sm btn--primary">全部恢复</button></div></div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:40px"><span class="checkbox"></span></th><th>直播间</th><th>最近决策</th><th>最近巡检</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="checkbox"></span></td><td><div class="cell-title">户外阿强</div><div class="meta-row"><span class="tag is-purple">快手</span><span class="cell-mono">556677</span></div></td><td><span class="tag is-amber">存储不足</span></td><td class="cell-mono">07:45:00</td><td class="row-actions"><button class="btn btn--sm btn--primary">恢复</button></td></tr>
|
||||
<tr><td><span class="checkbox"></span></td><td><div class="cell-title">英文角 Talk</div><div class="meta-row"><span class="tag is-blue">YouTube</span><span class="cell-mono">dQw4w9</span></div></td><td><span class="tag is-red">轮询失败</span></td><td class="cell-mono">06:30:00</td><td class="row-actions"><button class="btn btn--sm btn--primary">恢复</button></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card__head"><div><div class="card__title">未完成收尾</div><div class="card__sub">中断时未完成 MP4 finalize 的分片,可重新收尾</div></div><button class="btn btn--sm btn--primary">恢复选中收尾</button></div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:40px"><span class="checkbox"></span></th><th>分片</th><th>直播间</th><th>原因</th><th>结束时间</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="checkbox"></span></td><td class="cell-title">会话#7 · #8</td><td>游戏老王 <span class="tag is-blue">B站</span></td><td class="cell-sub">进程被强制结束,MP4 索引未写入</td><td class="cell-mono">09:58:02</td><td class="row-actions"><button class="btn btn--sm btn--primary">恢复收尾</button></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>会话详情 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="record-tasks" data-crumb="监控录制 / 录制任务 / 会话 #3">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">会话回顾</div>
|
||||
<h1>熊宇一 · 才艺专场 <span class="badge live is-danger" style="font-size:13px;vertical-align:middle"><span class="pip"></span>录制中</span></h1>
|
||||
<p>抖音 · 845878323112 · 会话 #3 · 开始于 2026-06-25 10:24:05</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<a class="btn" href="record-tasks.html">返回列表</a>
|
||||
<button class="btn">刷新</button>
|
||||
<button class="btn btn--primary">上传会话</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- overview -->
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(140px,1fr))">
|
||||
<div class="stat"><div class="stat__label">分片数</div><div class="stat__value">3</div></div>
|
||||
<div class="stat"><div class="stat__label">总时长</div><div class="stat__value">1h 02m</div></div>
|
||||
<div class="stat"><div class="stat__label">数据量</div><div class="stat__value">1.2 <small>GB</small></div></div>
|
||||
<div class="stat"><div class="stat__label">弹幕</div><div class="stat__value">8,420</div></div>
|
||||
<div class="stat"><div class="stat__label">已上传 / 失败</div><div class="stat__value">1 <small>/ 0</small></div></div>
|
||||
</div>
|
||||
|
||||
<!-- timeline -->
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">录制时间轴</div><div style="display:flex;gap:8px"><span class="chip"><span class="badge is-info"><span class="pip"></span></span>分片</span><span class="chip"><span class="badge is-danger"><span class="pip"></span></span>录制中</span><span class="chip">▲ 事件</span></div></div>
|
||||
<div class="card__body" style="display:grid;gap:14px">
|
||||
<div class="timeline-track">
|
||||
<div class="seg-block" style="left:0%;width:32%"></div>
|
||||
<div class="seg-block" style="left:33%;width:32%"></div>
|
||||
<div class="seg-block rec" style="left:66%;width:30%"></div>
|
||||
<div style="position:absolute;top:0;left:34%;color:var(--warning);font-size:11px">▲</div>
|
||||
<div style="position:absolute;top:0;left:67%;color:var(--danger);font-size:11px">▲</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-muted)" class="cell-mono"><span>10:24</span><span>10:54</span><span>11:24</span><span>现在</span></div>
|
||||
<div>
|
||||
<div class="cell-sub" style="margin-bottom:6px">弹幕热力(每 5 分钟)</div>
|
||||
<div class="heat-row">
|
||||
<div class="heat-bar" style="height:30%"></div><div class="heat-bar" style="height:48%"></div><div class="heat-bar" style="height:65%"></div>
|
||||
<div class="heat-bar" style="height:40%"></div><div class="heat-bar" style="height:88%;background:var(--warning)"></div><div class="heat-bar" style="height:72%"></div>
|
||||
<div class="heat-bar" style="height:55%"></div><div class="heat-bar" style="height:35%"></div><div class="heat-bar" style="height:60%"></div>
|
||||
<div class="heat-bar" style="height:78%"></div><div class="heat-bar" style="height:92%;background:var(--warning)"></div><div class="heat-bar" style="height:50%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- player + segments -->
|
||||
<div class="grid-12-7-5">
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">弹幕回放</div><span class="chip">分片 #1</span></div>
|
||||
<div class="card__body">
|
||||
<div class="player">
|
||||
<div class="danmaku" style="top:14%;right:8%">主播好强!!</div>
|
||||
<div class="danmaku" style="top:32%;right:24%">2333333</div>
|
||||
<div class="danmaku" style="top:56%;right:14%">送上小心心 ♥</div>
|
||||
<div class="player__play"><svg width="22" height="22" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg></div>
|
||||
<div class="player__bar"><span>10:35</span><div class="bar"><span></span></div><span>30:00</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">分片列表</div></div>
|
||||
<div class="card__body--flush"><div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>分片</th><th>状态</th><th class="num">时长</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="cell-title">#1</td><td><span class="badge"><span class="pip"></span>已完成</span></td><td class="num">30m</td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">#2</td><td><span class="badge"><span class="pip"></span>已完成</span></td><td class="num">30m</td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">#3</td><td><span class="badge live is-danger"><span class="pip"></span>录制中</span></td><td class="num">—</td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">查看</a></td></tr>
|
||||
</tbody>
|
||||
</table></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- session logs -->
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">会话日志</div></div>
|
||||
<div class="card__body--flush"><div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:160px">时间</th><th style="width:90px">级别</th><th>消息</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="cell-mono">11:24:05</td><td><span class="tag is-blue">信息</span></td><td>分片 #3 开始录制</td></tr>
|
||||
<tr><td class="cell-mono">10:54:05</td><td><span class="tag is-green">跟踪</span></td><td>分片 #2 收尾完成,MP4 finalize 成功</td></tr>
|
||||
<tr><td class="cell-mono">10:39:12</td><td><span class="tag is-amber">警告</span></td><td>弹幕轮询短暂超时,已自动恢复</td></tr>
|
||||
<tr><td class="cell-mono">10:24:05</td><td><span class="tag is-blue">信息</span></td><td>检测到开播,已启动录制会话 #3</td></tr>
|
||||
</tbody>
|
||||
</table></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>系统设置 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="settings" data-crumb="系统 / 系统设置">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">系统设置</div>
|
||||
<h1>系统设置</h1>
|
||||
<p>录制、存储、弹幕、上传归档、巡检、事件脚本与通知的全局默认配置。</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<button class="btn">导入配置</button>
|
||||
<button class="btn">导出配置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- account + display preference -->
|
||||
<div class="grid-2">
|
||||
<div class="card"><div class="card__body" style="display:flex;align-items:center;gap:14px">
|
||||
<span class="avatar" style="width:48px;height:48px;border-radius:13px;font-size:18px">管</span>
|
||||
<div style="flex:1"><div class="cell-title" style="font-size:15px">管理员</div><div class="cell-sub">admin · 账号到期 2027-01-01</div></div>
|
||||
<button class="btn btn--sm">账号安全</button>
|
||||
</div></div>
|
||||
<div class="card"><div class="card__body" style="display:flex;align-items:center;gap:20px;flex-wrap:wrap">
|
||||
<div><div class="cell-sub" style="margin-bottom:6px">主题</div><div class="segmented"><button class="active">浅色</button><button>深色</button><button>跟随系统</button></div></div>
|
||||
<div><div class="cell-sub" style="margin-bottom:6px">密度</div><div class="segmented"><button class="active">标准</button><button>紧凑</button></div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- anchor + forms -->
|
||||
<div class="anchor-layout">
|
||||
<nav class="anchor">
|
||||
<a class="active" href="#s-record">录制基础</a>
|
||||
<a href="#s-storage">存储保护</a>
|
||||
<a href="#s-clean">保留清理</a>
|
||||
<a href="#s-danmaku">弹幕录制</a>
|
||||
<a href="#s-path">路径模板</a>
|
||||
<a href="#s-poll">巡检与上传</a>
|
||||
<a href="#s-proxy">平台代理</a>
|
||||
<a href="#s-script">事件脚本</a>
|
||||
<a href="#s-notify">通知</a>
|
||||
</nav>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:20px;min-width:0">
|
||||
<!-- 录制基础 -->
|
||||
<div class="card" id="s-record">
|
||||
<div class="card__head"><div class="card__title">录制基础</div></div>
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>FFmpeg 路径</label><input class="input" value="ffmpeg"></div>
|
||||
<div class="form-item"><label>输出根目录</label><input class="input" value="/records"></div>
|
||||
<div class="form-item"><label>默认画质</label><select class="select-box"><option>原画</option><option>超清</option><option>高清</option><option>标清</option></select></div>
|
||||
<div class="form-item"><label>默认输出格式</label><select class="select-box"><option>MP4</option><option>TS</option></select></div>
|
||||
<div class="form-item"><label>保存模式</label><select class="select-box"><option>分段</option><option>单文件</option></select></div>
|
||||
<div class="form-item"><label>录制模板</label><select class="select-box"><option>均衡 MP4</option><option>直接封装</option><option>归档 TS</option></select></div>
|
||||
<div class="form-item"><label>分段时长(分钟)</label><input class="input" value="30"></div>
|
||||
<div class="form-item"><label>最大并发转码任务</label><input class="input" value="1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 存储保护 -->
|
||||
<div class="card" id="s-storage">
|
||||
<div class="card__head"><div class="card__title">存储保护</div><div class="card__sub">磁盘不足时自动暂停录制</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">启用存储守护</div><div class="toggle-row__h">低于阈值时暂停录制,恢复后自动继续</div></div><span class="switch on"></span></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>暂停阈值(MB)</label><input class="input" value="1024"></div>
|
||||
<div class="form-item"><label>恢复阈值(MB)</label><input class="input" value="4096"></div>
|
||||
<div class="form-item"><label>绿色水位(%)</label><input class="input" value="30"></div>
|
||||
<div class="form-item"><label>红色水位(%)</label><input class="input" value="10"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保留清理 -->
|
||||
<div class="card" id="s-clean">
|
||||
<div class="card__head"><div class="card__title">保留清理</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">启用保留清理</div><div class="toggle-row__h">按天数自动清理过期录制</div></div><span class="switch"></span></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>保留天数</label><input class="input" value="30"></div>
|
||||
<div class="form-item"><label>视频文件条件</label><select class="select-box"><option>不限文件状态</option><option>全部不存在</option><option>全部存在</option></select></div>
|
||||
<div class="form-item full"><label>同时删除本地文件</label><div class="toggle-row" style="background:transparent"><span class="toggle-row__h">关闭时仅删除数据库记录,保留磁盘文件</span><span class="switch"></span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹幕 -->
|
||||
<div class="card" id="s-danmaku">
|
||||
<div class="card__head"><div class="card__title">弹幕录制</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">启用弹幕录制</div><div class="toggle-row__h">录制时同步抓取弹幕到 XML</div></div><span class="switch on"></span></div>
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">记录非聊天事件</div><div class="toggle-row__h">礼物、进场、点赞等系统事件</div></div><span class="switch on"></span></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>最小轮询间隔(毫秒)</label><input class="input" value="2000"></div>
|
||||
<div class="form-item"><label>最大重试退避(秒)</label><input class="input" value="30"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 路径模板 -->
|
||||
<div class="card" id="s-path">
|
||||
<div class="card__head"><div class="card__title">路径模板</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="form-item full"><label>输出目录模板</label><input class="input cell-mono" value="{platform}/{yyyy}/{MM}/{dd}/{anchor}"><div class="hint">可用变量:{platform} {yyyy} {MM} {dd} {anchor} {roomId}</div></div>
|
||||
<div class="form-item full"><label>输出文件名模板</label><input class="input cell-mono" value="{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 巡检与上传 -->
|
||||
<div class="card" id="s-poll">
|
||||
<div class="card__head"><div class="card__title">巡检与上传归档</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">启用后台巡检</div><div class="toggle-row__h">周期性检测开播状态</div></div><span class="switch on"></span></div>
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">开播自动录制</div><div class="toggle-row__h">检测到开播立即启动录制任务</div></div><span class="switch on"></span></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>巡检间隔(秒)</label><input class="input" value="30"></div>
|
||||
<div class="form-item"><label>上传目标</label><select class="select-box"><option>OpenList</option><option>WebDAV</option><option>S3</option><option>不上传</option></select></div>
|
||||
</div>
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">上传后删除本地文件</div><div class="toggle-row__h">归档成功后释放磁盘空间</div></div><span class="switch"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 平台代理 -->
|
||||
<div class="card" id="s-proxy">
|
||||
<div class="card__head"><div class="card__title">平台代理与请求头</div><span class="chip">抖音 / B站 / 虎牙 …</span></div>
|
||||
<div class="card__body">
|
||||
<div class="alert alert--info" style="margin-bottom:16px"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="12" cy="12" r="9"/><path d="M12 8h.01M11 12h1v4h1"/></svg><div class="alert__body">为各平台单独配置 Cookie、UA、Referer 与代理,提升抓流成功率。</div></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>平台</label><select class="select-box"><option>抖音 Douyin</option><option>B站 Bilibili</option><option>虎牙 Huya</option></select></div>
|
||||
<div class="form-item"><label>代理地址</label><input class="input" placeholder="http://127.0.0.1:7890"></div>
|
||||
<div class="form-item full"><label>Cookie</label><textarea class="textarea" placeholder="粘贴平台 Cookie…"></textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 事件脚本 -->
|
||||
<div class="card" id="s-script">
|
||||
<div class="card__head"><div class="card__title">事件脚本</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">启用事件脚本</div><div class="toggle-row__h">开播 / 结束 / 分片完成时触发自定义脚本</div></div><span class="switch on"></span></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>脚本超时(秒)</label><input class="input" value="60"></div>
|
||||
<div class="form-item"><label>重试次数</label><input class="input" value="2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知 -->
|
||||
<div class="card" id="s-notify">
|
||||
<div class="card__head"><div class="card__title">通知</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">Webhook 通知</div><div class="toggle-row__h">开播与异常时推送到 Webhook</div></div><span class="switch on"></span></div>
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">邮件通知</div><div class="toggle-row__h">SMTP 邮件提醒</div></div><span class="switch"></span></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item full"><label>Webhook URL</label><input class="input cell-mono" placeholder="https://…"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- savebar -->
|
||||
<div class="savebar">
|
||||
<div class="savebar__t">检测到 <b>3 项</b>未保存的修改</div>
|
||||
<div style="display:flex;gap:8px"><button class="btn">重置</button><button class="btn btn--primary">保存设置</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
<script>
|
||||
// anchor active state
|
||||
document.querySelectorAll('.anchor a').forEach(a => a.addEventListener('click', e => {
|
||||
document.querySelectorAll('.anchor a').forEach(x => x.classList.remove('active'));
|
||||
a.classList.add('active');
|
||||
}));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Skeleton 加载态 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="dashboard" data-crumb="总览 / 仪表盘">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">系统概览</div>
|
||||
<h1>仪表盘</h1>
|
||||
<p style="display:flex;align-items:center;gap:8px">
|
||||
<span class="skel skel--text short" style="width:280px;display:inline-block"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-head__actions" style="display:flex;gap:8px;align-items:center">
|
||||
<span class="skel skel--btn" style="width:90px"></span>
|
||||
<span class="skel skel--btn" style="width:72px"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI skeleton -->
|
||||
<div class="stat-grid">
|
||||
<div class="skel-stat"><span class="skel skel--stat-value"></span><span class="skel skel--stat-label" style="width:65%"></span></div>
|
||||
<div class="skel-stat"><span class="skel skel--stat-value" style="width:40%"></span><span class="skel skel--stat-label"></span></div>
|
||||
<div class="skel-stat"><span class="skel skel--stat-value" style="width:55%"></span><span class="skel skel--stat-label" style="width:70%"></span></div>
|
||||
<div class="skel-stat"><span class="skel skel--stat-value" style="width:45%"></span><span class="skel skel--stat-label" style="width:60%"></span></div>
|
||||
<div class="skel-stat"><span class="skel skel--stat-value" style="width:48%"></span><span class="skel skel--stat-label" style="width:75%"></span></div>
|
||||
<div class="skel-stat"><span class="skel skel--stat-value" style="width:20%"></span><span class="skel skel--stat-label" style="width:50%"></span></div>
|
||||
</div>
|
||||
|
||||
<!-- storage + queue skeleton -->
|
||||
<div class="grid-2">
|
||||
<div class="skel-card">
|
||||
<div class="skel-card__body">
|
||||
<span class="skel skel--title" style="width:30%"></span>
|
||||
<div style="display:flex;align-items:center;gap:24px">
|
||||
<span class="skel" style="width:84px;height:84px;border-radius:99px;flex-shrink:0"></span>
|
||||
<div style="flex:1;display:grid;gap:12px">
|
||||
<span class="skel skel--text"></span>
|
||||
<span class="skel" style="height:8px;border-radius:99px"></span>
|
||||
<span class="skel skel--text short"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skel-card">
|
||||
<div class="skel-card__body">
|
||||
<span class="skel skel--title" style="width:30%"></span>
|
||||
<span class="skel skel--text"></span>
|
||||
<span class="skel skel--text short" style="width:50%"></span>
|
||||
<span class="skel skel--text short" style="width:40%"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- table skeleton -->
|
||||
<div class="grid-2">
|
||||
<div class="skel-card">
|
||||
<div class="skel-card__body" style="padding:20px;gap:0">
|
||||
<span class="skel skel--title" style="width:28%;margin-bottom:16px"></span>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skel-card">
|
||||
<div class="skel-card__body" style="padding:20px;gap:0">
|
||||
<span class="skel skel--title" style="width:28%;margin-bottom:16px"></span>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- live rooms table skeleton (dense) -->
|
||||
<div class="skel-card">
|
||||
<div class="skel-card__body" style="padding:20px;gap:0">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
|
||||
<span class="skel skel--title" style="width:200px"></span>
|
||||
<span class="skel skel--text short" style="width:120px"></span>
|
||||
</div>
|
||||
<div class="skel--row">
|
||||
<span class="skel skel--avatar"></span>
|
||||
<span class="skel skel--text" style="flex:1"></span>
|
||||
<span class="skel skel--pill" style="width:60px"></span>
|
||||
<span class="skel skel--pill" style="width:60px"></span>
|
||||
<span class="skel skel--text" style="width:120px"></span>
|
||||
<span class="skel" style="width:36px;height:22px;border-radius:99px"></span>
|
||||
<span class="skel skel--btn" style="width:60px;height:30px"></span>
|
||||
</div>
|
||||
<div class="skel--row">
|
||||
<span class="skel skel--avatar"></span>
|
||||
<span class="skel skel--text" style="flex:1"></span>
|
||||
<span class="skel skel--pill" style="width:60px"></span>
|
||||
<span class="skel skel--pill" style="width:60px"></span>
|
||||
<span class="skel skel--text" style="width:120px"></span>
|
||||
<span class="skel" style="width:36px;height:22px;border-radius:99px"></span>
|
||||
<span class="skel skel--btn" style="width:60px;height:30px"></span>
|
||||
</div>
|
||||
<div class="skel--row">
|
||||
<span class="skel skel--avatar"></span>
|
||||
<span class="skel skel--text" style="flex:1"></span>
|
||||
<span class="skel skel--pill" style="width:60px"></span>
|
||||
<span class="skel skel--pill" style="width:60px"></span>
|
||||
<span class="skel skel--text" style="width:120px"></span>
|
||||
<span class="skel" style="width:36px;height:22px;border-radius:99px"></span>
|
||||
<span class="skel skel--btn" style="width:60px;height:30px"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>转码任务 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="transcode-tasks" data-crumb="媒资归档 / 转码任务">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div><div class="page-head__kicker">后处理工作台</div><h1>转码任务</h1><p>录制产物的 FFmpeg 后处理队列,支持手动转码与失败重试。</p></div>
|
||||
<div class="page-head__actions"><a class="btn" href="media-browser.html">文件库</a><a class="btn" href="record-tasks.html">返回录制</a><button class="btn">刷新列表</button></div>
|
||||
</div>
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(160px,1fr))">
|
||||
<div class="stat"><div class="stat__label">待转码</div><div class="stat__value" style="color:var(--warning)">2</div></div>
|
||||
<div class="stat"><div class="stat__label">转码中</div><div class="stat__value" style="color:var(--info)">1</div></div>
|
||||
<div class="stat"><div class="stat__label">已完成</div><div class="stat__value" style="color:var(--success)">88</div></div>
|
||||
<div class="stat"><div class="stat__label">失败</div><div class="stat__value" style="color:var(--danger)">0</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<div class="segmented"><button class="active">全部</button><button>待转码 2</button><button>转码中 1</button><button>已完成 88</button></div>
|
||||
<label class="field field--search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="搜索源文件"></label>
|
||||
</div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>分片</th><th>源文件</th><th class="num">大小</th><th>状态</th><th style="width:160px">进度</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><div class="cell-title">游戏老王 · #6</div><div class="meta-row"><span class="tag is-blue">B站</span></div></td><td class="cell-mono">…_老王_06.ts</td><td class="num">520 MB</td><td><span class="badge is-info live"><span class="pip"></span>转码中</span></td><td><div class="bar"><span style="width:62%"></span></div><div class="cell-sub" style="margin-top:4px">62% · 预计 18s</div></td><td class="row-actions"><a class="btn btn--sm" href="record-task-detail.html">查分片</a></td></tr>
|
||||
<tr><td><div class="cell-title">音乐电台 · #4</div><div class="meta-row"><span class="tag is-amber">虎牙</span></div></td><td class="cell-mono">…_电台_04.ts</td><td class="num">410 MB</td><td><span class="badge is-warning"><span class="pip"></span>待转码</span></td><td><span class="cell-sub">排队中</span></td><td class="row-actions"><button class="btn btn--sm btn--primary">转码</button><a class="btn btn--sm" href="record-task-detail.html">查分片</a></td></tr>
|
||||
<tr><td><div class="cell-title">户外阿强 · #1</div><div class="meta-row"><span class="tag is-purple">快手</span></div></td><td class="cell-mono">…_阿强_01.ts</td><td class="num">300 MB</td><td><span class="badge is-warning"><span class="pip"></span>待转码</span></td><td><span class="cell-sub">排队中</span></td><td class="row-actions"><button class="btn btn--sm btn--primary">转码</button><a class="btn btn--sm" href="record-task-detail.html">查分片</a></td></tr>
|
||||
<tr><td><div class="cell-title">熊宇一 · #1</div><div class="meta-row"><span class="tag is-red">抖音</span></div></td><td class="cell-mono">…_熊宇一_01.ts</td><td class="num">420 MB</td><td><span class="badge"><span class="pip"></span>已完成</span></td><td><div class="bar"><span class="is-green" style="width:100%"></span></div><div class="cell-sub" style="margin-top:4px">耗时 42s</div></td><td class="row-actions"><a class="btn btn--sm" href="record-task-detail.html">查分片</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>上传任务 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="upload-tasks" data-crumb="媒资归档 / 上传任务">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div><div class="page-head__kicker">录制工作台</div><h1>上传任务</h1><p>录制产物的归档上传状态,支持手动重传与跳转分片 / 会话。</p></div>
|
||||
<div class="page-head__actions"><a class="btn" href="record-tasks.html">录制任务</a><button class="btn">刷新列表</button></div>
|
||||
</div>
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(160px,1fr))">
|
||||
<div class="stat"><div class="stat__label">总计</div><div class="stat__value">142</div></div>
|
||||
<div class="stat"><div class="stat__label">未上传</div><div class="stat__value" style="color:var(--warning)">5</div></div>
|
||||
<div class="stat"><div class="stat__label">已上传</div><div class="stat__value" style="color:var(--success)">135</div></div>
|
||||
<div class="stat"><div class="stat__label">上传失败</div><div class="stat__value" style="color:var(--danger)">2</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<div class="segmented"><button class="active">全部</button><button>未上传 5</button><button>已上传 135</button><button>失败 2</button></div>
|
||||
<label class="field field--search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="搜索文件 / 直播间"></label>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--sm">批量上传</button>
|
||||
<button class="btn btn--sm btn--primary">上传全部未上传</button>
|
||||
</div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:40px"><span class="checkbox"></span></th><th>直播间 / 分片</th><th>文件</th><th class="num">大小</th><th>状态</th><th>目标</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="checkbox"></span></td><td><div class="cell-title">熊宇一 · #2</div><div class="meta-row"><span class="tag is-red">抖音</span><span class="cell-mono">845878</span></div></td><td class="cell-mono">…_熊宇一_才艺_02.mp4</td><td class="num">440 MB</td><td><span class="tag is-amber">未上传</span></td><td>OpenList</td><td class="row-actions"><button class="btn btn--sm btn--primary">上传</button><a class="btn btn--sm" href="record-task-detail.html">详情</a></td></tr>
|
||||
<tr><td><span class="checkbox"></span></td><td><div class="cell-title">游戏老王 · #5</div><div class="meta-row"><span class="tag is-blue">B站</span><span class="cell-mono">123456</span></div></td><td class="cell-mono">…_老王_巅峰赛_05.ts</td><td class="num">512 MB</td><td><span class="tag is-red">上传失败</span></td><td>OpenList</td><td class="row-actions"><button class="btn btn--sm btn--primary">重传</button><a class="btn btn--sm" href="record-task-detail.html">详情</a></td></tr>
|
||||
<tr><td><span class="checkbox"></span></td><td><div class="cell-title">熊宇一 · #1</div><div class="meta-row"><span class="tag is-red">抖音</span><span class="cell-mono">845878</span></div></td><td class="cell-mono">…_熊宇一_才艺_01.mp4</td><td class="num">420 MB</td><td><span class="tag is-green">已上传</span></td><td>OpenList</td><td class="row-actions"><a class="btn btn--sm" href="record-task-detail.html">详情</a></td></tr>
|
||||
<tr><td><span class="checkbox"></span></td><td><div class="cell-title">音乐电台 · #3</div><div class="meta-row"><span class="tag is-amber">虎牙</span><span class="cell-mono">998877</span></div></td><td class="cell-mono">…_电台_LIVE_03.mp4</td><td class="num">388 MB</td><td><span class="tag is-green">已上传</span></td><td>OpenList</td><td class="row-actions"><a class="btn btn--sm" href="record-task-detail.html">详情</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="pagination"><span>共 142 条</span><div class="pagination__pages"><span class="pg">‹</span><span class="pg active">1</span><span class="pg">2</span><span class="pg">›</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,7 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the LiveRecorder WebApi image for linux/arm64 and export it as a
|
||||
# gzip-compressed `docker load`-able archive.
|
||||
# Build both the LiveRecorder WebApi and Frontend (nginx) images for
|
||||
# linux/arm64 and export them as a single gzip-compressed `docker load`-able
|
||||
# archive.
|
||||
#
|
||||
# The docker-compose.yml uses a split architecture:
|
||||
# nginx (frontend) — Vue SPA served by nginx, proxies /api/* to api:8080
|
||||
# api (backend) — .NET 8 WebApi
|
||||
#
|
||||
# Both images must be ARM64 for a full deployment on an ARM64 host.
|
||||
#
|
||||
# Target environment: Windows + Docker Desktop (WSL2 backend) + a host HTTP proxy.
|
||||
# It encodes the workarounds discovered while building from a network where the
|
||||
@@ -18,8 +25,9 @@
|
||||
# scripts/build-arm64-image.sh
|
||||
#
|
||||
# Override defaults via env vars:
|
||||
# PROXY=http://127.0.0.1:10808 IMAGE_TAG=live-recorder-webapi:arm64
|
||||
# OUTPUT=live-recorder-webapi-arm64.tar.gz WSL_DISTRO=Ubuntu
|
||||
# PROXY=http://127.0.0.1:10808
|
||||
# OUTPUT=live-recorder-arm64.tar.gz
|
||||
# WSL_DISTRO=Ubuntu
|
||||
set -euo pipefail
|
||||
|
||||
# --- Resolve repo root (script lives in <root>/scripts) ---
|
||||
@@ -30,30 +38,26 @@ cd "$ROOT_DIR"
|
||||
# --- Config ---
|
||||
PROXY="${PROXY:-http://127.0.0.1:10808}"
|
||||
PROXY_HOST="${PROXY_HOST:-http://host.docker.internal:${PROXY##*:}}" # container-visible proxy
|
||||
IMAGE_TAG="${IMAGE_TAG:-live-recorder-webapi:arm64}"
|
||||
OUTPUT="${OUTPUT:-live-recorder-webapi-arm64.tar.gz}"
|
||||
DOCKERFILE="${DOCKERFILE:-src/LiveRecorder.WebApi/Dockerfile}"
|
||||
API_IMAGE_TAG="${API_IMAGE_TAG:-live-recorder-webapi:arm64}"
|
||||
FRONTEND_IMAGE_TAG="${FRONTEND_IMAGE_TAG:-live-recorder-frontend:arm64}"
|
||||
OUTPUT="${OUTPUT:-live-recorder-arm64.tar.gz}"
|
||||
WSL_DISTRO="${WSL_DISTRO:-Ubuntu}"
|
||||
SDK_IMAGE="mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim"
|
||||
RUNTIME_IMAGE="mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim"
|
||||
DOTNET_SDK_IMAGE="mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim"
|
||||
DOTNET_RUNTIME_IMAGE="mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim"
|
||||
NGINX_IMAGE="${NGINX_IMAGE:-nginx:1.27-alpine}"
|
||||
NO_PROXY_HOSTS="mirrors.tuna.tsinghua.edu.cn,.tsinghua.edu.cn,localhost,127.0.0.1"
|
||||
|
||||
log() { printf '\n\033[1;36m[%s] %s\033[0m\n' "$(date +%H:%M:%S)" "$*"; }
|
||||
die() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
# --- 1. Ensure ARM64 emulation works -----------------------------------------
|
||||
# binfmt_misc lives in the (shared) WSL2 kernel and is lost on `wsl --shutdown`/reboot,
|
||||
# so this is idempotent and re-runs the registration whenever emulation is missing.
|
||||
ensure_arm64_emulation() {
|
||||
if docker run --rm --platform linux/arm64 "$RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64; then
|
||||
if docker run --rm --platform linux/arm64 "$DOTNET_RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64; then
|
||||
log "ARM64 emulation already works — skipping binfmt registration."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Registering qemu-aarch64 emulation via the '$WSL_DISTRO' WSL distro..."
|
||||
# Install the statically-linked emulator inside the distro, then re-register it pointing
|
||||
# directly at the static binary with the F (fix-binary) flag — the kernel opens the
|
||||
# interpreter at registration time so the held fd works inside Docker build containers.
|
||||
wsl.exe -d "$WSL_DISTRO" -u root -- bash -lc '
|
||||
set -e
|
||||
if [ ! -x /usr/bin/qemu-aarch64-static ]; then
|
||||
@@ -67,7 +71,7 @@ ensure_arm64_emulation() {
|
||||
> /proc/sys/fs/binfmt_misc/register
|
||||
' || die "binfmt registration failed (need a Debian/Ubuntu WSL distro named '$WSL_DISTRO' with apt)."
|
||||
|
||||
docker run --rm --platform linux/arm64 "$RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64 \
|
||||
docker run --rm --platform linux/arm64 "$DOTNET_RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64 \
|
||||
|| die "ARM64 emulation still not working after registration."
|
||||
log "ARM64 emulation registered and verified."
|
||||
}
|
||||
@@ -85,33 +89,53 @@ pull_with_retry() {
|
||||
docker image inspect "$img" >/dev/null 2>&1 || die "Could not pull $img (network)."
|
||||
}
|
||||
|
||||
# --- 3. Build ----------------------------------------------------------------
|
||||
build_image() {
|
||||
log "Building $IMAGE_TAG for linux/arm64 (restore via proxy, apt via mirror)..."
|
||||
# --- 3. Build API image ------------------------------------------------------
|
||||
build_api_image() {
|
||||
log "Building $API_IMAGE_TAG for linux/arm64 (restore via proxy, apt via mirror)..."
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
-f "$DOCKERFILE" \
|
||||
-t "$IMAGE_TAG" \
|
||||
-f src/LiveRecorder.WebApi/Dockerfile \
|
||||
-t "$API_IMAGE_TAG" \
|
||||
--build-arg "HTTP_PROXY=$PROXY_HOST" \
|
||||
--build-arg "HTTPS_PROXY=$PROXY_HOST" \
|
||||
--build-arg "NO_PROXY=$NO_PROXY_HOSTS" \
|
||||
--build-arg "DOTNET_SDK_IMAGE=$DOTNET_SDK_IMAGE" \
|
||||
--build-arg "DOTNET_RUNTIME_IMAGE=$DOTNET_RUNTIME_IMAGE" \
|
||||
--pull=false \
|
||||
--load \
|
||||
.
|
||||
}
|
||||
|
||||
# --- 4. Export + compress ----------------------------------------------------
|
||||
export_image() {
|
||||
log "Exporting $IMAGE_TAG -> $OUTPUT ..."
|
||||
# --- 4. Build Frontend (nginx) image -----------------------------------------
|
||||
# Frontend dist is pre-built locally (npm run build inside frontend/) and copied
|
||||
# directly — avoids running Node.js under QEMU ARM64 emulation which is prohibitively
|
||||
# slow. Build context is frontend/ (not repo root) to bypass .dockerignore rules.
|
||||
build_frontend_image() {
|
||||
log "Building $FRONTEND_IMAGE_TAG for linux/arm64 (pre-built dist -> nginx)..."
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
-f frontend/Dockerfile.arm64 \
|
||||
-t "$FRONTEND_IMAGE_TAG" \
|
||||
--pull=false \
|
||||
--load \
|
||||
frontend
|
||||
}
|
||||
|
||||
# --- 5. Export both images as a single archive -------------------------------
|
||||
export_images() {
|
||||
log "Exporting $API_IMAGE_TAG + $FRONTEND_IMAGE_TAG -> $OUTPUT ..."
|
||||
local tar="${OUTPUT%.gz}"
|
||||
docker save "$IMAGE_TAG" -o "$tar"
|
||||
docker save "$API_IMAGE_TAG" "$FRONTEND_IMAGE_TAG" -o "$tar"
|
||||
gzip -f "$tar"
|
||||
log "Done: $(ls -lh "$OUTPUT" | awk '{print $5, $9}')"
|
||||
log "Load on an arm64 host with: docker load < $OUTPUT"
|
||||
log "Then start with: docker compose up -d"
|
||||
}
|
||||
|
||||
ensure_arm64_emulation
|
||||
pull_with_retry "$SDK_IMAGE"
|
||||
pull_with_retry "$RUNTIME_IMAGE"
|
||||
build_image
|
||||
export_image
|
||||
pull_with_retry "$DOTNET_SDK_IMAGE"
|
||||
pull_with_retry "$DOTNET_RUNTIME_IMAGE"
|
||||
pull_with_retry "$NGINX_IMAGE"
|
||||
build_api_image
|
||||
build_frontend_image
|
||||
export_images
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
#!/bin/sh
|
||||
|
||||
# =========================
|
||||
# 基础配置
|
||||
# =========================
|
||||
|
||||
OPENLIST_BASE_URL="http://192.168.6.145:5244"
|
||||
OPENLIST_USERNAME="${OPENLIST_USERNAME:-admin}"
|
||||
OPENLIST_PASSWORD="${OPENLIST_PASSWORD:-768788}"
|
||||
|
||||
segment_path="$LIVE_RECORDER_SEGMENT_FILE_PATH"
|
||||
danmaku_path="$LIVE_RECORDER_DANMAKU_FILE_PATH"
|
||||
platform="$LIVE_RECORDER_PLATFORM"
|
||||
|
||||
log_file="$LIVE_RECORDER_SCRIPT_LOG_PATH"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$log_file"
|
||||
}
|
||||
|
||||
# =========================
|
||||
# 参数检查
|
||||
# =========================
|
||||
|
||||
if [ -z "$segment_path" ]; then
|
||||
log "错误:LIVE_RECORDER_SEGMENT_FILE_PATH 为空"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$danmaku_path" ]; then
|
||||
log "错误:LIVE_RECORDER_DANMAKU_FILE_PATH 为空"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$platform" ]; then
|
||||
log "错误:LIVE_RECORDER_PLATFORM 为空"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =========================
|
||||
# 简单 JSON 字符串转义
|
||||
# =========================
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
# 从响应里抽取业务 code
|
||||
extract_code() {
|
||||
printf '%s' "$1" | sed -n 's/.*"code"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p'
|
||||
}
|
||||
|
||||
# =========================
|
||||
# 1. 获取主播名和日期
|
||||
# =========================
|
||||
|
||||
anchor_name=$(printf '%s\n' "$segment_path" | awk -F'/' '{print $(NF-2)}')
|
||||
record_date=$(printf '%s\n' "$segment_path" | awk -F'/' '{print $(NF-1)}')
|
||||
|
||||
video_filename=$(basename "$segment_path")
|
||||
danmaku_filename=$(basename "$danmaku_path")
|
||||
|
||||
log "主播名: $anchor_name"
|
||||
log "日期: $record_date"
|
||||
log "视频文件名: $video_filename"
|
||||
log "弹幕文件名: $danmaku_filename"
|
||||
|
||||
remote_dir="/yidongpan/records/$anchor_name/$record_date"
|
||||
src_dir="/local/home/nanxun/live_recorder/records/$platform/$anchor_name/$record_date"
|
||||
|
||||
log "OpenList 源目录: $src_dir"
|
||||
log "OpenList 目标目录: $remote_dir"
|
||||
|
||||
# 转义后用于 JSON
|
||||
username_json=$(json_escape "$OPENLIST_USERNAME")
|
||||
password_json=$(json_escape "$OPENLIST_PASSWORD")
|
||||
remote_dir_json=$(json_escape "$remote_dir")
|
||||
src_dir_json=$(json_escape "$src_dir")
|
||||
video_filename_json=$(json_escape "$video_filename")
|
||||
danmaku_filename_json=$(json_escape "$danmaku_filename")
|
||||
|
||||
# =========================
|
||||
# 2. 登录获取 token
|
||||
# =========================
|
||||
|
||||
login_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/auth/login" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "{\"username\":\"$username_json\",\"password\":\"$password_json\"}")
|
||||
|
||||
login_code=$(extract_code "$login_resp")
|
||||
token=$(printf '%s' "$login_resp" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
|
||||
if [ "$login_code" != "200" ] || [ -z "$token" ]; then
|
||||
log "错误:登录失败,响应: $login_resp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "OpenList 登录成功"
|
||||
|
||||
# =========================
|
||||
# 3. 递归创建目标目录
|
||||
# OpenList 的 /api/fs/mkdir 在部分网盘驱动(如移动云盘)上
|
||||
# 不会自动创建父目录,父目录缺失时会报
|
||||
# "failed to get parent dir [...]: object not found"。
|
||||
# 因此这里从根开始逐级 list + mkdir,确保每一层父目录都已存在。
|
||||
# =========================
|
||||
|
||||
# 先判断完整目标目录是否已存在,存在则直接跳过创建
|
||||
list_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/list" \
|
||||
--header "Authorization: $token" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "{\"path\":\"$remote_dir_json\",\"password\":\"\",\"refresh\":false,\"page\":1,\"per_page\":1}")
|
||||
list_code=$(extract_code "$list_resp")
|
||||
|
||||
if [ "$list_code" = "200" ]; then
|
||||
log "目标目录已存在: $remote_dir"
|
||||
else
|
||||
log "目标目录不存在,逐级创建: $remote_dir"
|
||||
|
||||
accum=""
|
||||
old_ifs="$IFS"
|
||||
IFS='/'
|
||||
for seg in $remote_dir; do
|
||||
[ -z "$seg" ] && continue
|
||||
accum="$accum/$seg"
|
||||
accum_json=$(json_escape "$accum")
|
||||
|
||||
# 该层是否已存在?存在则跳过,避免无谓 mkdir 报错
|
||||
seg_list=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/list" \
|
||||
--header "Authorization: $token" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "{\"path\":\"$accum_json\",\"password\":\"\",\"refresh\":false,\"page\":1,\"per_page\":1}")
|
||||
seg_code=$(extract_code "$seg_list")
|
||||
if [ "$seg_code" = "200" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
mk_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/mkdir" \
|
||||
--header "Authorization: $token" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "{\"path\":\"$accum_json\"}")
|
||||
mk_code=$(extract_code "$mk_resp")
|
||||
if [ "$mk_code" != "200" ]; then
|
||||
IFS="$old_ifs"
|
||||
log "错误:创建目录失败 ($accum),响应: $mk_resp"
|
||||
exit 1
|
||||
fi
|
||||
log "已创建: $accum"
|
||||
done
|
||||
IFS="$old_ifs"
|
||||
|
||||
log "目标目录创建成功: $remote_dir"
|
||||
fi
|
||||
|
||||
# =========================
|
||||
# 4. 移动文件到目标目录
|
||||
# =========================
|
||||
|
||||
move_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/move" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header "Authorization: $token" \
|
||||
--data-raw "{\"src_dir\":\"$src_dir_json\",\"dst_dir\":\"$remote_dir_json\",\"names\":[\"$danmaku_filename_json\",\"$video_filename_json\"]}")
|
||||
|
||||
move_code=$(extract_code "$move_resp")
|
||||
|
||||
if [ "$move_code" = "200" ]; then
|
||||
log "文件移动成功: $danmaku_filename, $video_filename -> $remote_dir"
|
||||
else
|
||||
log "错误:文件移动失败,响应: $move_resp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -90,6 +90,16 @@ public interface IRecordResultRepository
|
||||
|
||||
Task<long> SumPendingUploadBytesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<(RecordResult Result, RecordTask Task)>> ListUploadStatusAsync(
|
||||
RecordArtifactUploadStatus? uploadStatusFilter,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountUploadStatusAsync(
|
||||
RecordArtifactUploadStatus? uploadStatusFilter,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default);
|
||||
|
||||
void Update(RecordResult recordResult);
|
||||
|
||||
@@ -42,6 +42,12 @@ public sealed class RecordSessionDto
|
||||
|
||||
public int TotalDanmakuMessageCount { get; init; }
|
||||
|
||||
public int UploadedSegmentCount { get; init; }
|
||||
|
||||
public int FailedUploadSegmentCount { get; init; }
|
||||
|
||||
public int UploadingSegmentCount { get; init; }
|
||||
|
||||
public required IReadOnlyList<RecordTaskDto> Tasks { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ public sealed class RecordTaskDto
|
||||
|
||||
public double? DurationSeconds { get; init; }
|
||||
|
||||
public RecordArtifactUploadStatus? UploadStatus { get; init; }
|
||||
|
||||
public string? PostProcessStage { get; init; }
|
||||
|
||||
public double? PostProcessProgressPercent { get; init; }
|
||||
@@ -169,3 +171,57 @@ public sealed class ManualSegmentCompletedTriggerResultDto
|
||||
|
||||
public required string Message { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UploadTaskItemDto
|
||||
{
|
||||
public Guid RecordTaskId { get; init; }
|
||||
|
||||
public Guid RecordSessionId { get; init; }
|
||||
|
||||
public Guid LiveRoomId { get; init; }
|
||||
|
||||
public required string LiveRoomTitle { get; init; }
|
||||
|
||||
public int Platform { get; init; }
|
||||
|
||||
public required string RoomId { get; init; }
|
||||
|
||||
public int SegmentIndex { get; init; }
|
||||
|
||||
public string OutputFormat { get; init; } = string.Empty;
|
||||
|
||||
public string? FilePath { get; init; }
|
||||
|
||||
public long? FileSizeBytes { get; init; }
|
||||
|
||||
public string? DanmakuFilePath { get; init; }
|
||||
|
||||
public RecordArtifactUploadStatus UploadStatus { get; init; }
|
||||
|
||||
public string? LastUploadProvider { get; init; }
|
||||
|
||||
public string? RemoteVideoPath { get; init; }
|
||||
|
||||
public string? RemoteDanmakuPath { get; init; }
|
||||
|
||||
public DateTimeOffset? LastUploadedAt { get; init; }
|
||||
|
||||
public string? UploadErrorMessage { get; init; }
|
||||
|
||||
public bool DeletedLocalFilesAfterUpload { get; init; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UploadTaskListResponse
|
||||
{
|
||||
public IReadOnlyList<UploadTaskItemDto> Items { get; init; } = [];
|
||||
|
||||
public int TotalCount { get; init; }
|
||||
|
||||
public int NotUploadedCount { get; init; }
|
||||
|
||||
public int SucceededCount { get; init; }
|
||||
|
||||
public int FailedCount { get; init; }
|
||||
}
|
||||
|
||||
@@ -82,6 +82,17 @@ public sealed class S3UploadSettingsDto
|
||||
public bool ForcePathStyle { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OpenListUploadSettingsDto
|
||||
{
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
public string BasePath { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class SystemSettingsDto
|
||||
{
|
||||
public string FfmpegPath { get; set; } = "ffmpeg";
|
||||
@@ -155,6 +166,8 @@ public sealed class SystemSettingsDto
|
||||
|
||||
public S3UploadSettingsDto S3Upload { get; set; } = new();
|
||||
|
||||
public OpenListUploadSettingsDto OpenListUpload { get; set; } = new();
|
||||
|
||||
public bool EnableEventScripts { get; set; } = false;
|
||||
|
||||
public bool EnableLiveStartedScript { get; set; } = false;
|
||||
@@ -434,6 +447,8 @@ public sealed class UpdateSystemSettingsRequest
|
||||
|
||||
public S3UploadSettingsDto S3Upload { get; set; } = new();
|
||||
|
||||
public OpenListUploadSettingsDto OpenListUpload { get; set; } = new();
|
||||
|
||||
public bool EnableEventScripts { get; set; } = false;
|
||||
|
||||
public bool EnableLiveStartedScript { get; set; } = false;
|
||||
|
||||
@@ -27,6 +27,7 @@ internal static class RecordModelMapper
|
||||
StartedAt = recordTask.StartedAt,
|
||||
EndedAt = recordTask.EndedAt,
|
||||
DurationSeconds = recordTask.DurationSeconds,
|
||||
UploadStatus = recordTask.Result?.UploadStatus,
|
||||
PostProcessStage = runtimeState?.Stage,
|
||||
PostProcessProgressPercent = runtimeState?.ProgressPercent,
|
||||
PostProcessDetail = runtimeState?.Detail
|
||||
@@ -87,6 +88,9 @@ internal static class RecordModelMapper
|
||||
EndedAt = recordSession.EndedAt,
|
||||
TotalFileSizeBytes = totalFileSizeBytes,
|
||||
TotalDanmakuMessageCount = totalDanmakuMessageCount,
|
||||
UploadedSegmentCount = orderedTasks.Count(item => item.Result?.UploadStatus == RecordArtifactUploadStatus.Succeeded),
|
||||
FailedUploadSegmentCount = orderedTasks.Count(item => item.Result?.UploadStatus == RecordArtifactUploadStatus.Failed),
|
||||
UploadingSegmentCount = orderedTasks.Count(item => item.Result?.UploadStatus == RecordArtifactUploadStatus.Uploading),
|
||||
Tasks = orderedTasks.Select(item => MapTaskWithFallback(
|
||||
item,
|
||||
recordSession,
|
||||
@@ -122,6 +126,7 @@ internal static class RecordModelMapper
|
||||
StartedAt = recordTask.StartedAt,
|
||||
EndedAt = recordTask.EndedAt,
|
||||
DurationSeconds = recordTask.DurationSeconds,
|
||||
UploadStatus = recordTask.Result?.UploadStatus,
|
||||
PostProcessStage = runtimeState?.Stage,
|
||||
PostProcessProgressPercent = runtimeState?.ProgressPercent,
|
||||
PostProcessDetail = runtimeState?.Detail
|
||||
|
||||
@@ -54,6 +54,10 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
private const string S3SecretKeyKey = "upload.s3.secret_key";
|
||||
private const string S3PrefixKey = "upload.s3.prefix";
|
||||
private const string S3ForcePathStyleKey = "upload.s3.force_path_style";
|
||||
private const string OpenListBaseUrlKey = "upload.openlist.base_url";
|
||||
private const string OpenListUsernameKey = "upload.openlist.username";
|
||||
private const string OpenListPasswordKey = "upload.openlist.password";
|
||||
private const string OpenListBasePathKey = "upload.openlist.base_path";
|
||||
private const string DouyinProxyEnabledKey = "platform_proxy.douyin.enabled";
|
||||
private const string DouyinProxyUrlKey = "platform_proxy.douyin.url";
|
||||
private const string BilibiliProxyEnabledKey = "platform_proxy.bilibili.enabled";
|
||||
@@ -180,6 +184,13 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
Prefix = GetValue(lookup, S3PrefixKey, string.Empty),
|
||||
ForcePathStyle = bool.TryParse(GetValue(lookup, S3ForcePathStyleKey, "false"), out var s3ForcePathStyle) && s3ForcePathStyle
|
||||
},
|
||||
OpenListUpload = new OpenListUploadSettingsDto
|
||||
{
|
||||
BaseUrl = GetValue(lookup, OpenListBaseUrlKey, string.Empty),
|
||||
Username = GetValue(lookup, OpenListUsernameKey, string.Empty),
|
||||
Password = GetValue(lookup, OpenListPasswordKey, string.Empty),
|
||||
BasePath = GetValue(lookup, OpenListBasePathKey, string.Empty)
|
||||
},
|
||||
EnableEventScripts = bool.TryParse(GetValue(lookup, EnableEventScriptsKey, "false"), out var enableEventScripts) && enableEventScripts,
|
||||
EnableLiveStartedScript = GetEventScriptEnabled(
|
||||
lookup,
|
||||
@@ -348,6 +359,11 @@ public sealed class SystemSettingsService : ISystemSettingsService
|
||||
await UpsertAsync(S3SecretKeyKey, s3Upload.SecretKey, now, cancellationToken);
|
||||
await UpsertAsync(S3PrefixKey, s3Upload.Prefix.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(S3ForcePathStyleKey, s3Upload.ForcePathStyle.ToString(), now, cancellationToken);
|
||||
var openListUpload = request.OpenListUpload ?? new OpenListUploadSettingsDto();
|
||||
await UpsertAsync(OpenListBaseUrlKey, openListUpload.BaseUrl.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(OpenListUsernameKey, openListUpload.Username.Trim(), now, cancellationToken);
|
||||
await UpsertAsync(OpenListPasswordKey, openListUpload.Password, now, cancellationToken);
|
||||
await UpsertAsync(OpenListBasePathKey, openListUpload.BasePath.Trim(), now, cancellationToken);
|
||||
foreach (var platformDefinition in LivePlatformCatalog.All)
|
||||
{
|
||||
var platformRequestSettings = request.GetPlatformRequestSettings(platformDefinition.Type);
|
||||
|
||||
@@ -85,6 +85,15 @@ public class RecordResult
|
||||
ErrorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public void MarkUploadStarted(string provider, DateTimeOffset startedAt)
|
||||
{
|
||||
UploadStatus = RecordArtifactUploadStatus.Uploading;
|
||||
LastUploadProvider = NormalizeNullable(provider);
|
||||
LastUploadedAt = startedAt;
|
||||
UploadErrorMessage = null;
|
||||
DeletedLocalFilesAfterUpload = false;
|
||||
}
|
||||
|
||||
public void MarkUploadSucceeded(
|
||||
string provider,
|
||||
string? remoteVideoPath,
|
||||
|
||||
@@ -4,5 +4,6 @@ public enum RecordArtifactUploadStatus
|
||||
{
|
||||
NotUploaded = 0,
|
||||
Succeeded = 1,
|
||||
Failed = 2
|
||||
Failed = 2,
|
||||
Uploading = 3
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ public enum UploadTargetType
|
||||
{
|
||||
None = 0,
|
||||
WebDav = 1,
|
||||
S3 = 2
|
||||
S3 = 2,
|
||||
OpenList = 3
|
||||
}
|
||||
|
||||
@@ -277,6 +277,48 @@ public sealed class RecordResultRepository : IRecordResultRepository
|
||||
.Where(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded)
|
||||
.SumAsync(item => item.FileSizeBytes ?? 0L, cancellationToken);
|
||||
|
||||
public async Task<List<(RecordResult Result, RecordTask Task)>> ListUploadStatusAsync(
|
||||
RecordArtifactUploadStatus? uploadStatusFilter,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _dbContext.RecordResults
|
||||
.Include(item => item.RecordTask!)
|
||||
.ThenInclude(task => task.LiveRoom)
|
||||
.AsQueryable();
|
||||
|
||||
if (uploadStatusFilter.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.UploadStatus == uploadStatusFilter.Value);
|
||||
}
|
||||
|
||||
var results = await query
|
||||
.OrderByDescending(item => item.CreatedAt)
|
||||
.Skip(skip)
|
||||
.Take(take)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return results
|
||||
.Where(item => item.RecordTask is not null)
|
||||
.Select(item => (item, item.RecordTask!))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public Task<int> CountUploadStatusAsync(
|
||||
RecordArtifactUploadStatus? uploadStatusFilter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = _dbContext.RecordResults.AsQueryable();
|
||||
|
||||
if (uploadStatusFilter.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.UploadStatus == uploadStatusFilter.Value);
|
||||
}
|
||||
|
||||
return query.CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) =>
|
||||
_dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LiveRecorder.Application.Abstractions.Logging;
|
||||
using LiveRecorder.Application.Abstractions.Settings;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
@@ -147,6 +148,8 @@ public sealed class RecordUploadService
|
||||
}
|
||||
|
||||
var uploader = CreateUploader(settings);
|
||||
recordResult.MarkUploadStarted(uploader.ProviderName, DateTimeOffset.UtcNow);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var remoteVideoPath = recordResult.RemoteVideoPath;
|
||||
@@ -325,6 +328,7 @@ public sealed class RecordUploadService
|
||||
{
|
||||
UploadTargetType.WebDav => new WebDavRecordArtifactUploader(settings.WebDavUpload),
|
||||
UploadTargetType.S3 => new S3RecordArtifactUploader(settings.S3Upload),
|
||||
UploadTargetType.OpenList => new OpenListRecordArtifactUploader(settings.OpenListUpload),
|
||||
_ => throw new InvalidOperationException("No supported upload target is configured.")
|
||||
};
|
||||
}
|
||||
@@ -620,3 +624,137 @@ internal sealed class S3RecordArtifactUploader : IRecordArtifactUploader
|
||||
private static string ConvertToHex(byte[] bytes) =>
|
||||
Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
internal sealed class OpenListRecordArtifactUploader : IRecordArtifactUploader
|
||||
{
|
||||
private readonly OpenListUploadSettingsDto _settings;
|
||||
|
||||
public OpenListRecordArtifactUploader(OpenListUploadSettingsDto settings)
|
||||
{
|
||||
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
}
|
||||
|
||||
public string ProviderName => "openlist";
|
||||
|
||||
public async Task<string> UploadFileAsync(string localPath, string relativeRemotePath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_settings.BaseUrl))
|
||||
{
|
||||
throw new InvalidOperationException("OpenList base URL is not configured.");
|
||||
}
|
||||
|
||||
var baseUrl = _settings.BaseUrl.Trim().TrimEnd('/');
|
||||
var remotePath = BuildRemotePath(_settings.BasePath, relativeRemotePath);
|
||||
var token = await LoginAsync(baseUrl, cancellationToken);
|
||||
await EnsureDirectoriesAsync(baseUrl, token, remotePath, cancellationToken);
|
||||
return await PutFileAsync(baseUrl, token, remotePath, localPath, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string> LoginAsync(string baseUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
||||
var payload = JsonSerializer.Serialize(new { username = _settings.Username, password = _settings.Password });
|
||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
using var response = await client.PostAsync($"{baseUrl}/api/auth/login", content, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
var code = root.TryGetProperty("code", out var codeElement) ? codeElement.GetInt32() : -1;
|
||||
if (code != 200)
|
||||
{
|
||||
var message = root.TryGetProperty("message", out var msgElement) ? msgElement.GetString() : body;
|
||||
throw new InvalidOperationException($"OpenList login failed with code {code}: {message}");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("data", out var dataElement) ||
|
||||
!dataElement.TryGetProperty("token", out var tokenElement) ||
|
||||
string.IsNullOrWhiteSpace(tokenElement.GetString()))
|
||||
{
|
||||
throw new InvalidOperationException("OpenList login response did not contain a token.");
|
||||
}
|
||||
|
||||
return tokenElement.GetString()!;
|
||||
}
|
||||
|
||||
private static async Task EnsureDirectoriesAsync(string baseUrl, string token, string remotePath, CancellationToken cancellationToken)
|
||||
{
|
||||
var segments = remotePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (segments.Length <= 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
||||
var accumulatedPath = string.Empty;
|
||||
for (var i = 0; i < segments.Length - 1; i++)
|
||||
{
|
||||
accumulatedPath += "/" + segments[i];
|
||||
await MkdirAsync(client, baseUrl, token, accumulatedPath, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task MkdirAsync(HttpClient client, string baseUrl, string token, string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(new { path });
|
||||
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/api/fs/mkdir")
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.TryAddWithoutValidation("Authorization", token);
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var code = doc.RootElement.TryGetProperty("code", out var codeElement) ? codeElement.GetInt32() : -1;
|
||||
if (code != 200)
|
||||
{
|
||||
var message = doc.RootElement.TryGetProperty("message", out var msgElement) ? msgElement.GetString() : body;
|
||||
// AList returns code 500 with "already exists" or "exist" when directory already exists — that's acceptable
|
||||
if (message != null && message.Contains("exist", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"OpenList mkdir failed with code {code} for path '{path}': {message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> PutFileAsync(string baseUrl, string token, string remotePath, string localPath, CancellationToken cancellationToken)
|
||||
{
|
||||
using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
|
||||
using var request = new HttpRequestMessage(HttpMethod.Put, $"{baseUrl}/api/fs/put");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", token);
|
||||
request.Headers.TryAddWithoutValidation("File-Path", Uri.EscapeDataString(remotePath));
|
||||
request.Content = new StreamContent(File.OpenRead(localPath));
|
||||
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var code = doc.RootElement.TryGetProperty("code", out var codeElement) ? codeElement.GetInt32() : -1;
|
||||
if (code != 200)
|
||||
{
|
||||
var message = doc.RootElement.TryGetProperty("message", out var msgElement) ? msgElement.GetString() : body;
|
||||
throw new InvalidOperationException($"OpenList file upload failed with code {code}: {message}");
|
||||
}
|
||||
|
||||
return $"{baseUrl}{remotePath}";
|
||||
}
|
||||
|
||||
private static string BuildRemotePath(string? basePath, string relativeRemotePath)
|
||||
{
|
||||
var parts = new[]
|
||||
{
|
||||
basePath?.Trim(),
|
||||
relativeRemotePath.Trim()
|
||||
}
|
||||
.Where(static item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(static item => item!.Trim('/'))
|
||||
.ToArray();
|
||||
|
||||
return "/" + string.Join('/', parts);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using LiveRecorder.Application.Abstractions.Persistence;
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
using LiveRecorder.Application.Services;
|
||||
using LiveRecorder.Application.Abstractions.Recording;
|
||||
using LiveRecorder.Domain.Entities;
|
||||
using LiveRecorder.Domain.Enums;
|
||||
using LiveRecorder.Infrastructure.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -12,6 +15,7 @@ public sealed class RecordTasksController : ControllerBase
|
||||
{
|
||||
private readonly RecordService _recordService;
|
||||
private readonly RecordUploadService _recordUploadService;
|
||||
private readonly IRecordResultRepository _recordResultRepository;
|
||||
private readonly IRecordMediaService _recordMediaService;
|
||||
private readonly IDanmakuService _danmakuService;
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
@@ -19,12 +23,14 @@ public sealed class RecordTasksController : ControllerBase
|
||||
public RecordTasksController(
|
||||
RecordService recordService,
|
||||
RecordUploadService recordUploadService,
|
||||
IRecordResultRepository recordResultRepository,
|
||||
IRecordMediaService recordMediaService,
|
||||
IDanmakuService danmakuService,
|
||||
LinkGenerator linkGenerator)
|
||||
{
|
||||
_recordService = recordService;
|
||||
_recordUploadService = recordUploadService;
|
||||
_recordResultRepository = recordResultRepository;
|
||||
_recordMediaService = recordMediaService;
|
||||
_danmakuService = danmakuService;
|
||||
_linkGenerator = linkGenerator;
|
||||
@@ -34,6 +40,62 @@ public sealed class RecordTasksController : ControllerBase
|
||||
public async Task<ActionResult<IReadOnlyList<RecordTaskDto>>> List([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken) =>
|
||||
Ok(await _recordService.ListAsync(liveRoomId, cancellationToken));
|
||||
|
||||
[HttpGet("upload-status")]
|
||||
public async Task<ActionResult<UploadTaskListResponse>> ListUploadStatus(
|
||||
[FromQuery] int? uploadStatus,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 50,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var filter = uploadStatus.HasValue && Enum.IsDefined(typeof(RecordArtifactUploadStatus), uploadStatus.Value)
|
||||
? (RecordArtifactUploadStatus)uploadStatus.Value
|
||||
: (RecordArtifactUploadStatus?)null;
|
||||
|
||||
var items = await _recordResultRepository.ListUploadStatusAsync(filter, skip, take, cancellationToken);
|
||||
var totalCount = await _recordResultRepository.CountUploadStatusAsync(filter, cancellationToken);
|
||||
var notUploadedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.NotUploaded, cancellationToken);
|
||||
var succeededCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Succeeded, cancellationToken);
|
||||
var failedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Failed, cancellationToken);
|
||||
|
||||
return Ok(new UploadTaskListResponse
|
||||
{
|
||||
Items = items.Select(MapUploadTaskItem).ToList(),
|
||||
TotalCount = totalCount,
|
||||
NotUploadedCount = notUploadedCount,
|
||||
SucceededCount = succeededCount,
|
||||
FailedCount = failedCount
|
||||
});
|
||||
}
|
||||
|
||||
private static UploadTaskItemDto MapUploadTaskItem((RecordResult Result, RecordTask Task) pair)
|
||||
{
|
||||
var (result, task) = pair;
|
||||
var liveRoom = task.LiveRoom;
|
||||
|
||||
return new UploadTaskItemDto
|
||||
{
|
||||
RecordTaskId = task.Id,
|
||||
RecordSessionId = task.RecordSessionId,
|
||||
LiveRoomId = task.LiveRoomId,
|
||||
LiveRoomTitle = liveRoom?.Title ?? liveRoom?.AnchorName ?? liveRoom?.RoomId ?? "Unknown Room",
|
||||
Platform = (int)(liveRoom?.Platform ?? LivePlatformType.Unknown),
|
||||
RoomId = liveRoom?.RoomId ?? string.Empty,
|
||||
SegmentIndex = task.SegmentIndex,
|
||||
OutputFormat = task.OutputFormat.ToString(),
|
||||
FilePath = result.FilePath,
|
||||
FileSizeBytes = result.FileSizeBytes,
|
||||
DanmakuFilePath = result.DanmakuFilePath,
|
||||
UploadStatus = result.UploadStatus,
|
||||
LastUploadProvider = result.LastUploadProvider,
|
||||
RemoteVideoPath = result.RemoteVideoPath,
|
||||
RemoteDanmakuPath = result.RemoteDanmakuPath,
|
||||
LastUploadedAt = result.LastUploadedAt,
|
||||
UploadErrorMessage = result.UploadErrorMessage,
|
||||
DeletedLocalFilesAfterUpload = result.DeletedLocalFilesAfterUpload,
|
||||
CreatedAt = result.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<RecordTaskDetailDto>> Get(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user