feat: add live room metadata, uploads, proxies and backups

This commit is contained in:
2026-04-26 17:35:51 +08:00
parent 85f24f8a84
commit d2b2f714e0
38 changed files with 2371 additions and 116 deletions
+298 -1
View File
@@ -17,6 +17,9 @@ const saving = ref(false);
const testingEmail = ref(false);
const testingWebhook = ref(false);
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,
@@ -59,6 +62,38 @@ const form = reactive<SystemSettings>({
enableBackgroundPolling: true,
autoStartRecordingOnLive: true,
pollingIntervalSeconds: 60,
useAliasForStorage: false,
enableFileUpload: false,
enableAutoUpload: false,
deleteLocalFilesAfterUpload: false,
uploadTarget: 0,
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
},
enableEventScripts: false,
liveStartedScriptMode: "path",
liveStartedScriptPath: "",
@@ -189,6 +224,12 @@ const eventScriptModeOptions = [
{ label: "脚本文本", value: "inline" }
];
const uploadTargetOptions = [
{ label: "不上传", value: 0 },
{ label: "WebDAV", value: 1 },
{ label: "S3", value: 2 }
];
const canSendTestEmail = computed(() =>
Boolean(form.emailSmtpHost.trim() && form.emailFromAddress.trim() && form.emailToAddresses.trim())
);
@@ -314,6 +355,57 @@ async function runRetentionCleanup() {
}
}
async function exportSettingsBackup() {
exportingSettings.value = true;
try {
const { data, headers } = await apiClient.get<Blob>("/settings/export", {
responseType: "blob"
});
const blob = data instanceof Blob ? data : new Blob([data], { type: "application/json;charset=utf-8" });
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = parseDownloadFileName(headers["content-disposition"]) ?? `live-recorder-settings-${Date.now()}.json`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
ElMessage.success("系统设置备份已导出。");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "系统设置导出失败"));
} finally {
exportingSettings.value = false;
}
}
function triggerImportSettings() {
settingsImportInput.value?.click();
}
async function importSettingsBackup(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) {
return;
}
importingSettings.value = true;
try {
const payload = JSON.parse(await file.text());
const { data } = await apiClient.post<SystemSettings>("/settings/import", payload);
Object.assign(form, data);
ElMessage.success("系统设置已从备份导入。");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "系统设置导入失败"));
} finally {
importingSettings.value = false;
}
}
function buildScriptTestRequest(eventType: ScriptEventType) {
if (eventType === "live_started") {
return {
@@ -366,6 +458,20 @@ 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;
}
onMounted(loadSettings);
</script>
@@ -379,9 +485,21 @@ onMounted(loadSettings);
</p>
</div>
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
<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-grid" v-loading="loading">
@@ -664,6 +782,181 @@ onMounted(loadSettings);
</el-form>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">上传与归档</h3>
<p class="section-subtitle">自动或手动把视频和对应弹幕 XML 上传到单一目标端并按开关决定是否在成功后删除本地文件</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="启用上传">
<el-switch v-model="form.enableFileUpload" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="自动上传">
<el-switch v-model="form.enableAutoUpload" :disabled="!form.enableFileUpload" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="上传后删本地">
<el-switch v-model="form.deleteLocalFilesAfterUpload" :disabled="!form.enableFileUpload" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="别名用于目录">
<el-switch v-model="form.useAliasForStorage" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="上传目标">
<el-select v-model="form.uploadTarget" :disabled="!form.enableFileUpload">
<el-option
v-for="option in uploadTargetOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div v-if="form.enableFileUpload && form.uploadTarget === 1" class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">WebDAV 目标</h4>
<p class="template-section__subtitle">按录制相对路径创建远端目录并上传视频与弹幕文件</p>
</div>
</div>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="Endpoint">
<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">支持自定义 EndpointBucketRegion 和路径前缀适合对象存储兼容服务</p>
</div>
</div>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="Endpoint">
<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="Bucket">
<el-input v-model="form.s3Upload.bucket" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Region">
<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="Access Key">
<el-input v-model="form.s3Upload.accessKey" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Secret Key">
<el-input v-model="form.s3Upload.secretKey" type="password" show-password />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Force Path Style">
<el-switch v-model="form.s3Upload.forcePathStyle" />
</el-form-item>
</el-col>
</el-row>
</el-form>
</div>
<div class="helper-panel">
自动上传固定处理视频文件 + 对应弹幕 XML只有两者都上传成功并且你打开上传后删本地系统才会清理本地文件
</div>
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">平台代理</h3>
<p class="section-subtitle">代理仅作用于平台状态查询取流和平台侧请求不影响邮件Webhook 和文件上传</p>
<div 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">适合单独给抖音状态查询和取流请求走代理</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">适合单独给 Bilibili 平台请求启用或关闭代理</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">虎牙平台单独配置不会和其它平台共用代理状态</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-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">事件脚本</h3>
<p class="section-subtitle">
@@ -1092,6 +1385,10 @@ onMounted(loadSettings);
border-radius: 14px;
}
.settings-import-input {
display: none;
}
.settings-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);