Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd5be2cc8e | ||
|
|
78f02978fd | ||
|
|
850c55f5da | ||
|
|
14773248d9 | ||
|
|
5a6c374320 | ||
|
|
9c2767f78c | ||
|
|
db458a9a14 | ||
|
|
50b207415a | ||
|
|
4b5077e3da |
@@ -11,6 +11,8 @@ webapi-build.log
|
||||
webapi-build-no-restore.log
|
||||
artifacts/
|
||||
data/
|
||||
!mobile/lib/features/live_recorder/data/
|
||||
!mobile/lib/features/live_recorder/data/**
|
||||
records/
|
||||
docker-data/
|
||||
src/LiveRecorder.WebApi/data/
|
||||
|
||||
Vendored
+86
-65
@@ -1,5 +1,5 @@
|
||||
pipeline {
|
||||
agent { label '构建机1' }
|
||||
agent { label '閺嬪嫬缂撻張?' }
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
@@ -8,15 +8,17 @@ pipeline {
|
||||
}
|
||||
|
||||
environment {
|
||||
REGISTRY_URL = 'reg.nxsir.cn'
|
||||
API_IMAGE_NAME = 'liverecorder/app-api'
|
||||
WEB_IMAGE_NAME = 'liverecorder/app-web'
|
||||
IMAGE_TAG = "${env.BUILD_ID}"
|
||||
DOCKER_CREDS = 'harbor_key'
|
||||
TARGET_PLATFORMS = 'linux/amd64,linux/arm64'
|
||||
BUILDER_NAME = 'liverecorder-buildx'
|
||||
WEB_NODE_IMAGE = 'docker.m.daocloud.io/library/node:22-alpine'
|
||||
WEB_NGINX_IMAGE = 'docker.m.daocloud.io/library/nginx:1.27-alpine'
|
||||
REGISTRY_URL = 'reg.nxsir.cn'
|
||||
API_IMAGE_NAME = 'liverecorder/app-api'
|
||||
WEB_IMAGE_NAME = 'liverecorder/app-web'
|
||||
IMAGE_TAG = "${env.BUILD_ID}"
|
||||
DOCKER_CREDS = 'harbor_key'
|
||||
TARGET_PLATFORMS = 'linux/amd64,linux/arm64'
|
||||
BUILDER_NAME = 'liverecorder-buildx'
|
||||
WEB_NODE_IMAGE = 'docker.m.daocloud.io/library/node:22-alpine'
|
||||
WEB_NGINX_IMAGE = 'docker.m.daocloud.io/library/nginx:1.27-alpine'
|
||||
HTTP_PROXY_URL = 'http://192.168.5.200:7890'
|
||||
NO_PROXY_HOSTS = '127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn'
|
||||
|
||||
GIT_REPO_URL = 'https://gitea.nxsir.cn/nanxun/live_recorder.git'
|
||||
GIT_BRANCH = 'main'
|
||||
@@ -26,6 +28,8 @@ pipeline {
|
||||
API_IMAGE_LATEST = "${REGISTRY_URL}/${API_IMAGE_NAME}:latest"
|
||||
WEB_IMAGE_TAGGED = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:${IMAGE_TAG}"
|
||||
WEB_IMAGE_LATEST = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:latest"
|
||||
API_CACHE_IMAGE = "${REGISTRY_URL}/${API_IMAGE_NAME}:buildcache"
|
||||
WEB_CACHE_IMAGE = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:buildcache"
|
||||
}
|
||||
|
||||
stages {
|
||||
@@ -52,9 +56,20 @@ pipeline {
|
||||
set -e
|
||||
sudo docker buildx version
|
||||
sudo docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||
|
||||
# 强制清理旧的 builder 实例,防止僵尸状态
|
||||
sudo docker buildx rm ${BUILDER_NAME} || true
|
||||
if ! sudo docker buildx inspect --builder ${BUILDER_NAME} >/dev/null 2>&1; then
|
||||
sudo docker buildx create \
|
||||
--name ${BUILDER_NAME} \
|
||||
--driver docker-container \
|
||||
--driver-opt network=host \
|
||||
--driver-opt 'env.HTTP_PROXY=${HTTP_PROXY_URL}' \
|
||||
--driver-opt 'env.HTTPS_PROXY=${HTTP_PROXY_URL}' \
|
||||
--driver-opt 'env.NO_PROXY=reg.nxsir.cn' \
|
||||
--driver-opt 'env.http_proxy=${HTTP_PROXY_URL}' \
|
||||
--driver-opt 'env.https_proxy=${HTTP_PROXY_URL}' \
|
||||
--driver-opt 'env.no_proxy=reg.nxsir.cn' \
|
||||
--use
|
||||
fi
|
||||
sudo docker buildx inspect --builder ${BUILDER_NAME} --bootstrap >/dev/null
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -82,53 +97,56 @@ pipeline {
|
||||
set -e
|
||||
run_with_heartbeat() {
|
||||
log_file="\$1"
|
||||
shift
|
||||
build_label="\$2"
|
||||
shift 2
|
||||
rm -f "\$log_file"
|
||||
: > "\$log_file"
|
||||
"\$@" >"\$log_file" 2>&1 &
|
||||
cmd_pid=\$!
|
||||
cmd_status=0
|
||||
tail --pid="\$cmd_pid" -n +1 -f "\$log_file" &
|
||||
tail_pid=\$!
|
||||
last_size=0
|
||||
while kill -0 "\$cmd_pid" >/dev/null 2>&1; do
|
||||
echo "[heartbeat] API multi-arch build still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "[heartbeat] \${build_label} still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
if [ -f "\$log_file" ]; then
|
||||
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
|
||||
if [ "\$current_size" -gt "\$last_size" ]; then
|
||||
start_byte=\$((last_size + 1))
|
||||
tail -c +"\$start_byte" "\$log_file" || true
|
||||
last_size=\$current_size
|
||||
fi
|
||||
fi
|
||||
sleep 20
|
||||
done
|
||||
wait "\$cmd_pid" || cmd_status=\$?
|
||||
wait "\$tail_pid" >/dev/null 2>&1 || true
|
||||
if [ -f "\$log_file" ]; then
|
||||
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
|
||||
if [ "\$current_size" -gt "\$last_size" ]; then
|
||||
start_byte=\$((last_size + 1))
|
||||
tail -c +"\$start_byte" "\$log_file" || true
|
||||
fi
|
||||
fi
|
||||
if [ "\$cmd_status" -ne 0 ]; then
|
||||
echo "[heartbeat] API multi-arch build failed with exit code \$cmd_status"
|
||||
echo "[heartbeat] \${build_label} failed with exit code \$cmd_status"
|
||||
fi
|
||||
return "\$cmd_status"
|
||||
}
|
||||
if ! sudo docker buildx inspect --builder ${BUILDER_NAME} >/dev/null 2>&1; then
|
||||
sudo docker buildx create \
|
||||
--name ${BUILDER_NAME} \
|
||||
--driver docker-container \
|
||||
--driver-opt network=host \
|
||||
--driver-opt 'env.HTTP_PROXY=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.HTTPS_PROXY=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.NO_PROXY=reg.nxsir.cn' \
|
||||
--driver-opt 'env.http_proxy=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.https_proxy=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.no_proxy=reg.nxsir.cn' \
|
||||
--use
|
||||
fi
|
||||
sudo docker buildx inspect --builder ${BUILDER_NAME} --bootstrap >/dev/null
|
||||
echo 'Building and pushing multi-arch API image: ${API_IMAGE_TAGGED}'
|
||||
run_with_heartbeat /tmp/live-recorder-api-buildx-${IMAGE_TAG}.log \
|
||||
run_with_heartbeat /tmp/live-recorder-api-buildx-${IMAGE_TAG}.log "API multi-arch build" \
|
||||
sudo docker buildx build \
|
||||
--builder ${BUILDER_NAME} \
|
||||
--platform ${TARGET_PLATFORMS} \
|
||||
--network host \
|
||||
--progress=plain \
|
||||
--provenance=false \
|
||||
--build-arg HTTP_PROXY=http://192.168.5.200:7890 \
|
||||
--build-arg HTTPS_PROXY=http://192.168.5.200:7890 \
|
||||
--build-arg NO_PROXY=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
|
||||
--build-arg http_proxy=http://192.168.5.200:7890 \
|
||||
--build-arg https_proxy=http://192.168.5.200:7890 \
|
||||
--build-arg no_proxy=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
|
||||
--cache-from type=registry,ref=${API_CACHE_IMAGE} \
|
||||
--cache-to type=registry,ref=${API_CACHE_IMAGE},mode=max \
|
||||
--build-arg HTTP_PROXY=${HTTP_PROXY_URL} \
|
||||
--build-arg HTTPS_PROXY=${HTTP_PROXY_URL} \
|
||||
--build-arg NO_PROXY=${NO_PROXY_HOSTS} \
|
||||
--build-arg http_proxy=${HTTP_PROXY_URL} \
|
||||
--build-arg https_proxy=${HTTP_PROXY_URL} \
|
||||
--build-arg no_proxy=${NO_PROXY_HOSTS} \
|
||||
-f src/LiveRecorder.WebApi/Dockerfile \
|
||||
-t ${API_IMAGE_TAGGED} \
|
||||
-t ${API_IMAGE_LATEST} \
|
||||
@@ -144,55 +162,58 @@ pipeline {
|
||||
set -e
|
||||
run_with_heartbeat() {
|
||||
log_file="\$1"
|
||||
shift
|
||||
build_label="\$2"
|
||||
shift 2
|
||||
rm -f "\$log_file"
|
||||
: > "\$log_file"
|
||||
"\$@" >"\$log_file" 2>&1 &
|
||||
cmd_pid=\$!
|
||||
cmd_status=0
|
||||
tail --pid="\$cmd_pid" -n +1 -f "\$log_file" &
|
||||
tail_pid=\$!
|
||||
last_size=0
|
||||
while kill -0 "\$cmd_pid" >/dev/null 2>&1; do
|
||||
echo "[heartbeat] Web multi-arch build still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "[heartbeat] \${build_label} still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
if [ -f "\$log_file" ]; then
|
||||
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
|
||||
if [ "\$current_size" -gt "\$last_size" ]; then
|
||||
start_byte=\$((last_size + 1))
|
||||
tail -c +"\$start_byte" "\$log_file" || true
|
||||
last_size=\$current_size
|
||||
fi
|
||||
fi
|
||||
sleep 20
|
||||
done
|
||||
wait "\$cmd_pid" || cmd_status=\$?
|
||||
wait "\$tail_pid" >/dev/null 2>&1 || true
|
||||
if [ -f "\$log_file" ]; then
|
||||
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
|
||||
if [ "\$current_size" -gt "\$last_size" ]; then
|
||||
start_byte=\$((last_size + 1))
|
||||
tail -c +"\$start_byte" "\$log_file" || true
|
||||
fi
|
||||
fi
|
||||
if [ "\$cmd_status" -ne 0 ]; then
|
||||
echo "[heartbeat] Web multi-arch build failed with exit code \$cmd_status"
|
||||
echo "[heartbeat] \${build_label} failed with exit code \$cmd_status"
|
||||
fi
|
||||
return "\$cmd_status"
|
||||
}
|
||||
if ! sudo docker buildx inspect --builder ${BUILDER_NAME} >/dev/null 2>&1; then
|
||||
sudo docker buildx create \
|
||||
--name ${BUILDER_NAME} \
|
||||
--driver docker-container \
|
||||
--driver-opt network=host \
|
||||
--driver-opt 'env.HTTP_PROXY=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.HTTPS_PROXY=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.NO_PROXY=reg.nxsir.cn' \
|
||||
--driver-opt 'env.http_proxy=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.https_proxy=http://192.168.5.200:7890' \
|
||||
--driver-opt 'env.no_proxy=reg.nxsir.cn' \
|
||||
--use
|
||||
fi
|
||||
sudo docker buildx inspect --builder ${BUILDER_NAME} --bootstrap >/dev/null
|
||||
echo 'Building and pushing multi-arch Web image: ${WEB_IMAGE_TAGGED}'
|
||||
run_with_heartbeat /tmp/live-recorder-web-buildx-${IMAGE_TAG}.log \
|
||||
run_with_heartbeat /tmp/live-recorder-web-buildx-${IMAGE_TAG}.log "Web multi-arch build" \
|
||||
sudo docker buildx build \
|
||||
--builder ${BUILDER_NAME} \
|
||||
--platform ${TARGET_PLATFORMS} \
|
||||
--network host \
|
||||
--progress=plain \
|
||||
--provenance=false \
|
||||
--cache-from type=registry,ref=${WEB_CACHE_IMAGE} \
|
||||
--cache-to type=registry,ref=${WEB_CACHE_IMAGE},mode=max \
|
||||
--build-arg NODE_IMAGE=${WEB_NODE_IMAGE} \
|
||||
--build-arg NGINX_IMAGE=${WEB_NGINX_IMAGE} \
|
||||
--build-arg HTTP_PROXY=http://192.168.5.200:7890 \
|
||||
--build-arg HTTPS_PROXY=http://192.168.5.200:7890 \
|
||||
--build-arg NO_PROXY=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
|
||||
--build-arg http_proxy=http://192.168.5.200:7890 \
|
||||
--build-arg https_proxy=http://192.168.5.200:7890 \
|
||||
--build-arg no_proxy=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
|
||||
--build-arg HTTP_PROXY=${HTTP_PROXY_URL} \
|
||||
--build-arg HTTPS_PROXY=${HTTP_PROXY_URL} \
|
||||
--build-arg NO_PROXY=${NO_PROXY_HOSTS} \
|
||||
--build-arg http_proxy=${HTTP_PROXY_URL} \
|
||||
--build-arg https_proxy=${HTTP_PROXY_URL} \
|
||||
--build-arg no_proxy=${NO_PROXY_HOSTS} \
|
||||
-f frontend/Dockerfile \
|
||||
--build-arg VITE_API_BASE_URL=/api \
|
||||
-t ${WEB_IMAGE_TAGGED} \
|
||||
|
||||
@@ -376,7 +376,7 @@ const eventScriptEnvironmentExamples = [
|
||||
|
||||
const eventScriptModeOptions = [
|
||||
{ label: "璺緞", value: "path" },
|
||||
{ label: "鑴氭湰鏂囨湰", value: "inline" }
|
||||
{ label: "脚本文本", value: "inline" }
|
||||
];
|
||||
|
||||
const uploadTargetOptions = [
|
||||
@@ -459,12 +459,12 @@ 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}`;
|
||||
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}`;
|
||||
return `Douyin/origin/2026/04/15/主播名?221530_origin_主播名峗直播标题_123456789/221530_origin_主播名峗直播标题_123456789_00001.${extension}`;
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
@@ -492,11 +492,11 @@ const pwdForm = reactive({
|
||||
const pwdRules = {
|
||||
currentPassword: [{ required: true, message: "Please enter the current password", trigger: "blur" }],
|
||||
newPassword: [
|
||||
{ required: true, message: "璇疯緭鍏ユ柊瀵嗙爜", trigger: "blur" },
|
||||
{ required: true, message: "请输入新密码", trigger: "blur" },
|
||||
{ min: 6, message: "Password must be at least 6 characters", trigger: "blur" }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: "璇峰啀娆¤緭鍏ユ柊瀵嗙爜", trigger: "blur" },
|
||||
{ required: true, message: "请再次输入新密码", trigger: "blur" },
|
||||
{
|
||||
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (value !== pwdForm.newPassword) {
|
||||
@@ -587,7 +587,7 @@ async function testEventScript(eventType: ScriptEventType) {
|
||||
} catch (error) {
|
||||
const result: EventScriptTestResult = {
|
||||
success: false,
|
||||
message: getApiErrorMessage(error, "浜嬩欢鑴氭湰娴嬭瘯澶辫触")
|
||||
message: getApiErrorMessage(error, "事件脚本测试失败")
|
||||
};
|
||||
scriptTestResults[eventType] = result;
|
||||
ElMessage.error(result.message);
|
||||
@@ -611,7 +611,7 @@ async function testWebhook() {
|
||||
} catch (error) {
|
||||
const result: WebhookTestResult = {
|
||||
success: false,
|
||||
message: getApiErrorMessage(error, "Webhook 娴嬭瘯澶辫触")
|
||||
message: getApiErrorMessage(error, "Webhook 测试失败")
|
||||
};
|
||||
webhookTestResult.value = result;
|
||||
ElMessage.error(result.message);
|
||||
@@ -737,7 +737,7 @@ async function exportSettingsBackup() {
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
ElMessage.success("Settings backup exported.");
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "绯荤粺璁剧疆瀵煎嚭澶辫触"));
|
||||
ElMessage.error(getApiErrorMessage(error, "系统设置导出失败"));
|
||||
} finally {
|
||||
exportingSettings.value = false;
|
||||
}
|
||||
@@ -764,7 +764,7 @@ async function importSettingsBackup(event: Event) {
|
||||
Object.assign(form, data);
|
||||
ElMessage.success("Settings imported from backup.");
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "绯荤粺璁剧疆瀵煎叆澶辫触"));
|
||||
ElMessage.error(getApiErrorMessage(error, "系统设置导入失败"));
|
||||
} finally {
|
||||
importingSettings.value = false;
|
||||
}
|
||||
@@ -963,7 +963,7 @@ watch(
|
||||
<el-tab-pane label="褰曞埗" name="recording">
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">褰曞埗鍩虹</h3>
|
||||
<p class="section-subtitle">Default quality, output format, segmentation, ffmpeg templates, and network tolerance settings are managed here.</p>
|
||||
<p class="section-subtitle">默认画质、输出格式、分段策略、ffmpeg 模板和网络容错等录制基础参数在此集中管理。</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
@@ -973,12 +973,12 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Output root">
|
||||
<el-form-item label="输出根目录">
|
||||
<el-input v-model="form.outputRoot" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Default quality">
|
||||
<el-form-item label="默认画质">
|
||||
<el-select v-model="form.defaultQuality">
|
||||
<el-option
|
||||
v-for="option in qualityOptions"
|
||||
@@ -990,7 +990,7 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="杈撳嚭鏍煎紡">
|
||||
<el-form-item label="输出格式">
|
||||
<el-select v-model="form.defaultOutputFormat">
|
||||
<el-option
|
||||
v-for="(label, value) in outputFormatLabelMap"
|
||||
@@ -1002,7 +1002,7 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="淇濆瓨妯″紡">
|
||||
<el-form-item label="保存模式">
|
||||
<el-select v-model="form.saveMode">
|
||||
<el-option
|
||||
v-for="(label, value) in saveModeLabelMap"
|
||||
@@ -1014,7 +1014,7 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="褰曞埗妯℃澘">
|
||||
<el-form-item label="录制模板">
|
||||
<el-select v-model="form.recordingTemplate">
|
||||
<el-option
|
||||
v-for="(label, value) in recordingTemplateLabelMap"
|
||||
@@ -1026,32 +1026,32 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="鍒嗘鏃堕暱锛堝垎閽燂級">
|
||||
<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="Max concurrent transcode tasks">
|
||||
<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-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-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-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-form-item label="启用 ffmpeg 自动重连">
|
||||
<el-switch v-model="form.enableAutoReconnect" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1064,18 +1064,18 @@ watch(
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">瀛樺偍淇濇姢</h3>
|
||||
<p class="section-subtitle">Pause recording and MP4 finalization below the threshold, then resume once free space recovers.</p>
|
||||
<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-form-item label="启用存储保护">
|
||||
<el-switch v-model="form.enableStorageGuard" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Pause below free space (MB)">
|
||||
<el-form-item label="暂停阈值:空闲空间低于 (MB)">
|
||||
<el-input-number
|
||||
v-model="form.pauseRecordingWhenFreeSpaceBelowMegabytes"
|
||||
:min="0"
|
||||
@@ -1085,7 +1085,7 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Resume above free space (MB)">
|
||||
<el-form-item label="恢复阈值:空闲空间高于 (MB)">
|
||||
<el-input-number
|
||||
v-model="form.resumeRecordingWhenFreeSpaceAboveMegabytes"
|
||||
:min="0"
|
||||
@@ -1098,32 +1098,33 @@ watch(
|
||||
</el-form>
|
||||
|
||||
<div class="helper-panel">
|
||||
鎭㈠闃堝€煎缓璁珮浜庢殏鍋滈槇鍊硷紝閬垮厤纾佺洏绌洪棿鍦ㄤ复鐣屽€奸檮杩戝弽澶嶆姈鍔ㄣ€侻P4 杞爜浼氶澶栧崰鐢ㄤ腑闂?TS 鏂囦欢绌洪棿銆? </div>
|
||||
恢复阈值建议高于暂停阈值,避免磁盘空间在临界值附近反复抖动。MP4 转码会额外占用中间 TS 文件空间。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">淇濈暀娓呯悊</h3>
|
||||
<p class="section-subtitle">Clean up inactive sessions, tasks, results, and logs by retention age, with optional file deletion.</p>
|
||||
<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-form-item label="启用自动清理">
|
||||
<el-switch v-model="form.enableRetentionCleanup" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="淇濈暀澶╂暟">
|
||||
<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-form-item label="删除磁盘文件">
|
||||
<el-switch v-model="form.retentionDeleteFiles" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Video file condition">
|
||||
<el-form-item label="视频文件条件">
|
||||
<el-select v-model="form.retentionVideoFileCondition" style="width: 100%">
|
||||
<el-option
|
||||
v-for="option in retentionVideoFileOptions"
|
||||
@@ -1135,8 +1136,8 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Task status condition (all segments)">
|
||||
<el-select v-model="form.retentionTaskStatuses" multiple placeholder="Any status" style="width: 100%">
|
||||
<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"
|
||||
@@ -1151,7 +1152,7 @@ watch(
|
||||
|
||||
<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">Run cleanup now</el-button>
|
||||
<el-button :loading="runningRetentionCleanup" @click="runRetentionCleanup">立即执行清理</el-button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -1244,7 +1245,7 @@ watch(
|
||||
</el-form>
|
||||
|
||||
<div class="example-box">
|
||||
<div class="example-box__label">绀轰緥杈撳嚭</div>
|
||||
<div class="example-box__label">示例输出</div>
|
||||
<div class="monospace example-box__value">榛樿鍒嗘锛歿{ segmentedExamplePath }}</div>
|
||||
<div class="monospace example-box__value">鐩綍妯℃澘鍚?{fileStem}锛歿{ nestedSegmentedExamplePath }}</div>
|
||||
</div>
|
||||
@@ -1267,26 +1268,26 @@ watch(
|
||||
</el-card>
|
||||
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="Polling and upload" name="polling">
|
||||
<el-tab-pane label="轮询与上传" name="polling">
|
||||
|
||||
<el-card class="surface-card settings-card" shadow="never">
|
||||
<h3 class="section-title">鍚庡彴宸℃</h3>
|
||||
<p class="section-subtitle">Control scheduled live-status checks and automatic recording starts when a room goes live.</p>
|
||||
<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-form-item label="启用后台巡检">
|
||||
<el-switch v-model="form.enableBackgroundPolling" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="妫€娴嬪埌寮€鎾悗鑷姩褰曞埗">
|
||||
<el-form-item label="检测到开播后自动录制">
|
||||
<el-switch v-model="form.autoStartRecordingOnLive" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Polling interval (s)">
|
||||
<el-form-item label="轮询间隔(秒)">
|
||||
<el-input-number v-model="form.pollingIntervalSeconds" :min="10" :max="3600" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1346,7 +1347,7 @@ watch(
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Endpoint">
|
||||
<el-form-item label="端点">
|
||||
<el-input v-model="form.webDavUpload.endpoint" placeholder="https://dav.example.com" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1356,12 +1357,12 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Username">
|
||||
<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-form-item label="密码">
|
||||
<el-input v-model="form.webDavUpload.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1380,17 +1381,17 @@ watch(
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Endpoint">
|
||||
<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="Bucket">
|
||||
<el-form-item label="存储桶">
|
||||
<el-input v-model="form.s3Upload.bucket" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Region">
|
||||
<el-form-item label="区域">
|
||||
<el-input v-model="form.s3Upload.region" placeholder="auto / us-east-1" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1400,17 +1401,17 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Access Key">
|
||||
<el-form-item label="访问密钥">
|
||||
<el-input v-model="form.s3Upload.accessKey" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="Secret Key">
|
||||
<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="Force Path Style">
|
||||
<el-form-item label="强制路径样式">
|
||||
<el-switch v-model="form.s3Upload.forcePathStyle" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1423,7 +1424,7 @@ watch(
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">骞冲彴浠g悊</h3>
|
||||
<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">
|
||||
@@ -1435,7 +1436,7 @@ watch(
|
||||
</div>
|
||||
<el-switch v-model="form.douyinProxy.enabled" />
|
||||
</div>
|
||||
<el-form-item label="浠g悊鍦板潃">
|
||||
<el-form-item label="代理地址">
|
||||
<el-input v-model="form.douyinProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
@@ -1448,7 +1449,7 @@ watch(
|
||||
</div>
|
||||
<el-switch v-model="form.bilibiliProxy.enabled" />
|
||||
</div>
|
||||
<el-form-item label="浠g悊鍦板潃">
|
||||
<el-form-item label="代理地址">
|
||||
<el-input v-model="form.bilibiliProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
@@ -1461,7 +1462,7 @@ watch(
|
||||
</div>
|
||||
<el-switch v-model="form.huyaProxy.enabled" />
|
||||
</div>
|
||||
<el-form-item label="浠g悊鍦板潃">
|
||||
<el-form-item label="代理地址">
|
||||
<el-input v-model="form.huyaProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
@@ -1469,22 +1470,23 @@ watch(
|
||||
</el-card>
|
||||
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="浜嬩欢鑴氭湰" name="scripts">
|
||||
<el-tab-pane label="事件脚本" name="scripts">
|
||||
|
||||
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
|
||||
<h3 class="section-title">浜嬩欢鑴氭湰</h3>
|
||||
<h3 class="section-title">事件脚本</h3>
|
||||
<p class="section-subtitle">
|
||||
寮€鎾€佷笅鎾€佸垎鐗囧畬鎴愭椂閮藉彲浠ユ墽琛岃剼鏈€傝剼鏈敮鎸佽矾寰勬ā寮忓拰鐩存帴濉啓鏂囨湰妯″紡锛涙祴璇曟寜閽細鐩存帴鐢ㄥ綋鍓嶈〃鍗曞€兼墽琛岋紝涓嶈姹傚厛淇濆瓨銆? </p>
|
||||
开播、下播、分片完成时都可以执行脚本。脚本支持路径模式和直接填写文本模式;测试按钮会直接用当前表单值执行,不要求先保存。
|
||||
</p>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<el-form-item label="鍚敤浜嬩欢鑴氭湰">
|
||||
<el-form-item label="启用事件脚本">
|
||||
<el-switch v-model="form.enableEventScripts" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="Script timeout (s)">
|
||||
<el-form-item label="脚本超时(秒)">
|
||||
<el-input-number v-model="form.eventScriptTimeoutSeconds" :min="1" :max="3600" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1495,8 +1497,8 @@ watch(
|
||||
<div class="event-script-section">
|
||||
<div class="event-script-section__header">
|
||||
<div>
|
||||
<h4 class="event-script-section__title">Live started</h4>
|
||||
<p class="event-script-section__subtitle">Triggered when the room first enters the online state.</p>
|
||||
<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" />
|
||||
@@ -1516,14 +1518,14 @@ watch(
|
||||
: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-form-item v-else label="脚本文本">
|
||||
<el-input
|
||||
v-model="form.liveStartedScriptContent"
|
||||
type="textarea"
|
||||
@@ -1541,7 +1543,7 @@ watch(
|
||||
{{ scriptTestResults.live_started?.detail }}
|
||||
</div>
|
||||
<div v-if="scriptTestResults.live_started?.customLogOutput" class="test-result__detail">
|
||||
鑷畾涔夋棩蹇楄緭鍑猴細{{ scriptTestResults.live_started?.customLogOutput }}
|
||||
自定义日志输出:{{ scriptTestResults.live_started?.customLogOutput }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1549,8 +1551,8 @@ watch(
|
||||
<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">Triggered when the room changes from online back to offline.</p>
|
||||
<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" />
|
||||
@@ -1570,14 +1572,14 @@ watch(
|
||||
: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-form-item v-else label="脚本文本">
|
||||
<el-input
|
||||
v-model="form.liveEndedScriptContent"
|
||||
type="textarea"
|
||||
@@ -1603,8 +1605,8 @@ watch(
|
||||
<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">Triggered after MP4 finalization completes and the segment status becomes completed.</p>
|
||||
<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" />
|
||||
@@ -1624,14 +1626,14 @@ watch(
|
||||
:disabled="!canTestScript('segment_completed')"
|
||||
@click="testEventScript('segment_completed')"
|
||||
>
|
||||
娴嬭瘯鑴氭湰
|
||||
测试脚本
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item v-if="form.segmentCompletedScriptMode === 'path'" label="鑴氭湰璺緞">
|
||||
<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-form-item v-else label="脚本文本">
|
||||
<el-input
|
||||
v-model="form.segmentCompletedScriptContent"
|
||||
type="textarea"
|
||||
@@ -1712,12 +1714,12 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="寮傚父閫氱煡">
|
||||
<el-form-item label="异常通知">
|
||||
<el-switch v-model="form.notifyWebhookOnException" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="Timeout (s)">
|
||||
<el-form-item label="超时(秒)">
|
||||
<el-input-number v-model="form.webhookTimeoutSeconds" :min="1" :max="300" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1751,7 +1753,7 @@ watch(
|
||||
</el-row>
|
||||
</el-form>
|
||||
<div class="template-help">
|
||||
<div class="template-help__label">Webhook 鍙橀噺</div>
|
||||
<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>
|
||||
@@ -1759,7 +1761,7 @@ watch(
|
||||
|
||||
<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>
|
||||
<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'">
|
||||
@@ -1790,7 +1792,7 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-form-item label="寮傚父鎻愰啋">
|
||||
<el-form-item label="异常提醒">
|
||||
<el-switch v-model="form.notifyOnException" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1817,12 +1819,12 @@ watch(
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-form-item label="SMTP 瀵嗙爜">
|
||||
<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-form-item label="发件地址">
|
||||
<el-input v-model="form.emailFromAddress" placeholder="noreply@example.com" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
@@ -1861,7 +1863,7 @@ watch(
|
||||
<div class="template-section">
|
||||
<div class="template-section__header">
|
||||
<div>
|
||||
<h4 class="template-section__title">寮傚父鎻愰啋妯℃澘</h4>
|
||||
<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>
|
||||
@@ -1942,7 +1944,7 @@ watch(
|
||||
</div>
|
||||
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="Proxy URL">
|
||||
<el-form-item label="代理 URL">
|
||||
<el-input
|
||||
v-model="form.platformRequestSettings[platform.key].proxy.proxyUrl"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
@@ -2004,10 +2006,10 @@ watch(
|
||||
<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">You can save the current settings from anywhere on this page.</div>
|
||||
<div class="settings-savebar__title">当前修改不会自动保存</div>
|
||||
<div class="settings-savebar__subtitle">您可以在此页面任意位置保存当前设置。</div>
|
||||
</div>
|
||||
<el-button type="primary" :loading="saving" @click="saveSettings">淇濆瓨璁剧疆</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class AuthRepository {
|
||||
const AuthRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<LoginResponse> login({
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final response = await _apiClient.postJson(
|
||||
'/api/auth/login',
|
||||
body: <String, dynamic>{
|
||||
'username': username,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
return LoginResponse.fromJson(response as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
Future<void> logout() {
|
||||
return _apiClient.postEmpty('/api/auth/logout');
|
||||
}
|
||||
|
||||
Future<void> changePassword({
|
||||
required String currentPassword,
|
||||
required String newPassword,
|
||||
}) {
|
||||
return _apiClient.postEmpty(
|
||||
'/api/auth/change-password',
|
||||
body: <String, dynamic>{
|
||||
'currentPassword': currentPassword,
|
||||
'newPassword': newPassword,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class LiveRoomsRepository {
|
||||
const LiveRoomsRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<List<LiveRoom>> listRooms() async {
|
||||
final response = await _apiClient.getJson('/api/live-rooms') as List<dynamic>;
|
||||
return response
|
||||
.map((dynamic item) => LiveRoom.fromJson(item as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<LiveRoom> getRoom(String roomId) async {
|
||||
final response = await _apiClient.getJson('/api/live-rooms/$roomId') as Map<String, dynamic>;
|
||||
return LiveRoom.fromJson(response);
|
||||
}
|
||||
|
||||
Future<LiveRoom> refreshRoom(String roomId) async {
|
||||
final response = await _apiClient.postJson('/api/live-rooms/$roomId/refresh') as Map<String, dynamic>;
|
||||
return LiveRoom.fromJson(response);
|
||||
}
|
||||
|
||||
Future<LiveRoom> setRoomEnabled({
|
||||
required String roomId,
|
||||
required bool isEnabled,
|
||||
}) async {
|
||||
final response = await _apiClient.putJson(
|
||||
'/api/live-rooms/$roomId/enabled',
|
||||
body: <String, dynamic>{'isEnabled': isEnabled},
|
||||
) as Map<String, dynamic>;
|
||||
return LiveRoom.fromJson(response);
|
||||
}
|
||||
|
||||
Future<LiveRoom> createRoom({
|
||||
required String url,
|
||||
int? platformOverride,
|
||||
}) async {
|
||||
final response = await _apiClient.postJson(
|
||||
'/api/live-rooms',
|
||||
body: <String, dynamic>{
|
||||
'url': url,
|
||||
if (platformOverride case final int value) 'platformOverride': value,
|
||||
},
|
||||
) as Map<String, dynamic>;
|
||||
return LiveRoom.fromJson(response);
|
||||
}
|
||||
|
||||
Future<LiveRoom> updateMetadata({
|
||||
required String roomId,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final response = await _apiClient.putJson(
|
||||
'/api/live-rooms/$roomId/metadata',
|
||||
body: payload,
|
||||
) as Map<String, dynamic>;
|
||||
return LiveRoom.fromJson(response);
|
||||
}
|
||||
|
||||
Future<LiveRoom> updateSettings({
|
||||
required String roomId,
|
||||
required Map<String, dynamic> payload,
|
||||
}) async {
|
||||
final response = await _apiClient.putJson(
|
||||
'/api/live-rooms/$roomId/settings',
|
||||
body: payload,
|
||||
) as Map<String, dynamic>;
|
||||
return LiveRoom.fromJson(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class LogsRepository {
|
||||
const LogsRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<List<SystemLog>> listLogs({
|
||||
String? liveRoomId,
|
||||
String? recordSessionId,
|
||||
String? recordTaskId,
|
||||
int? level,
|
||||
String? content,
|
||||
int take = 200,
|
||||
}) async {
|
||||
final response = await _apiClient.getJson(
|
||||
'/api/logs',
|
||||
queryParameters: <String, String>{
|
||||
if (liveRoomId != null && liveRoomId.isNotEmpty) 'liveRoomId': liveRoomId,
|
||||
if (recordSessionId != null && recordSessionId.isNotEmpty) 'recordSessionId': recordSessionId,
|
||||
if (recordTaskId != null && recordTaskId.isNotEmpty) 'recordTaskId': recordTaskId,
|
||||
if (level != null) 'level': '$level',
|
||||
if (content != null && content.trim().isNotEmpty) 'content': content.trim(),
|
||||
'take': '$take',
|
||||
},
|
||||
) as List<dynamic>;
|
||||
|
||||
return response
|
||||
.map((dynamic item) => SystemLog.fromJson(item as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class MediaRepository {
|
||||
const MediaRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<MediaBrowserResponse> browse({String? path}) async {
|
||||
final response = await _apiClient.getJson(
|
||||
'/api/media/browser',
|
||||
queryParameters: <String, String>{
|
||||
if (path != null && path.isNotEmpty) 'path': path,
|
||||
},
|
||||
) as Map<String, dynamic>;
|
||||
return MediaBrowserResponse.fromJson(response);
|
||||
}
|
||||
|
||||
Uri buildFileUri({
|
||||
required String relativePath,
|
||||
bool download = false,
|
||||
}) {
|
||||
return _apiClient.buildUri(
|
||||
'/api/media/file',
|
||||
queryParameters: <String, String>{
|
||||
'path': relativePath,
|
||||
if (download) 'download': 'true',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> transcodeFile(String relativePath) async {
|
||||
final response = await _apiClient.postJson(
|
||||
'/api/media/transcode-file',
|
||||
body: <String, dynamic>{'relativePath': relativePath},
|
||||
) as Map<String, dynamic>;
|
||||
return response['message']?.toString() ?? '转码任务已提交';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class RecordingsRepository {
|
||||
const RecordingsRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<List<RecordTask>> listTasks({String? liveRoomId}) async {
|
||||
final response = await _apiClient.getJson(
|
||||
'/api/record-tasks',
|
||||
queryParameters: <String, String>{
|
||||
if (liveRoomId != null && liveRoomId.isNotEmpty) 'liveRoomId': liveRoomId,
|
||||
},
|
||||
) as List<dynamic>;
|
||||
return response
|
||||
.map((dynamic item) => RecordTask.fromJson(item as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<RecordTaskDetail> getTaskDetail(String taskId) async {
|
||||
final response = await _apiClient.getJson('/api/record-tasks/$taskId') as Map<String, dynamic>;
|
||||
return RecordTaskDetail.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecordTask> startRecording({
|
||||
required String liveRoomId,
|
||||
required String preferredQuality,
|
||||
required int outputFormat,
|
||||
}) async {
|
||||
final response = await _apiClient.postJson(
|
||||
'/api/record-tasks/start',
|
||||
body: <String, dynamic>{
|
||||
'liveRoomId': liveRoomId,
|
||||
'preferredQuality': preferredQuality,
|
||||
'outputFormat': outputFormat,
|
||||
},
|
||||
) as Map<String, dynamic>;
|
||||
return RecordTask.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecordTask> stopTask(String taskId) async {
|
||||
final response = await _apiClient.postJson('/api/record-tasks/$taskId/stop') as Map<String, dynamic>;
|
||||
return RecordTask.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecordPreviewTicket> createPreviewTicket(String taskId) async {
|
||||
final response = await _apiClient.postJson('/api/record-tasks/$taskId/preview-ticket') as Map<String, dynamic>;
|
||||
return RecordPreviewTicket.fromJson(response);
|
||||
}
|
||||
|
||||
Future<void> uploadTask(String taskId) {
|
||||
return _apiClient.postEmpty('/api/record-tasks/$taskId/upload');
|
||||
}
|
||||
|
||||
Future<List<RecordSession>> listSessions({String? liveRoomId}) async {
|
||||
final response = await _apiClient.getJson(
|
||||
'/api/record-sessions',
|
||||
queryParameters: <String, String>{
|
||||
if (liveRoomId != null && liveRoomId.isNotEmpty) 'liveRoomId': liveRoomId,
|
||||
},
|
||||
) as List<dynamic>;
|
||||
return response
|
||||
.map((dynamic item) => RecordSession.fromJson(item as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<RecordSessionDetail> getSessionDetail(String sessionId) async {
|
||||
final response = await _apiClient.getJson('/api/record-sessions/$sessionId') as Map<String, dynamic>;
|
||||
return RecordSessionDetail.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecordSession> stopSession(String sessionId) async {
|
||||
final response = await _apiClient.postJson('/api/record-sessions/$sessionId/stop') as Map<String, dynamic>;
|
||||
return RecordSession.fromJson(response);
|
||||
}
|
||||
|
||||
Future<void> uploadSession(String sessionId) {
|
||||
return _apiClient.postEmpty('/api/record-sessions/$sessionId/upload');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class RecoveryRepository {
|
||||
const RecoveryRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<RecoveryOverview> getOverview() async {
|
||||
final response = await _apiClient.getJson('/api/recovery') as Map<String, dynamic>;
|
||||
return RecoveryOverview.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecoveryActionResult> retryLiveRoom(String roomId) async {
|
||||
final response = await _apiClient.postJson('/api/recovery/live-rooms/$roomId/retry') as Map<String, dynamic>;
|
||||
return RecoveryActionResult.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecoveryActionResult> retryAllLiveRooms() async {
|
||||
final response = await _apiClient.postJson('/api/recovery/live-rooms/retry-all') as Map<String, dynamic>;
|
||||
return RecoveryActionResult.fromJson(response);
|
||||
}
|
||||
|
||||
Future<RecoveryActionResult> resumeFinalization(String taskId) async {
|
||||
final response = await _apiClient.postJson('/api/recovery/finalizations/$taskId/resume') as Map<String, dynamic>;
|
||||
return RecoveryActionResult.fromJson(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
|
||||
|
||||
class SettingsRepository {
|
||||
const SettingsRepository(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<SystemSettings> getSettings() async {
|
||||
final response = await _apiClient.getJson('/api/settings') as Map<String, dynamic>;
|
||||
return SystemSettings.fromJson(response);
|
||||
}
|
||||
|
||||
Future<SystemSettings> updateSettings(SystemSettings settings) async {
|
||||
final response = await _apiClient.putJson(
|
||||
'/api/settings',
|
||||
body: settings.toJson(),
|
||||
) as Map<String, dynamic>;
|
||||
return SystemSettings.fromJson(response);
|
||||
}
|
||||
|
||||
Future<CleanupOperation> runRetentionCleanup() async {
|
||||
final response = await _apiClient.postJson('/api/settings/retention/run-now') as Map<String, dynamic>;
|
||||
return CleanupOperation.fromJson(response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ RUN dotnet publish "src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" -c Relea
|
||||
|
||||
FROM ${DOTNET_RUNTIME_IMAGE} AS runtime
|
||||
|
||||
RUN sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources
|
||||
RUN apt-get update -o Acquire::ForceIPv4=true \
|
||||
&& apt-get install -o Acquire::ForceIPv4=true -y --no-install-recommends ffmpeg nodejs ca-certificates curl jq \
|
||||
RUN sed -i 's|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g' /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update -o Acquire::ForceIPv4=true -o Acquire::Retries=5 \
|
||||
&& apt-get install -o Acquire::ForceIPv4=true -o Acquire::Retries=5 -y --no-install-recommends ffmpeg nodejs ca-certificates curl jq \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
Reference in New Issue
Block a user