feat: add live room metadata, uploads, proxies and backups
This commit is contained in:
@@ -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">支持自定义 Endpoint、Bucket、Region 和路径前缀,适合对象存储兼容服务。</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);
|
||||
|
||||
Reference in New Issue
Block a user