2970 lines
101 KiB
Vue
2970 lines
101 KiB
Vue
<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" > "$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 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="{ "event": "{{eventType}}", "summary": "{{summary}}", "roomId": "{{liveRoom.roomId}}", "eventScriptOutput": "{{eventScriptOutput}}" }"
|
||
/>
|
||
</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>
|