Files
live_recorder/frontend/src/views/SettingsView.vue
T

3118 lines
103 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type {
CleanupOperation,
CleanupVideoFileCondition,
EventScriptTestResult,
OpenListConnectionTestResult,
OpenListDirectoryItem,
OpenListDirectoryListResult,
PlatformProxySettings,
PlatformRequestSettings,
SystemSettings,
WebhookTestResult
} from "@/types";
import {
createDefaultPlatformRequestSettingsMap,
outputFormatLabelMap,
platformOptionList,
qualityOptionList,
recordingTemplateLabelMap,
saveModeLabelMap,
taskStatusLabelMap
} from "@/types";
import { useAuthStore } from "@/stores/auth";
import { useViewport } from "@/composables/useViewport";
import { useUiPreferences } from "@/composables/useUiPreferences";
import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
type ScriptEventType = "live_started" | "live_ended" | "segment_completed";
type SettingsFormModel = SystemSettings & {
douyinProxy: PlatformProxySettings;
bilibiliProxy: PlatformProxySettings;
huyaProxy: PlatformProxySettings;
douyinUserAgent: string;
douyinReferer: string;
douyinCookie: string;
};
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();
const loading = ref(false);
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);
const settingsImportInput = ref<HTMLInputElement | null>(null);
const scriptTesting = reactive<Record<ScriptEventType, boolean>>({
live_started: false,
live_ended: false,
segment_completed: false
});
const scriptTestResults = reactive<Record<ScriptEventType, EventScriptTestResult | null>>({
live_started: null,
live_ended: null,
segment_completed: null
});
const webhookTestResult = ref<WebhookTestResult | null>(null);
const retentionCleanupOperation = ref<CleanupOperation | null>(null);
const loadError = ref("");
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 profileInitial = computed(() => profileDisplayName.value.trim().slice(0, 1).toUpperCase() || "录");
const qualitySupportHint = "不同平台提供的画质档位并不完全一致;目标画质不可用时,录制器会自动选择最接近的可用流。";
const qualityOptions = qualityOptionList;
const platformRequestPlatforms = platformOptionList;
let retentionCleanupPollTimer: number | null = null;
const savebarStyle = computed(() => {
if (isMobile.value) {
return {
left: "16px",
right: "16px",
bottom: "12px"
};
}
return {
left: sidebarCollapsed.value ? "120px" : "304px",
right: "28px",
bottom: "18px"
};
});
const form = reactive<SettingsFormModel>({
ffmpegPath: "ffmpeg",
outputRoot: "records",
outputDirectoryTemplate: "{platform}/{yyyy}/{MM}/{dd}/{anchor}",
outputFileNameTemplate: "{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}",
defaultQuality: "origin",
defaultOutputFormat: 0,
saveMode: 0,
recordingTemplate: 0,
segmentDurationMinutes: 30,
maxConcurrentFfmpegTranscodeTasks: 1,
mp4FinalizeTimeoutMinutes: 60,
enableStorageGuard: true,
pauseRecordingWhenFreeSpaceBelowMegabytes: 1024,
resumeRecordingWhenFreeSpaceAboveMegabytes: 4096,
storageGreenThresholdPercent: 30,
storageRedThresholdPercent: 10,
enableRetentionCleanup: false,
retentionDays: 30,
retentionDeleteFiles: false,
retentionVideoFileCondition: "any",
retentionTaskStatuses: [],
enableAutoReconnect: true,
reconnectDelayMaxSeconds: 5,
readWriteTimeoutMilliseconds: 15000000,
enableDanmakuRecording: true,
danmakuIncludeNonChatEvents: true,
danmakuMinPollIntervalMilliseconds: 1000,
danmakuRetryDelayMaxSeconds: 15,
enableBackgroundPolling: true,
autoStartRecordingOnLive: true,
pollingIntervalSeconds: 60,
useAliasForStorage: false,
enableFileUpload: false,
enableAutoUpload: false,
deleteLocalFilesAfterUpload: false,
uploadTarget: 0,
platformRequestSettings: createDefaultPlatformRequestSettingsMap(),
douyinProxy: {
enabled: false,
proxyUrl: ""
},
bilibiliProxy: {
enabled: false,
proxyUrl: ""
},
huyaProxy: {
enabled: false,
proxyUrl: ""
},
webDavUpload: {
endpoint: "",
basePath: "",
username: "",
password: ""
},
s3Upload: {
endpoint: "",
bucket: "",
region: "",
accessKey: "",
secretKey: "",
prefix: "",
forcePathStyle: false
},
openListUpload: {
baseUrl: "",
username: "",
password: "",
basePath: "",
sourcePath: "",
destinationPath: ""
},
enableEventScripts: false,
enableLiveStartedScript: false,
liveStartedScriptMode: "path",
liveStartedScriptPath: "",
liveStartedScriptContent: "",
enableLiveEndedScript: false,
liveEndedScriptMode: "path",
liveEndedScriptPath: "",
liveEndedScriptContent: "",
enableSegmentCompletedScript: false,
segmentCompletedScriptMode: "path",
segmentCompletedScriptPath: "",
segmentCompletedScriptContent: "",
eventScriptTimeoutSeconds: 60,
eventScriptRetryAttempts: 3,
eventScriptRetryDelaySeconds: 10,
enableEmailNotification: false,
emailSmtpHost: "",
emailSmtpPort: 587,
emailUseSsl: true,
emailUsername: "",
emailPassword: "",
emailFromAddress: "",
emailFromDisplayName: "Live Recorder",
emailToAddresses: "",
notifyOnLiveStarted: true,
notifyOnException: true,
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;">直播已开始</h2>
<p>监控的直播间现已开播。</p>
<ul>
<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>直播地址:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
<div style="margin-top: 16px;">
<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}}] 录制异常:{{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;">检测到录制异常</h2>
<p>{{summary}}</p>
<ul>
<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>`,
enableWebhookNotification: false,
webhookUrl: "",
webhookHeaders: "",
webhookTimeoutSeconds: 15,
notifyWebhookOnLiveStarted: true,
notifyWebhookOnException: true,
webhookBodyTemplate: "",
douyinUserAgent: "",
douyinReferer: "https://live.douyin.com/",
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> {
const defaults = createDefaultPlatformRequestSettingsMap();
if (!value) {
return defaults;
}
for (const platform of platformRequestPlatforms) {
const current = value[platform.key];
if (!current) {
continue;
}
defaults[platform.key] = {
proxy: {
enabled: current.proxy?.enabled ?? defaults[platform.key].proxy.enabled,
proxyUrl: current.proxy?.proxyUrl ?? defaults[platform.key].proxy.proxyUrl
},
userAgent: current.userAgent ?? defaults[platform.key].userAgent,
referer: current.referer ?? defaults[platform.key].referer,
cookie: current.cookie ?? defaults[platform.key].cookie
};
}
return defaults;
}
function syncLegacyPlatformAliasesFromMap() {
form.douyinProxy = { ...form.platformRequestSettings.douyin.proxy };
form.bilibiliProxy = { ...form.platformRequestSettings.bilibili.proxy };
form.huyaProxy = { ...form.platformRequestSettings.huya.proxy };
form.douyinUserAgent = form.platformRequestSettings.douyin.userAgent;
form.douyinReferer = form.platformRequestSettings.douyin.referer;
form.douyinCookie = form.platformRequestSettings.douyin.cookie;
}
function syncPlatformRequestSettingsFromLegacyAliases() {
form.platformRequestSettings.douyin = {
...form.platformRequestSettings.douyin,
proxy: { ...form.douyinProxy },
userAgent: form.douyinUserAgent,
referer: form.douyinReferer,
cookie: form.douyinCookie
};
form.platformRequestSettings.bilibili = {
...form.platformRequestSettings.bilibili,
proxy: { ...form.bilibiliProxy }
};
form.platformRequestSettings.huya = {
...form.platformRequestSettings.huya,
proxy: { ...form.huyaProxy }
};
}
const outputTemplateTokens = [
"{platform}",
"{roomId}",
"{anchor}",
"{title}",
"{quality}",
"{yyyy}",
"{MM}",
"{dd}",
"{HHmmss}",
"{date}",
"{time}",
"{fileStem}",
"{segmentSuffix}"
];
const emailTemplateTokens = [
"{{appName}}",
"{{platform}}",
"{{roomId}}",
"{{title}}",
"{{anchor}}",
"{{sourceUrl}}",
"{{detectedAtUtc}}",
"{{source}}",
"{{summary}}",
"{{detail}}",
"{{liveRoomId}}",
"{{recordTaskId}}",
"{{taskStatus}}",
"{{occurredAtUtc}}",
"{{eventScriptOutput}}"
];
const webhookTemplateTokens = [
"{{appName}}",
"{{eventType}}",
"{{sentAtUtc}}",
"{{summary}}",
"{{detail}}",
"{{source}}",
"{{liveRoom.id}}",
"{{liveRoom.platform}}",
"{{liveRoom.roomId}}",
"{{liveRoom.title}}",
"{{liveRoom.anchorName}}",
"{{liveRoom.sourceUrl}}",
"{{recordTask.id}}",
"{{recordTask.recordSessionId}}",
"{{recordTask.status}}",
"{{recordTask.segmentIndex}}",
"{{recordTask.outputFilePath}}",
"{{eventScriptOutput}}",
"{{report.date}}",
"{{report.summary.activeLiveRoomCount}}",
"{{report.summary.sessionCount}}",
"{{report.summary.segmentCount}}",
"{{report.summary.totalDurationSeconds}}",
"{{report.summary.warningCount}}",
"{{report.summary.errorCount}}",
"{{report.summary.totalDanmakuCount}}"
];
const eventScriptEnvironmentExamples = [
{ 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: "所有事件"
},
{ 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: "仅分片完成"
},
{
name: "LIVE_RECORDER_DANMAKU_FILE_PATH",
example: "/app/records/Douyin/Streamer Name/2026-04-25/203000_casual-stream__00001.xml",
scope: "仅分片完成"
},
{ 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 = [
{ label: "路径", value: "path" },
{ label: "脚本文本", value: "inline" }
];
const uploadTargetOptions = [
{ label: "不上传", value: 0 },
{ label: "WebDAV", value: 1 },
{ label: "S3", value: 2 },
{ label: "OpenList", value: 3 }
];
const retentionVideoFileOptions = [
{ 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: "等待执行",
running: "执行中",
completed: "已完成",
failed: "失败"
};
const retentionCleanupStatusLabel = computed(() =>
retentionCleanupOperation.value ? retentionCleanupStatusLabelMap[retentionCleanupOperation.value.status] : ""
);
const retentionCleanupFinished = computed(() =>
retentionCleanupOperation.value?.status === "completed" || retentionCleanupOperation.value?.status === "failed"
);
const retentionCleanupTagType = computed(() => {
if (!retentionCleanupOperation.value) {
return "info";
}
if (retentionCleanupOperation.value.status === "failed") {
return "danger";
}
if (retentionCleanupOperation.value.status === "completed" && retentionCleanupOperation.value.warnings.length === 0) {
return "success";
}
if (retentionCleanupOperation.value.warnings.length > 0) {
return "warning";
}
return "info";
});
const retentionCleanupProgressText = computed(() => {
if (!retentionCleanupOperation.value) {
return "";
}
if (retentionCleanupOperation.value.totalSessionCount === 0) {
return retentionCleanupOperation.value.status === "queued" ? "正在扫描候选录制会话" : "0 / 0";
}
return `${retentionCleanupOperation.value.processedSessionCount} / ${retentionCleanupOperation.value.totalSessionCount}`;
});
const retentionCleanupSummary = computed(() => {
if (!retentionCleanupOperation.value) {
return "";
}
return [
`会话 ${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) ?? []);
const canSendTestEmail = computed(() =>
Boolean(form.emailSmtpHost.trim() && form.emailFromAddress.trim() && form.emailToAddresses.trim())
);
const canSendTestWebhook = computed(() => Boolean(form.webhookUrl.trim()));
const segmentedExamplePath = computed(() => {
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
return `Douyin/2026/04/15/主播名_221530_origin_主播名_直播标题_123456789_00001.${extension}`;
});
const nestedSegmentedExamplePath = computed(() => {
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
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 = "";
try {
const { data } = await apiClient.get<SystemSettings>("/settings");
Object.assign(form, data);
form.platformRequestSettings = normalizePlatformRequestSettings(data.platformRequestSettings);
syncLegacyPlatformAliasesFromMap();
markSettingsSaved();
} catch (error) {
loadError.value = getApiErrorMessage(error, "系统设置加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
const pwdForm = reactive({
currentPassword: "",
newPassword: "",
confirmPassword: ""
});
const pwdRules = {
currentPassword: [{ required: true, message: "请输入当前密码", trigger: "blur" }],
newPassword: [
{ required: true, message: "请输入新密码", 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("两次输入的密码不一致"));
} else {
callback();
}
},
trigger: "blur"
}
]
};
const pwdFormRef = ref<InstanceType<typeof import("element-plus").ElForm> | null>(null);
async function changePassword() {
const valid = await pwdFormRef.value?.validate().catch(() => false);
if (!valid) return;
changingPassword.value = true;
try {
await authStore.changePassword(pwdForm.currentPassword, pwdForm.newPassword);
ElMessage.success("密码已更新");
pwdForm.currentPassword = "";
pwdForm.newPassword = "";
pwdForm.confirmPassword = "";
pwdFormRef.value?.resetFields();
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "密码修改失败,请稍后重试。"));
} finally {
changingPassword.value = false;
}
}
async function saveSettings() {
saving.value = true;
try {
syncPlatformRequestSettingsFromLegacyAliases();
const payload: SystemSettings = {
...form,
platformRequestSettings: normalizePlatformRequestSettings(form.platformRequestSettings)
};
const { data } = await apiClient.put<SystemSettings>("/settings", payload);
Object.assign(form, data);
form.platformRequestSettings = normalizePlatformRequestSettings(data.platformRequestSettings);
syncLegacyPlatformAliasesFromMap();
markSettingsSaved();
ElMessage.success("设置已保存");
} finally {
saving.value = false;
}
}
async function sendTestEmail() {
testingEmail.value = true;
try {
await apiClient.post("/settings/test-email", {
emailSmtpHost: form.emailSmtpHost,
emailSmtpPort: form.emailSmtpPort,
emailUseSsl: form.emailUseSsl,
emailUsername: form.emailUsername,
emailPassword: form.emailPassword,
emailFromAddress: form.emailFromAddress,
emailFromDisplayName: form.emailFromDisplayName,
emailToAddresses: form.emailToAddresses,
emailLiveStartedSubjectTemplate: form.emailLiveStartedSubjectTemplate,
emailLiveStartedBodyTemplateHtml: form.emailLiveStartedBodyTemplateHtml,
emailExceptionSubjectTemplate: form.emailExceptionSubjectTemplate,
emailExceptionBodyTemplateHtml: form.emailExceptionBodyTemplateHtml
});
ElMessage.success("测试邮件已发送,请检查收件箱");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "测试邮件发送失败。"));
} finally {
testingEmail.value = false;
}
}
async function testEventScript(eventType: ScriptEventType) {
scriptTesting[eventType] = true;
try {
const { data } = await apiClient.post<EventScriptTestResult>("/settings/test-event-script", buildScriptTestRequest(eventType));
scriptTestResults[eventType] = data;
ElMessage[data.success ? "success" : "warning"](data.message);
} catch (error) {
const result: EventScriptTestResult = {
success: false,
message: getApiErrorMessage(error, "事件脚本测试失败")
};
scriptTestResults[eventType] = result;
ElMessage.error(result.message);
} finally {
scriptTesting[eventType] = false;
}
}
async function testWebhook() {
testingWebhook.value = true;
try {
const { data } = await apiClient.post<WebhookTestResult>("/settings/test-webhook", {
webhookUrl: form.webhookUrl,
webhookHeaders: form.webhookHeaders,
webhookTimeoutSeconds: form.webhookTimeoutSeconds,
webhookBodyTemplate: form.webhookBodyTemplate
});
webhookTestResult.value = data;
ElMessage[data.success ? "success" : "warning"](data.message);
} catch (error) {
const result: WebhookTestResult = {
success: false,
message: getApiErrorMessage(error, "Webhook 测试失败")
};
webhookTestResult.value = result;
ElMessage.error(result.message);
} finally {
testingWebhook.value = false;
}
}
async function runRetentionCleanup() {
runningRetentionCleanup.value = true;
try {
const { data } = await apiClient.post<CleanupOperation>("/settings/retention/run-now");
await startRetentionCleanupTracking(data);
ElMessage.success("保留清理任务已创建");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "保留清理执行失败"));
} finally {
runningRetentionCleanup.value = false;
}
}
function persistRetentionCleanupOperationId(id: string | null) {
if (typeof window === "undefined") {
return;
}
if (id) {
window.sessionStorage.setItem(retentionCleanupStorageKey, id);
return;
}
window.sessionStorage.removeItem(retentionCleanupStorageKey);
}
function stopRetentionCleanupPolling() {
if (retentionCleanupPollTimer !== null && typeof window !== "undefined") {
window.clearTimeout(retentionCleanupPollTimer);
}
retentionCleanupPollTimer = null;
}
function clearTrackedRetentionCleanup() {
retentionCleanupOperation.value = null;
persistRetentionCleanupOperationId(null);
stopRetentionCleanupPolling();
}
function scheduleRetentionCleanupPolling(operationId: string) {
stopRetentionCleanupPolling();
if (typeof window === "undefined") {
return;
}
retentionCleanupPollTimer = window.setTimeout(() => {
void refreshRetentionCleanupOperation(operationId, { silent: true });
}, 2000);
}
async function refreshRetentionCleanupOperation(operationId: string, options?: { silent?: boolean }) {
try {
const { data } = await apiClient.get<CleanupOperation>(`/cleanup-operations/${operationId}`);
retentionCleanupOperation.value = data;
persistRetentionCleanupOperationId(data.id);
if (data.status === "queued" || data.status === "running") {
scheduleRetentionCleanupPolling(data.id);
return;
}
stopRetentionCleanupPolling();
} catch (error) {
stopRetentionCleanupPolling();
if (!options?.silent) {
ElMessage.error(getApiErrorMessage(error, "保留清理任务状态加载失败。"));
}
}
}
async function startRetentionCleanupTracking(operation: CleanupOperation) {
retentionCleanupOperation.value = operation;
persistRetentionCleanupOperationId(operation.id);
if (operation.status === "queued" || operation.status === "running") {
scheduleRetentionCleanupPolling(operation.id);
return;
}
stopRetentionCleanupPolling();
}
async function restoreRetentionCleanupTracking() {
if (typeof window === "undefined") {
return;
}
const operationId = window.sessionStorage.getItem(retentionCleanupStorageKey);
if (!operationId) {
return;
}
await refreshRetentionCleanupOperation(operationId, { silent: true });
}
async function exportSettingsBackup() {
exportingSettings.value = true;
try {
const { data, headers } = await apiClient.get<Blob>("/settings/export", {
responseType: "blob"
});
const blob = data instanceof Blob ? data : new Blob([data], { type: "application/json;charset=utf-8" });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = parseDownloadFileName(headers["content-disposition"]) ?? `live-recorder-settings-${Date.now()}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
ElMessage.success("配置备份已导出");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "系统设置导出失败"));
} finally {
exportingSettings.value = false;
}
}
function triggerImportSettings() {
settingsImportInput.value?.click();
}
async function importSettingsBackup(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) {
return;
}
importingSettings.value = true;
try {
const payload = JSON.parse(await file.text());
const { data } = await apiClient.post<SystemSettings>("/settings/import", payload);
Object.assign(form, data);
ElMessage.success("配置备份已导入,请检查后保存");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "系统设置导入失败"));
} finally {
importingSettings.value = false;
}
}
function buildScriptTestRequest(eventType: ScriptEventType) {
if (eventType === "live_started") {
return {
eventType,
scriptMode: form.liveStartedScriptMode,
scriptPath: form.liveStartedScriptPath,
scriptContent: form.liveStartedScriptContent,
timeoutSeconds: form.eventScriptTimeoutSeconds
};
}
if (eventType === "live_ended") {
return {
eventType,
scriptMode: form.liveEndedScriptMode,
scriptPath: form.liveEndedScriptPath,
scriptContent: form.liveEndedScriptContent,
timeoutSeconds: form.eventScriptTimeoutSeconds
};
}
return {
eventType,
scriptMode: form.segmentCompletedScriptMode,
scriptPath: form.segmentCompletedScriptPath,
scriptContent: form.segmentCompletedScriptContent,
timeoutSeconds: form.eventScriptTimeoutSeconds
};
}
function canTestScript(eventType: ScriptEventType) {
if (eventType === "live_started") {
return form.liveStartedScriptMode === "inline"
? Boolean(form.liveStartedScriptContent.trim())
: Boolean(form.liveStartedScriptPath.trim());
}
if (eventType === "live_ended") {
return form.liveEndedScriptMode === "inline"
? Boolean(form.liveEndedScriptContent.trim())
: Boolean(form.liveEndedScriptPath.trim());
}
return form.segmentCompletedScriptMode === "inline"
? Boolean(form.segmentCompletedScriptContent.trim())
: Boolean(form.segmentCompletedScriptPath.trim());
}
function scriptTestStateType(result?: EventScriptTestResult | null) {
return result?.success ? "success" : "warning";
}
function parseDownloadFileName(contentDisposition?: string) {
if (!contentDisposition) {
return null;
}
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
return decodeURIComponent(utf8Match[1]);
}
const plainMatch = contentDisposition.match(/filename=\"?([^\"]+)\"?/i);
return plainMatch?.[1] ?? null;
}
async function syncSettingsHash(hash = route.hash) {
if (!hash) {
return;
}
if (hash === "#security") {
activeSettingTab.value = "account";
}
await nextTick();
document.querySelector(hash)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
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();
});
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>
<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 :loading="exportingSettings" @click="exportSettingsBackup">导出配置</el-button>
<el-button :loading="importingSettings" @click="triggerImportSettings">导入配置</el-button>
</div>
</div>
<input
ref="settingsImportInput"
type="file"
accept="application/json,.json"
class="settings-import-input"
@change="importSettingsBackup"
/>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<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>
<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"
: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>
<p class="section-subtitle">默认画质输出格式分段策略ffmpeg 模板和网络容错等录制基础参数在此集中管理</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="ffmpeg 路径">
<el-input v-model="form.ffmpegPath" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="输出根目录">
<el-input v-model="form.outputRoot" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="默认画质">
<el-select v-model="form.defaultQuality">
<el-option
v-for="option in qualityOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="输出格式">
<el-select v-model="form.defaultOutputFormat">
<el-option
v-for="(label, value) in outputFormatLabelMap"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="保存模式">
<el-select v-model="form.saveMode">
<el-option
v-for="(label, value) in saveModeLabelMap"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="录制模板">
<el-select v-model="form.recordingTemplate">
<el-option
v-for="(label, value) in recordingTemplateLabelMap"
:key="value"
:label="label"
:value="Number(value)"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="分段时长(分钟)">
<el-input-number v-model="form.segmentDurationMinutes" :min="1" :max="720" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大并发转码任务数">
<el-input-number v-model="form.maxConcurrentFfmpegTranscodeTasks" :min="1" :max="16" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="MP4 转码超时(分钟)">
<el-input-number v-model="form.mp4FinalizeTimeoutMinutes" :min="1" :max="1440" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大重连延迟(秒)">
<el-input-number v-model="form.reconnectDelayMaxSeconds" :min="1" :max="300" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="读写超时(毫秒)">
<el-input-number v-model="form.readWriteTimeoutMilliseconds" :min="1000" :max="60000000" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="启用 ffmpeg 自动重连">
<el-switch v-model="form.enableAutoReconnect" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
{{ qualitySupportHint }}
</div>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">存储保护</h3>
<p class="section-subtitle">当磁盘空闲空间低于阈值时暂停录制和 MP4 转码空间恢复后自动继续</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="启用存储保护">
<el-switch v-model="form.enableStorageGuard" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="暂停阈值:空闲空间低于 (MB)">
<el-input-number
v-model="form.pauseRecordingWhenFreeSpaceBelowMegabytes"
:min="0"
:max="1048576"
:disabled="!form.enableStorageGuard"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="恢复阈值:空闲空间高于 (MB)">
<el-input-number
v-model="form.resumeRecordingWhenFreeSpaceAboveMegabytes"
:min="0"
:max="1048576"
:disabled="!form.enableStorageGuard"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-form-item label="绿色水位线:剩余空间高于 (%)">
<el-input-number
v-model="form.storageGreenThresholdPercent"
:min="5"
:max="90"
:step="5"
:disabled="!form.enableStorageGuard"
/>
<div class="field-hint">高于此比例时正常录制低于时拒绝新录制</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="红色水位线:剩余空间低于 (%)">
<el-input-number
v-model="form.storageRedThresholdPercent"
:min="1"
:max="85"
:step="5"
:disabled="!form.enableStorageGuard"
/>
<div class="field-hint">低于此比例时暂停所有录制和转码仅保留上传</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
恢复阈值建议高于暂停阈值避免磁盘空间在临界值附近反复抖动MP4 转码会额外占用中间 TS 文件空间<br/>
绿色/红色水位线控制三级存储保护<b>绿色</b>(正常录制) <b>黄色</b>(拒绝新录制现有继续转码上传) <b>红色</b>(暂停所有录制转码仅上传清盘)
</div>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">保留清理</h3>
<p class="section-subtitle">按保留天数清理不活跃的会话任务结果和日志可选删除磁盘文件</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="启用自动清理">
<el-switch v-model="form.enableRetentionCleanup" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="保留天数">
<el-input-number v-model="form.retentionDays" :min="1" :max="3650" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="删除磁盘文件">
<el-switch v-model="form.retentionDeleteFiles" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="视频文件条件">
<el-select v-model="form.retentionVideoFileCondition" style="width: 100%">
<el-option
v-for="option in retentionVideoFileOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="任务状态条件(所有分段)">
<el-select v-model="form.retentionTaskStatuses" multiple placeholder="任意状态" style="width: 100%">
<el-option
v-for="option in retentionTaskStatusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="action-strip">
<div class="helper-text">立即执行和每日自动清理使用相同规则刚修改筛选条件时请先保存设置</div>
<el-button :loading="runningRetentionCleanup" @click="runRetentionCleanup">立即执行清理</el-button>
</div>
<div
v-if="retentionCleanupOperation"
class="test-result"
:class="retentionCleanupOperation.status === 'failed' || retentionCleanupOperation.warnings.length ? 'test-result--warning' : 'test-result--success'"
>
<div class="test-result__title">当前清理任务</div>
<div class="test-result__meta">
状态={{ retentionCleanupStatusLabel }} ·
进度={{ retentionCleanupProgressText }} ·
{{ retentionCleanupSummary }}
</div>
<div v-if="retentionCleanupOperation.errorMessage" class="test-result__detail">
{{ retentionCleanupOperation.errorMessage }}
</div>
<ul v-if="retentionCleanupWarningsPreview.length" class="test-result__list">
<li v-for="warning in retentionCleanupWarningsPreview" :key="warning">{{ warning }}</li>
</ul>
<div class="action-strip">
<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>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">弹幕录制</h3>
<p class="section-subtitle">控制弹幕 XML 并行录制非聊天事件采集和失败重试节奏</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="启用弹幕录制">
<el-switch v-model="form.enableDanmakuRecording" />
</el-form-item>
</el-col>
<el-col :span="12">
<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="最小轮询间隔(毫秒)">
<el-input-number
v-model="form.danmakuMinPollIntervalMilliseconds"
:min="100"
:max="60000"
:disabled="!form.enableDanmakuRecording"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大重试退避(秒)">
<el-input-number
v-model="form.danmakuRetryDelayMaxSeconds"
:min="1"
:max="300"
:disabled="!form.enableDanmakuRecording"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">路径模板</h3>
<p class="section-subtitle">目录和文件名模板均支持变量分片目录结构完全由模板控制</p>
<el-form label-position="top">
<el-form-item label="目录模板">
<el-input
v-model="form.outputDirectoryTemplate"
type="textarea"
:rows="3"
placeholder="{platform}/{yyyy}/{MM}/{dd}/{anchor}"
/>
</el-form-item>
<el-form-item label="输出文件名模板">
<el-input
v-model="form.outputFileNameTemplate"
type="textarea"
:rows="3"
placeholder="{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}"
/>
</el-form-item>
</el-form>
<div class="example-box">
<div class="example-box__label">示例输出</div>
<div class="monospace example-box__value">默认分段{{ segmentedExamplePath }}</div>
<div class="monospace example-box__value">目录模板含 {fileStem}{{ nestedSegmentedExamplePath }}</div>
</div>
<div class="helper-panel">
<code>{fileStem}</code> 仅用于目录模板表示渲染后的文件主名<code>{segmentSuffix}</code> 仅用于文件名模板分片模式下生成 <code>_00001</code> 一类后缀单文件模式下为空
</div>
<div class="helper-panel">
目录和文件名模板中的时间变量统一使用北京时间UTC+8),上方示例也使用相同时区
</div>
<div class="helper-panel">
<code>{quality}</code> 会生成 <code>origin</code><code>FULL_HD</code><code>HD</code><code>SD</code> 等稳定画质标识适合用于目录或自动化脚本
</div>
<div class="token-list">
<span v-for="token in outputTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="轮询与上传" name="upload">
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">后台巡检</h3>
<p class="section-subtitle">控制定时直播状态检查和直播间开播时自动开始录制</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="启用后台巡检">
<el-switch v-model="form.enableBackgroundPolling" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="检测到开播后自动录制">
<el-switch v-model="form.autoStartRecordingOnLive" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="轮询间隔(秒)">
<el-input-number v-model="form.pollingIntervalSeconds" :min="10" :max="3600" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</el-card>
<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">
<el-col :span="6">
<el-form-item label="启用上传">
<el-switch v-model="form.enableFileUpload" />
</el-form-item>
</el-col>
<el-col :span="6">
<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-switch v-model="form.deleteLocalFilesAfterUpload" :disabled="!form.enableFileUpload" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="别名用于目录">
<el-switch v-model="form.useAliasForStorage" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="上传目标">
<el-select v-model="form.uploadTarget" :disabled="!form.enableFileUpload">
<el-option
v-for="option in uploadTargetOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div v-if="form.enableFileUpload && form.uploadTarget === 1" class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">WebDAV 目标</h4>
<p class="template-section__subtitle">按录制相对路径创建远端目录并上传视频及对应弹幕文件</p>
</div>
</div>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="端点">
<el-input v-model="form.webDavUpload.endpoint" placeholder="https://dav.example.com" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="基础路径">
<el-input v-model="form.webDavUpload.basePath" placeholder="/live-recorder" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="用户名">
<el-input v-model="form.webDavUpload.username" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="密码">
<el-input v-model="form.webDavUpload.password" type="password" show-password />
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<div v-if="form.enableFileUpload && form.uploadTarget === 2" class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">S3 目标</h4>
<p class="template-section__subtitle">支持兼容对象存储服务的自定义端点存储桶区域和前缀</p>
</div>
</div>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="端点">
<el-input v-model="form.s3Upload.endpoint" placeholder="https://s3.example.com" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="存储桶">
<el-input v-model="form.s3Upload.bucket" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="区域">
<el-input v-model="form.s3Upload.region" placeholder="auto / us-east-1" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="前缀">
<el-input v-model="form.s3Upload.prefix" placeholder="live-recorder/" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="访问密钥">
<el-input v-model="form.s3Upload.accessKey" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="秘密密钥">
<el-input v-model="form.s3Upload.secretKey" type="password" show-password />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="强制路径样式">
<el-switch v-model="form.s3Upload.forcePathStyle" />
</el-form-item>
</el-col>
</el-row>
</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>
<div v-if="false" class="event-script-grid">
<div class="event-script-section">
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">Douyin</h4>
<p class="event-script-section__subtitle">Use a dedicated proxy for Douyin status checks and stream requests.</p>
</div>
<el-switch v-model="form.douyinProxy.enabled" />
</div>
<el-form-item label="代理地址">
<el-input v-model="form.douyinProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
</el-form-item>
</div>
<div class="event-script-section">
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">Bilibili</h4>
<p class="event-script-section__subtitle">Use a dedicated proxy for Bilibili requests without sharing other platform settings.</p>
</div>
<el-switch v-model="form.bilibiliProxy.enabled" />
</div>
<el-form-item label="代理地址">
<el-input v-model="form.bilibiliProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
</el-form-item>
</div>
<div class="event-script-section event-script-section--full">
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">Huya</h4>
<p class="event-script-section__subtitle">Huya keeps its proxy configuration separate from other platforms.</p>
</div>
<el-switch v-model="form.huyaProxy.enabled" />
</div>
<el-form-item label="代理地址">
<el-input v-model="form.huyaProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
</el-form-item>
</div>
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="自动化脚本" name="automation">
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">事件脚本</h3>
<p class="section-subtitle">
开播下播分片完成时都可以执行脚本脚本支持路径模式和直接填写文本模式测试按钮会直接用当前表单值执行不要求先保存
</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="启用事件脚本">
<el-switch v-model="form.enableEventScripts" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="脚本超时(秒)">
<el-input-number v-model="form.eventScriptTimeoutSeconds" :min="1" :max="3600" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="重试次数">
<el-input-number v-model="form.eventScriptRetryAttempts" :min="0" :max="20" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="重试间隔(秒)">
<el-input-number v-model="form.eventScriptRetryDelaySeconds" :min="0" :max="3600" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="event-script-grid">
<div class="event-script-section">
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">开播</h4>
<p class="event-script-section__subtitle">当直播间首次进入开播状态时触发</p>
</div>
<div class="event-script-section__actions">
<el-switch v-model="form.enableLiveStartedScript" inline-prompt active-text="On" inactive-text="Off" />
<el-radio-group v-model="form.liveStartedScriptMode" size="small">
<el-radio-button
v-for="option in eventScriptModeOptions"
:key="`live-started-${option.value}`"
:label="option.value"
>
{{ option.label }}
</el-radio-button>
</el-radio-group>
<el-button
size="small"
type="primary"
:loading="scriptTesting.live_started"
:disabled="!canTestScript('live_started')"
@click="testEventScript('live_started')"
>
测试脚本
</el-button>
</div>
</div>
<el-form-item v-if="form.liveStartedScriptMode === 'path'" label="脚本路径">
<el-input v-model="form.liveStartedScriptPath" placeholder="/app/scripts/live-started.sh" />
</el-form-item>
<el-form-item v-else label="脚本文本">
<el-input
v-model="form.liveStartedScriptContent"
type="textarea"
:rows="6"
placeholder='echo "started: $LIVE_RECORDER_ROOM_ID"'
/>
</el-form-item>
<div
v-if="scriptTestResults.live_started"
class="test-result"
:class="`test-result--${scriptTestStateType(scriptTestResults.live_started)}`"
>
<div class="test-result__title">{{ scriptTestResults.live_started?.message }}</div>
<div v-if="scriptTestResults.live_started?.detail" class="test-result__detail">
{{ scriptTestResults.live_started?.detail }}
</div>
<div v-if="scriptTestResults.live_started?.customLogOutput" class="test-result__detail">
自定义日志输出{{ scriptTestResults.live_started?.customLogOutput }}
</div>
</div>
</div>
<div class="event-script-section">
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">下播</h4>
<p class="event-script-section__subtitle">当直播间从开播状态回到离线状态时触发</p>
</div>
<div class="event-script-section__actions">
<el-switch v-model="form.enableLiveEndedScript" inline-prompt active-text="On" inactive-text="Off" />
<el-radio-group v-model="form.liveEndedScriptMode" size="small">
<el-radio-button
v-for="option in eventScriptModeOptions"
:key="`live-ended-${option.value}`"
:label="option.value"
>
{{ option.label }}
</el-radio-button>
</el-radio-group>
<el-button
size="small"
type="primary"
:loading="scriptTesting.live_ended"
:disabled="!canTestScript('live_ended')"
@click="testEventScript('live_ended')"
>
测试脚本
</el-button>
</div>
</div>
<el-form-item v-if="form.liveEndedScriptMode === 'path'" label="脚本路径">
<el-input v-model="form.liveEndedScriptPath" placeholder="/app/scripts/live-ended.sh" />
</el-form-item>
<el-form-item v-else label="脚本文本">
<el-input
v-model="form.liveEndedScriptContent"
type="textarea"
:rows="6"
placeholder='echo "ended: $LIVE_RECORDER_ROOM_ID"'
/>
</el-form-item>
<div
v-if="scriptTestResults.live_ended"
class="test-result"
:class="`test-result--${scriptTestStateType(scriptTestResults.live_ended)}`"
>
<div class="test-result__title">{{ scriptTestResults.live_ended?.message }}</div>
<div v-if="scriptTestResults.live_ended?.detail" class="test-result__detail">
{{ scriptTestResults.live_ended?.detail }}
</div>
<div v-if="scriptTestResults.live_ended?.customLogOutput" class="test-result__detail">
自定义日志输出{{ scriptTestResults.live_ended?.customLogOutput }}
</div>
</div>
</div>
<div class="event-script-section event-script-section--full">
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">分片完成</h4>
<p class="event-script-section__subtitle"> MP4 转码最终化完成且分段状态变为已完成时触发</p>
</div>
<div class="event-script-section__actions">
<el-switch v-model="form.enableSegmentCompletedScript" inline-prompt active-text="On" inactive-text="Off" />
<el-radio-group v-model="form.segmentCompletedScriptMode" size="small">
<el-radio-button
v-for="option in eventScriptModeOptions"
:key="`segment-completed-${option.value}`"
:label="option.value"
>
{{ option.label }}
</el-radio-button>
</el-radio-group>
<el-button
size="small"
type="primary"
:loading="scriptTesting.segment_completed"
:disabled="!canTestScript('segment_completed')"
@click="testEventScript('segment_completed')"
>
测试脚本
</el-button>
</div>
</div>
<el-form-item v-if="form.segmentCompletedScriptMode === 'path'" label="脚本路径">
<el-input v-model="form.segmentCompletedScriptPath" placeholder="/app/scripts/segment-completed.sh" />
</el-form-item>
<el-form-item v-else label="脚本文本">
<el-input
v-model="form.segmentCompletedScriptContent"
type="textarea"
:rows="8"
placeholder='printf "segment completed: %s\n" "$LIVE_RECORDER_SEGMENT_FILE_PATH" > "$LIVE_RECORDER_SCRIPT_LOG_PATH"'
/>
</el-form-item>
<div
v-if="scriptTestResults.segment_completed"
class="test-result"
:class="`test-result--${scriptTestStateType(scriptTestResults.segment_completed)}`"
>
<div class="test-result__title">{{ scriptTestResults.segment_completed?.message }}</div>
<div v-if="scriptTestResults.segment_completed?.detail" class="test-result__detail">
{{ scriptTestResults.segment_completed?.detail }}
</div>
<div v-if="scriptTestResults.segment_completed?.customLogOutput" class="test-result__detail">
自定义日志输出{{ scriptTestResults.segment_completed?.customLogOutput }}
</div>
</div>
</div>
</div>
<div class="helper-panel">
<div class="event-script-help__intro">
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">
内联脚本在 Linux / Docker 下通过 <code>/bin/sh -c</code> 执行 Windows 下通过 <code>PowerShell -Command</code> 执行
</div>
<div class="event-script-help__intro">
脚本需要向系统日志追加内容时请写入 <code>LIVE_RECORDER_SCRIPT_LOG_PATH</code> 指向的临时文件
</div>
<div class="event-script-help__intro">
变量名 <code>LIVE_RECORDER_OCCURRED_AT_UTC</code> 为兼容旧版本而保留实际值使用北京时间UTC+8)。
</div>
<div class="event-script-help__intro">
官方 Docker 镜像默认包含 <code>curl</code> <code>jq</code>宿主机或自定义镜像只能使用对应环境中已有的命令
</div>
<div class="event-script-help__intro">
将重试次数设为 <code>0</code> 可关闭自动重试重试耗尽后会通过现有异常通知渠道告警
</div>
<div class="event-script-example">
<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>
<code class="event-script-example__value">{{ item.example }}</code>
<span class="event-script-example__scope">{{ item.scope }}</span>
</div>
</div>
</div>
<div class="event-script-help__usage">
<div><strong>Bash:</strong> <code>printf 'segment completed: %s\n' "$LIVE_RECORDER_SEGMENT_FILE_PATH" &gt; "$LIVE_RECORDER_SCRIPT_LOG_PATH"</code></div>
<div><strong>PowerShell:</strong> <code>Set-Content -Path $env:LIVE_RECORDER_SCRIPT_LOG_PATH -Value "segment completed: $env:LIVE_RECORDER_SEGMENT_FILE_PATH"</code></div>
</div>
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="通知" name="notifications">
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">Webhook 通知</h3>
<p class="section-subtitle">使用自定义请求头发送 JSON POST异常通知同时覆盖存储不足停录和脚本重试耗尽</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="启用 Webhook">
<el-switch v-model="form.enableWebhookNotification" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="开播通知">
<el-switch v-model="form.notifyWebhookOnLiveStarted" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="异常通知">
<el-switch v-model="form.notifyWebhookOnException" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="超时(秒)">
<el-input-number v-model="form.webhookTimeoutSeconds" :min="1" :max="300" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="Webhook URL">
<el-input v-model="form.webhookUrl" placeholder="https://example.com/live-recorder/webhook" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="自定义请求头">
<el-input
v-model="form.webhookHeaders"
type="textarea"
:rows="4"
placeholder="Authorization: Bearer xxx&#10;X-Signature: your-signature"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="自定义 JSON Body 模板">
<el-input
v-model="form.webhookBodyTemplate"
type="textarea"
:rows="8"
placeholder="{&#10; &quot;event&quot;: &quot;{{eventType}}&quot;,&#10; &quot;summary&quot;: &quot;{{summary}}&quot;,&#10; &quot;roomId&quot;: &quot;{{liveRoom.roomId}}&quot;,&#10; &quot;eventScriptOutput&quot;: &quot;{{eventScriptOutput}}&quot;&#10;}"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="template-help">
<div class="template-help__label">Webhook 变量</div>
<div class="token-list">
<span v-for="token in webhookTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
</div>
<div class="action-strip">
<div class="helper-text">测试会使用当前 URL请求头和超时设置发送一条包含脚本输出示例的开播通知</div>
<el-button :loading="testingWebhook" :disabled="!canSendTestWebhook" @click="testWebhook">测试 Webhook</el-button>
</div>
<div v-if="webhookTestResult" class="test-result" :class="webhookTestResult.success ? 'test-result--success' : 'test-result--warning'">
<div class="test-result__title">{{ webhookTestResult.message }}</div>
<div v-if="webhookTestResult.detail" class="test-result__detail">{{ webhookTestResult.detail }}</div>
</div>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">邮件通知</h3>
<p class="section-subtitle">配置 SMTP开播和异常 HTML 模板异常通知同时覆盖存储不足停录和脚本重试耗尽</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="启用邮件通知">
<el-switch v-model="form.enableEmailNotification" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="启用 SSL">
<el-switch v-model="form.emailUseSsl" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="开播提醒">
<el-switch v-model="form.notifyOnLiveStarted" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="异常提醒">
<el-switch v-model="form.notifyOnException" />
</el-form-item>
</el-col>
<el-col :span="8">
<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 端口">
<el-input-number v-model="form.emailSmtpPort" :min="1" :max="65535" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="发件人名称">
<el-input v-model="form.emailFromDisplayName" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP 用户名">
<el-input v-model="form.emailUsername" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP 密码">
<el-input v-model="form.emailPassword" type="password" show-password />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="发件地址">
<el-input v-model="form.emailFromAddress" placeholder="noreply@example.com" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="收件人列表">
<el-input
v-model="form.emailToAddresses"
type="textarea"
:rows="3"
placeholder="多个地址可用逗号、分号或换行分隔"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<div class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">开播通知模板</h4>
<p class="template-section__subtitle">主题使用纯文本正文支持 HTML 和事件脚本输出占位符</p>
</div>
</div>
<el-form-item label="主题模板">
<el-input v-model="form.emailLiveStartedSubjectTemplate" />
</el-form-item>
<el-form-item label="HTML 正文模板">
<el-input v-model="form.emailLiveStartedBodyTemplateHtml" type="textarea" :rows="10" />
</el-form-item>
</div>
</el-col>
<el-col :span="24">
<div class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">异常提醒模板</h4>
<p class="template-section__subtitle">异常邮件可在 HTML 正文中插入来源摘要详情任务上下文和脚本输出</p>
</div>
</div>
<el-form-item label="主题模板">
<el-input v-model="form.emailExceptionSubjectTemplate" />
</el-form-item>
<el-form-item label="HTML 正文模板">
<el-input v-model="form.emailExceptionBodyTemplateHtml" type="textarea" :rows="12" />
</el-form-item>
</div>
</el-col>
</el-row>
</el-form>
<div class="template-help">
<div class="template-help__label">可用占位符</div>
<div class="token-list">
<span v-for="token in emailTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
</div>
<div class="helper-panel">
邮件模板里的 <code v-pre>{{detectedAtUtc}}</code> <code v-pre>{{occurredAtUtc}}</code> 字段名保持不变但实际渲染值已经统一改成北京时间UTC+8)。<code v-pre>{{eventScriptOutput}}</code> 则对应脚本通过自定义日志文件输出的文本内容
</div>
<div class="action-strip">
<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="账户安全" name="account">
<el-card id="security" class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">账号安全</h3>
<p class="section-subtitle">复用现有修改密码逻辑保存后立即对当前登录账户生效</p>
<el-form
ref="pwdFormRef"
:model="pwdForm"
:rules="pwdRules"
label-position="top"
style="max-width: 480px;"
>
<el-form-item label="当前密码" prop="currentPassword">
<el-input v-model="pwdForm.currentPassword" type="password" show-password />
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input v-model="pwdForm.newPassword" type="password" show-password />
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword">
<el-input v-model="pwdForm.confirmPassword" type="password" show-password />
</el-form-item>
<el-button type="primary" :loading="changingPassword" @click="changePassword">修改密码</el-button>
</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">平台请求设置</h3>
<p class="section-subtitle">为每个平台分别配置代理User-AgentReferer Cookie</p>
<div class="event-script-grid">
<div
v-for="platform in platformRequestPlatforms"
:key="platform.key"
class="event-script-section event-script-section--full"
>
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">{{ platform.label }}</h4>
<p class="event-script-section__subtitle">
这些设置仅用于 {{ platform.label }} 的状态检查和直播流请求
</p>
</div>
<el-switch v-model="form.platformRequestSettings[platform.key].proxy.enabled" />
</div>
<el-form label-position="top">
<el-form-item label="代理 URL">
<el-input
v-model="form.platformRequestSettings[platform.key].proxy.proxyUrl"
placeholder="http://127.0.0.1:7890"
/>
</el-form-item>
<el-form-item label="User-Agent">
<el-input
v-model="form.platformRequestSettings[platform.key].userAgent"
type="textarea"
:rows="2"
/>
</el-form-item>
<el-form-item label="Referer">
<el-input v-model="form.platformRequestSettings[platform.key].referer" />
</el-form-item>
<el-form-item label="Cookie">
<el-input
v-model="form.platformRequestSettings[platform.key].cookie"
type="textarea"
:rows="3"
placeholder="仅用于该平台的可选 Cookie"
/>
</el-form-item>
</el-form>
</div>
</div>
</el-card>
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">Douyin request headers</h3>
<p class="section-subtitle">These disguise parameters are used for both Douyin API requests and ffmpeg stream input when troubleshooting stream access checks.</p>
<el-form label-position="top">
<el-form-item label="Douyin User-Agent">
<el-input v-model="form.douyinUserAgent" type="textarea" :rows="3" />
</el-form-item>
<el-form-item label="Douyin Referer">
<el-input v-model="form.douyinReferer" />
</el-form-item>
<el-form-item label="Douyin Cookie">
<el-input
v-model="form.douyinCookie"
type="textarea"
:rows="4"
placeholder="可粘贴 ttwid、msToken 等 Cookie"
/>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
<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>
<el-button :disabled="saving" @click="discardSettingsChanges">放弃修改</el-button>
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
</div>
</div>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
padding-bottom: 132px;
}
.page-error-alert {
border-radius: 14px;
}
.page-toolbar :deep(.el-button--primary) {
display: none;
}
.settings-import-input {
display: none;
}
.settings-overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.settings-overview-card :deep(.el-card__body) {
display: grid;
gap: 18px;
}
.settings-overview-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.settings-overview-card__eyebrow {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.settings-profile {
display: flex;
align-items: center;
gap: 14px;
}
.settings-profile__avatar {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 999px;
background: var(--accent);
color: #ffffff;
font-size: 20px;
font-weight: 800;
}
.settings-profile__name {
color: var(--text-primary);
font-size: 18px;
font-weight: 700;
}
.settings-profile__meta {
margin-top: 4px;
color: var(--text-muted);
font-size: 13px;
}
.settings-overview-list {
display: grid;
gap: 10px;
}
.settings-overview-list div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
font-size: 13px;
}
.settings-overview-list span {
color: var(--text-muted);
}
.settings-overview-list strong {
color: var(--text-primary);
}
.settings-preferences {
display: grid;
gap: 14px;
}
.settings-preferences__row {
display: grid;
gap: 8px;
}
.settings-preferences__row > span,
.settings-preferences__row strong {
color: var(--text-primary);
font-size: 13px;
font-weight: 700;
}
.settings-preferences__row--switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border-radius: 14px;
border: 1px solid var(--border-subtle);
background: var(--surface-muted);
}
.settings-preferences__row--switch p {
margin: 6px 0 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.65;
}
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: start;
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;
}
.settings-tabs :deep(.el-tabs__header) {
margin: 0;
border: 1px solid var(--border-subtle);
border-bottom: 0;
border-radius: 12px 12px 0 0;
background: var(--surface-muted);
box-shadow: var(--shadow-soft);
}
.settings-tabs :deep(.el-tabs__nav-wrap) {
padding-inline: 16px;
}
.settings-tabs :deep(.el-tabs__nav-wrap::after) {
background: var(--border-subtle);
}
.settings-tabs :deep(.el-tabs__nav) {
border: 0 !important;
background: transparent;
}
.settings-tabs :deep(.el-tabs__item) {
height: 48px;
color: var(--text-secondary);
border: 0 !important;
transition:
color 0.18s ease,
background-color 0.18s ease;
}
.settings-tabs :deep(.el-tabs__item:hover) {
color: var(--text-primary);
}
.settings-tabs :deep(.el-tabs__item.is-active) {
color: var(--text-primary);
background: var(--surface);
box-shadow: inset 0 -2px 0 var(--accent);
}
.settings-tabs :deep(.el-tabs__content) {
padding: 18px;
border: 1px solid var(--border-subtle);
border-top: 0;
border-radius: 0 0 12px 12px;
background: var(--surface);
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;
}
.settings-card :deep(.el-form) {
max-width: 1120px;
}
.settings-card :deep(.el-input-number) {
width: 100%;
}
.helper-panel {
margin-top: 14px;
padding: 14px 16px;
border-radius: 12px;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
.action-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
}
.helper-text {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
.example-box {
display: grid;
gap: 10px;
padding: 16px 18px;
border-radius: 12px;
background: var(--surface);
border: 1px solid var(--border-subtle);
}
.example-box__label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.example-box__value {
font-size: 12px;
line-height: 1.7;
color: var(--text-primary);
}
.event-script-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.event-script-section {
padding: 16px;
border-radius: 10px;
border: 1px solid var(--border-subtle);
background: var(--surface);
}
.event-script-section--full {
grid-column: 1 / -1;
}
.event-script-section__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.event-script-section__actions {
display: grid;
gap: 10px;
justify-items: end;
}
.event-script-section__title {
margin: 0;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.event-script-section__subtitle {
margin: 6px 0 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
}
.event-script-help__intro {
margin-bottom: 12px;
}
.event-script-example {
display: grid;
gap: 10px;
margin-top: 12px;
padding: 14px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border-subtle);
}
.event-script-example__label {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.event-script-example__grid {
display: grid;
gap: 8px;
}
.event-script-example__row {
display: grid;
grid-template-columns: minmax(220px, 1.2fr) minmax(0, 1.8fr) auto;
gap: 12px;
align-items: start;
font-size: 12px;
}
.event-script-example__value {
overflow-wrap: anywhere;
color: var(--text-primary);
}
.event-script-example__scope {
color: var(--text-muted);
white-space: nowrap;
}
.event-script-help__usage {
display: grid;
gap: 6px;
margin-top: 12px;
}
.template-section {
padding: 18px 18px 4px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: var(--surface);
}
.template-section__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.template-section__title {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.template-section__subtitle {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.7;
color: var(--text-secondary);
}
.template-help {
margin-top: 8px;
padding: 16px 18px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: var(--surface-muted);
}
.template-help__label {
margin-bottom: 10px;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.token-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.token-chip {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border-subtle);
color: var(--text-primary);
font-size: 12px;
}
.test-result {
display: grid;
gap: 6px;
margin-top: 14px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
}
.test-result--success {
background: rgba(240, 253, 244, 0.9);
border-color: rgba(34, 197, 94, 0.18);
}
.test-result--warning {
background: rgba(255, 247, 237, 0.92);
border-color: rgba(245, 158, 11, 0.18);
}
:global(html[data-theme="dark"]) .test-result--success {
background: rgba(14, 43, 28, 0.88);
border-color: rgba(34, 197, 94, 0.28);
}
:global(html[data-theme="dark"]) .test-result--warning {
background: rgba(58, 34, 10, 0.88);
border-color: rgba(245, 158, 11, 0.28);
}
.settings-savebar {
position: fixed;
z-index: 60;
pointer-events: none;
transition:
left 0.2s ease,
right 0.2s ease,
bottom 0.2s ease;
}
.settings-savebar__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 18px;
border-radius: var(--radius-md);
border: 1px solid var(--border-base);
background: var(--surface-raised);
box-shadow: var(--shadow-lg);
box-shadow: var(--shadow-float);
backdrop-filter: blur(18px);
pointer-events: auto;
}
.settings-savebar__copy {
min-width: 0;
}
.settings-savebar__title {
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
}
.settings-savebar__subtitle {
margin-top: 4px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
}
.test-result__title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.test-result__meta,
.test-result__detail {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.test-result__list {
margin: 0;
padding-left: 18px;
color: var(--text-secondary);
font-size: 13px;
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;
}
.event-script-grid {
grid-template-columns: 1fr;
}
.event-script-section--full {
grid-column: auto;
}
.event-script-section__header,
.action-strip {
flex-direction: column;
align-items: flex-start;
}
.action-strip :deep(.el-button) {
width: 100%;
}
.settings-savebar__content {
flex-direction: column;
align-items: stretch;
}
.settings-savebar__content :deep(.el-button) {
width: 100%;
}
}
@media (max-width: 768px) {
.page-stack {
padding-bottom: 112px;
}
.settings-tabs :deep(.el-tabs__nav-wrap) {
padding-inline: 10px;
}
.settings-tabs :deep(.el-tabs__content) {
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%;
}
.settings-card :deep(.el-form) {
max-width: none;
}
.event-script-example__row {
grid-template-columns: 1fr;
gap: 4px;
}
.event-script-example__scope {
white-space: normal;
}
.template-section {
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;
}
}
</style>