feat: add shared PostgreSQL fnOS service and refresh UI

This commit is contained in:
2026-08-02 20:14:39 +08:00
parent de9f5ae110
commit e5b50ea85c
52 changed files with 3577 additions and 338 deletions
+327 -179
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { ElMessage, ElMessageBox } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type {
CleanupOperation,
@@ -26,7 +26,7 @@ import {
import { useAuthStore } from "@/stores/auth";
import { useViewport } from "@/composables/useViewport";
import { useUiPreferences } from "@/composables/useUiPreferences";
import { useRoute } from "vue-router";
import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
type ScriptEventType = "live_started" | "live_ended" | "segment_completed";
@@ -41,6 +41,7 @@ type SettingsFormModel = SystemSettings & {
const authStore = useAuthStore();
const route = useRoute();
const router = useRouter();
const retentionCleanupStorageKey = "live-recorder-settings-retention-cleanup-operation-id";
const { isMobile } = useViewport();
const { themeMode, density, sidebarCollapsed } = useUiPreferences();
@@ -74,13 +75,20 @@ const scriptTestResults = reactive<Record<ScriptEventType, EventScriptTestResult
const webhookTestResult = ref<WebhookTestResult | null>(null);
const retentionCleanupOperation = ref<CleanupOperation | null>(null);
const loadError = ref("");
const activeSettingTab = ref("recording");
const settingSections = ["recording", "upload", "automation", "notifications", "platform", "account"] as const;
type SettingSection = typeof settingSections[number];
function normalizeSettingSection(value: unknown): SettingSection {
const section = String(value || "recording") as SettingSection;
return settingSections.includes(section) ? section : "recording";
}
const activeSettingTab = ref<SettingSection>(normalizeSettingSection(route.params.section));
const profileDisplayName = computed(() => authStore.user?.displayName || authStore.user?.username || "管理员");
const profileUsername = computed(() => authStore.user?.username || "--");
const profileUserId = computed(() => authStore.user?.userId || "--");
const profileExpiresAt = computed(() => authStore.user?.expiresAt || "--");
const profileInitial = computed(() => profileDisplayName.value.trim().slice(0, 1).toUpperCase() || "录");
const qualitySupportHint = "Different platforms expose different quality ladders. If a target quality is unavailable, the recorder automatically falls back to the closest stream that platform offers.";
const qualitySupportHint = "不同平台提供的画质档位并不完全一致;目标画质不可用时,录制器会自动选择最接近的可用流。";
const qualityOptions = qualityOptionList;
const platformRequestPlatforms = platformOptionList;
let retentionCleanupPollTimer: number | null = null;
@@ -201,34 +209,34 @@ const form = reactive<SettingsFormModel>({
emailToAddresses: "",
notifyOnLiveStarted: true,
notifyOnException: true,
emailLiveStartedSubjectTemplate: "[{{appName}}] Live started: {{anchor}} {{title}} ({{roomId}})",
emailLiveStartedSubjectTemplate: "[{{appName}}] 直播已开始:{{anchor}} {{title}}{{roomId}}",
emailLiveStartedBodyTemplateHtml: `<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #3e5f7c;">Live started</h2>
<p>The monitored live room is now online.</p>
<h2 style="margin: 0 0 16px; color: #3e5f7c;">直播已开始</h2>
<p>监控的直播间现已开播。</p>
<ul>
<li><strong>Platform:</strong> {{platform}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Title:</strong> {{title}}</li>
<li><strong>Anchor:</strong> {{anchor}}</li>
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
<li><strong>平台:</strong> {{platform}}</li>
<li><strong>房间号:</strong> {{roomId}}</li>
<li><strong>标题:</strong> {{title}}</li>
<li><strong>主播:</strong> {{anchor}}</li>
<li><strong>检测时间(北京时间):</strong> {{detectedAtUtc}}</li>
</ul>
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
<p><strong>直播地址:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
<div style="margin-top: 16px;">
<strong>Event Script Output:</strong>
<strong>事件脚本输出:</strong>
</div>
<div style="margin-top: 8px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{eventScriptOutput}}</div>
</div>`,
emailExceptionSubjectTemplate: "[{{appName}}] Exception: {{source}}",
emailExceptionSubjectTemplate: "[{{appName}}] 录制异常:{{source}}",
emailExceptionBodyTemplateHtml: `<div style="font-family: 'Segoe UI', 'PingFang SC', sans-serif; color: #1f2937; line-height: 1.7;">
<h2 style="margin: 0 0 16px; color: #8b5e3c;">Exception detected</h2>
<h2 style="margin: 0 0 16px; color: #8b5e3c;">检测到录制异常</h2>
<p>{{summary}}</p>
<ul>
<li><strong>Source:</strong> {{source}}</li>
<li><strong>Live Room ID:</strong> {{liveRoomId}}</li>
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
<li><strong>Task Status:</strong> {{taskStatus}}</li>
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
<li><strong>来源:</strong> {{source}}</li>
<li><strong>直播间 ID</strong> {{liveRoomId}}</li>
<li><strong>平台房间号:</strong> {{roomId}}</li>
<li><strong>录制任务 ID</strong> {{recordTaskId}}</li>
<li><strong>任务状态:</strong> {{taskStatus}}</li>
<li><strong>发生时间(北京时间):</strong> {{occurredAtUtc}}</li>
</ul>
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
</div>`,
@@ -244,6 +252,33 @@ const form = reactive<SettingsFormModel>({
douyinCookie: ""
});
const savedSettingsSnapshot = ref<SettingsFormModel | null>(null);
function cloneSettingsForm(): SettingsFormModel {
return JSON.parse(JSON.stringify(form)) as SettingsFormModel;
}
function markSettingsSaved() {
savedSettingsSnapshot.value = cloneSettingsForm();
}
const isDirty = computed(() => {
if (!savedSettingsSnapshot.value || loading.value) {
return false;
}
return JSON.stringify(form) !== JSON.stringify(savedSettingsSnapshot.value);
});
function discardSettingsChanges() {
if (!savedSettingsSnapshot.value) {
return;
}
Object.assign(form, JSON.parse(JSON.stringify(savedSettingsSnapshot.value)) as SettingsFormModel);
ElMessage.info("已放弃未保存的修改");
}
function normalizePlatformRequestSettings(
value?: Record<string, PlatformRequestSettings> | null
): Record<string, PlatformRequestSettings> {
@@ -364,36 +399,36 @@ const webhookTemplateTokens = [
];
const eventScriptEnvironmentExamples = [
{ name: "LIVE_RECORDER_EVENT", example: "segment_completed", scope: "All events" },
{ name: "LIVE_RECORDER_PLATFORM", example: "Douyin", scope: "All events" },
{ name: "LIVE_RECORDER_LIVE_ROOM_ID", example: "6f73a2f2-1d4c-4e7a-a9b1-3d29d54ed901", scope: "All events" },
{ name: "LIVE_RECORDER_ROOM_ID", example: "676493068539", scope: "All events" },
{ name: "LIVE_RECORDER_TITLE", example: "Casual stream", scope: "All events" },
{ name: "LIVE_RECORDER_ANCHOR", example: "Streamer Name", scope: "All events" },
{ name: "LIVE_RECORDER_SOURCE_URL", example: "https://live.douyin.com/676493068539", scope: "All events" },
{ name: "LIVE_RECORDER_OCCURRED_AT_UTC", example: "2026-04-25T20:34:56.7890000+08:00", scope: "All events" },
{ name: "LIVE_RECORDER_EVENT", example: "segment_completed", scope: "所有事件" },
{ name: "LIVE_RECORDER_PLATFORM", example: "Douyin", scope: "所有事件" },
{ name: "LIVE_RECORDER_LIVE_ROOM_ID", example: "6f73a2f2-1d4c-4e7a-a9b1-3d29d54ed901", scope: "所有事件" },
{ name: "LIVE_RECORDER_ROOM_ID", example: "676493068539", scope: "所有事件" },
{ name: "LIVE_RECORDER_TITLE", example: "日常直播", scope: "所有事件" },
{ name: "LIVE_RECORDER_ANCHOR", example: "主播名称", scope: "所有事件" },
{ name: "LIVE_RECORDER_SOURCE_URL", example: "https://live.douyin.com/676493068539", scope: "所有事件" },
{ name: "LIVE_RECORDER_OCCURRED_AT_UTC", example: "2026-04-25T20:34:56.7890000+08:00", scope: "所有事件" },
{
name: "LIVE_RECORDER_SCRIPT_LOG_PATH",
example: "/tmp/live-recorder-script-log-7a13c2c5e5cd4f2d8ec2c3b2d5f3f1aa.txt",
scope: "All events"
scope: "所有事件"
},
{ name: "LIVE_RECORDER_RECORD_SESSION_ID", example: "8e2e9c64-b8f6-4d15-b6cb-1d4ce0adab77", scope: "Segment completed only" },
{ name: "LIVE_RECORDER_RECORD_TASK_ID", example: "2a4810a2-7ef4-4a22-90d4-0211b90cc54c", scope: "Segment completed only" },
{ name: "LIVE_RECORDER_SEGMENT_INDEX", example: "1", scope: "Segment completed only" },
{ name: "LIVE_RECORDER_RECORD_SESSION_ID", example: "8e2e9c64-b8f6-4d15-b6cb-1d4ce0adab77", scope: "仅分片完成" },
{ name: "LIVE_RECORDER_RECORD_TASK_ID", example: "2a4810a2-7ef4-4a22-90d4-0211b90cc54c", scope: "仅分片完成" },
{ name: "LIVE_RECORDER_SEGMENT_INDEX", example: "1", scope: "仅分片完成" },
{
name: "LIVE_RECORDER_SEGMENT_FILE_PATH",
example: "/app/records/Douyin/Streamer Name/2026-04-25/203000_casual-stream__00001.mp4",
scope: "Segment completed only"
scope: "仅分片完成"
},
{
name: "LIVE_RECORDER_DANMAKU_FILE_PATH",
example: "/app/records/Douyin/Streamer Name/2026-04-25/203000_casual-stream__00001.xml",
scope: "Segment completed only"
scope: "仅分片完成"
},
{ name: "LIVE_RECORDER_DURATION_SECONDS", example: "2185.1", scope: "Segment completed only" },
{ name: "LIVE_RECORDER_FILE_SIZE_BYTES", example: "734003200", scope: "Segment completed only" },
{ name: "LIVE_RECORDER_TASK_STATUS", example: "Completed", scope: "Segment completed only" },
{ name: "LIVE_RECORDER_SESSION_STATUS", example: "Running", scope: "Segment completed only" }
{ name: "LIVE_RECORDER_DURATION_SECONDS", example: "2185.1", scope: "仅分片完成" },
{ name: "LIVE_RECORDER_FILE_SIZE_BYTES", example: "734003200", scope: "仅分片完成" },
{ name: "LIVE_RECORDER_TASK_STATUS", example: "Completed", scope: "仅分片完成" },
{ name: "LIVE_RECORDER_SESSION_STATUS", example: "Running", scope: "仅分片完成" }
];
const eventScriptModeOptions = [
@@ -402,25 +437,25 @@ const eventScriptModeOptions = [
];
const uploadTargetOptions = [
{ label: "Do not upload", value: 0 },
{ label: "不上传", value: 0 },
{ label: "WebDAV", value: 1 },
{ label: "S3", value: 2 },
{ label: "OpenList", value: 3 }
];
const retentionVideoFileOptions = [
{ label: "Any file state", value: "any" as CleanupVideoFileCondition },
{ label: "All video files missing", value: "allMissing" as CleanupVideoFileCondition },
{ label: "All video files present", value: "allPresent" as CleanupVideoFileCondition }
{ label: "任意文件状态", value: "any" as CleanupVideoFileCondition },
{ label: "视频文件全部缺失", value: "allMissing" as CleanupVideoFileCondition },
{ label: "视频文件全部存在", value: "allPresent" as CleanupVideoFileCondition }
];
const retentionTaskStatusOptions = Object.entries(taskStatusLabelMap)
.map(([value, label]) => ({ value: Number(value), label }))
.filter((option) => option.value !== 1 && option.value !== 2 && option.value !== 3);
const retentionCleanupStatusLabelMap: Record<CleanupOperation["status"], string> = {
queued: "Queued",
running: "Running",
completed: "Completed",
failed: "Failed"
queued: "等待执行",
running: "执行中",
completed: "已完成",
failed: "失败"
};
const retentionCleanupStatusLabel = computed(() =>
retentionCleanupOperation.value ? retentionCleanupStatusLabelMap[retentionCleanupOperation.value.status] : ""
@@ -453,7 +488,7 @@ const retentionCleanupProgressText = computed(() => {
}
if (retentionCleanupOperation.value.totalSessionCount === 0) {
return retentionCleanupOperation.value.status === "queued" ? "Scanning candidate sessions" : "0 / 0";
return retentionCleanupOperation.value.status === "queued" ? "正在扫描候选录制会话" : "0 / 0";
}
return `${retentionCleanupOperation.value.processedSessionCount} / ${retentionCleanupOperation.value.totalSessionCount}`;
@@ -464,13 +499,13 @@ const retentionCleanupSummary = computed(() => {
}
return [
`sessions ${retentionCleanupOperation.value.deletedSessionCount}`,
`tasks ${retentionCleanupOperation.value.deletedTaskCount}`,
`results ${retentionCleanupOperation.value.deletedResultCount}`,
`logs ${retentionCleanupOperation.value.deletedLogCount}`,
`files ${retentionCleanupOperation.value.deletedFileCount}`,
`danmaku ${retentionCleanupOperation.value.deletedDanmakuFileCount}`
].join(" ");
`会话 ${retentionCleanupOperation.value.deletedSessionCount}`,
`任务 ${retentionCleanupOperation.value.deletedTaskCount}`,
`结果 ${retentionCleanupOperation.value.deletedResultCount}`,
`日志 ${retentionCleanupOperation.value.deletedLogCount}`,
`视频 ${retentionCleanupOperation.value.deletedFileCount}`,
`弹幕 ${retentionCleanupOperation.value.deletedDanmakuFileCount}`
].join(" · ");
});
const retentionCleanupWarningsPreview = computed(() => retentionCleanupOperation.value?.warnings.slice(0, 6) ?? []);
@@ -610,8 +645,9 @@ async function loadSettings() {
Object.assign(form, data);
form.platformRequestSettings = normalizePlatformRequestSettings(data.platformRequestSettings);
syncLegacyPlatformAliasesFromMap();
markSettingsSaved();
} catch (error) {
loadError.value = getApiErrorMessage(error, "Failed to load system settings. Please try again later.");
loadError.value = getApiErrorMessage(error, "系统设置加载失败,请稍后重试。");
} finally {
loading.value = false;
}
@@ -624,17 +660,17 @@ const pwdForm = reactive({
});
const pwdRules = {
currentPassword: [{ required: true, message: "Please enter the current password", trigger: "blur" }],
currentPassword: [{ required: true, message: "请输入当前密码", trigger: "blur" }],
newPassword: [
{ required: true, message: "请输入新密码", trigger: "blur" },
{ min: 6, message: "Password must be at least 6 characters", trigger: "blur" }
{ min: 6, message: "密码至少需要 6 个字符", trigger: "blur" }
],
confirmPassword: [
{ required: true, message: "请再次输入新密码", trigger: "blur" },
{
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (value !== pwdForm.newPassword) {
callback(new Error("Passwords do not match"));
callback(new Error("两次输入的密码不一致"));
} else {
callback();
}
@@ -653,13 +689,13 @@ async function changePassword() {
changingPassword.value = true;
try {
await authStore.changePassword(pwdForm.currentPassword, pwdForm.newPassword);
ElMessage.success("Password updated.");
ElMessage.success("密码已更新");
pwdForm.currentPassword = "";
pwdForm.newPassword = "";
pwdForm.confirmPassword = "";
pwdFormRef.value?.resetFields();
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "Failed to change password. Please try again later."));
ElMessage.error(getApiErrorMessage(error, "密码修改失败,请稍后重试。"));
} finally {
changingPassword.value = false;
}
@@ -678,7 +714,8 @@ async function saveSettings() {
Object.assign(form, data);
form.platformRequestSettings = normalizePlatformRequestSettings(data.platformRequestSettings);
syncLegacyPlatformAliasesFromMap();
ElMessage.success("Settings saved.");
markSettingsSaved();
ElMessage.success("设置已保存");
} finally {
saving.value = false;
}
@@ -705,7 +742,7 @@ async function sendTestEmail() {
ElMessage.success("测试邮件已发送,请检查收件箱");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "Failed to send test email."));
ElMessage.error(getApiErrorMessage(error, "测试邮件发送失败。"));
} finally {
testingEmail.value = false;
}
@@ -760,7 +797,7 @@ async function runRetentionCleanup() {
try {
const { data } = await apiClient.post<CleanupOperation>("/settings/retention/run-now");
await startRetentionCleanupTracking(data);
ElMessage.success("Retention cleanup background task created.");
ElMessage.success("保留清理任务已创建");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "保留清理执行失败"));
} finally {
@@ -823,7 +860,7 @@ async function refreshRetentionCleanupOperation(operationId: string, options?: {
stopRetentionCleanupPolling();
if (!options?.silent) {
ElMessage.error(getApiErrorMessage(error, "Failed to load retention cleanup task status."));
ElMessage.error(getApiErrorMessage(error, "保留清理任务状态加载失败。"));
}
}
}
@@ -869,7 +906,7 @@ async function exportSettingsBackup() {
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
ElMessage.success("Settings backup exported.");
ElMessage.success("配置备份已导出");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "系统设置导出失败"));
} finally {
@@ -896,7 +933,7 @@ async function importSettingsBackup(event: Event) {
const payload = JSON.parse(await file.text());
const { data } = await apiClient.post<SystemSettings>("/settings/import", payload);
Object.assign(form, data);
ElMessage.success("Settings imported from backup.");
ElMessage.success("配置备份已导入,请检查后保存");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "系统设置导入失败"));
} finally {
@@ -976,7 +1013,7 @@ async function syncSettingsHash(hash = route.hash) {
}
if (hash === "#security") {
activeSettingTab.value = "security";
activeSettingTab.value = "account";
}
await nextTick();
@@ -984,6 +1021,9 @@ async function syncSettingsHash(hash = route.hash) {
}
onMounted(async () => {
if (String(route.params.section || "") !== activeSettingTab.value) {
await router.replace({ name: "settings", params: { section: activeSettingTab.value }, hash: route.hash });
}
await loadSettings();
await restoreRetentionCleanupTracking();
await syncSettingsHash();
@@ -993,12 +1033,45 @@ onBeforeUnmount(() => {
stopRetentionCleanupPolling();
});
watch(
() => route.params.section,
(section) => {
activeSettingTab.value = normalizeSettingSection(section);
}
);
watch(
activeSettingTab,
(section) => {
if (String(route.params.section || "") !== section) {
void router.replace({ name: "settings", params: { section }, hash: route.hash });
}
}
);
watch(
() => route.hash,
(hash) => {
void syncSettingsHash(hash);
}
);
onBeforeRouteLeave(async () => {
if (!isDirty.value) {
return true;
}
try {
await ElMessageBox.confirm(
"当前设置尚未保存,离开后修改会丢失。",
"确认离开设置页",
{ confirmButtonText: "离开", cancelButtonText: "继续编辑", type: "warning" }
);
return true;
} catch {
return false;
}
});
</script>
<template>
@@ -1015,7 +1088,6 @@ watch(
<div class="page-toolbar">
<el-button :loading="exportingSettings" @click="exportSettingsBackup">导出配置</el-button>
<el-button :loading="importingSettings" @click="triggerImportSettings">导入配置</el-button>
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
</div>
</div>
@@ -1029,71 +1101,42 @@ watch(
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="settings-overview-grid">
<el-card id="profile" class="surface-card settings-overview-card" shadow="never">
<div class="settings-overview-card__header">
<div>
<div class="settings-overview-card__eyebrow">个人资料</div>
<h3 class="section-title">当前登录账户</h3>
</div>
<section id="preferences" class="settings-quickbar surface-card" aria-label="账户与显示偏好">
<div class="settings-profile settings-profile--compact">
<div class="settings-profile__avatar">{{ profileInitial }}</div>
<div>
<div class="settings-profile__name">{{ profileDisplayName }}</div>
<div class="settings-profile__meta">{{ profileUsername }} · {{ profileUserId }}</div>
</div>
<div class="settings-profile">
<div class="settings-profile__avatar">{{ profileInitial }}</div>
<div>
<div class="settings-profile__name">{{ profileDisplayName }}</div>
<div class="settings-profile__meta">{{ profileUsername }}</div>
</div>
</div>
<div class="settings-overview-list">
<div><span>用户名</span><strong>{{ profileUsername }}</strong></div>
<div><span>用户 ID</span><strong>{{ profileUserId }}</strong></div>
<div><span>邮箱</span><strong>--</strong></div>
<div><span>角色</span><strong>--</strong></div>
<div><span>当前空间</span><strong>--</strong></div>
<div><span>在线状态</span><strong>在线</strong></div>
<div><span>凭证到期</span><strong>{{ profileExpiresAt }}</strong></div>
</div>
</el-card>
<el-card id="preferences" class="surface-card settings-overview-card" shadow="never">
<div class="settings-overview-card__header">
<div>
<div class="settings-overview-card__eyebrow">偏好设置</div>
<h3 class="section-title">控制台显示偏好</h3>
</div>
</div>
<div class="settings-preferences">
<div class="settings-preferences__row">
<span>主题模式</span>
<el-select v-model="themeMode">
<el-option label="跟随系统" value="system" />
<el-option label="浅色" value="light" />
<el-option label="深色" value="dark" />
</el-select>
</div>
<div class="settings-preferences__row">
<span>显示密度</span>
<el-select v-model="density">
<el-option label="舒适密度" value="comfortable" />
<el-option label="紧凑密度" value="compact" />
</el-select>
</div>
<div class="settings-preferences__row settings-preferences__row--switch">
<div>
<strong>侧栏折叠</strong>
<p>继续复用当前前端偏好存储逻辑</p>
</div>
<el-switch v-model="sidebarCollapsed" />
</div>
</div>
</el-card>
</div>
</div>
<label class="settings-quickbar__control">
<span>主题</span>
<el-select v-model="themeMode" size="small">
<el-option label="跟随系统" value="system" />
<el-option label="浅色" value="light" />
<el-option label="深色" value="dark" />
</el-select>
</label>
<label class="settings-quickbar__control">
<span>密度</span>
<el-select v-model="density" size="small">
<el-option label="舒适" value="comfortable" />
<el-option label="紧凑" value="compact" />
</el-select>
</label>
<label class="settings-quickbar__switch">
<span>折叠侧栏</span>
<el-switch v-model="sidebarCollapsed" />
</label>
</section>
<div class="settings-grid" v-loading="loading">
<el-tabs v-model="activeSettingTab" type="border-card" class="settings-tabs">
<el-tabs
v-model="activeSettingTab"
type="border-card"
class="settings-tabs"
:tab-position="isMobile ? 'top' : 'left'"
>
<el-tab-pane label="录制" name="recording">
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">录制基础</h3>
@@ -1312,7 +1355,7 @@ watch(
</el-form>
<div class="action-strip">
<div class="helper-text">Run now and the daily retention cleanup use the same saved rules. Save this section first if you just changed the filters.</div>
<div class="helper-text">立即执行和每日自动清理使用相同规则刚修改筛选条件时请先保存设置</div>
<el-button :loading="runningRetentionCleanup" @click="runRetentionCleanup">立即执行清理</el-button>
</div>
@@ -1321,10 +1364,10 @@ watch(
class="test-result"
:class="retentionCleanupOperation.status === 'failed' || retentionCleanupOperation.warnings.length ? 'test-result--warning' : 'test-result--success'"
>
<div class="test-result__title">Current cleanup task</div>
<div class="test-result__title">当前清理任务</div>
<div class="test-result__meta">
Status={{ retentionCleanupStatusLabel }}
progress={{ retentionCleanupProgressText }}
状态={{ retentionCleanupStatusLabel }} ·
进度={{ retentionCleanupProgressText }} ·
{{ retentionCleanupSummary }}
</div>
<div v-if="retentionCleanupOperation.errorMessage" class="test-result__detail">
@@ -1334,8 +1377,8 @@ watch(
<li v-for="warning in retentionCleanupWarningsPreview" :key="warning">{{ warning }}</li>
</ul>
<div class="action-strip">
<div class="helper-text">This page keeps polling the same task after refresh until it finishes or fails.</div>
<el-button v-if="retentionCleanupFinished" text @click="clearTrackedRetentionCleanup">Dismiss</el-button>
<div class="helper-text">刷新页面后仍会继续跟踪该任务直到完成或失败</div>
<el-button v-if="retentionCleanupFinished" text @click="clearTrackedRetentionCleanup">关闭</el-button>
<el-tag v-else :type="retentionCleanupTagType">{{ retentionCleanupStatusLabel }}</el-tag>
</div>
</div>
@@ -1343,7 +1386,7 @@ watch(
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">弹幕录制</h3>
<p class="section-subtitle">Control parallel danmaku XML recording, non-chat event capture, and retry / polling pacing.</p>
<p class="section-subtitle">控制弹幕 XML 并行录制非聊天事件采集和失败重试节奏</p>
<el-form label-position="top">
<el-row :gutter="16">
@@ -1353,12 +1396,12 @@ watch(
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Record non-chat events">
<el-form-item label="记录非聊天事件">
<el-switch v-model="form.danmakuIncludeNonChatEvents" :disabled="!form.enableDanmakuRecording" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Minimum polling interval (ms)">
<el-form-item label="最小轮询间隔(毫秒)">
<el-input-number
v-model="form.danmakuMinPollIntervalMilliseconds"
:min="100"
@@ -1368,7 +1411,7 @@ watch(
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="鏈€澶ч噸璇曢€€閬匡紙绉掞級">
<el-form-item label="最大重试退避(秒)">
<el-input-number
v-model="form.danmakuRetryDelayMaxSeconds"
:min="1"
@@ -1383,7 +1426,7 @@ watch(
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">路径模板</h3>
<p class="section-subtitle">Both directory and filename templates support variables. Segmented layouts are fully controlled by the templates themselves.</p>
<p class="section-subtitle">目录和文件名模板均支持变量分片目录结构完全由模板控制</p>
<el-form label-position="top">
<el-form-item label="目录模板">
@@ -1395,7 +1438,7 @@ watch(
/>
</el-form-item>
<el-form-item label="Output filename template">
<el-form-item label="输出文件名模板">
<el-input
v-model="form.outputFileNameTemplate"
type="textarea"
@@ -1412,15 +1455,15 @@ watch(
</div>
<div class="helper-panel">
<code>{fileStem}</code> is only for directory templates and represents the rendered filename stem. <code>{segmentSuffix}</code> is only for filename templates and expands to suffixes like <code>_00001</code> in segmented mode while staying empty in single-file mode.
<code>{fileStem}</code> 仅用于目录模板表示渲染后的文件主名<code>{segmentSuffix}</code> 仅用于文件名模板分片模式下生成 <code>_00001</code> 一类后缀单文件模式下为空
</div>
<div class="helper-panel">
Time variables in both directory and filename templates are rendered in Beijing time (UTC+8), and the examples above use the same timezone.
目录和文件名模板中的时间变量统一使用北京时间UTC+8上方示例也使用相同时区
</div>
<div class="helper-panel">
<code>{quality}</code> renders a stable quality key such as <code>origin</code>, <code>FULL_HD</code>, <code>HD</code>, or <code>SD</code>, which works well in directory names or automation scripts.
<code>{quality}</code> 会生成 <code>origin</code><code>FULL_HD</code><code>HD</code><code>SD</code> 等稳定画质标识适合用于目录或自动化脚本
</div>
<div class="token-list">
@@ -1429,7 +1472,7 @@ watch(
</el-card>
</el-tab-pane>
<el-tab-pane label="轮询与上传" name="polling">
<el-tab-pane label="轮询与上传" name="upload">
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">后台巡检</h3>
@@ -1501,7 +1544,7 @@ watch(
<div class="template-section__header">
<div>
<h4 class="template-section__title">WebDAV 目标</h4>
<p class="template-section__subtitle">Create remote directories from recording-relative paths and upload the video plus danmaku files.</p>
<p class="template-section__subtitle">按录制相对路径创建远端目录并上传视频及对应弹幕文件</p>
</div>
</div>
@@ -1535,7 +1578,7 @@ watch(
<div class="template-section__header">
<div>
<h4 class="template-section__title">S3 目标</h4>
<p class="template-section__subtitle">Supports custom endpoint, bucket, region, and prefix settings for object-storage compatible services.</p>
<p class="template-section__subtitle">支持兼容对象存储服务的自定义端点存储桶区域和前缀</p>
</div>
</div>
@@ -1740,7 +1783,7 @@ watch(
</el-card>
</el-tab-pane>
<el-tab-pane label="事件脚本" name="scripts">
<el-tab-pane label="自动化脚本" name="automation">
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">事件脚本</h3>
@@ -1942,26 +1985,26 @@ watch(
Live-started and live-ended scripts receive the shared event variables. Segment-completed scripts also receive file paths, danmaku paths, duration, file size, and task status values. Missing values are passed as empty strings.
</div>
<div class="event-script-help__intro">
Inline script mode runs with <code>/bin/sh -c</code> on Linux / Docker and with <code>PowerShell -Command</code> on Windows.
内联脚本在 Linux / Docker 下通过 <code>/bin/sh -c</code> 执行 Windows 下通过 <code>PowerShell -Command</code> 执行
</div>
<div class="event-script-help__intro">
If a script wants to append custom content to the system log, write text into the temporary file pointed to by <code>LIVE_RECORDER_SCRIPT_LOG_PATH</code>.
脚本需要向系统日志追加内容时请写入 <code>LIVE_RECORDER_SCRIPT_LOG_PATH</code> 指向的临时文件
</div>
<div class="event-script-help__intro">
The variable name <code>LIVE_RECORDER_OCCURRED_AT_UTC</code> is kept for compatibility, but the actual value is rendered in Beijing time (UTC+8).
变量名 <code>LIVE_RECORDER_OCCURRED_AT_UTC</code> 为兼容旧版本而保留实际值使用北京时间UTC+8
</div>
<div class="event-script-help__intro">
The official Docker image includes <code>curl</code> and <code>jq</code> by default. If you run on a host machine or a custom image, rely on the commands available in that environment.
官方 Docker 镜像默认包含 <code>curl</code> <code>jq</code>宿主机或自定义镜像只能使用对应环境中已有的命令
</div>
<div class="event-script-help__intro">
Set <code>Retry attempts</code> to <code>0</code> to disable automatic retries. Retry exhaustion failures are sent through the existing exception notification channel.
将重试次数设为 <code>0</code> 可关闭自动重试重试耗尽后会通过现有异常通知渠道告警
</div>
<div class="event-script-example">
<div class="event-script-example__label">Environment variable examples</div>
<div class="event-script-example__label">环境变量示例</div>
<div class="event-script-example__grid">
<div v-for="item in eventScriptEnvironmentExamples" :key="item.name" class="event-script-example__row">
<code>{{ item.name }}</code>
@@ -1983,7 +2026,7 @@ watch(
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">Webhook 通知</h3>
<p class="section-subtitle">Send fixed JSON POST payloads with custom headers. Exception notifications also cover low-storage stop events and event-script failures after retries are exhausted.</p>
<p class="section-subtitle">使用自定义请求头发送 JSON POST异常通知同时覆盖存储不足停录和脚本重试耗尽</p>
<el-form label-position="top">
<el-row :gutter="16">
@@ -2044,7 +2087,7 @@ watch(
</div>
<div class="action-strip">
<div class="helper-text">The test sends a sample live_started payload, including sample event script output, using the current URL, headers, and timeout values from this form.</div>
<div class="helper-text">测试会使用当前 URL请求头和超时设置发送一条包含脚本输出示例的开播通知</div>
<el-button :loading="testingWebhook" :disabled="!canSendTestWebhook" @click="testWebhook">测试 Webhook</el-button>
</div>
@@ -2056,7 +2099,7 @@ watch(
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">邮件通知</h3>
<p class="section-subtitle">Configure SMTP plus HTML templates for live-started and exception alerts. Exception notifications also cover low-storage stop events and event-script failures after retries are exhausted.</p>
<p class="section-subtitle">配置 SMTP开播和异常 HTML 模板异常通知同时覆盖存储不足停录和脚本重试耗尽</p>
<el-form label-position="top">
<el-row :gutter="16">
@@ -2071,7 +2114,7 @@ watch(
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="Live started alert">
<el-form-item label="开播提醒">
<el-switch v-model="form.notifyOnLiveStarted" />
</el-form-item>
</el-col>
@@ -2082,23 +2125,23 @@ watch(
</el-col>
<el-col :span="8">
<el-form-item label="SMTP Host">
<el-form-item label="SMTP 主机">
<el-input v-model="form.emailSmtpHost" placeholder="smtp.example.com" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP Port">
<el-form-item label="SMTP 端口">
<el-input-number v-model="form.emailSmtpPort" :min="1" :max="65535" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Sender name">
<el-form-item label="发件人名称">
<el-input v-model="form.emailFromDisplayName" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP username">
<el-form-item label="SMTP 用户名">
<el-input v-model="form.emailUsername" />
</el-form-item>
</el-col>
@@ -2114,7 +2157,7 @@ watch(
</el-col>
<el-col :span="24">
<el-form-item label="Recipient list">
<el-form-item label="收件人列表">
<el-input
v-model="form.emailToAddresses"
type="textarea"
@@ -2128,8 +2171,8 @@ watch(
<div class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">Live started template</h4>
<p class="template-section__subtitle">Subject templates render plain text, while the body template supports HTML and can include event script output placeholders.</p>
<h4 class="template-section__title">开播通知模板</h4>
<p class="template-section__subtitle">主题使用纯文本正文支持 HTML 和事件脚本输出占位符</p>
</div>
</div>
@@ -2148,7 +2191,7 @@ watch(
<div class="template-section__header">
<div>
<h4 class="template-section__title">异常提醒模板</h4>
<p class="template-section__subtitle">Exception emails inject source, summary, detail, task context values, and optional event script output into the HTML body.</p>
<p class="template-section__subtitle">异常邮件可在 HTML 正文中插入来源摘要详情任务上下文和脚本输出</p>
</div>
</div>
@@ -2165,7 +2208,7 @@ watch(
</el-form>
<div class="template-help">
<div class="template-help__label">Available placeholders</div>
<div class="template-help__label">可用占位符</div>
<div class="token-list">
<span v-for="token in emailTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
@@ -2176,13 +2219,13 @@ watch(
</div>
<div class="action-strip">
<div class="helper-text">Sending a test email does not save settings. The email renders both the live-started and exception template examples.</div>
<div class="helper-text">发送测试邮件不会保存设置邮件会同时渲染开播和异常模板示例</div>
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">测试邮件</el-button>
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="Security and platform" name="security">
<el-tab-pane label="账户安全" name="account">
<el-card id="security" class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">账号安全</h3>
@@ -2208,9 +2251,12 @@ watch(
</el-form>
</el-card>
</el-tab-pane>
<el-tab-pane label="平台请求" name="platform">
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">Platform request settings</h3>
<p class="section-subtitle">Configure independent proxy, User-Agent, Referer, and Cookie values for each platform.</p>
<h3 class="section-title">平台请求设置</h3>
<p class="section-subtitle">为每个平台分别配置代理User-AgentReferer Cookie</p>
<div class="event-script-grid">
<div
@@ -2222,7 +2268,7 @@ watch(
<div>
<h4 class="event-script-section__title">{{ platform.label }}</h4>
<p class="event-script-section__subtitle">
These settings apply only to {{ platform.label }} status checks and stream requests.
这些设置仅用于 {{ platform.label }} 的状态检查和直播流请求
</p>
</div>
<el-switch v-model="form.platformRequestSettings[platform.key].proxy.enabled" />
@@ -2253,7 +2299,7 @@ watch(
v-model="form.platformRequestSettings[platform.key].cookie"
type="textarea"
:rows="3"
placeholder="Optional cookies for this platform only"
placeholder="仅用于该平台的可选 Cookie"
/>
</el-form-item>
</el-form>
@@ -2288,12 +2334,13 @@ watch(
</el-tabs>
</div>
<div class="settings-savebar" :style="savebarStyle">
<div v-if="isDirty" class="settings-savebar" :style="savebarStyle">
<div class="settings-savebar__content">
<div class="settings-savebar__copy">
<div class="settings-savebar__title">当前修改不会自动保存</div>
<div class="settings-savebar__subtitle">您可以在此页面任意位置保存当前设置</div>
<div class="settings-savebar__subtitle">保存后立即应用到录制和后台任务</div>
</div>
<el-button :disabled="saving" @click="discardSettingsChanges">放弃修改</el-button>
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
</div>
</div>
@@ -2443,6 +2490,37 @@ watch(
gap: 18px;
}
.settings-quickbar {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 16px;
}
.settings-profile--compact {
min-width: 220px;
margin-right: auto;
}
.settings-quickbar__control {
display: grid;
grid-template-columns: auto 118px;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
}
.settings-quickbar__switch {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
}
.settings-grid__full {
grid-column: auto;
}
@@ -2497,6 +2575,55 @@ watch(
box-shadow: var(--shadow-soft);
}
@media (min-width: 769px) {
.settings-tabs {
display: grid;
grid-template-columns: 168px minmax(0, 1fr);
align-items: start;
border: 1px solid var(--border-subtle);
border-radius: 12px;
background: var(--surface);
overflow: hidden;
}
.settings-tabs :deep(.el-tabs__header.is-left) {
width: 168px;
min-height: 100%;
margin: 0;
border: 0;
border-right: 1px solid var(--border-subtle);
border-radius: 0;
background: var(--surface-muted);
box-shadow: none;
}
.settings-tabs :deep(.el-tabs__nav-wrap.is-left) {
padding: 10px;
}
.settings-tabs :deep(.el-tabs__item.is-left) {
height: 40px;
margin: 2px 0;
padding: 0 12px;
border-radius: 7px;
text-align: left;
}
.settings-tabs :deep(.el-tabs__item.is-left.is-active) {
color: var(--accent);
background: var(--accent-soft);
box-shadow: none;
}
.settings-tabs :deep(.el-tabs__content) {
min-width: 0;
padding: 16px;
border: 0;
border-radius: 0;
box-shadow: none;
}
}
.settings-card :deep(.el-card__body) {
padding-top: 20px;
}
@@ -2929,6 +3056,27 @@ watch(
padding: 14px;
}
.settings-quickbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.settings-profile--compact {
grid-column: 1 / -1;
min-width: 0;
margin: 0;
}
.settings-quickbar__control {
grid-template-columns: 1fr;
}
.settings-quickbar__switch {
grid-column: 1 / -1;
justify-content: space-between;
}
.settings-card :deep(.el-col) {
flex: 0 0 100%;
max-width: 100%;