This commit is contained in:
2026-04-16 15:19:48 +08:00
commit 1c892259a9
127 changed files with 17390 additions and 0 deletions
+657
View File
@@ -0,0 +1,657 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import apiClient from "@/api/client";
import type { SystemSettings } from "@/types";
import { outputFormatLabelMap, recordingTemplateLabelMap, saveModeLabelMap } from "@/types";
const loading = ref(false);
const saving = ref(false);
const testingEmail = ref(false);
const form = reactive<SystemSettings>({
ffmpegPath: "ffmpeg",
outputRoot: "records",
outputDirectoryTemplate: "{platform}/{yyyy}/{MM}/{dd}/{anchor}",
outputFileNameTemplate: "{HHmmss}_{anchor}_{title}_{roomId}{segmentSuffix}",
defaultQuality: "origin",
defaultOutputFormat: 0,
saveMode: 0,
recordingTemplate: 0,
segmentDurationMinutes: 30,
enableAutoReconnect: true,
reconnectDelayMaxSeconds: 5,
readWriteTimeoutMilliseconds: 15000000,
enableDanmakuRecording: true,
danmakuIncludeNonChatEvents: true,
danmakuMinPollIntervalMilliseconds: 1000,
danmakuRetryDelayMaxSeconds: 15,
enableBackgroundPolling: true,
autoStartRecordingOnLive: true,
pollingIntervalSeconds: 60,
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 (UTC):</strong> {{detectedAtUtc}}</li>
</ul>
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
</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 (UTC):</strong> {{occurredAtUtc}}</li>
</ul>
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
</div>`,
douyinUserAgent: "",
douyinReferer: "https://live.douyin.com/",
douyinCookie: ""
});
const outputTemplateTokens = [
"{platform}",
"{roomId}",
"{anchor}",
"{title}",
"{yyyy}",
"{MM}",
"{dd}",
"{HHmmss}",
"{date}",
"{time}",
"{fileStem}",
"{segmentSuffix}"
];
const emailTemplateTokens = [
"{{appName}}",
"{{platform}}",
"{{roomId}}",
"{{title}}",
"{{anchor}}",
"{{sourceUrl}}",
"{{detectedAtUtc}}",
"{{source}}",
"{{summary}}",
"{{detail}}",
"{{liveRoomId}}",
"{{recordTaskId}}",
"{{taskStatus}}",
"{{occurredAtUtc}}"
];
const canSendTestEmail = computed(() =>
Boolean(form.emailSmtpHost.trim() && form.emailFromAddress.trim() && form.emailToAddresses.trim())
);
const segmentedExamplePath = computed(() => {
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
return `Douyin/2026/04/15/主播名/221530_主播名_直播标题_123456789_00001.${extension}`;
});
const nestedSegmentedExamplePath = computed(() => {
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
return `Douyin/2026/04/15/主播名/221530_主播名_直播标题_123456789/221530_主播名_直播标题_123456789_00001.${extension}`;
});
async function loadSettings() {
loading.value = true;
try {
const { data } = await apiClient.get<SystemSettings>("/settings");
Object.assign(form, data);
} finally {
loading.value = false;
}
}
async function saveSettings() {
saving.value = true;
try {
const { data } = await apiClient.put<SystemSettings>("/settings", form);
Object.assign(form, data);
ElMessage.success("设置已保存");
} finally {
saving.value = false;
}
}
async function sendTestEmail() {
testingEmail.value = true;
try {
await apiClient.post("/settings/test-email", {
emailSmtpHost: form.emailSmtpHost,
emailSmtpPort: form.emailSmtpPort,
emailUseSsl: form.emailUseSsl,
emailUsername: form.emailUsername,
emailPassword: form.emailPassword,
emailFromAddress: form.emailFromAddress,
emailFromDisplayName: form.emailFromDisplayName,
emailToAddresses: form.emailToAddresses,
emailLiveStartedSubjectTemplate: form.emailLiveStartedSubjectTemplate,
emailLiveStartedBodyTemplateHtml: form.emailLiveStartedBodyTemplateHtml,
emailExceptionSubjectTemplate: form.emailExceptionSubjectTemplate,
emailExceptionBodyTemplateHtml: form.emailExceptionBodyTemplateHtml
});
ElMessage.success("测试邮件已发送,请检查收件箱");
} catch (error: any) {
const message = error?.response?.data?.message ?? error?.message ?? "测试邮件发送失败";
ElMessage.error(message);
} finally {
testingEmail.value = false;
}
}
onMounted(loadSettings);
</script>
<template>
<div class="page-stack">
<div class="page-header">
<div>
<h1 class="page-title">系统设置</h1>
<p class="page-subtitle">
配置录制参数路径模板弹幕录制后台巡检和邮件通知测试邮件会直接使用当前表单值不要求先保存
</p>
</div>
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
</div>
<div class="settings-grid" v-loading="loading">
<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-input v-model="form.defaultQuality" placeholder="origin / FULL_HD / HD / SD" />
</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.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>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">弹幕录制</h3>
<p class="section-subtitle">控制是否并行录制弹幕 XML是否保留非聊天事件以及轮询和失败重连节奏</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="启用弹幕录制">
<el-switch v-model="form.enableDanmakuRecording" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="记录非聊天事件">
<el-switch v-model="form.danmakuIncludeNonChatEvents" :disabled="!form.enableDanmakuRecording" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最小轮询间隔(毫秒)">
<el-input-number
v-model="form.danmakuMinPollIntervalMilliseconds"
:min="100"
:max="60000"
:disabled="!form.enableDanmakuRecording"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="最大重试退避(秒)">
<el-input-number
v-model="form.danmakuRetryDelayMaxSeconds"
:min="1"
:max="300"
:disabled="!form.enableDanmakuRecording"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
XML 文件会继续与视频同 stem 保存不额外新增单独的弹幕路径模板关闭记录非聊天事件只会保留聊天 <code>&lt;d&gt;</code> 节点
</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-form-item label="目录模板">
<el-input
v-model="form.outputDirectoryTemplate"
type="textarea"
:rows="3"
placeholder="{platform}/{yyyy}/{MM}/{dd}/{anchor}"
/>
</el-form-item>
<el-form-item label="文件名模板">
<el-input
v-model="form.outputFileNameTemplate"
type="textarea"
:rows="3"
placeholder="{HHmmss}_{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">
`{fileStem}` 只用于目录模板表示按文件模板渲染后的基础文件名`{segmentSuffix}` 只用于文件模板分段模式下会渲染为 `_00001` 这类后缀单文件模式下为空
</div>
<div class="token-list">
<span v-for="token in outputTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</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="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">支持 SMTP开播提醒和异常提醒 HTML 模板测试发送会直接使用当前表单中的 SMTP 与模板</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="启用邮件通知">
<el-switch v-model="form.enableEmailNotification" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="启用 SSL">
<el-switch v-model="form.emailUseSsl" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="开播提醒">
<el-switch v-model="form.notifyOnLiveStarted" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="异常提醒">
<el-switch v-model="form.notifyOnException" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP 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="发件人名称">
<el-input v-model="form.emailFromDisplayName" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP 用户名">
<el-input v-model="form.emailUsername" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP 密码">
<el-input v-model="form.emailPassword" type="password" show-password />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="发件地址">
<el-input v-model="form.emailFromAddress" placeholder="noreply@example.com" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="收件人列表">
<el-input
v-model="form.emailToAddresses"
type="textarea"
:rows="3"
placeholder="多个地址可用逗号、分号或换行分隔"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<div class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">开播提醒模板</h4>
<p class="template-section__subtitle">主题模板输出纯文本正文模板支持 HTML</p>
</div>
</div>
<el-form-item label="主题模板">
<el-input v-model="form.emailLiveStartedSubjectTemplate" />
</el-form-item>
<el-form-item label="HTML 正文模板">
<el-input v-model="form.emailLiveStartedBodyTemplateHtml" type="textarea" :rows="10" />
</el-form-item>
</div>
</el-col>
<el-col :span="24">
<div class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">异常提醒模板</h4>
<p class="template-section__subtitle">异常邮件会把错误摘要和任务上下文按变量注入到 HTML </p>
</div>
</div>
<el-form-item label="主题模板">
<el-input v-model="form.emailExceptionSubjectTemplate" />
</el-form-item>
<el-form-item label="HTML 正文模板">
<el-input v-model="form.emailExceptionBodyTemplateHtml" type="textarea" :rows="12" />
</el-form-item>
</div>
</el-col>
</el-row>
</el-form>
<div class="template-help">
<div class="template-help__label">可用占位符</div>
<div class="token-list">
<span v-for="token in emailTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
</div>
<div class="email-actions">
<div class="helper-text">测试发送不会自动保存配置邮件中会同时渲染开播提醒示例异常提醒示例</div>
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">测试发送</el-button>
</div>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">Douyin 请求头</h3>
<p class="section-subtitle">这些伪装参数会同时用于 Douyin API 请求和 ffmpeg 输入拉流适合处理直播流读取校验问题</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>
</div>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
align-items: start;
gap: 20px;
}
.settings-grid__full {
grid-column: 1 / -1;
}
.settings-card :deep(.el-card__body) {
padding-top: 20px;
}
.example-box {
display: grid;
gap: 10px;
padding: 16px 18px;
border-radius: 12px;
background: linear-gradient(180deg, rgba(243, 246, 250, 0.92), rgba(255, 255, 255, 0.84));
border: 1px solid rgba(91, 110, 129, 0.14);
}
.example-box__label {
font-size: 12px;
color: #6e7883;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.example-box__value {
font-size: 12px;
line-height: 1.7;
color: #44505c;
}
.helper-panel {
margin-top: 14px;
padding: 14px 16px;
border-radius: 12px;
background: rgba(245, 248, 251, 0.74);
border: 1px solid rgba(91, 110, 129, 0.12);
color: #627080;
font-size: 13px;
line-height: 1.7;
}
.template-section {
padding: 18px 18px 4px;
border-radius: 12px;
border: 1px solid rgba(91, 110, 129, 0.12);
background: linear-gradient(180deg, rgba(248, 250, 252, 0.94), rgba(255, 255, 255, 0.84));
}
.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: #1e2937;
}
.template-section__subtitle {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.7;
color: #697586;
}
.template-help {
margin-top: 8px;
padding: 16px 18px;
border-radius: 12px;
border: 1px solid rgba(91, 110, 129, 0.12);
background: rgba(245, 248, 251, 0.72);
}
.template-help__label {
margin-bottom: 10px;
font-size: 12px;
font-weight: 700;
color: #687381;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.email-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(91, 110, 129, 0.12);
}
.helper-text {
color: #657180;
font-size: 13px;
line-height: 1.7;
}
@media (max-width: 1100px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.email-actions {
flex-direction: column;
align-items: flex-start;
}
}
</style>