diff --git a/frontend/src/components/ui/StatusBadge.vue b/frontend/src/components/ui/StatusBadge.vue index 355e386..691a954 100644 --- a/frontend/src/components/ui/StatusBadge.vue +++ b/frontend/src/components/ui/StatusBadge.vue @@ -94,6 +94,18 @@ function resolveToneByNumber(value: number, context: BadgeContext): Tone { return "red"; } + if (value === 3) { + return "blue"; + } + + if (value === 4) { + return "yellow"; + } + + if (value === 5) { + return "orange"; + } + return "gray"; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d275cc2..bb624ba 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -257,6 +257,10 @@ export interface RecordArtifactUploadItemResult { remoteVideoPath?: string; remoteDanmakuPath?: string; deletedLocalFilesAfterUpload: boolean; + uploadStatus?: number; + progressPercent?: number; + attemptCount: number; + nextAttemptAt?: string; } export interface RecordArtifactUploadBatchResult { @@ -286,6 +290,11 @@ export interface UploadTaskItem { uploadErrorMessage?: string; deletedLocalFilesAfterUpload: boolean; createdAt: string; + uploadProgressPercent?: number; + uploadAttemptCount: number; + nextUploadAttemptAt?: string; + currentUploadArtifact?: string; + externalUploadTaskId?: string; } export interface UploadTaskListResponse { @@ -294,6 +303,9 @@ export interface UploadTaskListResponse { notUploadedCount: number; succeededCount: number; failedCount: number; + queuedCount: number; + uploadingCount: number; + waitingRetryCount: number; } export interface ManualSegmentCompletedTriggerResult { @@ -480,6 +492,7 @@ export interface SystemSettings { platformRequestSettings: Record; webDavUpload: WebDavUploadSettings; s3Upload: S3UploadSettings; + openListUpload: OpenListUploadSettings; enableEventScripts: boolean; enableLiveStartedScript: boolean; liveStartedScriptMode: string; @@ -599,6 +612,32 @@ export interface S3UploadSettings { forcePathStyle: boolean; } +export interface OpenListUploadSettings { + baseUrl: string; + username: string; + password: string; + basePath: string; + sourcePath: string; + destinationPath: string; +} + +export interface OpenListConnectionTestResult { + success: boolean; + version?: string; + message: string; +} + +export interface OpenListDirectoryItem { + name: string; + path: string; +} + +export interface OpenListDirectoryListResult { + path: string; + canWrite: boolean; + directories: OpenListDirectoryItem[]; +} + export interface EventScriptTestResult { success: boolean; message: string; @@ -813,7 +852,9 @@ export const uploadStatusLabelMap: Record = { 0: "未上传", 1: "已上传", 2: "上传失败", - 3: "上传中" + 3: "上传中", + 4: "排队中", + 5: "等待重试" }; export interface DanmakuEvent { diff --git a/frontend/src/views/RecordSessionDetailView.vue b/frontend/src/views/RecordSessionDetailView.vue index 79cd4a4..4be8f42 100644 --- a/frontend/src/views/RecordSessionDetailView.vue +++ b/frontend/src/views/RecordSessionDetailView.vue @@ -113,7 +113,8 @@ async function uploadSessionArtifacts() { try { const { data } = await apiClient.post(`/record-sessions/${props.id}/upload`); - const message = `会话上传完成:成功 ${data.successCount},失败 ${data.failedCount}。`; + const queued = data.items.some(item => item.uploadStatus === 4 || item.uploadStatus === 5); + const message = `${queued ? "会话上传已加入队列" : "会话上传完成"}:已受理 ${data.successCount},失败 ${data.failedCount}。`; ElMessage[data.failedCount === 0 ? "success" : "warning"](message); await loadDetail(); } catch (error) { diff --git a/frontend/src/views/RecordTasksView.vue b/frontend/src/views/RecordTasksView.vue index 5d77f9d..6c51d47 100644 --- a/frontend/src/views/RecordTasksView.vue +++ b/frontend/src/views/RecordTasksView.vue @@ -516,8 +516,9 @@ async function uploadSession(session: RecordSession) { try { const { data } = await apiClient.post(`/record-sessions/${session.id}/upload`); + const queued = data.items.some(item => item.uploadStatus === 4 || item.uploadStatus === 5); ElMessage[data.failedCount === 0 ? "success" : "warning"]( - `会话上传完成:成功 ${data.successCount},失败 ${data.failedCount}。` + `${queued ? "会话上传已加入队列" : "会话上传完成"}:已受理 ${data.successCount},失败 ${data.failedCount}。` ); await loadSessions({ resetPanels: false, resetSelection: false }); } catch (error) { diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 35c7192..1dee370 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -6,6 +6,9 @@ import type { CleanupOperation, CleanupVideoFileCondition, EventScriptTestResult, + OpenListConnectionTestResult, + OpenListDirectoryItem, + OpenListDirectoryListResult, PlatformProxySettings, PlatformRequestSettings, SystemSettings, @@ -47,6 +50,13 @@ const saving = ref(false); const changingPassword = ref(false); const testingEmail = ref(false); const testingWebhook = ref(false); +const testingOpenList = ref(false); +const openListDirectoryLoading = ref(false); +const openListDirectoryDialogVisible = ref(false); +const openListDirectoryPickerTarget = ref<"source" | "destination">("source"); +const openListCurrentDirectory = ref("/"); +const openListCurrentDirectoryCanWrite = ref(false); +const openListDirectories = ref([]); const runningRetentionCleanup = ref(false); const exportingSettings = ref(false); const importingSettings = ref(false); @@ -156,6 +166,14 @@ const form = reactive({ prefix: "", forcePathStyle: false }, + openListUpload: { + baseUrl: "", + username: "", + password: "", + basePath: "", + sourcePath: "", + destinationPath: "" + }, enableEventScripts: false, enableLiveStartedScript: false, liveStartedScriptMode: "path", @@ -386,7 +404,8 @@ const eventScriptModeOptions = [ const uploadTargetOptions = [ { label: "Do not upload", value: 0 }, { label: "WebDAV", value: 1 }, - { label: "S3", value: 2 } + { label: "S3", value: 2 }, + { label: "OpenList", value: 3 } ]; const retentionVideoFileOptions = [ @@ -471,6 +490,117 @@ const nestedSegmentedExamplePath = computed(() => { return `Douyin/origin/2026/04/15/主播名_221530_origin_主播名_直播标题_123456789/221530_origin_主播名_直播标题_123456789_00001.${extension}`; }); +function normalizeOpenListPath(path: string) { + const segments = path.replace(/\\/g, "/").split("/").filter(Boolean); + return segments.length === 0 ? "/" : `/${segments.join("/")}`; +} + +function joinOpenListPath(root: string, relativePath: string) { + return normalizeOpenListPath(`${root}/${relativePath}`); +} + +const openListSourcePreview = computed(() => + form.openListUpload.sourcePath.trim() + ? joinOpenListPath(form.openListUpload.sourcePath, segmentedExamplePath.value) + : "请先选择源挂载根目录" +); + +const openListDestinationPreview = computed(() => + form.openListUpload.destinationPath.trim() + ? joinOpenListPath(form.openListUpload.destinationPath, segmentedExamplePath.value) + : "请先选择目标归档根目录" +); + +const openListPickerTitle = computed(() => + openListDirectoryPickerTarget.value === "source" + ? "选择 OpenList 源挂载根目录" + : "选择 OpenList 目标归档根目录" +); + +const openListParentDirectory = computed(() => { + const normalized = normalizeOpenListPath(openListCurrentDirectory.value); + if (normalized === "/") { + return "/"; + } + + const index = normalized.lastIndexOf("/"); + return index <= 0 ? "/" : normalized.slice(0, index); +}); + +function buildOpenListConnectionPayload() { + return { + baseUrl: form.openListUpload.baseUrl, + username: form.openListUpload.username, + password: form.openListUpload.password + }; +} + +async function testOpenListConnection() { + testingOpenList.value = true; + + try { + const { data } = await apiClient.post( + "/settings/openlist/test", + buildOpenListConnectionPayload() + ); + ElMessage[data.success ? "success" : "warning"](data.message); + } catch (error) { + ElMessage.error(getApiErrorMessage(error, "OpenList 连接测试失败")); + } finally { + testingOpenList.value = false; + } +} + +async function loadOpenListDirectories(path: string) { + openListDirectoryLoading.value = true; + + try { + const { data } = await apiClient.post( + "/settings/openlist/directories", + { + ...buildOpenListConnectionPayload(), + path: normalizeOpenListPath(path) + } + ); + openListCurrentDirectory.value = data.path; + openListCurrentDirectoryCanWrite.value = data.canWrite; + openListDirectories.value = data.directories; + } catch (error) { + ElMessage.error(getApiErrorMessage(error, "OpenList 目录加载失败")); + } finally { + openListDirectoryLoading.value = false; + } +} + +async function openOpenListDirectoryPicker(target: "source" | "destination") { + openListDirectoryPickerTarget.value = target; + openListDirectoryDialogVisible.value = true; + const configuredPath = target === "source" + ? form.openListUpload.sourcePath + : form.openListUpload.destinationPath; + await loadOpenListDirectories(configuredPath || "/"); +} + +async function enterOpenListDirectory(path: string) { + await loadOpenListDirectories(path); +} + +function selectOpenListCurrentDirectory() { + if (openListDirectoryPickerTarget.value === "destination" && !openListCurrentDirectoryCanWrite.value) { + ElMessage.warning("该目录未声明写入权限,请选择可写的归档目录。"); + return; + } + + if (openListDirectoryPickerTarget.value === "source") { + form.openListUpload.sourcePath = openListCurrentDirectory.value; + } else { + form.openListUpload.destinationPath = openListCurrentDirectory.value; + form.openListUpload.basePath = openListCurrentDirectory.value; + } + + openListDirectoryDialogVisible.value = false; +} + async function loadSettings() { loading.value = true; loadError.value = ""; @@ -1326,9 +1456,9 @@ watch( - -

Upload and archive

-

Upload video files and matching danmaku XML automatically or manually, then optionally delete local files after a successful upload.

+ +

上传与归档

+

分片完成后可自动加入上传队列;视频和对应弹幕 XML 均校验成功后,才会按设置清理本地文件。

@@ -1338,12 +1468,12 @@ watch( - + - + @@ -1427,7 +1557,7 @@ watch( - + @@ -1450,11 +1580,119 @@ watch( +
+
+
+

OpenList 服务端复制

+

+ 源目录应是 OpenList 可见的本地录制挂载,目标目录是归档根目录。系统由 OpenList 在服务端复制,避免大文件经 Live Recorder 中转。 +

+
+ 测试连接 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ 源文件示例 + {{ openListSourcePreview }} +
+
+ 归档结果示例 + {{ openListDestinationPreview }} +
+
+ + +
+
自动上传固定处理“视频文件 + 对应弹幕 XML”。只有两者都上传成功并且你打开“上传后删本地”时,系统才会清理本地文件。
+ +
+ + 返回上级 + + {{ openListCurrentDirectory }} + + {{ openListCurrentDirectoryCanWrite ? "可写" : "只读或未知" }} + +
+ +
+ + +
+ + +
+

平台代理

These proxies only affect platform-side status checks and stream requests, not email, webhook, or file uploads.

@@ -2575,6 +2813,76 @@ watch( line-height: 1.7; } +.openlist-path-preview { + display: grid; + gap: 10px; + margin: 4px 0 16px; + padding: 14px; + border: 1px solid var(--border-subtle); + border-radius: 12px; + background: var(--surface); +} + +.openlist-path-preview > div { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.openlist-path-preview span { + color: var(--text-secondary); + font-size: 13px; +} + +.openlist-path-preview code, +.openlist-directory-toolbar code, +.openlist-directory-item code { + overflow-wrap: anywhere; + color: var(--text-primary); + font-size: 12px; +} + +.openlist-directory-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} + +.openlist-directory-toolbar code { + flex: 1; +} + +.openlist-directory-list { + min-height: 220px; + max-height: 52vh; + overflow-y: auto; + display: grid; + align-content: start; + gap: 8px; +} + +.openlist-directory-item { + display: grid; + grid-template-columns: auto minmax(120px, auto) minmax(0, 1fr); + gap: 10px; + align-items: center; + width: 100%; + padding: 11px 12px; + border: 1px solid var(--border-subtle); + border-radius: 10px; + color: var(--text-primary); + background: var(--surface); + cursor: pointer; + text-align: left; +} + +.openlist-directory-item:hover { + border-color: var(--primary); + background: var(--surface-raised); +} + @media (max-width: 960px) { .settings-overview-grid { grid-template-columns: 1fr; @@ -2643,6 +2951,16 @@ watch( padding: 16px 16px 2px; } + .openlist-path-preview > div, + .openlist-directory-item { + grid-template-columns: 1fr; + } + + .openlist-directory-toolbar { + align-items: flex-start; + flex-wrap: wrap; + } + .settings-savebar__content { padding: 12px 14px; border-radius: 12px; diff --git a/frontend/src/views/UploadTasksView.vue b/frontend/src/views/UploadTasksView.vue index 983250e..31334ec 100644 --- a/frontend/src/views/UploadTasksView.vue +++ b/frontend/src/views/UploadTasksView.vue @@ -1,5 +1,5 @@ + + + +