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

2970 lines
101 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 } 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 { useRoute } 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 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 activeSettingTab = ref("recording");
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 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}}] Live started: {{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>
<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>
</ul>
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
<div style="margin-top: 16px;">
<strong>Event Script Output:</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}}",
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>
<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>
</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: ""
});
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: "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_SCRIPT_LOG_PATH",
example: "/tmp/live-recorder-script-log-7a13c2c5e5cd4f2d8ec2c3b2d5f3f1aa.txt",
scope: "All events"
},
{ 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_SEGMENT_FILE_PATH",
example: "/app/records/Douyin/Streamer Name/2026-04-25/203000_casual-stream__00001.mp4",
scope: "Segment completed only"
},
{
name: "LIVE_RECORDER_DANMAKU_FILE_PATH",
example: "/app/records/Douyin/Streamer Name/2026-04-25/203000_casual-stream__00001.xml",
scope: "Segment completed only"
},
{ 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" }
];
const eventScriptModeOptions = [
{ label: "路径", value: "path" },
{ label: "脚本文本", value: "inline" }
];
const uploadTargetOptions = [
{ label: "Do not upload", 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 }
];
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"
};
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" ? "Scanning candidate sessions" : "0 / 0";
}
return `${retentionCleanupOperation.value.processedSessionCount} / ${retentionCleanupOperation.value.totalSessionCount}`;
});
const retentionCleanupSummary = computed(() => {
if (!retentionCleanupOperation.value) {
return "";
}
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(" 路 ");
});
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();
} catch (error) {
loadError.value = getApiErrorMessage(error, "Failed to load system settings. Please try again later.");
} finally {
loading.value = false;
}
}
const pwdForm = reactive({
currentPassword: "",
newPassword: "",
confirmPassword: ""
});
const pwdRules = {
currentPassword: [{ required: true, message: "Please enter the current password", trigger: "blur" }],
newPassword: [
{ required: true, message: "请输入新密码", trigger: "blur" },
{ min: 6, message: "Password must be at least 6 characters", 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"));
} 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("Password updated.");
pwdForm.currentPassword = "";
pwdForm.newPassword = "";
pwdForm.confirmPassword = "";
pwdFormRef.value?.resetFields();
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "Failed to change password. Please try again later."));
} 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();
ElMessage.success("Settings saved.");
} 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, "Failed to send test email."));
} 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("Retention cleanup background task created.");
} 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, "Failed to load retention cleanup task status."));
}
}
}
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("Settings backup exported.");
} 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("Settings imported from backup.");
} 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 = "security";
}
await nextTick();
document.querySelector(hash)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
onMounted(async () => {
await loadSettings();
await restoreRetentionCleanupTracking();
await syncSettingsHash();
});
onBeforeUnmount(() => {
stopRetentionCleanupPolling();
});
watch(
() => route.hash,
(hash) => {
void syncSettingsHash(hash);
}
);
</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>
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</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" />
<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>
</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 class="settings-grid" v-loading="loading">
<el-tabs v-model="activeSettingTab" type="border-card" class="settings-tabs">
<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">Run now and the daily retention cleanup use the same saved rules. Save this section first if you just changed the filters.</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">Current cleanup task</div>
<div class="test-result__meta">
Status={{ retentionCleanupStatusLabel }}
progress={{ 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">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>
<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">Control parallel danmaku XML recording, non-chat event capture, and retry / polling pacing.</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="Record non-chat events">
<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-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">Both directory and filename templates support variables. Segmented layouts are fully controlled by the templates themselves.</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="Output filename template">
<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> 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.
</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.
</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.
</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="polling">
<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">Create remote directories from recording-relative paths and upload the video plus danmaku files.</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">Supports custom endpoint, bucket, region, and prefix settings for object-storage compatible services.</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="scripts">
<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">
Inline script mode runs with <code>/bin/sh -c</code> on Linux / Docker and with <code>PowerShell -Command</code> on Windows.
</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>.
</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).
</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.
</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.
</div>
<div class="event-script-example">
<div class="event-script-example__label">Environment variable examples</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">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>
<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">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>
<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">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>
<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="Live started alert">
<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 Host">
<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-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-input v-model="form.emailFromDisplayName" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP username">
<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="Recipient list">
<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">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>
</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">Exception emails inject source, summary, detail, task context values, and optional event script output into the HTML body.</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">Available placeholders</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">Sending a test email does not save settings. The email renders both the live-started and exception template examples.</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-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-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>
<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">
These settings apply only to {{ platform.label }} status checks and stream requests.
</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="Optional cookies for this platform only"
/>
</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 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 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-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);
}
.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-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>