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