feat: add reliable OpenList segment uploads
This commit is contained in:
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
+42
-1
@@ -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<string, PlatformRequestSettings>;
|
||||
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<number, string> = {
|
||||
0: "未上传",
|
||||
1: "已上传",
|
||||
2: "上传失败",
|
||||
3: "上传中"
|
||||
3: "上传中",
|
||||
4: "排队中",
|
||||
5: "等待重试"
|
||||
};
|
||||
|
||||
export interface DanmakuEvent {
|
||||
|
||||
@@ -113,7 +113,8 @@ async function uploadSessionArtifacts() {
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>(`/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) {
|
||||
|
||||
@@ -516,8 +516,9 @@ async function uploadSession(session: RecordSession) {
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>(`/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) {
|
||||
|
||||
@@ -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<OpenListDirectoryItem[]>([]);
|
||||
const runningRetentionCleanup = ref(false);
|
||||
const exportingSettings = ref(false);
|
||||
const importingSettings = ref(false);
|
||||
@@ -156,6 +166,14 @@ const form = reactive<SettingsFormModel>({
|
||||
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<OpenListConnectionTestResult>(
|
||||
"/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<OpenListDirectoryListResult>(
|
||||
"/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(
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">Upload and archive</h3>
|
||||
<p class="section-subtitle">Upload video files and matching danmaku XML automatically or manually, then optionally delete local files after a successful upload.</p>
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">上传与归档</h3>
|
||||
<p class="section-subtitle">分片完成后可自动加入上传队列;视频和对应弹幕 XML 均校验成功后,才会按设置清理本地文件。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
@@ -1338,12 +1468,12 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="鑷姩涓婁紶">
|
||||
<el-form-item label="自动上传">
|
||||
<el-switch v-model="form.enableAutoUpload" :disabled="!form.enableFileUpload" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="涓婁紶鍚庡垹鏈湴">
|
||||
<el-form-item label="上传后删本地">
|
||||
<el-switch v-model="form.deleteLocalFilesAfterUpload" :disabled="!form.enableFileUpload" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1427,7 +1557,7 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鍓嶇紑">
|
||||
<el-form-item label="前缀">
|
||||
<el-input v-model="form.s3Upload.prefix" placeholder="live-recorder/" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1450,11 +1580,119 @@ watch(
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<div v-if="form.enableFileUpload && form.uploadTarget === 3" class="template-section">
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">OpenList 服务端复制</h4>
|
||||
<p class="template-section__subtitle">
|
||||
源目录应是 OpenList 可见的本地录制挂载,目标目录是归档根目录。系统由 OpenList 在服务端复制,避免大文件经 Live Recorder 中转。
|
||||
</p>
|
||||
</div>
|
||||
<el-button :loading="testingOpenList" @click="testOpenListConnection">测试连接</el-button>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="OpenList 地址">
|
||||
<el-input v-model="form.openListUpload.baseUrl" placeholder="https://openlist.example.com" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.openListUpload.username" autocomplete="username" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="form.openListUpload.password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="源挂载根目录">
|
||||
<el-input v-model="form.openListUpload.sourcePath" readonly placeholder="从 OpenList 现有目录中选择">
|
||||
<template #append>
|
||||
<el-button @click="openOpenListDirectoryPicker('source')">选择</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="目标归档根目录">
|
||||
<el-input v-model="form.openListUpload.destinationPath" readonly placeholder="从 OpenList 现有目录中选择">
|
||||
<template #append>
|
||||
<el-button @click="openOpenListDirectoryPicker('destination')">选择</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
|
||||
<div class="openlist-path-preview">
|
||||
<div>
|
||||
<span>源文件示例</span>
|
||||
<code>{{ openListSourcePreview }}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span>归档结果示例</span>
|
||||
<code>{{ openListDestinationPreview }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="路径中已有平台、日期或主播目录时会直接复用;系统只逐级创建缺失目录。同名文件大小或可用哈希不一致时会标记冲突,绝不覆盖。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="helper-panel">
|
||||
自动上传固定处理“视频文件 + 对应弹幕 XML”。只有两者都上传成功并且你打开“上传后删本地”时,系统才会清理本地文件。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="openListDirectoryDialogVisible" :title="openListPickerTitle" width="min(680px, 92vw)">
|
||||
<div class="openlist-directory-toolbar">
|
||||
<el-button
|
||||
:disabled="openListCurrentDirectory === '/' || openListDirectoryLoading"
|
||||
@click="enterOpenListDirectory(openListParentDirectory)"
|
||||
>
|
||||
返回上级
|
||||
</el-button>
|
||||
<code>{{ openListCurrentDirectory }}</code>
|
||||
<el-tag :type="openListCurrentDirectoryCanWrite ? 'success' : 'info'">
|
||||
{{ openListCurrentDirectoryCanWrite ? "可写" : "只读或未知" }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div v-loading="openListDirectoryLoading" class="openlist-directory-list">
|
||||
<el-empty v-if="!openListDirectoryLoading && openListDirectories.length === 0" description="当前目录没有子目录" />
|
||||
<button
|
||||
v-for="directory in openListDirectories"
|
||||
:key="directory.path"
|
||||
type="button"
|
||||
class="openlist-directory-item"
|
||||
@click="enterOpenListDirectory(directory.path)"
|
||||
>
|
||||
<span>📁</span>
|
||||
<strong>{{ directory.name }}</strong>
|
||||
<code>{{ directory.path }}</code>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="openListDirectoryDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="selectOpenListCurrentDirectory">选择当前目录</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">平台代理</h3>
|
||||
<p class="section-subtitle">These proxies only affect platform-side status checks and stream requests, not email, webhook, or file uploads.</p>
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Bell, CircleCheck, CircleClose, UploadFilled } from "@element-plus/icons-vue";
|
||||
@@ -21,18 +21,25 @@ const totalCount = ref(0);
|
||||
const notUploadedCount = ref(0);
|
||||
const succeededCount = ref(0);
|
||||
const failedCount = ref(0);
|
||||
const queuedCount = ref(0);
|
||||
const uploadingCount = ref(0);
|
||||
const waitingRetryCount = 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);
|
||||
let refreshTimer: number | null = null;
|
||||
|
||||
const filterOptions = [
|
||||
{ label: "全部", value: null as number | null },
|
||||
{ label: "待上传", value: 0 },
|
||||
{ label: "已上传", value: 1 },
|
||||
{ label: "上传失败", value: 2 }
|
||||
{ label: "上传失败", value: 2 },
|
||||
{ label: "上传中", value: 3 },
|
||||
{ label: "排队中", value: 4 },
|
||||
{ label: "等待重试", value: 5 }
|
||||
];
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize)));
|
||||
@@ -56,6 +63,9 @@ async function loadUploadStatus() {
|
||||
notUploadedCount.value = data.notUploadedCount;
|
||||
succeededCount.value = data.succeededCount;
|
||||
failedCount.value = data.failedCount;
|
||||
queuedCount.value = data.queuedCount;
|
||||
uploadingCount.value = data.uploadingCount;
|
||||
waitingRetryCount.value = data.waitingRetryCount;
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorMessage(error, "上传任务列表加载失败,请稍后重试。");
|
||||
} finally {
|
||||
@@ -113,7 +123,7 @@ async function retryAllFailed() {
|
||||
}
|
||||
|
||||
ElMessage[successCount > 0 ? "success" : "warning"](
|
||||
`重试完成:成功 ${successCount},失败 ${failCount}。`
|
||||
`重试请求已处理:已受理 ${successCount},失败 ${failCount}。`
|
||||
);
|
||||
await loadUploadStatus();
|
||||
} finally {
|
||||
@@ -147,7 +157,7 @@ async function uploadAllPending() {
|
||||
}
|
||||
|
||||
ElMessage[successCount > 0 ? "success" : "warning"](
|
||||
`批量上传完成:成功 ${successCount},失败 ${failCount}。`
|
||||
`批量上传请求已处理:已受理 ${successCount},失败 ${failCount}。`
|
||||
);
|
||||
await loadUploadStatus();
|
||||
} finally {
|
||||
@@ -183,7 +193,26 @@ function formatFileSize(bytes?: number) {
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
onMounted(loadUploadStatus);
|
||||
function formatProgress(value?: number) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? `${Math.max(0, Math.min(100, value)).toFixed(1)}%`
|
||||
: "-";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadUploadStatus();
|
||||
refreshTimer = window.setInterval(() => {
|
||||
if (!loading.value && !uploadingTaskId.value && !retryingFailed.value && !uploadingAllPending.value) {
|
||||
void loadUploadStatus();
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (refreshTimer !== null) {
|
||||
window.clearInterval(refreshTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -210,6 +239,8 @@ onMounted(loadUploadStatus);
|
||||
<MetricCard label="待上传" :value="notUploadedCount" description="尚未上传的分片" :icon="UploadFilled" />
|
||||
<MetricCard label="已上传" :value="succeededCount" description="上传成功的分片" :icon="CircleCheck" />
|
||||
<MetricCard label="上传失败" :value="failedCount" description="上传失败的分片" :icon="CircleClose" />
|
||||
<MetricCard label="队列处理中" :value="queuedCount + uploadingCount" description="排队或正在由 OpenList 复制" :icon="UploadFilled" />
|
||||
<MetricCard label="等待重试" :value="waitingRetryCount" description="按退避策略等待下一次尝试" :icon="Bell" />
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card upload-card" shadow="never">
|
||||
@@ -305,6 +336,25 @@ onMounted(loadUploadStatus);
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="进度 / 重试" width="150">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.uploadStatus === 3 || row.uploadStatus === 4 || row.uploadStatus === 5">
|
||||
<el-progress
|
||||
:percentage="Math.round(row.uploadProgressPercent || 0)"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
/>
|
||||
<div class="cell-subtitle">
|
||||
{{ formatProgress(row.uploadProgressPercent) }} · 第 {{ row.uploadAttemptCount || 0 }} 次
|
||||
</div>
|
||||
<div v-if="row.nextUploadAttemptAt" class="cell-subtitle">
|
||||
下次:{{ formatDate(row.nextUploadAttemptAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="上传方式" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.lastUploadProvider || "-" }}
|
||||
@@ -334,7 +384,7 @@ onMounted(loadUploadStatus);
|
||||
<div class="task-actions-cell">
|
||||
<el-button size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button
|
||||
v-if="row.uploadStatus !== 1"
|
||||
v-if="row.uploadStatus !== 1 && row.uploadStatus !== 3 && row.uploadStatus !== 4 && row.uploadStatus !== 5"
|
||||
size="small"
|
||||
type="primary"
|
||||
:loading="uploadingTaskId === row.recordTaskId"
|
||||
|
||||
Reference in New Issue
Block a user