Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
746979bdd5 | ||
|
|
8960b68ac8 | ||
|
|
f0fb4db755 | ||
|
|
c056a3e741 | ||
|
|
60d1930447 | ||
|
|
4eb2c0724e | ||
|
|
90a67bdf72 | ||
|
|
506dca898e | ||
|
|
51e4c41ee2 | ||
|
|
7a33b2c6e3 | ||
|
|
83b5ea042d | ||
|
|
d38c9490ba | ||
|
|
96ed3396d9 | ||
|
|
fdab9978bf | ||
|
|
e0bd001541 | ||
|
|
e6068d93e0 | ||
|
|
534f8030c1 | ||
|
|
dc9ab046c5 | ||
|
|
0eb47e9918 | ||
|
|
5e2e0530f1 | ||
|
|
40acd35ad7 | ||
|
|
df75ab272f | ||
|
|
713e0111a6 | ||
|
|
c3abb1f792 | ||
|
|
fd552ce2cc | ||
|
|
f2e55e46c4 | ||
|
|
19d358b312 | ||
|
|
e0d3969e46 | ||
|
|
ecc737f0bd | ||
|
|
e5b50ea85c | ||
|
|
de9f5ae110 | ||
|
|
e8772a39b5 | ||
|
|
e62dfd2de1 | ||
|
|
9091c1abc7 | ||
|
|
12ba62e2a0 | ||
|
|
ad86c080e3 | ||
|
|
8a079b4698 | ||
|
|
48da49ac72 | ||
|
|
cdea5e8732 | ||
|
|
5d57dff482 | ||
|
|
68ab2c773a | ||
|
|
05efe5e125 | ||
|
|
7a12b929dd | ||
|
|
cee591b729 | ||
|
|
4312f23f85 | ||
|
|
83f16757e7 | ||
|
|
bcba0d752b | ||
|
|
1c700f67bc | ||
|
|
af7a3d1276 | ||
|
|
4c6e730a08 | ||
|
|
e649fb7139 | ||
|
|
02a0b1bd87 | ||
|
|
06921cbb3e | ||
|
|
761041a70e | ||
|
|
75b07bf10e | ||
|
|
affc5adb4a | ||
|
|
1b3512042c | ||
|
|
fbf6b52a08 | ||
|
|
293aa31f60 | ||
|
|
1cb05292c0 | ||
|
|
fd1121bde0 | ||
|
|
8a709bc217 | ||
|
|
9df174bb0b | ||
|
|
f8abae0a7a | ||
|
|
f9c7ec5d43 | ||
|
|
90fd9f0ba8 | ||
|
|
a062bf84bf | ||
|
|
df70b64956 | ||
|
|
5196ffa0f0 | ||
|
|
6da0690fd3 | ||
|
|
e640d5b5cc | ||
|
|
0831b33ea0 | ||
|
|
f5ad1dec00 | ||
|
|
a819322559 | ||
|
|
2b0345722e | ||
|
|
9e5bbbb948 | ||
|
|
f9016931e8 | ||
|
|
46da2d78f0 | ||
|
|
524a053263 | ||
|
|
24e5cf2a06 | ||
|
|
cbee29bef9 | ||
|
|
a5c2cc3202 | ||
|
|
b2aaef093d | ||
|
|
f7fa02d13c | ||
|
|
b82110461a | ||
|
|
fd5be2cc8e | ||
|
|
78f02978fd | ||
|
|
850c55f5da | ||
|
|
14773248d9 | ||
|
|
5a6c374320 | ||
|
|
9c2767f78c | ||
|
|
db458a9a14 | ||
|
|
50b207415a | ||
|
|
4b5077e3da |
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|apply_patch",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "[ ! -f \"/home/nanxunai/.agents/skills/impeccable/scripts/hook.mjs\" ] || node \"/home/nanxunai/.agents/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 5,
|
||||
"statusMessage": "Checking UI changes"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "[ ! -f \"/home/nanxunai/.agents/skills/impeccable/scripts/hook.mjs\" ] || node \"/home/nanxunai/.agents/skills/impeccable/scripts/hook.mjs\"",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Design deep pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
.codex-temp/
|
||||
.codex_tmp_danmaku
|
||||
.codex_tmp_danmaku/
|
||||
.ci
|
||||
.ci/
|
||||
artifacts
|
||||
artifacts/
|
||||
ConsoleApp1
|
||||
|
||||
+15
@@ -5,11 +5,19 @@
|
||||
**/*.suo
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/dist-postgres/
|
||||
frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
**/.dotnet-cli-home/
|
||||
.codex-temp/
|
||||
.tools/
|
||||
build.log
|
||||
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/
|
||||
@@ -29,3 +37,10 @@ src/LiveRecorder.WebApi/live-recorder.db*
|
||||
# Frontend source/config TypeScript files must stay trackable.
|
||||
!frontend/**/*.ts
|
||||
!frontend/**/*.tsx
|
||||
|
||||
# Docker image build artifacts and the ARM64 QEMU emulator binary.
|
||||
*.tar
|
||||
*.tar.gz
|
||||
qemu-*-static
|
||||
# Stray root lockfile created by running npm in the repo root (frontend has its own).
|
||||
/package-lock.json
|
||||
|
||||
Vendored
+520
-171
@@ -1,223 +1,572 @@
|
||||
pipeline {
|
||||
agent { label '构建机1' }
|
||||
agent {
|
||||
node {
|
||||
label '构建机1'
|
||||
customWorkspace '/home/nanxunai/goujian/workspace/liverecorder-release'
|
||||
}
|
||||
}
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
disableConcurrentBuilds()
|
||||
skipDefaultCheckout(true)
|
||||
timeout(time: 180, unit: 'MINUTES')
|
||||
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '20'))
|
||||
}
|
||||
|
||||
parameters {
|
||||
booleanParam(
|
||||
name: 'RUN_TESTS',
|
||||
defaultValue: true,
|
||||
description: '运行前端构建检查和 .NET 全量测试'
|
||||
)
|
||||
booleanParam(
|
||||
name: 'BUILD_FNOS',
|
||||
defaultValue: true,
|
||||
description: '构建 fnOS x86_64 FPK 安装包'
|
||||
)
|
||||
booleanParam(
|
||||
name: 'UPLOAD_FNOS',
|
||||
defaultValue: true,
|
||||
description: '将本次构建的 fnOS 包上传到 OpenList(仅 BUILD_FNOS 生效)'
|
||||
)
|
||||
choice(
|
||||
name: 'API_IMAGE_PLATFORMS',
|
||||
choices: ['amd64+arm64', 'amd64', 'arm64', 'none'],
|
||||
description: '选择需要构建并推送的 API Docker 镜像架构'
|
||||
)
|
||||
choice(
|
||||
name: 'WEB_IMAGE_PLATFORMS',
|
||||
choices: ['amd64+arm64', 'amd64', 'arm64', 'none'],
|
||||
description: '选择需要构建并推送的 Web Docker 镜像架构'
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
PATH = '/home/nanxunai/.local/bin:/usr/local/bin:/usr/bin:/bin'
|
||||
REGISTRY_URL = 'reg.nxsir.cn'
|
||||
API_IMAGE_REPO = 'reg.nxsir.cn/live_recorder/app-api'
|
||||
WEB_IMAGE_REPO = 'reg.nxsir.cn/live_recorder/app-web'
|
||||
HARBOR_CREDENTIALS = 'harbor_key'
|
||||
OPENLIST_CREDENTIALS = 'openlist_key'
|
||||
NODE_CREDENTIALS = 'bbb939ea-4f01-4b47-aecb-c5ee2a551ef4'
|
||||
OPENLIST_BASE_URL = 'https://openlist.nxsir.cn'
|
||||
OPENLIST_REMOTE_DIR = '/yidongpan/构建产物/liverecorder'
|
||||
|
||||
GIT_REPO_URL = 'https://gitea.nxsir.cn/nanxun/live_recorder.git'
|
||||
GIT_BRANCH = 'main'
|
||||
GIT_CREDS = ''
|
||||
FNPACK_BIN = '/home/nanxunai/.local/bin/fnpack'
|
||||
FNPACK_SHA256 = '54b97fa7b70968c4d05c79840f5daeff508957d0bb2062fdb0376d00d9615c93'
|
||||
DOTNET = '/home/nanxunai/.local/bin/dotnet'
|
||||
|
||||
API_IMAGE_TAGGED = "${REGISTRY_URL}/${API_IMAGE_NAME}:${IMAGE_TAG}"
|
||||
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"
|
||||
BUILDER_NAME = 'liverecorder-ci'
|
||||
BUILDKIT_IMAGE = 'docker.m.daocloud.io/moby/buildkit:buildx-stable-1'
|
||||
BINFMT_IMAGE = 'docker.m.daocloud.io/tonistiigi/binfmt:latest'
|
||||
ARM_TEST_IMAGE = 'docker.m.daocloud.io/library/alpine:latest'
|
||||
NODE_BUILD_IMAGE = 'docker.m.daocloud.io/library/node:22-alpine'
|
||||
NGINX_IMAGE = 'docker.m.daocloud.io/library/nginx:1.27-alpine'
|
||||
DOTNET_SDK_IMAGE = 'mcr.m.daocloud.io/dotnet/sdk:8.0-bookworm-slim'
|
||||
DOTNET_RUNTIME_IMAGE = 'mcr.m.daocloud.io/dotnet/aspnet:8.0-bookworm-slim'
|
||||
|
||||
HTTP_PROXY_URL = 'http://192.168.5.200:7890'
|
||||
NO_PROXY_HOSTS = '127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn,openlist.nxsir.cn,mirrors.tuna.tsinghua.edu.cn,.tsinghua.edu.cn,mirrors.huaweicloud.com,.huaweicloud.com,mcr.m.daocloud.io,docker.m.daocloud.io,.m.daocloud.io'
|
||||
|
||||
CI_ROOT = "${WORKSPACE}/.ci"
|
||||
CI_CACHE_ROOT = '/home/nanxunai/.cache/liverecorder-ci'
|
||||
DOCKER_CONFIG = '/home/nanxunai/.cache/liverecorder-ci/docker'
|
||||
NUGET_PACKAGES = '/home/nanxunai/.cache/liverecorder-ci/nuget-packages'
|
||||
NUGET_HTTP_CACHE_PATH = '/home/nanxunai/.cache/liverecorder-ci/nuget-http'
|
||||
NUGET_PLUGINS_CACHE_PATH = '/home/nanxunai/.cache/liverecorder-ci/nuget-plugins'
|
||||
DOTNET_CLI_HOME = '/home/nanxunai/.cache/liverecorder-ci/dotnet-home'
|
||||
NPM_CONFIG_CACHE = '/home/nanxunai/.cache/liverecorder-ci/npm-cache'
|
||||
LIVERECORDER_NUGET_FEED = '/home/nanxunai/.cache/liverecorder-ci/nuget-feed'
|
||||
LIVERECORDER_BUILD_TMPDIR = "${WORKSPACE}/.ci/fnos-tmp"
|
||||
LIVERECORDER_VERIFY_TMPDIR = "${WORKSPACE}/.ci/fnos-verify"
|
||||
LIVERECORDER_SKIP_NPM_CI = '1'
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: "${NODE_CREDENTIALS}",
|
||||
usernameVariable: 'JENKINS_NODE_USERNAME',
|
||||
passwordVariable: 'JENKINS_NODE_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
if [ -d "$WORKSPACE/.ci" ]; then
|
||||
printf '%s\n' "$JENKINS_NODE_PASSWORD" | \
|
||||
sudo -S -p '' chown -R -- "$(id -u):$(id -g)" "$WORKSPACE/.ci"
|
||||
fi
|
||||
'''
|
||||
}
|
||||
deleteDir()
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
|
||||
stage('Metadata And Preflight') {
|
||||
steps {
|
||||
script {
|
||||
def userRemoteConfig = [url: env.GIT_REPO_URL]
|
||||
if (env.GIT_CREDS?.trim()) {
|
||||
userRemoteConfig.credentialsId = env.GIT_CREDS.trim()
|
||||
if (!params.RUN_TESTS &&
|
||||
!params.BUILD_FNOS &&
|
||||
params.API_IMAGE_PLATFORMS == 'none' &&
|
||||
params.WEB_IMAGE_PLATFORMS == 'none') {
|
||||
error('Select at least one test or build output.')
|
||||
}
|
||||
|
||||
checkout([
|
||||
$class: 'GitSCM',
|
||||
branches: [[name: "*/${env.GIT_BRANCH}"]],
|
||||
userRemoteConfigs: [userRemoteConfig]
|
||||
])
|
||||
env.APP_VERSION = sh(
|
||||
script: "sed -n 's/^version=//p' fnos/manifest | head -n 1",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
env.SHORT_SHA = sh(script: 'git rev-parse --short=8 HEAD', returnStdout: true).trim()
|
||||
if (!env.APP_VERSION || !env.SHORT_SHA) {
|
||||
error('Unable to resolve fnOS version or Git commit.')
|
||||
}
|
||||
env.FNOS_VERSION = sh(
|
||||
script: '''
|
||||
set -euo pipefail
|
||||
base=$(sed -n 's/^version=//p' fnos/manifest | head -n 1)
|
||||
major=${base%%.*}
|
||||
remainder=${base#*.}
|
||||
minor=${remainder%%.*}
|
||||
patch=${remainder#*.}
|
||||
test "$base" = "$major.$minor.$patch" || {
|
||||
echo "Invalid base version: $base" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$major.$minor.$patch" in
|
||||
*[!0-9.]*|.*|*..*|*.) echo "Invalid base version: $base" >&2; exit 1 ;;
|
||||
esac
|
||||
test "$BUILD_NUMBER" -lt 100000 || {
|
||||
echo 'BUILD_NUMBER must remain below 100000 for fnOS version ordering.' >&2
|
||||
exit 1
|
||||
}
|
||||
ci_patch=$((patch * 100000 + BUILD_NUMBER))
|
||||
printf '%s.%s.%s\n' "$major" "$minor" "$ci_patch"
|
||||
''',
|
||||
returnStdout: true
|
||||
).trim()
|
||||
env.IMMUTABLE_TAG = "${env.APP_VERSION}-b${env.BUILD_NUMBER}-${env.SHORT_SHA}"
|
||||
env.FPK_BASENAME = "liverecorder-${env.FNOS_VERSION}-x86_64.fpk"
|
||||
env.FPK_PATH = "${env.WORKSPACE}/artifacts/ci/${env.FPK_BASENAME}"
|
||||
currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.APP_VERSION} ${env.SHORT_SHA}"
|
||||
currentBuild.description = "${env.IMMUTABLE_TAG}"
|
||||
}
|
||||
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: "${NODE_CREDENTIALS}",
|
||||
usernameVariable: 'JENKINS_NODE_USERNAME',
|
||||
passwordVariable: 'JENKINS_NODE_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
test "$(uname -m)" = x86_64
|
||||
for command_name in git sudo docker curl python3 npm node sha256sum stat tar; do
|
||||
command -v "$command_name" >/dev/null
|
||||
done
|
||||
test -x "$DOTNET"
|
||||
test -x "$FNPACK_BIN"
|
||||
printf '%s %s\n' "$FNPACK_SHA256" "$FNPACK_BIN" | sha256sum --check --status
|
||||
./scripts/ci-docker.sh version >/dev/null
|
||||
./scripts/ci-docker.sh buildx version
|
||||
|
||||
available_kb=$(df -Pk "$WORKSPACE" | awk 'NR == 2 { print $4 }')
|
||||
if [ "$available_kb" -lt 2097152 ]; then
|
||||
echo "At least 2 GiB free workspace disk is required; available KiB: $available_kb" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p \
|
||||
"$CI_CACHE_ROOT" \
|
||||
"$DOCKER_CONFIG" \
|
||||
"$NUGET_PACKAGES" \
|
||||
"$NUGET_HTTP_CACHE_PATH" \
|
||||
"$NUGET_PLUGINS_CACHE_PATH" \
|
||||
"$DOTNET_CLI_HOME" \
|
||||
"$NPM_CONFIG_CACHE" \
|
||||
"$LIVERECORDER_NUGET_FEED" \
|
||||
"$LIVERECORDER_BUILD_TMPDIR" \
|
||||
"$LIVERECORDER_VERIFY_TMPDIR" \
|
||||
"$(dirname -- "$FPK_PATH")"
|
||||
chmod 700 "$DOCKER_CONFIG"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Prepare Buildx') {
|
||||
stage('Restore And Test') {
|
||||
when {
|
||||
expression { params.RUN_TESTS }
|
||||
}
|
||||
steps {
|
||||
sh """
|
||||
set -e
|
||||
sudo docker buildx version
|
||||
sudo docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||
|
||||
# 强制清理旧的 builder 实例,防止僵尸状态
|
||||
sudo docker buildx rm ${BUILDER_NAME} || true
|
||||
"""
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
npm ci --prefix frontend
|
||||
npm run build --prefix frontend
|
||||
"$DOTNET" restore LiveRecorder.sln --disable-parallel
|
||||
"$DOTNET" test LiveRecorder.sln -c Release --no-restore --logger 'console;verbosity=normal' /maxcpucount:1
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Login Registry') {
|
||||
stage('Prepare fnOS Dependencies') {
|
||||
when {
|
||||
expression { params.BUILD_FNOS && !params.RUN_TESTS }
|
||||
}
|
||||
steps {
|
||||
withCredentials([
|
||||
usernamePassword(
|
||||
credentialsId: "${DOCKER_CREDS}",
|
||||
usernameVariable: 'DOCKER_USERNAME',
|
||||
passwordVariable: 'DOCKER_PASSWORD'
|
||||
)
|
||||
]) {
|
||||
sh """
|
||||
set -e
|
||||
echo "\$DOCKER_PASSWORD" | sudo docker login ${REGISTRY_URL} -u "\$DOCKER_USERNAME" --password-stdin
|
||||
"""
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
npm ci --prefix frontend
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build fnOS x64') {
|
||||
when {
|
||||
expression { params.BUILD_FNOS }
|
||||
}
|
||||
steps {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
PACKAGE_VERSION="$FNOS_VERSION" FNPACK="$FNPACK_BIN" \
|
||||
./scripts/build-fnos-package.sh "$FPK_PATH"
|
||||
./scripts/verify-fnos-package.sh "$FPK_PATH" "$FNOS_VERSION"
|
||||
test -s "$FPK_PATH.sha256"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('Prepare Docker Builder') {
|
||||
when {
|
||||
expression {
|
||||
params.API_IMAGE_PLATFORMS != 'none' ||
|
||||
params.WEB_IMAGE_PLATFORMS != 'none'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: "${NODE_CREDENTIALS}",
|
||||
usernameVariable: 'JENKINS_NODE_USERNAME',
|
||||
passwordVariable: 'JENKINS_NODE_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
if [ "$API_IMAGE_PLATFORMS" = arm64 ] ||
|
||||
[ "$API_IMAGE_PLATFORMS" = amd64+arm64 ] ||
|
||||
[ "$WEB_IMAGE_PLATFORMS" = arm64 ] ||
|
||||
[ "$WEB_IMAGE_PLATFORMS" = amd64+arm64 ]; then
|
||||
if ! ./scripts/ci-docker.sh run --rm --platform linux/arm64 "$ARM_TEST_IMAGE" uname -m 2>/dev/null | grep -q aarch64; then
|
||||
./scripts/ci-docker.sh run --privileged --rm "$BINFMT_IMAGE" --install arm64
|
||||
fi
|
||||
./scripts/ci-docker.sh run --rm --platform linux/arm64 "$ARM_TEST_IMAGE" uname -m | grep -q aarch64
|
||||
fi
|
||||
|
||||
if ./scripts/ci-docker.sh buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
|
||||
./scripts/ci-docker.sh buildx use "$BUILDER_NAME"
|
||||
else
|
||||
./scripts/ci-docker.sh buildx create \
|
||||
--name "$BUILDER_NAME" \
|
||||
--driver docker-container \
|
||||
--driver-opt "image=$BUILDKIT_IMAGE" \
|
||||
--driver-opt network=host \
|
||||
--driver-opt "env.http_proxy=$HTTP_PROXY_URL" \
|
||||
--driver-opt "env.https_proxy=$HTTP_PROXY_URL" \
|
||||
--driver-opt "env.HTTP_PROXY=$HTTP_PROXY_URL" \
|
||||
--driver-opt "env.HTTPS_PROXY=$HTTP_PROXY_URL" \
|
||||
--driver-opt "env.no_proxy=.daocloud.vip" \
|
||||
--driver-opt "env.NO_PROXY=.daocloud.vip" \
|
||||
--use
|
||||
fi
|
||||
./scripts/ci-docker.sh buildx inspect "$BUILDER_NAME" --bootstrap
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build And Push API Image') {
|
||||
stage('Prepare Harbor Authentication') {
|
||||
when {
|
||||
expression {
|
||||
params.API_IMAGE_PLATFORMS != 'none' ||
|
||||
params.WEB_IMAGE_PLATFORMS != 'none'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh """
|
||||
set -e
|
||||
run_with_heartbeat() {
|
||||
log_file="\$1"
|
||||
shift
|
||||
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=\$!
|
||||
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)"
|
||||
sleep 20
|
||||
done
|
||||
wait "\$cmd_pid" || cmd_status=\$?
|
||||
wait "\$tail_pid" >/dev/null 2>&1 || true
|
||||
if [ "\$cmd_status" -ne 0 ]; then
|
||||
echo "[heartbeat] API multi-arch build 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 \
|
||||
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 \
|
||||
-f src/LiveRecorder.WebApi/Dockerfile \
|
||||
-t ${API_IMAGE_TAGGED} \
|
||||
-t ${API_IMAGE_LATEST} \
|
||||
--push \
|
||||
.
|
||||
"""
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: "${HARBOR_CREDENTIALS}",
|
||||
usernameVariable: 'HARBOR_USERNAME',
|
||||
passwordVariable: 'HARBOR_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
set +x
|
||||
auth=$(printf '%s:%s' "$HARBOR_USERNAME" "$HARBOR_PASSWORD" | base64 -w0)
|
||||
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$REGISTRY_URL" "$auth" >"$DOCKER_CONFIG/config.json"
|
||||
chmod 600 "$DOCKER_CONFIG/config.json"
|
||||
unset auth
|
||||
set -x
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build And Push Web Image') {
|
||||
stage('Build And Push amd64') {
|
||||
when {
|
||||
expression {
|
||||
params.API_IMAGE_PLATFORMS.contains('amd64') ||
|
||||
params.WEB_IMAGE_PLATFORMS.contains('amd64')
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh """
|
||||
set -e
|
||||
run_with_heartbeat() {
|
||||
log_file="\$1"
|
||||
shift
|
||||
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=\$!
|
||||
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)"
|
||||
sleep 20
|
||||
done
|
||||
wait "\$cmd_pid" || cmd_status=\$?
|
||||
wait "\$tail_pid" >/dev/null 2>&1 || true
|
||||
if [ "\$cmd_status" -ne 0 ]; then
|
||||
echo "[heartbeat] Web multi-arch build 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
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: "${NODE_CREDENTIALS}",
|
||||
usernameVariable: 'JENKINS_NODE_USERNAME',
|
||||
passwordVariable: 'JENKINS_NODE_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
arch=amd64
|
||||
platform=linux/amd64
|
||||
api_ref="$API_IMAGE_REPO:$IMMUTABLE_TAG-$arch"
|
||||
web_ref="$WEB_IMAGE_REPO:$IMMUTABLE_TAG-$arch"
|
||||
|
||||
if [ "$API_IMAGE_PLATFORMS" = amd64 ] || [ "$API_IMAGE_PLATFORMS" = amd64+arm64 ]; then
|
||||
./scripts/ci-docker.sh buildx build \
|
||||
--builder "$BUILDER_NAME" --platform "$platform" --network host \
|
||||
--progress=plain --provenance=false \
|
||||
--build-arg DOTNET_SDK_IMAGE="$DOTNET_SDK_IMAGE" \
|
||||
--build-arg DOTNET_RUNTIME_IMAGE="$DOTNET_RUNTIME_IMAGE" \
|
||||
--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_ref" --push .
|
||||
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 \
|
||||
sudo docker buildx build \
|
||||
--builder ${BUILDER_NAME} \
|
||||
--platform ${TARGET_PLATFORMS} \
|
||||
--network host \
|
||||
--progress=plain \
|
||||
--provenance=false \
|
||||
--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 \
|
||||
-f frontend/Dockerfile \
|
||||
if [ "$WEB_IMAGE_PLATFORMS" = amd64 ] || [ "$WEB_IMAGE_PLATFORMS" = amd64+arm64 ]; then
|
||||
./scripts/ci-docker.sh buildx build \
|
||||
--builder "$BUILDER_NAME" --platform "$platform" --network host \
|
||||
--progress=plain --provenance=false \
|
||||
--build-arg NODE_IMAGE="$NODE_BUILD_IMAGE" \
|
||||
--build-arg NGINX_IMAGE="$NGINX_IMAGE" \
|
||||
--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" \
|
||||
--build-arg VITE_API_BASE_URL=/api \
|
||||
-t ${WEB_IMAGE_TAGGED} \
|
||||
-t ${WEB_IMAGE_LATEST} \
|
||||
--push \
|
||||
frontend
|
||||
"""
|
||||
-f frontend/Dockerfile -t "$web_ref" --push frontend
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build And Push arm64') {
|
||||
when {
|
||||
expression {
|
||||
params.API_IMAGE_PLATFORMS.contains('arm64') ||
|
||||
params.WEB_IMAGE_PLATFORMS.contains('arm64')
|
||||
}
|
||||
}
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: "${NODE_CREDENTIALS}",
|
||||
usernameVariable: 'JENKINS_NODE_USERNAME',
|
||||
passwordVariable: 'JENKINS_NODE_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
arch=arm64
|
||||
platform=linux/arm64
|
||||
api_ref="$API_IMAGE_REPO:$IMMUTABLE_TAG-$arch"
|
||||
web_ref="$WEB_IMAGE_REPO:$IMMUTABLE_TAG-$arch"
|
||||
|
||||
if [ "$API_IMAGE_PLATFORMS" = arm64 ] || [ "$API_IMAGE_PLATFORMS" = amd64+arm64 ]; then
|
||||
./scripts/ci-docker.sh buildx build \
|
||||
--builder "$BUILDER_NAME" --platform "$platform" --network host \
|
||||
--progress=plain --provenance=false \
|
||||
--build-arg DOTNET_SDK_IMAGE="$DOTNET_SDK_IMAGE" \
|
||||
--build-arg DOTNET_RUNTIME_IMAGE="$DOTNET_RUNTIME_IMAGE" \
|
||||
--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_ref" --push .
|
||||
fi
|
||||
if [ "$WEB_IMAGE_PLATFORMS" = arm64 ] || [ "$WEB_IMAGE_PLATFORMS" = amd64+arm64 ]; then
|
||||
./scripts/ci-docker.sh buildx build \
|
||||
--builder "$BUILDER_NAME" --platform "$platform" --network host \
|
||||
--progress=plain --provenance=false \
|
||||
--build-arg NODE_IMAGE="$NODE_BUILD_IMAGE" \
|
||||
--build-arg NGINX_IMAGE="$NGINX_IMAGE" \
|
||||
--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" \
|
||||
--build-arg VITE_API_BASE_URL=/api \
|
||||
-f frontend/Dockerfile -t "$web_ref" --push frontend
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Publish Multi-arch Manifests') {
|
||||
when {
|
||||
expression {
|
||||
params.API_IMAGE_PLATFORMS == 'amd64+arm64' ||
|
||||
params.WEB_IMAGE_PLATFORMS == 'amd64+arm64'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: "${NODE_CREDENTIALS}",
|
||||
usernameVariable: 'JENKINS_NODE_USERNAME',
|
||||
passwordVariable: 'JENKINS_NODE_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
publish_manifest() {
|
||||
repository=$1
|
||||
immutable_ref="$repository:$IMMUTABLE_TAG"
|
||||
./scripts/ci-docker.sh buildx imagetools create \
|
||||
--tag "$immutable_ref" \
|
||||
"$immutable_ref-amd64" "$immutable_ref-arm64"
|
||||
|
||||
manifest=$(./scripts/ci-docker.sh buildx imagetools inspect --raw "$immutable_ref")
|
||||
printf '%s' "$manifest" | grep -q '"architecture"[[:space:]]*:[[:space:]]*"amd64"'
|
||||
printf '%s' "$manifest" | grep -q '"architecture"[[:space:]]*:[[:space:]]*"arm64"'
|
||||
./scripts/ci-docker.sh buildx imagetools create \
|
||||
--tag "$repository:$APP_VERSION" --tag "$repository:latest" \
|
||||
"$immutable_ref"
|
||||
}
|
||||
|
||||
if [ "$API_IMAGE_PLATFORMS" = amd64+arm64 ]; then
|
||||
publish_manifest "$API_IMAGE_REPO"
|
||||
fi
|
||||
if [ "$WEB_IMAGE_PLATFORMS" = amd64+arm64 ]; then
|
||||
publish_manifest "$WEB_IMAGE_REPO"
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Upload fnOS Artifact') {
|
||||
when {
|
||||
expression { params.BUILD_FNOS && params.UPLOAD_FNOS }
|
||||
}
|
||||
steps {
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: "${OPENLIST_CREDENTIALS}",
|
||||
usernameVariable: 'OPENLIST_USERNAME',
|
||||
passwordVariable: 'OPENLIST_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set -euo pipefail
|
||||
./scripts/upload-openlist-artifact.sh "$FPK_PATH" "$OPENLIST_REMOTE_DIR"
|
||||
./scripts/upload-openlist-artifact.sh "$FPK_PATH.sha256" "$OPENLIST_REMOTE_DIR"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build Summary') {
|
||||
steps {
|
||||
script {
|
||||
def summary = [
|
||||
'Commit: ' + sh(script: 'git rev-parse HEAD', returnStdout: true).trim(),
|
||||
'Product version: ' + env.APP_VERSION,
|
||||
'Tests: ' + (params.RUN_TESTS ? 'executed' : 'skipped')
|
||||
]
|
||||
if (params.BUILD_FNOS) {
|
||||
def checksum = sh(
|
||||
script: "cut -d ' ' -f 1 '" + env.FPK_PATH + ".sha256'",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
summary.addAll([
|
||||
'fnOS package version: ' + env.FNOS_VERSION,
|
||||
'fnOS: ' + env.FPK_BASENAME,
|
||||
'Checksum: ' + checksum
|
||||
])
|
||||
if (params.UPLOAD_FNOS) {
|
||||
summary << 'OpenList: ' + env.OPENLIST_BASE_URL + env.OPENLIST_REMOTE_DIR + '/' + env.FPK_BASENAME
|
||||
}
|
||||
}
|
||||
if (params.API_IMAGE_PLATFORMS != 'none') {
|
||||
summary << 'API platforms: ' + params.API_IMAGE_PLATFORMS
|
||||
if (params.API_IMAGE_PLATFORMS.contains('amd64')) {
|
||||
summary << 'API amd64: ' + env.API_IMAGE_REPO + ':' + env.IMMUTABLE_TAG + '-amd64'
|
||||
}
|
||||
if (params.API_IMAGE_PLATFORMS.contains('arm64')) {
|
||||
summary << 'API arm64: ' + env.API_IMAGE_REPO + ':' + env.IMMUTABLE_TAG + '-arm64'
|
||||
}
|
||||
if (params.API_IMAGE_PLATFORMS == 'amd64+arm64') {
|
||||
summary << 'API multi-arch: ' + env.API_IMAGE_REPO + ':' + env.IMMUTABLE_TAG
|
||||
}
|
||||
}
|
||||
if (params.WEB_IMAGE_PLATFORMS != 'none') {
|
||||
summary << 'Web platforms: ' + params.WEB_IMAGE_PLATFORMS
|
||||
if (params.WEB_IMAGE_PLATFORMS.contains('amd64')) {
|
||||
summary << 'Web amd64: ' + env.WEB_IMAGE_REPO + ':' + env.IMMUTABLE_TAG + '-amd64'
|
||||
}
|
||||
if (params.WEB_IMAGE_PLATFORMS.contains('arm64')) {
|
||||
summary << 'Web arm64: ' + env.WEB_IMAGE_REPO + ':' + env.IMMUTABLE_TAG + '-arm64'
|
||||
}
|
||||
if (params.WEB_IMAGE_PLATFORMS == 'amd64+arm64') {
|
||||
summary << 'Web multi-arch: ' + env.WEB_IMAGE_REPO + ':' + env.IMMUTABLE_TAG
|
||||
}
|
||||
}
|
||||
echo summary.join('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
echo "Pipeline completed successfully."
|
||||
echo "LiveRecorder ${env.IMMUTABLE_TAG} selected pipeline completed successfully."
|
||||
}
|
||||
failure {
|
||||
echo "Pipeline failed. Please check the build log."
|
||||
echo 'LiveRecorder pipeline failed; stable Docker tags were only updated if all architecture builds succeeded.'
|
||||
}
|
||||
always {
|
||||
sh """
|
||||
set +e
|
||||
sudo docker logout ${REGISTRY_URL} >/dev/null 2>&1 || true
|
||||
true
|
||||
"""
|
||||
deleteDir()
|
||||
withCredentials([usernamePassword(
|
||||
credentialsId: "${NODE_CREDENTIALS}",
|
||||
usernameVariable: 'JENKINS_NODE_USERNAME',
|
||||
passwordVariable: 'JENKINS_NODE_PASSWORD'
|
||||
)]) {
|
||||
sh '''
|
||||
set +e
|
||||
if [ -x ./scripts/ci-docker.sh ] && [ -n "${IMMUTABLE_TAG:-}" ]; then
|
||||
for repository in "$API_IMAGE_REPO" "$WEB_IMAGE_REPO"; do
|
||||
for suffix in amd64 arm64; do
|
||||
./scripts/ci-docker.sh rmi "$repository:$IMMUTABLE_TAG-$suffix" >/dev/null 2>&1 || true
|
||||
done
|
||||
./scripts/ci-docker.sh manifest rm "$repository:$IMMUTABLE_TAG" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
if [ -f "$DOCKER_CONFIG/config.json" ]; then
|
||||
: >"$DOCKER_CONFIG/config.json"
|
||||
fi
|
||||
if [ -x ./scripts/ci-docker.sh ] && [ -n "${BUILDER_NAME:-}" ]; then
|
||||
./scripts/ci-docker.sh buildx prune \
|
||||
--builder "$BUILDER_NAME" --filter until=168h \
|
||||
--keep-storage 8gb --force >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -d "${CI_ROOT:-}" ]; then
|
||||
printf '%s\n' "$JENKINS_NODE_PASSWORD" | \
|
||||
sudo -S -p '' chown -R -- "$(id -u):$(id -g)" "$CI_ROOT" || true
|
||||
fi
|
||||
'''
|
||||
}
|
||||
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="HuaweiCloud" value="https://mirrors.huaweicloud.com/repository/nuget/v3/index.json" protocolVersion="3" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Live Recorder 是一个多平台直播录制系统,支持自动检测开播、实时录制、弹幕采集、通知推送与事件脚本编排。
|
||||
|
||||
- 后端:`.NET 8 Web API + EF Core + SQLite`
|
||||
- 后端:`.NET 8 Web API + EF Core + PostgreSQL`
|
||||
- 前端:`Vue 3 + TypeScript + Element Plus`
|
||||
|
||||
当前已适配 **抖音**(Douyin)与 **Bilibili**(部分),架构将平台特有逻辑隔离在 `Platforms` 目录下,新增虎牙、斗鱼、快手等平台无需改动应用层服务。
|
||||
@@ -163,7 +163,7 @@ ILiveDanmakuConnection
|
||||
|------|------|
|
||||
| FFmpeg 路径 | ffmpeg 可执行文件路径 |
|
||||
| 输出根目录 | 录制文件输出根目录 |
|
||||
| 输出模板 | 路径模板,支持 `{platform}` `{roomId}` `{anchor}` `{title}` `{yyyy}` `{MM}` `{dd}` `{HHmmss}` 等变量 |
|
||||
| 输出模板 | 路径模板,支持 `{platform}` `{roomId}` `{anchor}` `{title}` `{sessionId}` `{yyyy}` `{MM}` `{dd}` `{HHmmss}` 等变量;已有主播/日期目录直接复用,同名文件自动追加会话短 ID,绝不静默覆盖 |
|
||||
| 默认画质 | 优先选择的流画质 |
|
||||
| 保存格式 | MP4 / TS |
|
||||
| 保存模式 | 单文件 / 分段 |
|
||||
@@ -252,10 +252,39 @@ docker compose up -d
|
||||
- 数据目录 `./data` 与录制目录 `./records` 映射到宿主机
|
||||
- 支持多架构构建(`linux/amd64`, `linux/arm64`)
|
||||
|
||||
### fnOS 原生 FPK
|
||||
|
||||
```bash
|
||||
./scripts/build-fnos-package.sh
|
||||
./scripts/smoke-fnos-package.sh \
|
||||
artifacts/fnos/liverecorder-1.2.19-x86_64.fpk \
|
||||
/path/to/nxsir-postgresql-15.1.1-x86_64.fpk
|
||||
```
|
||||
|
||||
- PostgreSQL 共享服务在独立仓库构建和发布:<https://gitea.nxsir.cn/nanxun/postgresqlfpk>
|
||||
- 使用 fnOS 开发者平台提供的官方 `fnpack` 构建;可通过 `FNPACK=/path/to/fnpack` 指定工具路径
|
||||
- Live Recorder 与 PostgreSQL 共享服务分别构建为 x86_64 原生 FPK,均不依赖 Docker
|
||||
- Live Recorder 直接依赖 `nxsir.postgresql` 和商店版 `nodejs_v22`;fnOS 会通过 `install_dep_apps` 检查并启用依赖
|
||||
- PostgreSQL 共享服务只监听 `127.0.0.1:15432`,独立管理界面默认端口为 `15433`
|
||||
- 每个应用经回环接入 API 获得独立数据库、独立 SCRAM 角色和随机密码;支持 pgvector
|
||||
- PostgreSQL 管理界面有独立管理员登录、客户端/数据库/角色/会话管理、只读 SQL 和手动备份恢复
|
||||
- Live Recorder 安装向导会要求设置自身 `admin` 密码,并填写 PostgreSQL 服务的应用接入令牌
|
||||
- Live Recorder 1.2.5 仅连接 PostgreSQL 共享服务,不再打包、启动或回退到原内置 PostgreSQL
|
||||
- 已删除旧内置 PostgreSQL 自动迁移能力;升级前必须确认应用已经取得共享数据库凭据,遗留数据目录不会被自动删除
|
||||
- Node.js 由 fnOS 商店的 `nodejs_v22` 提供,使用 `/var/apps/nodejs_v22/target/bin/node`,Live Recorder FPK 不再内置 Node.js
|
||||
- 依赖 fnOS 系统环境同时提供 `ffmpeg` 和 `ffprobe`,安装前请先确认二者可执行
|
||||
- Web 管理界面默认使用端口 `18080`
|
||||
- PostgreSQL 数据位于其应用持久化目录,手动备份位于共享目录 `postgresql/backups`
|
||||
- 录制文件默认保存在 fnOS 共享目录 `liverecorder/records`;可在“设置 → 录制 → 输出根目录”修改
|
||||
|
||||
更完整的安装、端口与凭据说明见 [docs/postgresql-migration.md](docs/postgresql-migration.md)。
|
||||
其他 fnOS 应用接入共享数据库时,请参考独立仓库的 [fnOS PostgreSQL 共享服务接入指南](https://gitea.nxsir.cn/nanxun/postgresqlfpk/src/branch/main/docs/fnos-postgresql-client-integration.md)。
|
||||
|
||||
## 验证
|
||||
|
||||
- `dotnet build LiveRecorder.sln --no-restore -m:1`
|
||||
- `npm run build`(frontend 目录)
|
||||
- `npm run test:e2e`(frontend 目录,覆盖 1920 / 1552 / 1366 / 1024 / 768 / 390)
|
||||
- Docker Compose 完整部署验证通过
|
||||
|
||||
## 后续规划
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
postgres:
|
||||
image: ${POSTGRES_IMAGE:-postgres:16-alpine}
|
||||
image: ${POSTGRES_IMAGE:-docker.m.daocloud.io/library/postgres:16-alpine}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-live_recorder}
|
||||
@@ -21,8 +21,8 @@ services:
|
||||
dockerfile: src/LiveRecorder.WebApi/Dockerfile
|
||||
network: host
|
||||
args:
|
||||
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim}
|
||||
DOTNET_RUNTIME_IMAGE: ${DOTNET_RUNTIME_IMAGE:-mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim}
|
||||
DOTNET_SDK_IMAGE: ${DOTNET_SDK_IMAGE:-mcr.m.daocloud.io/dotnet/sdk:8.0-bookworm-slim}
|
||||
DOTNET_RUNTIME_IMAGE: ${DOTNET_RUNTIME_IMAGE:-mcr.m.daocloud.io/dotnet/aspnet:8.0-bookworm-slim}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
@@ -43,8 +43,8 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
network: host
|
||||
args:
|
||||
NODE_IMAGE: ${NODE_IMAGE:-node:22-alpine}
|
||||
NGINX_IMAGE: ${NGINX_IMAGE:-nginx:1.27-alpine}
|
||||
NODE_IMAGE: ${NODE_IMAGE:-docker.m.daocloud.io/library/node:22-alpine}
|
||||
NGINX_IMAGE: ${NGINX_IMAGE:-docker.m.daocloud.io/library/nginx:1.27-alpine}
|
||||
VITE_API_BASE_URL: /api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
|
||||
@@ -1,4 +1,45 @@
|
||||
# PostgreSQL 切换与 SQLite 历史数据迁移
|
||||
# PostgreSQL 共享服务部署说明
|
||||
|
||||
## fnOS 原生部署
|
||||
|
||||
fnOS 方案由两个独立 FPK 和一个商店运行时依赖组成:
|
||||
|
||||
| 应用 | 默认端口 | 持久化内容 |
|
||||
|---|---:|---|
|
||||
| `nxsir.postgresql` | 管理界面 `15433`、数据库 `127.0.0.1:15432` | PostgreSQL 数据、凭据散列、审计日志 |
|
||||
| `liverecorder` | Web 管理界面 `18080` | 应用日志、签发后的数据库客户端凭据 |
|
||||
|
||||
Live Recorder 还依赖商店应用 `nodejs_v22`,并从 `/var/apps/nodejs_v22/target/bin/node` 调用 Node.js 22。FPK 的 `install_dep_apps=nxsir.postgresql:nodejs_v22` 会让 fnOS 检查并启用两个依赖。
|
||||
|
||||
安装顺序:
|
||||
|
||||
1. 安装 PostgreSQL 共享服务,设置独立管理密码与长度至少 20 位的应用接入令牌。
|
||||
2. 安装 Live Recorder,设置应用管理员密码,并填写同一个接入令牌。
|
||||
3. Live Recorder 只通过 `127.0.0.1` 注册。共享服务为它创建独立数据库和 SCRAM 角色,随机密码只在注册响应中返回一次。
|
||||
4. 注册成功后,接入令牌会从 Live Recorder 持久化目录删除;签发凭据保存在权限为 `0600` 的 `postgres-client.conf`。
|
||||
|
||||
Live Recorder 1.2.5 起只使用共享 PostgreSQL,不再打包、启动或回退到旧内置 PostgreSQL,也不再提供旧内置数据库的自动迁移能力。升级前必须确认 `postgres-client.conf` 有效,或在升级向导填写共享服务应用接入令牌。历史版本留下的 `postgres/` 和 `postgres-migration/` 目录不会被应用读取,也不会自动删除。
|
||||
|
||||
录制路径默认是 fnOS 共享目录 `liverecorder/records`,也可以在“设置 → 录制 → 输出根目录”修改;路径模板会继续在该根目录下生成平台、主播、日期等层级,已有目录会直接复用,不会重复嵌套。
|
||||
|
||||
### 管理与备份
|
||||
|
||||
- PostgreSQL 管理面板使用独立 `admin` 会话,不复用 Live Recorder 登录。
|
||||
- SQL 工作台只接受单条 `SELECT`、`WITH`、`EXPLAIN`、`SHOW`、`VALUES` 或 `TABLE`,并在只读事务、30 秒超时和 1000 行上限下执行。
|
||||
- 自动签发的应用数据库与角色不能在普通数据库/角色页面直接删除,应从客户端页面吊销。
|
||||
- 备份仅手动触发,使用 custom-format `pg_dump` 并保存 SHA-256;恢复需要明确输入目标数据库名称确认。
|
||||
|
||||
### fnOS 验证
|
||||
|
||||
PostgreSQL 共享服务由独立仓库构建和发布:<https://gitea.nxsir.cn/nanxun/postgresqlfpk>。
|
||||
|
||||
```bash
|
||||
./scripts/smoke-fnos-package.sh \
|
||||
artifacts/fnos/liverecorder-1.2.19-x86_64.fpk \
|
||||
/path/to/nxsir-postgresql-15.1.1-x86_64.fpk
|
||||
```
|
||||
|
||||
下面保留 Docker/宿主机从旧 SQLite 导入 PostgreSQL 的流程。
|
||||
|
||||
这份说明对应当前主线版本:应用正式运行数据库已经切换为 PostgreSQL,SQLite 仅用于一次性历史数据导入。
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
".url": {
|
||||
"liverecorder.Application": {
|
||||
"title": "Live Recorder",
|
||||
"icon": "images/icon_{0}.png",
|
||||
"type": "url",
|
||||
"protocol": "",
|
||||
"port": "18080",
|
||||
"url": "/",
|
||||
"allUsers": false
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
PASSWORD="${wizard_admin_password:-}"
|
||||
PASSWORD_CONFIRM="${wizard_admin_password_confirm:-}"
|
||||
POSTGRES_ENROLLMENT_TOKEN="${wizard_postgres_enrollment_token:-}"
|
||||
unset wizard_admin_password wizard_admin_password_confirm wizard_postgres_enrollment_token
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "$1" >&2
|
||||
if [ -n "${TRIM_TEMP_LOGFILE:-}" ]; then
|
||||
printf '%s\n' "$1" >>"$TRIM_TEMP_LOGFILE" 2>/dev/null || true
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ "$PASSWORD" = "$PASSWORD_CONFIRM" ] || fail "管理员密码两次输入不一致。"
|
||||
[ "${#PASSWORD}" -ge 10 ] || fail "管理员密码至少需要 10 个字符。"
|
||||
[ "${#PASSWORD}" -le 256 ] || fail "管理员密码不能超过 256 个字符。"
|
||||
case "$PASSWORD" in
|
||||
*$'\n'*|*$'\r'*) fail "管理员密码不能包含换行符。" ;;
|
||||
esac
|
||||
[ "${#POSTGRES_ENROLLMENT_TOKEN}" -ge 20 ] || fail "PostgreSQL 应用接入令牌至少需要 20 个字符。"
|
||||
[ "${#POSTGRES_ENROLLMENT_TOKEN}" -le 256 ] || fail "PostgreSQL 应用接入令牌不能超过 256 个字符。"
|
||||
case "$POSTGRES_ENROLLMENT_TOKEN" in
|
||||
*$'\n'*|*$'\r'*) fail "PostgreSQL 应用接入令牌不能包含换行符。" ;;
|
||||
esac
|
||||
|
||||
mkdir -p "${TRIM_PKGVAR}/run" "${TRIM_PKGVAR}/log"
|
||||
chmod 0700 "${TRIM_PKGVAR}" "${TRIM_PKGVAR}/run" 2>/dev/null || true
|
||||
umask 077
|
||||
printf '%s\n' "$PASSWORD" >"${TRIM_PKGVAR}/admin-password.seed"
|
||||
printf '%s\n' "$POSTGRES_ENROLLMENT_TOKEN" >"${TRIM_PKGVAR}/postgres-enrollment-token.seed"
|
||||
unset PASSWORD PASSWORD_CONFIRM POSTGRES_ENROLLMENT_TOKEN
|
||||
exit 0
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/bin/bash
|
||||
exit 0
|
||||
Executable
+309
@@ -0,0 +1,309 @@
|
||||
#!/bin/bash
|
||||
set -u
|
||||
|
||||
PACKAGE_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
APP_ROOT="${TRIM_APPDEST:-$PACKAGE_ROOT/app}"
|
||||
DATA_ROOT="${TRIM_PKGVAR:-$PACKAGE_ROOT/var}"
|
||||
VOLUME_ROOT="${TRIM_APPDEST_VOL:-$DATA_ROOT/volume}"
|
||||
RECORD_ROOT="${LIVE_RECORDER_RECORD_ROOT:-$VOLUME_ROOT/@appshare/liverecorder/records}"
|
||||
RUNTIME_ROOT="$APP_ROOT/runtime"
|
||||
SERVER="$APP_ROOT/server/LiveRecorder.WebApi"
|
||||
NODEJS_ROOT="${NODEJS_ROOT:-/var/apps/nodejs_v22/target}"
|
||||
NODE_BIN="$NODEJS_ROOT/bin/node"
|
||||
CURL_BIN="$RUNTIME_ROOT/bin/curl"
|
||||
CA_BUNDLE="$RUNTIME_ROOT/etc/ssl/certs/ca-certificates.crt"
|
||||
RUN_ROOT="$DATA_ROOT/run"
|
||||
LOG_ROOT="$DATA_ROOT/log"
|
||||
APP_PID_FILE="$RUN_ROOT/liverecorder.pid"
|
||||
LOG_MONITOR_PID_FILE="$RUN_ROOT/liverecorder-log-monitor.pid"
|
||||
APP_LOG="$LOG_ROOT/liverecorder.log"
|
||||
APP_LOG_MAX_BYTES=52428800
|
||||
APP_LOG_RETAINED_FILES=3
|
||||
ADMIN_PASSWORD_FILE="$DATA_ROOT/admin-password.seed"
|
||||
POSTGRES_ENROLLMENT_TOKEN_FILE="$DATA_ROOT/postgres-enrollment-token.seed"
|
||||
POSTGRES_CREDENTIALS_FILE="$DATA_ROOT/postgres-client.conf"
|
||||
SERVICE_PORT="${TRIM_SERVICE_PORT:-18080}"
|
||||
POSTGRES_SERVICE_API="${POSTGRES_SERVICE_API:-http://127.0.0.1:15433}"
|
||||
SYSTEM_PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}"
|
||||
RUNTIME_PATH="$RUNTIME_ROOT/bin:$NODEJS_ROOT/bin:$SYSTEM_PATH"
|
||||
RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/lib"
|
||||
DATABASE_CONNECTION_STRING=""
|
||||
SHARED_DB_HOST=""
|
||||
SHARED_DB_PORT=""
|
||||
SHARED_DB_NAME=""
|
||||
SHARED_DB_USER=""
|
||||
SHARED_DB_PASSWORD=""
|
||||
|
||||
log_message() {
|
||||
mkdir -p "$LOG_ROOT"
|
||||
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$APP_LOG"
|
||||
}
|
||||
|
||||
rotate_app_log() {
|
||||
[ -f "$APP_LOG" ] || return 0
|
||||
size=$(wc -c <"$APP_LOG" 2>/dev/null | tr -d '[:space:]')
|
||||
case "$size" in ''|*[!0-9]*) return 0 ;; esac
|
||||
[ "$size" -ge "$APP_LOG_MAX_BYTES" ] || return 0
|
||||
|
||||
index=$APP_LOG_RETAINED_FILES
|
||||
rm -f "$APP_LOG.$index"
|
||||
while [ "$index" -gt 1 ]; do
|
||||
previous=$((index - 1))
|
||||
if [ -f "$APP_LOG.$previous" ]; then
|
||||
mv "$APP_LOG.$previous" "$APP_LOG.$index"
|
||||
fi
|
||||
index=$previous
|
||||
done
|
||||
|
||||
tail -c "$APP_LOG_MAX_BYTES" "$APP_LOG" >"$APP_LOG.1.tmp" 2>/dev/null || return 0
|
||||
mv "$APP_LOG.1.tmp" "$APP_LOG.1"
|
||||
: >"$APP_LOG"
|
||||
}
|
||||
|
||||
start_log_monitor() {
|
||||
rm -f "$LOG_MONITOR_PID_FILE"
|
||||
(
|
||||
while app_pid >/dev/null 2>&1; do
|
||||
rotate_app_log
|
||||
sleep 60
|
||||
done
|
||||
) &
|
||||
printf '%s\n' "$!" >"$LOG_MONITOR_PID_FILE"
|
||||
}
|
||||
|
||||
stop_log_monitor() {
|
||||
if [ -f "$LOG_MONITOR_PID_FILE" ]; then
|
||||
monitor_pid=$(sed -n '1p' "$LOG_MONITOR_PID_FILE" | tr -d '[:space:]')
|
||||
case "$monitor_pid" in
|
||||
''|*[!0-9]*) ;;
|
||||
*) kill "$monitor_pid" 2>/dev/null || true ;;
|
||||
esac
|
||||
fi
|
||||
rm -f "$LOG_MONITOR_PID_FILE"
|
||||
}
|
||||
|
||||
app_pid() {
|
||||
if [ -f "$APP_PID_FILE" ]; then
|
||||
pid=$(sed -n '1p' "$APP_PID_FILE" | tr -d '[:space:]')
|
||||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||||
printf '%s' "$pid"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
run_native() {
|
||||
env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$RUNTIME_PATH" \
|
||||
SSL_CERT_FILE="$CA_BUNDLE" CURL_CA_BUNDLE="$CA_BUNDLE" "$@"
|
||||
}
|
||||
|
||||
read_credential_field() {
|
||||
key=$1
|
||||
sed -n "s/^${key}=//p" "$POSTGRES_CREDENTIALS_FILE" | sed -n '1p'
|
||||
}
|
||||
|
||||
configure_shared_credentials() {
|
||||
[ -s "$POSTGRES_CREDENTIALS_FILE" ] || return 1
|
||||
SHARED_DB_HOST=$(read_credential_field host)
|
||||
SHARED_DB_PORT=$(read_credential_field port)
|
||||
SHARED_DB_NAME=$(read_credential_field database)
|
||||
SHARED_DB_USER=$(read_credential_field username)
|
||||
SHARED_DB_PASSWORD=$(read_credential_field password)
|
||||
[ "$SHARED_DB_HOST" = "127.0.0.1" ] || return 1
|
||||
case "$SHARED_DB_PORT" in ''|*[!0-9]*) return 1 ;; esac
|
||||
case "$SHARED_DB_NAME$SHARED_DB_USER" in *[!a-z0-9_]*) return 1 ;; esac
|
||||
[ -n "$SHARED_DB_PASSWORD" ] || return 1
|
||||
DATABASE_CONNECTION_STRING="Host=$SHARED_DB_HOST;Port=$SHARED_DB_PORT;Database=$SHARED_DB_NAME;Username=$SHARED_DB_USER;Password=$SHARED_DB_PASSWORD;SSL Mode=Disable;Timeout=15;Command Timeout=120;Keepalive=30"
|
||||
}
|
||||
|
||||
enroll_shared_database() {
|
||||
[ -s "$POSTGRES_ENROLLMENT_TOKEN_FILE" ] || {
|
||||
log_message "缺少 PostgreSQL 共享服务接入令牌。请在升级或安装向导中重新填写。"
|
||||
return 1
|
||||
}
|
||||
token=$(sed -n '1p' "$POSTGRES_ENROLLMENT_TOKEN_FILE")
|
||||
[ -n "$token" ] || return 1
|
||||
response_file="$DATA_ROOT/postgres-enrollment-response.tmp"
|
||||
rm -f "$response_file"
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 60 ]; do
|
||||
if run_native "$CURL_BIN" \
|
||||
--fail --silent --show-error \
|
||||
--connect-timeout 3 --max-time 10 \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"appId":"liverecorder","displayName":"Live Recorder","requestedExtensions":[]}' \
|
||||
"$POSTGRES_SERVICE_API/internal/v1/enroll" >"$response_file" 2>>"$APP_LOG"; then
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 2
|
||||
done
|
||||
unset token
|
||||
[ -s "$response_file" ] || {
|
||||
log_message "无法从 PostgreSQL 共享服务取得数据库凭据。"
|
||||
rm -f "$response_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
host=$(sed -n 's/.*"host":"\([^"]*\)".*/\1/p' "$response_file")
|
||||
port=$(sed -n 's/.*"port":\([0-9][0-9]*\).*/\1/p' "$response_file")
|
||||
database=$(sed -n 's/.*"database":"\([^"]*\)".*/\1/p' "$response_file")
|
||||
username=$(sed -n 's/.*"username":"\([^"]*\)".*/\1/p' "$response_file")
|
||||
password=$(sed -n 's/.*"password":"\([^"]*\)".*/\1/p' "$response_file")
|
||||
[ "$host" = "127.0.0.1" ] && [ -n "$port" ] && [ -n "$database" ] && [ -n "$username" ] && [ -n "$password" ] || {
|
||||
log_message "PostgreSQL 共享服务返回了无效凭据。"
|
||||
rm -f "$response_file"
|
||||
return 1
|
||||
}
|
||||
umask 077
|
||||
{
|
||||
printf 'host=%s\n' "$host"
|
||||
printf 'port=%s\n' "$port"
|
||||
printf 'database=%s\n' "$database"
|
||||
printf 'username=%s\n' "$username"
|
||||
printf 'password=%s\n' "$password"
|
||||
} >"$POSTGRES_CREDENTIALS_FILE.tmp"
|
||||
mv "$POSTGRES_CREDENTIALS_FILE.tmp" "$POSTGRES_CREDENTIALS_FILE"
|
||||
chmod 0600 "$POSTGRES_CREDENTIALS_FILE"
|
||||
rm -f "$POSTGRES_ENROLLMENT_TOKEN_FILE" "$response_file"
|
||||
configure_shared_credentials
|
||||
}
|
||||
|
||||
ensure_shared_credentials() {
|
||||
if configure_shared_credentials; then
|
||||
return 0
|
||||
fi
|
||||
rm -f "$POSTGRES_CREDENTIALS_FILE"
|
||||
enroll_shared_database
|
||||
}
|
||||
|
||||
select_database_connection() {
|
||||
ensure_shared_credentials || {
|
||||
log_message "无法连接 PostgreSQL 共享服务或取得数据库凭据,应用不会启动。"
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
system_media_tools_available() {
|
||||
missing_tools=""
|
||||
for tool_name in ffmpeg ffprobe; do
|
||||
if ! PATH="$SYSTEM_PATH" command -v "$tool_name" >/dev/null 2>&1; then
|
||||
missing_tools="$missing_tools $tool_name"
|
||||
fi
|
||||
done
|
||||
if [ -n "$missing_tools" ]; then
|
||||
log_message "缺少 fnOS 系统媒体工具:${missing_tools# }。请先在系统环境中安装 FFmpeg(必须同时提供 ffmpeg 与 ffprobe)。"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
launch_app_process() {
|
||||
(
|
||||
export ASPNETCORE_ENVIRONMENT=Production
|
||||
export ASPNETCORE_URLS="http://0.0.0.0:$SERVICE_PORT"
|
||||
export ConnectionStrings__DefaultConnection="$DATABASE_CONNECTION_STRING"
|
||||
export LIVE_RECORDER_DEFAULT_OUTPUT_ROOT="$RECORD_ROOT"
|
||||
if [ -f "$ADMIN_PASSWORD_FILE" ]; then
|
||||
LIVE_RECORDER_DEFAULT_ADMIN_PASSWORD=$(sed -n '1p' "$ADMIN_PASSWORD_FILE")
|
||||
export LIVE_RECORDER_DEFAULT_ADMIN_PASSWORD
|
||||
fi
|
||||
export DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
|
||||
export DOTNET_BUNDLE_EXTRACT_BASE_DIR="$DATA_ROOT/dotnet-bundle"
|
||||
export XDG_CACHE_HOME="$DATA_ROOT/cache"
|
||||
export TMPDIR="$DATA_ROOT/tmp"
|
||||
export LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH"
|
||||
export PATH="$RUNTIME_PATH"
|
||||
export SSL_CERT_FILE="$CA_BUNDLE"
|
||||
export CURL_CA_BUNDLE="$CA_BUNDLE"
|
||||
mkdir -p "$XDG_CACHE_HOME" "$TMPDIR"
|
||||
cd "$APP_ROOT/server" || exit 1
|
||||
exec "$SERVER"
|
||||
) >>"$APP_LOG" 2>&1 &
|
||||
APP_PROCESS_PID=$!
|
||||
printf '%s\n' "$APP_PROCESS_PID" >"$APP_PID_FILE"
|
||||
}
|
||||
|
||||
start_app() {
|
||||
mkdir -p "$DATA_ROOT" "$RUN_ROOT" "$LOG_ROOT" "$RECORD_ROOT" "$DATA_ROOT/dotnet-bundle"
|
||||
chmod 0700 "$DATA_ROOT" "$RUN_ROOT" "$DATA_ROOT/dotnet-bundle" 2>/dev/null || true
|
||||
|
||||
if [ ! -x "$SERVER" ]; then
|
||||
log_message "应用程序不存在或不可执行:$SERVER"
|
||||
return 1
|
||||
fi
|
||||
if [ ! -x "$NODE_BIN" ] || [ ! -x "$CURL_BIN" ] || [ ! -s "$CA_BUNDLE" ]; then
|
||||
log_message "FPK 原生运行环境不完整。"
|
||||
return 1
|
||||
fi
|
||||
system_media_tools_available || return 1
|
||||
if pid=$(app_pid); then
|
||||
log_message "应用已运行,PID $pid。"
|
||||
return 0
|
||||
fi
|
||||
|
||||
rm -f "$APP_PID_FILE"
|
||||
stop_log_monitor
|
||||
rotate_app_log
|
||||
select_database_connection || return 1
|
||||
launch_attempt=1
|
||||
launch_app_process
|
||||
pid=$APP_PROCESS_PID
|
||||
start_log_monitor
|
||||
|
||||
attempt=0
|
||||
while [ "$attempt" -lt 90 ]; do
|
||||
if run_native "$CURL_BIN" -fsS "http://127.0.0.1:$SERVICE_PORT/health/ready" >/dev/null 2>&1; then
|
||||
rm -f "$ADMIN_PASSWORD_FILE"
|
||||
log_message "应用启动成功,PID $pid,端口 $SERVICE_PORT。"
|
||||
return 0
|
||||
fi
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
if [ "$launch_attempt" -ge 3 ]; then
|
||||
break
|
||||
fi
|
||||
log_message "应用启动进程提前退出,3 秒后重试($launch_attempt/3)。"
|
||||
sleep 3
|
||||
launch_attempt=$((launch_attempt + 1))
|
||||
launch_app_process
|
||||
pid=$APP_PROCESS_PID
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
log_message "应用未能在 90 秒内就绪。"
|
||||
stop_app
|
||||
return 1
|
||||
}
|
||||
|
||||
stop_app() {
|
||||
stop_log_monitor
|
||||
if pid=$(app_pid); then
|
||||
kill "$pid" 2>/dev/null || true
|
||||
attempt=0
|
||||
while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 50 ]; do
|
||||
sleep 1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
rm -f "$APP_PID_FILE"
|
||||
}
|
||||
|
||||
case "${1:-status}" in
|
||||
start) start_app ;;
|
||||
stop) stop_app ;;
|
||||
restart) stop_app && start_app ;;
|
||||
status)
|
||||
if app_pid >/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
exit 3
|
||||
;;
|
||||
*) printf 'usage: %s {start|stop|restart|status}\n' "$0" >&2; exit 2 ;;
|
||||
esac
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
# Persistent application data and recordings are preserved by default.
|
||||
exit 0
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
"$(dirname -- "$0")/main" stop || true
|
||||
exit 0
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
TOKEN="${wizard_postgres_enrollment_token:-}"
|
||||
unset wizard_postgres_enrollment_token
|
||||
if [ -n "$TOKEN" ]; then
|
||||
[ "${#TOKEN}" -ge 20 ] || { printf '%s\n' "PostgreSQL 应用接入令牌至少需要 20 个字符。" >&2; exit 1; }
|
||||
case "$TOKEN" in
|
||||
*$'\n'*|*$'\r'*) printf '%s\n' "PostgreSQL 应用接入令牌不能包含换行符。" >&2; exit 1 ;;
|
||||
esac
|
||||
umask 077
|
||||
printf '%s\n' "$TOKEN" >"${TRIM_PKGVAR}/postgres-enrollment-token.seed"
|
||||
fi
|
||||
unset TOKEN
|
||||
exit 0
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
# PostgreSQL data, logs and application settings live in TRIM_PKGVAR and are
|
||||
# intentionally not touched while fnOS replaces the immutable application.
|
||||
exit 0
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"defaults": {
|
||||
"run-as": "package"
|
||||
},
|
||||
"join-groups": ["video"]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"data-share": {
|
||||
"shares": [
|
||||
{
|
||||
"name": "liverecorder",
|
||||
"permission": {
|
||||
"rw": ["liverecorder"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "liverecorder/records",
|
||||
"permission": {
|
||||
"rw": ["liverecorder"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
appname=liverecorder
|
||||
version=1.2.19
|
||||
display_name=Live Recorder
|
||||
desc=原生直播录制系统,使用独立 PostgreSQL 共享服务,支持分片录制、弹幕采集与 OpenList 自动上传
|
||||
platform=x86
|
||||
source=thirdparty
|
||||
maintainer=Live Recorder Contributors
|
||||
os_min_version=1.2.0
|
||||
desktop_uidir=ui
|
||||
desktop_applaunchname=liverecorder.Application
|
||||
checkport=true
|
||||
ctl_stop=true
|
||||
install_dep_apps=nxsir.postgresql:nodejs_v22
|
||||
@@ -0,0 +1,69 @@
|
||||
[
|
||||
{
|
||||
"stepTitle": "设置 Live Recorder 管理员密码",
|
||||
"items": [
|
||||
{
|
||||
"type": "tips",
|
||||
"helpText": "此密码用于登录 Live Recorder,不是 fnOS 系统密码。用户名固定为 admin。"
|
||||
},
|
||||
{
|
||||
"type": "password",
|
||||
"field": "wizard_admin_password",
|
||||
"label": "管理员密码",
|
||||
"rules": [
|
||||
{
|
||||
"required": true,
|
||||
"message": "请输入管理员密码"
|
||||
},
|
||||
{
|
||||
"min": 10,
|
||||
"message": "管理员密码至少需要 10 个字符"
|
||||
},
|
||||
{
|
||||
"max": 256,
|
||||
"message": "管理员密码不能超过 256 个字符"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "password",
|
||||
"field": "wizard_admin_password_confirm",
|
||||
"label": "再次输入密码",
|
||||
"rules": [
|
||||
{
|
||||
"required": true,
|
||||
"message": "请再次输入管理员密码"
|
||||
},
|
||||
{
|
||||
"min": 10,
|
||||
"message": "管理员密码至少需要 10 个字符"
|
||||
},
|
||||
{
|
||||
"max": 256,
|
||||
"message": "管理员密码不能超过 256 个字符"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "password",
|
||||
"field": "wizard_postgres_enrollment_token",
|
||||
"label": "PostgreSQL 应用接入令牌",
|
||||
"helpText": "填写安装 PostgreSQL 共享服务时设置的应用接入令牌。令牌只用于首次签发独立数据库凭据。",
|
||||
"rules": [
|
||||
{
|
||||
"required": true,
|
||||
"message": "请输入 PostgreSQL 应用接入令牌"
|
||||
},
|
||||
{
|
||||
"min": 20,
|
||||
"message": "接入令牌至少需要 20 个字符"
|
||||
},
|
||||
{
|
||||
"max": 256,
|
||||
"message": "接入令牌不能超过 256 个字符"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"stepTitle": "升级 Live Recorder",
|
||||
"items": [
|
||||
{
|
||||
"type": "tips",
|
||||
"helpText": "本版本仅使用独立 PostgreSQL 共享服务,不再包含或启动内置数据库。已有共享数据库凭据可继续使用。"
|
||||
},
|
||||
{
|
||||
"type": "password",
|
||||
"field": "wizard_postgres_enrollment_token",
|
||||
"label": "PostgreSQL 应用接入令牌",
|
||||
"helpText": "尚未取得共享数据库凭据时需要填写应用接入令牌;已有有效凭据的后续升级可以留空。",
|
||||
"rules": [
|
||||
{
|
||||
"max": 256,
|
||||
"message": "接入令牌不能超过 256 个字符"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,3 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
npm-debug.log
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
registry=https://registry.npmmirror.com/
|
||||
audit=false
|
||||
fund=false
|
||||
+18
-3
@@ -1,10 +1,25 @@
|
||||
ARG NODE_IMAGE=node:22-alpine
|
||||
ARG NGINX_IMAGE=nginx:1.27-alpine
|
||||
ARG NODE_IMAGE=docker.m.daocloud.io/library/node:22-alpine
|
||||
ARG NGINX_IMAGE=docker.m.daocloud.io/library/nginx:1.27-alpine
|
||||
|
||||
FROM ${NODE_IMAGE} AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
# Proxy settings for npm ci / npm run build (npm registry is only reachable
|
||||
# via the proxy from the build network).
|
||||
ARG HTTP_PROXY
|
||||
ARG HTTPS_PROXY
|
||||
ARG NO_PROXY
|
||||
ARG http_proxy
|
||||
ARG https_proxy
|
||||
ARG no_proxy
|
||||
ENV HTTP_PROXY=${HTTP_PROXY} \
|
||||
HTTPS_PROXY=${HTTPS_PROXY} \
|
||||
NO_PROXY=${NO_PROXY} \
|
||||
http_proxy=${http_proxy} \
|
||||
https_proxy=${https_proxy} \
|
||||
no_proxy=${no_proxy}
|
||||
|
||||
COPY package*.json .npmrc ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
ARG NGINX_IMAGE=docker.m.daocloud.io/library/nginx:1.27-alpine
|
||||
|
||||
FROM ${NGINX_IMAGE}
|
||||
|
||||
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
COPY dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
Generated
+64
@@ -16,6 +16,7 @@
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0",
|
||||
@@ -1012,6 +1013,22 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmmirror.com/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -2681,6 +2698,53 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.9",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.9.tgz",
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
@@ -17,6 +18,7 @@
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.0",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const viewports = [
|
||||
{ name: "desktop-1920", width: 1920, height: 1080 },
|
||||
{ name: "desktop-1552", width: 1552, height: 761 },
|
||||
{ name: "desktop-1366", width: 1366, height: 768 },
|
||||
{ name: "tablet-1024", width: 1024, height: 768 },
|
||||
{ name: "tablet-768", width: 768, height: 1024 },
|
||||
{ name: "mobile-390", width: 390, height: 844 }
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
outputDir: "./test-results",
|
||||
fullyParallel: false,
|
||||
retries: 0,
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:47173",
|
||||
colorScheme: "light",
|
||||
reducedMotion: "reduce",
|
||||
trace: "retain-on-failure"
|
||||
},
|
||||
webServer: {
|
||||
command: "npm run dev -- --host 127.0.0.1 --port 47173 --strictPort",
|
||||
env: { VITE_DISABLE_DEVTOOLS: "1" },
|
||||
url: "http://127.0.0.1:47173",
|
||||
reuseExistingServer: false,
|
||||
timeout: 120_000
|
||||
},
|
||||
projects: viewports.map(({ name, width, height }) => ({
|
||||
name,
|
||||
use: { viewport: { width, height } }
|
||||
}))
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { ElNotification } from "element-plus";
|
||||
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
|
||||
import { isNoBackendPreviewMode } from "@/utils/devPreview";
|
||||
|
||||
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
const apiBaseUrl = "/api";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: apiBaseUrl,
|
||||
@@ -154,7 +154,7 @@ function notifyBackendUnavailable(message: string) {
|
||||
}
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("live-recorder-token");
|
||||
const token = localStorage.getItem("live-recorder-token") ?? sessionStorage.getItem("live-recorder-token");
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
@@ -172,6 +172,8 @@ apiClient.interceptors.response.use(
|
||||
markBackendAvailable();
|
||||
localStorage.removeItem("live-recorder-token");
|
||||
localStorage.removeItem("live-recorder-user");
|
||||
sessionStorage.removeItem("live-recorder-token");
|
||||
sessionStorage.removeItem("live-recorder-user");
|
||||
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { createDanmakuEngine } from "./danmakuEngine";
|
||||
import type { DanmakuEngine } from "./danmakuEngine";
|
||||
import type { DanmakuEvent } from "@/types";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
videoSrc: string;
|
||||
danmakuEvents: DanmakuEvent[];
|
||||
autoplay?: boolean;
|
||||
}>(),
|
||||
{
|
||||
autoplay: false,
|
||||
}
|
||||
);
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null);
|
||||
const overlayRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
let engine: DanmakuEngine | null = null;
|
||||
const isPlaying = ref(false);
|
||||
const hasError = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
function onPlay(): void {
|
||||
isPlaying.value = true;
|
||||
engine?.start();
|
||||
}
|
||||
|
||||
function onPause(): void {
|
||||
isPlaying.value = false;
|
||||
engine?.stop();
|
||||
}
|
||||
|
||||
function onSeeking(): void {
|
||||
engine?.stop();
|
||||
}
|
||||
|
||||
function onSeeked(): void {
|
||||
if (!videoRef.value) return;
|
||||
lastKnownTime = videoRef.value.currentTime;
|
||||
if (isPlaying.value) {
|
||||
engine?.start();
|
||||
}
|
||||
}
|
||||
|
||||
function onEnded(): void {
|
||||
isPlaying.value = false;
|
||||
engine?.stop();
|
||||
}
|
||||
|
||||
function onVideoError(): void {
|
||||
hasError.value = true;
|
||||
errorMessage.value = "视频加载失败,请刷新预览票据后重试。";
|
||||
}
|
||||
|
||||
function onLoadedMetadata(): void {
|
||||
hasError.value = false;
|
||||
errorMessage.value = "";
|
||||
}
|
||||
|
||||
let lastKnownTime = 0;
|
||||
|
||||
function initEngine(): void {
|
||||
if (!videoRef.value || !overlayRef.value) return;
|
||||
|
||||
// Clean up previous engine
|
||||
engine?.destroy();
|
||||
|
||||
engine = createDanmakuEngine(
|
||||
videoRef.value,
|
||||
props.danmakuEvents,
|
||||
overlayRef.value
|
||||
);
|
||||
|
||||
if (isPlaying.value) {
|
||||
engine.start();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.danmakuEvents,
|
||||
() => {
|
||||
nextTick(() => {
|
||||
if (videoRef.value && overlayRef.value) {
|
||||
initEngine();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.videoSrc,
|
||||
() => {
|
||||
hasError.value = false;
|
||||
errorMessage.value = "";
|
||||
isPlaying.value = false;
|
||||
engine?.destroy();
|
||||
engine = null;
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (videoRef.value && overlayRef.value) {
|
||||
initEngine();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
engine?.destroy();
|
||||
engine = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="danmaku-player" :class="{ 'has-error': hasError }">
|
||||
<!-- Error overlay -->
|
||||
<div v-if="hasError" class="danmaku-player__error">
|
||||
<p>{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Video element -->
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src="videoSrc"
|
||||
:autoplay="autoplay"
|
||||
:key="videoSrc"
|
||||
class="danmaku-player__video"
|
||||
controls
|
||||
preload="auto"
|
||||
crossorigin="anonymous"
|
||||
@play="onPlay"
|
||||
@pause="onPause"
|
||||
@seeking="onSeeking"
|
||||
@seeked="onSeeked"
|
||||
@ended="onEnded"
|
||||
@error="onVideoError"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
/>
|
||||
|
||||
<!-- Danmaku overlay -->
|
||||
<div
|
||||
ref="overlayRef"
|
||||
class="danmaku-player__overlay"
|
||||
:class="{ 'is-paused': !isPlaying && !hasError }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.danmaku-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #09121d;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-soft, 0 4px 16px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
|
||||
.danmaku-player__video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 72vh;
|
||||
background: #09121d;
|
||||
}
|
||||
|
||||
.danmaku-player__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.danmaku-player__overlay.is-paused :deep(.danmaku-chat) {
|
||||
animation-play-state: paused !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.danmaku-player__error {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(9, 18, 29, 0.92);
|
||||
color: #f0aa6b;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Danmaku chat messages (global styles since DOM elements are created by engine) */
|
||||
:deep(.danmaku-chat) {
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
text-shadow:
|
||||
1px 1px 2px rgba(0, 0, 0, 0.9),
|
||||
-1px -1px 2px rgba(0, 0, 0, 0.7);
|
||||
pointer-events: auto;
|
||||
font-weight: 700;
|
||||
will-change: transform;
|
||||
line-height: 1.2;
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
/* Danmaku event notifications (global styles since DOM elements are created by engine) */
|
||||
:deep(.danmaku-event-notification) {
|
||||
position: absolute;
|
||||
bottom: 64px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 8px 18px;
|
||||
border-radius: 24px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 12;
|
||||
animation: danmaku-event-in 0.3s ease-out, danmaku-event-out 0.4s ease-in 3.1s forwards;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification--gift) {
|
||||
border-left: 3px solid #f0aa6b;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification--like) {
|
||||
border-left: 3px solid #f25d8e;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification--member) {
|
||||
border-left: 3px solid #ffc53d;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification--superchat) {
|
||||
border-left: 3px solid #5dade2;
|
||||
}
|
||||
|
||||
@keyframes danmaku-event-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes danmaku-event-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.danmaku-player__video {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
:deep(.danmaku-chat) {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
:deep(.danmaku-event-notification) {
|
||||
bottom: 56px;
|
||||
font-size: 12px;
|
||||
padding: 6px 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,326 @@
|
||||
import type { DanmakuEvent } from "@/types";
|
||||
|
||||
const CHAT_TRACK_COUNT = 12;
|
||||
const TRACK_HEIGHT_PX = 32;
|
||||
const CHAT_SCROLL_DURATION_SECONDS = 8;
|
||||
const MAX_ACTIVE_CHATS = 80;
|
||||
const MAX_ACTIVE_EVENTS = 3;
|
||||
const EVENT_DISPLAY_DURATION_MS = 3500;
|
||||
const LOOKBACK_SECONDS = 0.15;
|
||||
const LOOKAHEAD_SECONDS = 0.1;
|
||||
|
||||
interface ActiveChat {
|
||||
id: string;
|
||||
element: HTMLSpanElement;
|
||||
trackIndex: number;
|
||||
spawnTime: number;
|
||||
}
|
||||
|
||||
interface ActiveEventNotification {
|
||||
id: string;
|
||||
element: HTMLDivElement;
|
||||
spawnTime: number;
|
||||
}
|
||||
|
||||
export interface DanmakuEngine {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
reset(): void;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
export function createDanmakuEngine(
|
||||
video: HTMLVideoElement,
|
||||
events: DanmakuEvent[],
|
||||
overlay: HTMLElement
|
||||
): DanmakuEngine {
|
||||
let animationId = 0;
|
||||
let running = false;
|
||||
|
||||
// Pre-sort events by offsetSeconds
|
||||
const sortedEvents = [...events].sort(
|
||||
(a, b) => a.offsetSeconds - b.offsetSeconds
|
||||
);
|
||||
|
||||
// Separate chats and non-chat events
|
||||
const chatEvents = sortedEvents.filter((e) => e.type === "chat");
|
||||
const nonChatEvents = sortedEvents.filter((e) => e.type !== "chat");
|
||||
|
||||
let nextChatIndex = 0;
|
||||
let nextEventIndex = 0;
|
||||
|
||||
// Track occupancy
|
||||
const activeChats: ActiveChat[] = [];
|
||||
const activeEventNotifications: ActiveEventNotification[] = [];
|
||||
|
||||
let lastTime = 0;
|
||||
|
||||
function spawnChat(event: DanmakuEvent, currentTime: number): void {
|
||||
// Garbage collect finished chats first
|
||||
while (
|
||||
activeChats.length > 0 &&
|
||||
currentTime - activeChats[0].spawnTime > CHAT_SCROLL_DURATION_SECONDS
|
||||
) {
|
||||
const finished = activeChats.shift()!;
|
||||
if (finished.element.parentNode) {
|
||||
finished.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Cap active chats
|
||||
if (activeChats.length >= MAX_ACTIVE_CHATS) {
|
||||
const oldest = activeChats.shift()!;
|
||||
if (oldest.element.parentNode) {
|
||||
oldest.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the least-occupied track
|
||||
const trackUsage = new Array<number>(CHAT_TRACK_COUNT).fill(0);
|
||||
for (const chat of activeChats) {
|
||||
if (chat.trackIndex < CHAT_TRACK_COUNT) {
|
||||
trackUsage[chat.trackIndex]++;
|
||||
}
|
||||
}
|
||||
let bestTrack = 0;
|
||||
let minUsage = Infinity;
|
||||
// Add some randomness to avoid all chats on the same "best" track
|
||||
const startTrack = Math.floor(Math.random() * CHAT_TRACK_COUNT);
|
||||
for (let offset = 0; offset < CHAT_TRACK_COUNT; offset++) {
|
||||
const trackIndex = (startTrack + offset) % CHAT_TRACK_COUNT;
|
||||
if (trackUsage[trackIndex] < minUsage) {
|
||||
minUsage = trackUsage[trackIndex];
|
||||
bestTrack = trackIndex;
|
||||
}
|
||||
}
|
||||
|
||||
const element = document.createElement("span");
|
||||
element.className = "danmaku-chat";
|
||||
element.textContent = event.content || "";
|
||||
|
||||
const color = event.color || "FFFFFF";
|
||||
const fontSize = event.fontSize || 25;
|
||||
const topPx = bestTrack * TRACK_HEIGHT_PX;
|
||||
|
||||
element.style.cssText = [
|
||||
`color: #${color}`,
|
||||
`font-size: ${fontSize}px`,
|
||||
`top: ${topPx}px`,
|
||||
"position: absolute",
|
||||
"white-space: nowrap",
|
||||
"text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.8)",
|
||||
"pointer-events: auto",
|
||||
"font-weight: 700",
|
||||
"will-change: transform",
|
||||
"left: 100%",
|
||||
`transform: translateX(0)`,
|
||||
].join("; ");
|
||||
|
||||
overlay.appendChild(element);
|
||||
|
||||
activeChats.push({
|
||||
id: `chat-${event.offsetSeconds}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
element,
|
||||
trackIndex: bestTrack,
|
||||
spawnTime: currentTime,
|
||||
});
|
||||
}
|
||||
|
||||
function spawnEventNotification(event: DanmakuEvent): void {
|
||||
// Garbage collect finished notifications
|
||||
const now = performance.now();
|
||||
while (
|
||||
activeEventNotifications.length > 0 &&
|
||||
now - activeEventNotifications[0].spawnTime > EVENT_DISPLAY_DURATION_MS
|
||||
) {
|
||||
const finished = activeEventNotifications.shift()!;
|
||||
if (finished.element.parentNode) {
|
||||
finished.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Cap active notifications
|
||||
if (activeEventNotifications.length >= MAX_ACTIVE_EVENTS) {
|
||||
const oldest = activeEventNotifications.shift()!;
|
||||
if (oldest.element.parentNode) {
|
||||
oldest.element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
const element = document.createElement("div");
|
||||
element.className = `danmaku-event-notification danmaku-event-notification--${event.type}`;
|
||||
|
||||
const userLabel = event.user ? `<strong>${escapeHtml(event.user)}</strong>` : "";
|
||||
const contentLabel = escapeHtml(event.content || "");
|
||||
|
||||
let typeIcon = "";
|
||||
switch (event.type) {
|
||||
case "gift":
|
||||
typeIcon = "🎁 ";
|
||||
break;
|
||||
case "like":
|
||||
typeIcon = "❤️ ";
|
||||
break;
|
||||
case "member":
|
||||
typeIcon = "⭐ ";
|
||||
break;
|
||||
case "enter":
|
||||
typeIcon = "👤 ";
|
||||
break;
|
||||
case "superchat":
|
||||
typeIcon = "💬 ";
|
||||
break;
|
||||
default:
|
||||
typeIcon = "📌 ";
|
||||
break;
|
||||
}
|
||||
|
||||
element.innerHTML = `${typeIcon}${userLabel} ${contentLabel}`;
|
||||
|
||||
overlay.appendChild(element);
|
||||
|
||||
activeEventNotifications.push({
|
||||
id: `event-${event.offsetSeconds}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
element,
|
||||
spawnTime: performance.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function binarySearchFirst(
|
||||
arr: DanmakuEvent[],
|
||||
startIndex: number,
|
||||
target: number
|
||||
): number {
|
||||
let low = startIndex;
|
||||
let high = arr.length - 1;
|
||||
while (low <= high) {
|
||||
const mid = Math.floor((low + high) / 2);
|
||||
if (arr[mid].offsetSeconds < target) {
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
function tick(): void {
|
||||
if (!running) return;
|
||||
|
||||
const currentTime = video.currentTime;
|
||||
|
||||
// If seeking backwards, reset
|
||||
if (currentTime < lastTime - 0.5) {
|
||||
resetState();
|
||||
}
|
||||
lastTime = currentTime;
|
||||
|
||||
const lookback = currentTime - LOOKBACK_SECONDS;
|
||||
const lookahead = currentTime + LOOKAHEAD_SECONDS;
|
||||
|
||||
// Binary search to find the start of new chats
|
||||
nextChatIndex = binarySearchFirst(chatEvents, nextChatIndex, lookback);
|
||||
// Spawn chats within the window
|
||||
while (nextChatIndex < chatEvents.length && chatEvents[nextChatIndex].offsetSeconds <= lookahead) {
|
||||
spawnChat(chatEvents[nextChatIndex], currentTime);
|
||||
nextChatIndex++;
|
||||
}
|
||||
|
||||
// Same for non-chat events
|
||||
nextEventIndex = binarySearchFirst(nonChatEvents, nextEventIndex, lookback);
|
||||
while (nextEventIndex < nonChatEvents.length && nonChatEvents[nextEventIndex].offsetSeconds <= lookahead) {
|
||||
spawnEventNotification(nonChatEvents[nextEventIndex]);
|
||||
nextEventIndex++;
|
||||
}
|
||||
|
||||
// Update chat positions based on elapsed time since spawn
|
||||
const overlayWidth = overlay.clientWidth || video.clientWidth || 640;
|
||||
for (let i = activeChats.length - 1; i >= 0; i--) {
|
||||
const chat = activeChats[i];
|
||||
const elapsed = currentTime - chat.spawnTime;
|
||||
const progress = Math.max(0, Math.min(1, elapsed / CHAT_SCROLL_DURATION_SECONDS));
|
||||
const translateX = -overlayWidth * progress;
|
||||
chat.element.style.transform = `translateX(${translateX}px)`;
|
||||
|
||||
// Remove finished chats
|
||||
if (elapsed > CHAT_SCROLL_DURATION_SECONDS + 0.5) {
|
||||
if (chat.element.parentNode) {
|
||||
chat.element.remove();
|
||||
}
|
||||
activeChats.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Garbage collect finished event notifications
|
||||
const now = performance.now();
|
||||
for (let i = activeEventNotifications.length - 1; i >= 0; i--) {
|
||||
const notif = activeEventNotifications[i];
|
||||
if (now - notif.spawnTime > EVENT_DISPLAY_DURATION_MS) {
|
||||
if (notif.element.parentNode) {
|
||||
notif.element.remove();
|
||||
}
|
||||
activeEventNotifications.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
animationId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function resetState(): void {
|
||||
// Clear all active elements
|
||||
for (const chat of activeChats) {
|
||||
if (chat.element.parentNode) {
|
||||
chat.element.remove();
|
||||
}
|
||||
}
|
||||
activeChats.length = 0;
|
||||
|
||||
for (const notif of activeEventNotifications) {
|
||||
if (notif.element.parentNode) {
|
||||
notif.element.remove();
|
||||
}
|
||||
}
|
||||
activeEventNotifications.length = 0;
|
||||
|
||||
// Reset indices to the beginning
|
||||
nextChatIndex = 0;
|
||||
nextEventIndex = 0;
|
||||
// Reset lastTime so a seek to 0 doesn't trigger another reset
|
||||
lastTime = 0;
|
||||
}
|
||||
|
||||
function start(): void {
|
||||
if (running) return;
|
||||
running = true;
|
||||
resetState();
|
||||
lastTime = video.currentTime;
|
||||
animationId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function stop(): void {
|
||||
running = false;
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
animationId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
stop();
|
||||
resetState();
|
||||
start();
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
stop();
|
||||
resetState();
|
||||
}
|
||||
|
||||
return { start, stop, reset, destroy };
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
@@ -8,78 +8,27 @@ withDefaults(
|
||||
description?: string;
|
||||
icon?: Component | null;
|
||||
}>(),
|
||||
{
|
||||
description: "",
|
||||
icon: null
|
||||
}
|
||||
{ description: "", icon: null }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="metric-card">
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label">{{ label }}</span>
|
||||
<span v-if="icon" class="metric-card__icon">
|
||||
<el-icon><component :is="icon" /></el-icon>
|
||||
<article class="stat-card">
|
||||
<div class="stat-card__top" style="display:flex;align-items:center;justify-content:space-between">
|
||||
<span class="stat-card__label">{{ label }}</span>
|
||||
<span v-if="icon" class="stat-card__ic">
|
||||
<el-icon :size="18"><component :is="icon" /></el-icon>
|
||||
</span>
|
||||
</div>
|
||||
<div class="metric-card__value">{{ value }}</div>
|
||||
<div v-if="description" class="metric-card__description">{{ description }}</div>
|
||||
<div class="stat-card__value">{{ value }}</div>
|
||||
<div v-if="description" class="stat-card__hint">{{ description }}</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metric-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-height: 164px;
|
||||
padding: 20px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
|
||||
var(--surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.metric-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.metric-card__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: var(--accent);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
color: var(--text-primary);
|
||||
font-size: clamp(28px, 2vw, 40px);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.06em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.metric-card__description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
.stat-card__top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.stat-card__ic {
|
||||
width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center;
|
||||
background: var(--accent-soft); color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name?: string | null;
|
||||
showLabel?: boolean;
|
||||
}>(), {
|
||||
name: "",
|
||||
showLabel: true
|
||||
});
|
||||
|
||||
const label = computed(() => props.name?.trim() || "未知平台");
|
||||
const kind = computed(() => {
|
||||
const value = label.value.toLowerCase();
|
||||
if (value.includes("哔哩") || value.includes("bili")) return "bilibili";
|
||||
if (value.includes("抖音") || value.includes("douyin") || value.includes("tiktok")) return "douyin";
|
||||
if (value.includes("斗鱼") || value.includes("douyu")) return "douyu";
|
||||
return "generic";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="platform-mark" :class="`platform-mark--${kind}`" :title="label">
|
||||
<svg v-if="kind === 'bilibili'" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m8 3 3 3M16 3l-3 3" />
|
||||
<rect x="3" y="6" width="18" height="14" rx="4" />
|
||||
<path d="M8.5 11.5v2.5M15.5 11.5v2.5M9 17c1.8 1.1 4.2 1.1 6 0" />
|
||||
</svg>
|
||||
<svg v-else-if="kind === 'douyin'" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M14 4v10.2a4 4 0 1 1-3-3.86V7.1c2.25 1.9 4.35 2.9 7 2.9V7.1c-1.8-.1-3.15-1.05-4-3.1Z" />
|
||||
</svg>
|
||||
<svg v-else-if="kind === 'douyu'" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M3 12c3.8-5.4 9.5-6.9 16-4.1l2-2v7.8l-2-1.6C14.1 17.6 7.8 17.8 3 12Z" />
|
||||
<circle cx="15.8" cy="9.5" r="1" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M3.5 12h17M12 3.5c2.4 2.4 3.6 5.2 3.6 8.5S14.4 18.1 12 20.5C9.6 18.1 8.4 15.3 8.4 12S9.6 5.9 12 3.5Z" />
|
||||
</svg>
|
||||
<span v-if="showLabel">{{ label }}</span>
|
||||
<span v-else class="sr-only">{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.platform-mark { display: inline-flex; align-items: center; gap: 5px; min-width: 0; color: var(--text-muted); font-size: inherit; line-height: 1.2; white-space: nowrap; }
|
||||
.platform-mark svg { width: 17px; height: 17px; flex: 0 0 auto; padding: 2px; border-radius: 4px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.platform-mark--bilibili svg { color: #c93e6d; background: #ffedf3; }
|
||||
.platform-mark--douyin svg { color: #141923; background: #edf0f5; }
|
||||
.platform-mark--douyu svg { color: #b94c00; background: #fff0e4; }
|
||||
.platform-mark--generic svg { color: var(--accent); background: var(--accent-soft); }
|
||||
:global(html[data-theme="dark"]) .platform-mark--bilibili svg { color: #ff92b4; background: rgba(217, 72, 117, .20); }
|
||||
:global(html[data-theme="dark"]) .platform-mark--douyin svg { color: #f7f9fc; background: rgba(236, 241, 248, .14); }
|
||||
:global(html[data-theme="dark"]) .platform-mark--douyu svg { color: #ff9b57; background: rgba(218, 92, 8, .20); }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; }
|
||||
</style>
|
||||
@@ -51,15 +51,25 @@ const visible = computed({
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.right-drawer .el-drawer__body) {
|
||||
:global(.right-drawer) {
|
||||
max-width: 100vw;
|
||||
max-width: 100dvw;
|
||||
}
|
||||
|
||||
:global(.right-drawer .el-drawer__body) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.right-drawer__shell {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(180deg, var(--surface-raised), var(--surface));
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.right-drawer__header {
|
||||
@@ -69,6 +79,11 @@ const visible = computed({
|
||||
gap: 16px;
|
||||
padding: 24px 24px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.right-drawer__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.right-drawer__eyebrow {
|
||||
@@ -100,9 +115,14 @@ const visible = computed({
|
||||
}
|
||||
|
||||
.right-drawer__body {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 20px 24px 24px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
touch-action: pan-y;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.right-drawer__footer {
|
||||
@@ -111,5 +131,45 @@ const visible = computed({
|
||||
gap: 12px;
|
||||
padding: 16px 24px 24px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex: 0 0 auto;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.right-drawer__header {
|
||||
gap: 10px;
|
||||
padding: 18px 16px 14px;
|
||||
}
|
||||
|
||||
.right-drawer__eyebrow {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.right-drawer__title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.right-drawer__subtitle {
|
||||
margin-top: 5px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.right-drawer__body {
|
||||
padding: 16px;
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
.right-drawer__footer {
|
||||
gap: 8px;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
:global(.right-drawer__footer .el-button) {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
margin: 0;
|
||||
padding-inline: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
size?: number;
|
||||
}>(), {
|
||||
src: "",
|
||||
alt: "",
|
||||
size: 48
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
error: [event: Event];
|
||||
}>();
|
||||
const failed = ref(false);
|
||||
const style = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`
|
||||
}));
|
||||
|
||||
watch(() => props.src, () => {
|
||||
failed.value = false;
|
||||
});
|
||||
|
||||
function handleError(event: Event) {
|
||||
failed.value = true;
|
||||
emit("error", event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="safe-avatar" :style="style">
|
||||
<img
|
||||
v-if="src && !failed"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="handleError"
|
||||
/>
|
||||
<span v-else class="safe-avatar__fallback"><slot /></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.safe-avatar {
|
||||
display: inline-grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.safe-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.safe-avatar__fallback {
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -94,6 +94,18 @@ function resolveToneByNumber(value: number, context: BadgeContext): Tone {
|
||||
return "red";
|
||||
}
|
||||
|
||||
if (value === 3) {
|
||||
return "blue";
|
||||
}
|
||||
|
||||
if (value === 4) {
|
||||
return "yellow";
|
||||
}
|
||||
|
||||
if (value === 5) {
|
||||
return "orange";
|
||||
}
|
||||
|
||||
return "gray";
|
||||
}
|
||||
|
||||
@@ -238,21 +250,21 @@ const displayLabel = computed(() => {
|
||||
}
|
||||
|
||||
.status-badge--blue {
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
border-color: rgba(37, 99, 235, 0.16);
|
||||
color: #2563eb;
|
||||
background: var(--accent-soft);
|
||||
border-color: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.status-badge--yellow {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-color: rgba(245, 158, 11, 0.18);
|
||||
color: #b45309;
|
||||
background: var(--warning-soft);
|
||||
border-color: color-mix(in srgb, var(--warning) 22%, transparent);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-badge--red {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.16);
|
||||
color: #dc2626;
|
||||
background: var(--danger-soft);
|
||||
border-color: color-mix(in srgb, var(--danger) 20%, transparent);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-badge--orange {
|
||||
@@ -262,9 +274,9 @@ const displayLabel = computed(() => {
|
||||
}
|
||||
|
||||
.status-badge--indigo {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: rgba(99, 102, 241, 0.18);
|
||||
color: #4f46e5;
|
||||
background: var(--accent-soft);
|
||||
border-color: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .status-badge--gray {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
interface StorageCapacityStatus {
|
||||
isEnabled: boolean;
|
||||
isAvailable: boolean;
|
||||
checkedPath: string;
|
||||
volumeRoot: string;
|
||||
totalBytes: number;
|
||||
usedBytes: number;
|
||||
availableBytes: number;
|
||||
usagePercent: number;
|
||||
freePercent: number;
|
||||
tier: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{ status: StorageCapacityStatus }>();
|
||||
|
||||
const percentage = computed(() => {
|
||||
const value = Number(props.status.usagePercent);
|
||||
return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0;
|
||||
});
|
||||
|
||||
const tier = computed(() => props.status.tier?.toLowerCase() || "red");
|
||||
const statusLabel = computed(() => {
|
||||
if (!props.status.isAvailable) return "无法读取";
|
||||
if (!props.status.isEnabled) return "保护未启用";
|
||||
if (tier.value === "green") return "空间充足";
|
||||
if (tier.value === "yellow") return "空间偏低";
|
||||
return "空间紧张";
|
||||
});
|
||||
const tagType = computed<"success" | "warning" | "danger" | "info">(() => {
|
||||
if (!props.status.isAvailable) return "danger";
|
||||
if (!props.status.isEnabled) return "info";
|
||||
if (tier.value === "green") return "success";
|
||||
if (tier.value === "yellow") return "warning";
|
||||
return "danger";
|
||||
});
|
||||
const barColor = computed(() => {
|
||||
if (!props.status.isAvailable) return "var(--danger)";
|
||||
if (!props.status.isEnabled) return "var(--text-muted)";
|
||||
if (tier.value === "green") return "var(--success)";
|
||||
if (tier.value === "yellow") return "var(--warning)";
|
||||
return "var(--danger)";
|
||||
});
|
||||
const ringStyle = computed(() => ({
|
||||
background: `conic-gradient(${barColor.value} ${percentage.value}%, var(--surface-strong) 0)`
|
||||
}));
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return "--";
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
let value = bytes;
|
||||
let index = 0;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 100 || index === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[index]}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="storage-capacity" data-testid="storage-capacity">
|
||||
<div
|
||||
class="storage-capacity__ring"
|
||||
:style="ringStyle"
|
||||
role="img"
|
||||
:aria-label="`存储已使用 ${percentage.toFixed(1)}%`"
|
||||
>
|
||||
<div class="storage-capacity__ring-inner">
|
||||
<strong>{{ status.isAvailable ? `${percentage.toFixed(1)}%` : "--" }}</strong>
|
||||
<span>已使用</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="storage-capacity__detail">
|
||||
<div class="storage-capacity__summary">
|
||||
<strong>{{ status.isAvailable ? `${formatBytes(status.usedBytes)} / ${formatBytes(status.totalBytes)}` : "容量不可用" }}</strong>
|
||||
<el-tooltip v-if="status.message" :content="status.message" placement="top">
|
||||
<el-tag :type="tagType" effect="light" tabindex="0">{{ statusLabel }}</el-tag>
|
||||
</el-tooltip>
|
||||
<el-tag v-else :type="tagType" effect="light">{{ statusLabel }}</el-tag>
|
||||
</div>
|
||||
<p class="storage-capacity__path" :title="status.checkedPath">{{ status.checkedPath || "未配置输出路径" }}</p>
|
||||
<p class="storage-capacity__volume" :title="status.volumeRoot">
|
||||
检测卷:{{ status.volumeRoot || "--" }}
|
||||
</p>
|
||||
|
||||
<dl class="storage-capacity__metrics">
|
||||
<div><dt>已使用</dt><dd>{{ status.isAvailable ? formatBytes(status.usedBytes) : "--" }}</dd></div>
|
||||
<div><dt>可用</dt><dd>{{ status.isAvailable ? formatBytes(status.availableBytes) : "--" }}</dd></div>
|
||||
<div><dt>剩余比例</dt><dd>{{ status.isAvailable ? `${status.freePercent.toFixed(1)}%` : "--" }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.storage-capacity { display: grid; grid-template-columns: 106px minmax(0, 1fr); align-items: center; gap: 20px; }
|
||||
.storage-capacity__ring { width: 100px; height: 100px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; }
|
||||
.storage-capacity__ring-inner { width: 76px; height: 76px; display: grid; place-content: center; border-radius: 50%; background: var(--surface); text-align: center; }
|
||||
.storage-capacity__ring strong { display: block; color: var(--text-primary); font-size: 19px; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||
.storage-capacity__ring span { margin-top: 3px; color: var(--text-muted); font-size: 9px; }
|
||||
.storage-capacity__detail { min-width: 0; }
|
||||
.storage-capacity__summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.storage-capacity__summary > strong { min-width: 0; color: var(--text-primary); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||
.storage-capacity__path { margin: 7px 0 0; overflow: hidden; color: var(--text-secondary); font-family: var(--font-mono); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.storage-capacity__volume { margin: 3px 0 0; overflow: hidden; color: var(--text-muted); font-family: var(--font-mono); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.storage-capacity__metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin: 13px 0 0; }
|
||||
.storage-capacity__metrics > div { min-width: 0; padding: 9px; border-radius: var(--radius-sm); background: var(--surface-muted); }
|
||||
.storage-capacity__metrics dt { color: var(--text-muted); font-size: 9px; }
|
||||
.storage-capacity__metrics dd { margin: 3px 0 0; overflow: hidden; color: var(--text-primary); font-size: 11px; font-weight: 700; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
@media (max-width: 640px) {
|
||||
.storage-capacity { grid-template-columns: 88px minmax(0, 1fr); gap: 14px; }
|
||||
.storage-capacity__ring { width: 82px; height: 82px; }
|
||||
.storage-capacity__ring-inner { width: 62px; height: 62px; }
|
||||
.storage-capacity__ring strong { font-size: 16px; }
|
||||
.storage-capacity__summary { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
.storage-capacity__metrics { gap: 5px; }
|
||||
.storage-capacity__metrics > div { padding: 7px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ref } from "vue";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import type { DanmakuEvent, DanmakuResponse, SessionDanmakuResponse } from "@/types";
|
||||
|
||||
export function useDanmakuPlayer() {
|
||||
const danmakuEvents = ref<DanmakuEvent[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
async function loadTaskDanmaku(taskId: string): Promise<boolean> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<DanmakuResponse>(`/record-tasks/${taskId}/danmaku`);
|
||||
danmakuEvents.value = data.events;
|
||||
return data.events.length > 0;
|
||||
} catch (err) {
|
||||
if ((err as { response?: { status?: number } })?.response?.status === 404) {
|
||||
error.value = "该分片没有弹幕数据。";
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, "弹幕数据加载失败。");
|
||||
}
|
||||
danmakuEvents.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSessionDanmaku(sessionId: string): Promise<boolean> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<SessionDanmakuResponse>(`/record-sessions/${sessionId}/danmaku`);
|
||||
// Flatten all task events into a single list with session-level offsets
|
||||
const allEvents: DanmakuEvent[] = [];
|
||||
for (const task of data.tasks) {
|
||||
allEvents.push(...task.events);
|
||||
}
|
||||
// Sort by offset for proper playback order
|
||||
allEvents.sort((a, b) => a.offsetSeconds - b.offsetSeconds);
|
||||
danmakuEvents.value = allEvents;
|
||||
return allEvents.length > 0;
|
||||
} catch (err) {
|
||||
if ((err as { response?: { status?: number } })?.response?.status === 404) {
|
||||
error.value = "该场次没有弹幕数据。";
|
||||
} else {
|
||||
error.value = getApiErrorMessage(err, "弹幕数据加载失败。");
|
||||
}
|
||||
danmakuEvents.value = [];
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
danmakuEvents.value = [];
|
||||
error.value = "";
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
return { danmakuEvents, loading, error, loadTaskDanmaku, loadSessionDanmaku, clear };
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const STORAGE_KEYS = {
|
||||
|
||||
const state = reactive({
|
||||
themeMode: "system" as ThemeMode,
|
||||
density: "comfortable" as DensityMode,
|
||||
density: "compact" as DensityMode,
|
||||
sidebarCollapsed: false,
|
||||
systemTheme: "light" as "light" | "dark",
|
||||
initialized: false
|
||||
@@ -25,7 +25,8 @@ function readStoredThemeMode(): ThemeMode {
|
||||
}
|
||||
|
||||
function readStoredDensity(): DensityMode {
|
||||
return window.localStorage.getItem(STORAGE_KEYS.density) === "compact" ? "compact" : "comfortable";
|
||||
const stored = window.localStorage.getItem(STORAGE_KEYS.density);
|
||||
return stored === "comfortable" ? "comfortable" : "compact";
|
||||
}
|
||||
|
||||
function readStoredSidebarCollapsed() {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
// CSS mobile rules use `max-width: 768px`, so 768px must follow the same
|
||||
// rendering branch instead of receiving the desktop/table markup.
|
||||
const MOBILE_BREAKPOINT = 769;
|
||||
const TABLET_BREAKPOINT = 1024;
|
||||
const DESKTOP_BREAKPOINT = 1280;
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const LoginView = () => import("@/views/LoginView.vue");
|
||||
const MainLayout = () => import("@/components/layout/MainLayout.vue");
|
||||
const DashboardView = () => import("@/views/DashboardView.vue");
|
||||
const LiveRoomsView = () => import("@/views/LiveRoomsView.vue");
|
||||
const RecordTasksView = () => import("@/views/RecordTasksView.vue");
|
||||
const TranscodeTasksView = () => import("@/views/TranscodeTasksView.vue");
|
||||
const UploadTasksView = () => import("@/views/UploadTasksView.vue");
|
||||
const RecordTaskDetailView = () => import("@/views/RecordTaskDetailView.vue");
|
||||
const RecordSessionDetailView = () => import("@/views/RecordSessionDetailView.vue");
|
||||
const MediaBrowserView = () => import("@/views/MediaBrowserView.vue");
|
||||
@@ -29,7 +31,8 @@ const router = createRouter({
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
redirect: "/live-rooms"
|
||||
name: "dashboard",
|
||||
component: DashboardView
|
||||
},
|
||||
{
|
||||
path: "live-rooms",
|
||||
@@ -46,6 +49,11 @@ const router = createRouter({
|
||||
name: "transcode-tasks",
|
||||
component: TranscodeTasksView
|
||||
},
|
||||
{
|
||||
path: "upload-tasks",
|
||||
name: "upload-tasks",
|
||||
component: UploadTasksView
|
||||
},
|
||||
{
|
||||
path: "media-browser",
|
||||
name: "media-browser",
|
||||
@@ -79,7 +87,7 @@ const router = createRouter({
|
||||
component: RecoveryView
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
path: "settings/:section?",
|
||||
name: "settings",
|
||||
component: SettingsView
|
||||
}
|
||||
|
||||
+24
-10
@@ -6,38 +6,51 @@ import type { AuthenticatedUser, LoginResponse } from "@/types";
|
||||
const USER_STORAGE_KEY = "live-recorder-user";
|
||||
const TOKEN_STORAGE_KEY = "live-recorder-token";
|
||||
|
||||
function readStoredValue(key: string) {
|
||||
return localStorage.getItem(key) ?? sessionStorage.getItem(key);
|
||||
}
|
||||
|
||||
function clearStoredSession() {
|
||||
[localStorage, sessionStorage].forEach((storage) => {
|
||||
storage.removeItem(TOKEN_STORAGE_KEY);
|
||||
storage.removeItem(USER_STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const token = ref(localStorage.getItem(TOKEN_STORAGE_KEY) ?? "");
|
||||
const token = ref(readStoredValue(TOKEN_STORAGE_KEY) ?? "");
|
||||
const user = ref<AuthenticatedUser | null>(
|
||||
(() => {
|
||||
const raw = localStorage.getItem(USER_STORAGE_KEY);
|
||||
const raw = readStoredValue(USER_STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as AuthenticatedUser) : null;
|
||||
})()
|
||||
);
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value));
|
||||
|
||||
function persistSession(session: LoginResponse) {
|
||||
function persistSession(session: LoginResponse, rememberMe: boolean) {
|
||||
token.value = session.token;
|
||||
user.value = session.user;
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, session.token);
|
||||
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user));
|
||||
clearStoredSession();
|
||||
const storage = rememberMe ? localStorage : sessionStorage;
|
||||
storage.setItem(TOKEN_STORAGE_KEY, session.token);
|
||||
storage.setItem(USER_STORAGE_KEY, JSON.stringify(session.user));
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
token.value = "";
|
||||
user.value = null;
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
localStorage.removeItem(USER_STORAGE_KEY);
|
||||
clearStoredSession();
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
async function login(username: string, password: string, rememberMe: boolean) {
|
||||
const { data } = await apiClient.post<LoginResponse>("/auth/login", {
|
||||
username,
|
||||
password
|
||||
password,
|
||||
rememberMe
|
||||
});
|
||||
|
||||
persistSession(data);
|
||||
persistSession(data, rememberMe);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -54,6 +67,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
currentPassword,
|
||||
newPassword
|
||||
});
|
||||
clearSession();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+368
-826
File diff suppressed because it is too large
Load Diff
+323
-3
@@ -141,6 +141,9 @@ export interface RecordTask {
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
durationSeconds?: number;
|
||||
isHiddenArtifactSource: boolean;
|
||||
mergedIntoRecordTaskId?: string;
|
||||
uploadStatus?: number;
|
||||
postProcessStage?: string;
|
||||
postProcessProgressPercent?: number;
|
||||
postProcessDetail?: string;
|
||||
@@ -169,6 +172,7 @@ export interface RecordSession {
|
||||
id: string;
|
||||
liveRoomId: string;
|
||||
liveRoomTitle: string;
|
||||
anchorName?: string;
|
||||
platform: number;
|
||||
roomId: string;
|
||||
status: number;
|
||||
@@ -179,14 +183,30 @@ export interface RecordSession {
|
||||
segmentCount: number;
|
||||
recorderProcessId?: number;
|
||||
errorMessage?: string;
|
||||
isRecovering: boolean;
|
||||
recoveryReason?: string;
|
||||
createdAt: string;
|
||||
startedAt?: string;
|
||||
endedAt?: string;
|
||||
totalFileSizeBytes: number;
|
||||
totalDanmakuMessageCount: number;
|
||||
uploadedSegmentCount: number;
|
||||
failedUploadSegmentCount: number;
|
||||
uploadingSegmentCount: number;
|
||||
tasks: RecordTask[];
|
||||
}
|
||||
|
||||
export interface RecordSessionListResponse {
|
||||
items: RecordSession[];
|
||||
totalCount: number;
|
||||
skip: number;
|
||||
take: number;
|
||||
totalSessionCount: number;
|
||||
activeSessionCount: number;
|
||||
totalTaskCount: number;
|
||||
totalDanmakuCount: number;
|
||||
}
|
||||
|
||||
export interface SystemLog {
|
||||
id: string;
|
||||
level: number;
|
||||
@@ -253,6 +273,10 @@ export interface RecordArtifactUploadItemResult {
|
||||
remoteVideoPath?: string;
|
||||
remoteDanmakuPath?: string;
|
||||
deletedLocalFilesAfterUpload: boolean;
|
||||
uploadStatus?: number;
|
||||
progressPercent?: number;
|
||||
attemptCount: number;
|
||||
nextAttemptAt?: string;
|
||||
}
|
||||
|
||||
export interface RecordArtifactUploadBatchResult {
|
||||
@@ -262,6 +286,55 @@ export interface RecordArtifactUploadBatchResult {
|
||||
items: RecordArtifactUploadItemResult[];
|
||||
}
|
||||
|
||||
export interface UploadTaskItem {
|
||||
recordTaskId: string;
|
||||
recordSessionId: string;
|
||||
liveRoomId: string;
|
||||
liveRoomTitle: string;
|
||||
platform: number;
|
||||
roomId: string;
|
||||
segmentIndex: number;
|
||||
outputFormat: string;
|
||||
filePath?: string;
|
||||
fileSizeBytes?: number;
|
||||
danmakuFilePath?: string;
|
||||
uploadStatus: number;
|
||||
lastUploadProvider?: string;
|
||||
remoteVideoPath?: string;
|
||||
remoteDanmakuPath?: string;
|
||||
lastUploadedAt?: string;
|
||||
uploadErrorMessage?: string;
|
||||
deletedLocalFilesAfterUpload: boolean;
|
||||
createdAt: string;
|
||||
uploadProgressPercent?: number;
|
||||
uploadAttemptCount: number;
|
||||
nextUploadAttemptAt?: string;
|
||||
currentUploadArtifact?: string;
|
||||
externalUploadTaskId?: string;
|
||||
}
|
||||
|
||||
export interface UploadTaskListResponse {
|
||||
items: UploadTaskItem[];
|
||||
totalCount: number;
|
||||
notUploadedCount: number;
|
||||
failedArtifactCount: number;
|
||||
succeededCount: number;
|
||||
failedCount: number;
|
||||
queuedCount: number;
|
||||
uploadingCount: number;
|
||||
waitingRetryCount: number;
|
||||
matchingRetryableCount: number;
|
||||
queueHealth: UploadQueueHealth;
|
||||
}
|
||||
|
||||
export interface UploadQueueHealth {
|
||||
state: "Healthy" | "RateLimited" | "AuthenticationBlocked" | "Disabled" | string;
|
||||
isPaused: boolean;
|
||||
reason?: string;
|
||||
retryAt?: string;
|
||||
lastErrorAt?: string;
|
||||
}
|
||||
|
||||
export interface ManualSegmentCompletedTriggerResult {
|
||||
recordTaskId: string;
|
||||
success: boolean;
|
||||
@@ -421,9 +494,12 @@ export interface SystemSettings {
|
||||
enableStorageGuard: boolean;
|
||||
pauseRecordingWhenFreeSpaceBelowMegabytes: number;
|
||||
resumeRecordingWhenFreeSpaceAboveMegabytes: number;
|
||||
storageGreenThresholdPercent: number;
|
||||
storageRedThresholdPercent: number;
|
||||
enableRetentionCleanup: boolean;
|
||||
retentionDays: number;
|
||||
retentionDeleteFiles: boolean;
|
||||
retentionRequireUploadSuccess: boolean;
|
||||
retentionVideoFileCondition: CleanupVideoFileCondition;
|
||||
retentionTaskStatuses: number[];
|
||||
enableAutoReconnect: boolean;
|
||||
@@ -444,6 +520,7 @@ export interface SystemSettings {
|
||||
platformRequestSettings: Record<string, PlatformRequestSettings>;
|
||||
webDavUpload: WebDavUploadSettings;
|
||||
s3Upload: S3UploadSettings;
|
||||
openListUpload: OpenListUploadSettings;
|
||||
enableEventScripts: boolean;
|
||||
enableLiveStartedScript: boolean;
|
||||
liveStartedScriptMode: string;
|
||||
@@ -458,6 +535,8 @@ export interface SystemSettings {
|
||||
segmentCompletedScriptPath: string;
|
||||
segmentCompletedScriptContent: string;
|
||||
eventScriptTimeoutSeconds: number;
|
||||
eventScriptRetryAttempts: number;
|
||||
eventScriptRetryDelaySeconds: number;
|
||||
enableEmailNotification: boolean;
|
||||
emailSmtpHost: string;
|
||||
emailSmtpPort: number;
|
||||
@@ -561,6 +640,41 @@ export interface S3UploadSettings {
|
||||
forcePathStyle: boolean;
|
||||
}
|
||||
|
||||
export interface OpenListUploadSettings {
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
basePath: string;
|
||||
sourcePath: string;
|
||||
destinationPath: string;
|
||||
}
|
||||
|
||||
export interface OpenListConnectionTestResult {
|
||||
success: boolean;
|
||||
version?: string;
|
||||
message: string;
|
||||
sourcePath?: OpenListPathCheck;
|
||||
destinationPath?: OpenListPathCheck;
|
||||
}
|
||||
|
||||
export interface OpenListPathCheck {
|
||||
path: string;
|
||||
success: boolean;
|
||||
canWrite: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface OpenListDirectoryItem {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface OpenListDirectoryListResult {
|
||||
path: string;
|
||||
canWrite: boolean;
|
||||
directories: OpenListDirectoryItem[];
|
||||
}
|
||||
|
||||
export interface EventScriptTestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -578,14 +692,36 @@ export interface RecoveryOverview {
|
||||
storage: StorageGuardStatus;
|
||||
liveRooms: RecoverableLiveRoom[];
|
||||
finalizations: RecoverableFinalization[];
|
||||
mergedArtifacts: MergedArtifactRecord[];
|
||||
}
|
||||
|
||||
export interface MergedArtifactRecord {
|
||||
sourceRecordTaskId: string;
|
||||
recordSessionId: string;
|
||||
mergedIntoRecordTaskId?: string;
|
||||
sourceVideoPath?: string;
|
||||
mergedVideoPath?: string;
|
||||
recoveryDirectory?: string;
|
||||
manifestPath?: string;
|
||||
sourceDurationSeconds?: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface StorageGuardStatus {
|
||||
isEnabled: boolean;
|
||||
isAvailable: boolean;
|
||||
hasEnoughSpace: boolean;
|
||||
checkedPath: string;
|
||||
volumeRoot: string;
|
||||
totalBytes: number;
|
||||
usedBytes: number;
|
||||
availableBytes: number;
|
||||
requiredBytes: number;
|
||||
usagePercent: number;
|
||||
freePercent: number;
|
||||
greenThresholdPercent: number;
|
||||
redThresholdPercent: number;
|
||||
tier: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -624,6 +760,34 @@ export interface RecoveryActionResult {
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
export interface RecordingFailureListResponse {
|
||||
items: RecordingFailureItem[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface RecordingFailureItem {
|
||||
recordTaskId: string;
|
||||
recordSessionId: string;
|
||||
liveRoomId: string;
|
||||
liveRoomTitle: string;
|
||||
roomId: string;
|
||||
platformName: string;
|
||||
segmentIndex: number;
|
||||
failureKind: string;
|
||||
failureLabel: string;
|
||||
recommendedAction: string;
|
||||
errorMessage?: string;
|
||||
filePath?: string;
|
||||
fileSizeBytes?: number;
|
||||
durationSeconds?: number;
|
||||
fileExists: boolean;
|
||||
canAccept: boolean;
|
||||
canRepair: boolean;
|
||||
canRetryRoom: boolean;
|
||||
isRepairing: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TranscodeTaskItem {
|
||||
task: RecordTask;
|
||||
result?: RecordResult;
|
||||
@@ -668,7 +832,7 @@ export const availabilityLabelMap: Record<number, string> = {
|
||||
|
||||
export const currentRecordingStateLabelMap: Record<number, string> = {
|
||||
0: "未开播",
|
||||
1: "开播中",
|
||||
1: "已开播(未录制)",
|
||||
2: "录制中"
|
||||
};
|
||||
|
||||
@@ -749,6 +913,22 @@ export const autoStartDecisionLabelMap: Record<string, string> = {
|
||||
poll_failed: "轮询失败"
|
||||
};
|
||||
|
||||
export const autoStartDecisionSummaryMap: Record<string, string> = {
|
||||
started: "自动开录已成功启动。",
|
||||
skipped_disabled: "直播间已禁用,本次自动开录已跳过。",
|
||||
skipped_storage: "存储空间不满足录制要求,本次自动开录已跳过。",
|
||||
skipped_active_session: "已有活动录制会话,本次自动开录已跳过。",
|
||||
skipped_offline: "直播间当前未开播,本次自动开录已跳过。",
|
||||
skipped_debounce: "仍处于防抖等待时间,本次自动开录已跳过。",
|
||||
failed_startup: "录制进程启动失败。",
|
||||
poll_failed_transient: "直播状态巡检暂时失败,系统将自动重试。",
|
||||
poll_failed: "直播状态巡检失败。"
|
||||
};
|
||||
|
||||
export function formatAutoStartDecisionSummary(code?: string, fallback?: string) {
|
||||
return (code && autoStartDecisionSummaryMap[code]) || fallback || "暂无自动开录摘要";
|
||||
}
|
||||
|
||||
export const platformLabelMap: Record<number, string> = {
|
||||
0: "未知",
|
||||
1: "Douyin",
|
||||
@@ -767,11 +947,151 @@ export const platformLabelMap: Record<number, string> = {
|
||||
export const uploadTargetLabelMap: Record<number, string> = {
|
||||
0: "不上传",
|
||||
1: "WebDAV",
|
||||
2: "S3"
|
||||
2: "S3",
|
||||
3: "OpenList"
|
||||
};
|
||||
|
||||
export const uploadStatusLabelMap: Record<number, string> = {
|
||||
0: "未上传",
|
||||
1: "已上传",
|
||||
2: "上传失败"
|
||||
2: "上传失败",
|
||||
3: "上传中",
|
||||
4: "排队中",
|
||||
5: "等待重试"
|
||||
};
|
||||
|
||||
export interface DanmakuEvent {
|
||||
offsetSeconds: number;
|
||||
type: string;
|
||||
content: string;
|
||||
user?: string;
|
||||
userId?: string;
|
||||
color?: string;
|
||||
fontSize?: number;
|
||||
mode?: number;
|
||||
timestampMs?: number;
|
||||
giftName?: string;
|
||||
count?: number;
|
||||
raw?: string;
|
||||
}
|
||||
|
||||
export interface DanmakuResponse {
|
||||
recordTaskId: string;
|
||||
segmentIndex: number;
|
||||
platform?: string;
|
||||
roomId?: string;
|
||||
liveRoomId?: string;
|
||||
recordSessionId: string;
|
||||
startedAt?: string;
|
||||
events: DanmakuEvent[];
|
||||
}
|
||||
|
||||
export interface SessionDanmakuResponse {
|
||||
recordSessionId: string;
|
||||
tasks: DanmakuResponse[];
|
||||
}
|
||||
|
||||
// Dashboard types
|
||||
export interface DashboardData {
|
||||
activeRecordingCount: number;
|
||||
liveRoomCount: number;
|
||||
offlineRoomCount: number;
|
||||
totalRoomCount: number;
|
||||
todayRecordingSeconds: number;
|
||||
todayDataBytes: number;
|
||||
todayDanmakuCount: number;
|
||||
activeSessionCount: number;
|
||||
recentErrorCount: number;
|
||||
currentErrorCount: number;
|
||||
storageStatus: DashboardStorageStatus;
|
||||
recentSessions: DashboardRecentSession[];
|
||||
topRooms: DashboardTopRoom[];
|
||||
pendingTranscodeCount: number;
|
||||
pendingUploadCount: number;
|
||||
queuedDataBytes: number;
|
||||
oldestTranscodeUpdatedAt?: string | null;
|
||||
oldestUploadProgressAt?: string | null;
|
||||
stalledUploadCount: number;
|
||||
uploadCleanupFailureCount: number;
|
||||
}
|
||||
|
||||
export interface DashboardStorageStatus {
|
||||
isEnabled: boolean;
|
||||
isAvailable: boolean;
|
||||
hasEnoughSpace: boolean;
|
||||
message: string;
|
||||
checkedPath: string;
|
||||
volumeRoot: string;
|
||||
totalBytes: number;
|
||||
usedBytes: number;
|
||||
availableBytes: number;
|
||||
requiredBytes: number;
|
||||
tier: string;
|
||||
usagePercent: number;
|
||||
freePercent: number;
|
||||
greenThresholdPercent: number;
|
||||
redThresholdPercent: number;
|
||||
}
|
||||
|
||||
export interface DashboardRecentSession {
|
||||
id: string;
|
||||
liveRoomId: string;
|
||||
liveRoomTitle: string;
|
||||
platformName: string;
|
||||
segmentCount: number;
|
||||
status: number;
|
||||
startedAt?: string;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface DashboardTopRoom {
|
||||
liveRoomId: string;
|
||||
title?: string;
|
||||
anchorName?: string;
|
||||
platformName: string;
|
||||
roomId: string;
|
||||
sessionCount: number;
|
||||
totalDurationSeconds: number;
|
||||
}
|
||||
|
||||
// Video metadata types
|
||||
export interface VideoMetadata {
|
||||
durationSeconds?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
videoCodec?: string;
|
||||
audioCodec?: string;
|
||||
frameRate?: number;
|
||||
bitRate?: number;
|
||||
}
|
||||
|
||||
// Session playlist types
|
||||
export interface SessionPlaylistSegment {
|
||||
recordTaskId: string;
|
||||
segmentIndex: number;
|
||||
previewTicketUrl: string;
|
||||
durationSeconds?: number;
|
||||
}
|
||||
|
||||
export interface SessionPlaylist {
|
||||
recordSessionId: string;
|
||||
liveRoomTitle: string;
|
||||
segments: SessionPlaylistSegment[];
|
||||
}
|
||||
|
||||
// Bandwidth types
|
||||
export interface BandwidthSummary {
|
||||
totalTrafficMB: number;
|
||||
averageBitrateKbps: number;
|
||||
peakBitrateKbps: number;
|
||||
}
|
||||
|
||||
export interface BandwidthTimeline {
|
||||
points: BandwidthPoint[];
|
||||
}
|
||||
|
||||
export interface BandwidthPoint {
|
||||
timestamp: string;
|
||||
bytesDownloaded: number;
|
||||
bitrateKbps?: number;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import axios from "axios";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import type {
|
||||
DailyReviewPushResult,
|
||||
@@ -194,7 +195,9 @@ onMounted(loadReport);
|
||||
<div class="cell-subtitle">{{ row.anchorName || row.roomId }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平台" width="120" prop="platformName" />
|
||||
<el-table-column label="平台" width="140">
|
||||
<template #default="{ row }"><PlatformMark :name="row.platformName" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="会话" width="90" prop="sessionCount" />
|
||||
<el-table-column label="分片" width="90" prop="segmentCount" />
|
||||
<el-table-column label="录制时长" width="120">
|
||||
@@ -231,7 +234,7 @@ onMounted(loadReport);
|
||||
<div class="highlight-item__label">{{ item.label }}</div>
|
||||
<div class="highlight-item__title">{{ item.liveRoomTitle }}</div>
|
||||
<div class="highlight-item__meta">
|
||||
<span>{{ item.platformName }}</span>
|
||||
<PlatformMark :name="item.platformName" />
|
||||
<span>{{ item.roomId }}</span>
|
||||
<span>{{ formatDuration(item.durationSeconds) }}</span>
|
||||
</div>
|
||||
@@ -273,7 +276,9 @@ onMounted(loadReport);
|
||||
<div class="cell-subtitle">分片 #{{ row.segmentIndex }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="平台" width="120" prop="platformName" />
|
||||
<el-table-column label="平台" width="140">
|
||||
<template #default="{ row }"><PlatformMark :name="row.platformName" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="弹幕数" width="100" prop="danmakuCount" />
|
||||
<el-table-column label="操作" width="200">
|
||||
<template #default="{ row }">
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
ArrowRight,
|
||||
Film,
|
||||
FolderOpened,
|
||||
Plus,
|
||||
Refresh,
|
||||
Upload,
|
||||
Warning
|
||||
} from "@element-plus/icons-vue";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||||
import StatusBadge from "@/components/ui/StatusBadge.vue";
|
||||
import StorageCapacity from "@/components/ui/StorageCapacity.vue";
|
||||
import type { DashboardData, DashboardRecentSession } from "@/types";
|
||||
import { sessionStatusLabelMap } from "@/types";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const data = ref<DashboardData | null>(null);
|
||||
|
||||
const queueTotal = computed(() => (data.value?.pendingTranscodeCount ?? 0) + (data.value?.pendingUploadCount ?? 0));
|
||||
const attentionCount = computed(() => {
|
||||
if (!data.value) return 0;
|
||||
return Number(data.value.currentErrorCount > 0)
|
||||
+ Number(data.value.storageStatus.tier !== "Green")
|
||||
+ Number(queueTotal.value > 0);
|
||||
});
|
||||
const hasAttention = computed(() => attentionCount.value > 0);
|
||||
const activeSessions = computed(() => data.value?.recentSessions.filter((item) => [1, 2, 3, 7].includes(item.status)).slice(0, 2) ?? []);
|
||||
|
||||
function formatDuration(seconds?: number) {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "--";
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) return `${hours} 小时 ${minutes} 分`;
|
||||
return `${Math.max(1, minutes)} 分钟`;
|
||||
}
|
||||
|
||||
function formatTimer(seconds?: number) {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds < 0) return "--:--:--";
|
||||
const hours = Math.floor(seconds / 3600).toString().padStart(2, "0");
|
||||
const minutes = Math.floor((seconds % 3600) / 60).toString().padStart(2, "0");
|
||||
const remainder = Math.floor(seconds % 60).toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}:${remainder}`;
|
||||
}
|
||||
|
||||
function formatDataSize(bytes?: number) {
|
||||
if (typeof bytes !== "number" || bytes < 0) return "--";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return "--";
|
||||
return new Date(value).toLocaleString([], { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function formatRelativeHint(value?: string | null) {
|
||||
if (!value) return "暂无等待中的任务";
|
||||
const elapsed = Math.max(0, Date.now() - new Date(value).getTime());
|
||||
const minutes = Math.floor(elapsed / 60_000);
|
||||
if (minutes < 1) return "最早任务刚刚进入队列";
|
||||
if (minutes < 60) return `最早任务已等待 ${minutes} 分钟`;
|
||||
return `最早任务已等待 ${Math.floor(minutes / 60)} 小时`;
|
||||
}
|
||||
|
||||
function sessionStatus(session: DashboardRecentSession) {
|
||||
return sessionStatusLabelMap[session.status] ?? "未知";
|
||||
}
|
||||
|
||||
function roomInitial(session: DashboardRecentSession) {
|
||||
return session.liveRoomTitle.trim().slice(0, 1) || "播";
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
try {
|
||||
const { data: result } = await apiClient.get<DashboardData>("/dashboard");
|
||||
data.value = result;
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorMessage(error, "运行中心数据加载失败。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack dashboard-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-kicker">OPERATIONS</div>
|
||||
<h1 class="page-title">运行中心</h1>
|
||||
<p class="page-subtitle">优先处理异常和积压,再关注正在进行的录制与今日产出。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadData">刷新状态</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="router.push({ name: 'live-rooms', query: { action: 'create' } })">添加直播间</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
|
||||
<el-skeleton v-if="loading && !data" animated :rows="8" />
|
||||
|
||||
<template v-else-if="data">
|
||||
<section v-if="hasAttention" class="attention-bar" aria-label="需要关注">
|
||||
<span class="attention-bar__icon"><el-icon><Warning /></el-icon></span>
|
||||
<span class="attention-bar__copy">
|
||||
<strong>有 {{ attentionCount }} 项需要关注</strong>
|
||||
<span>
|
||||
最近 30 分钟 {{ data.currentErrorCount }} 个异常,{{ queueTotal }} 个处理任务等待完成,
|
||||
存储状态{{ data.storageStatus.tier === "Green" ? "正常" : "已触发预警" }}。
|
||||
</span>
|
||||
</span>
|
||||
<el-button size="small" @click="router.push({ name: queueTotal > 0 ? 'upload-tasks' : 'logs' })">立即处理</el-button>
|
||||
</section>
|
||||
|
||||
<div v-if="data.recentErrorCount > data.currentErrorCount" class="history-note">
|
||||
<span>近 24 小时共记录 {{ data.recentErrorCount }} 个历史异常;当前状态以最近 30 分钟的 {{ data.currentErrorCount }} 个异常为准。</span>
|
||||
<el-button link size="small" @click="router.push({ name: 'logs' })">查看历史日志</el-button>
|
||||
</div>
|
||||
|
||||
<div class="primary-grid">
|
||||
<section class="live-hero" aria-label="当前录制概况">
|
||||
<div class="live-hero__top">
|
||||
<span class="live-hero__label"><i class="live-pulse" />正在录制</span>
|
||||
<span class="live-hero__badge">{{ data.activeRecordingCount }} 路活跃</span>
|
||||
</div>
|
||||
<div class="live-hero__count">{{ String(data.activeRecordingCount).padStart(2, "0") }}<small>当前录制会话</small></div>
|
||||
|
||||
<div v-if="activeSessions.length" class="active-session-list">
|
||||
<button
|
||||
v-for="session in activeSessions"
|
||||
:key="session.id"
|
||||
class="active-session"
|
||||
type="button"
|
||||
@click="router.push({ name: 'record-session-detail', params: { id: session.id } })"
|
||||
>
|
||||
<span class="active-session__avatar">{{ roomInitial(session) }}</span>
|
||||
<span class="active-session__copy">
|
||||
<strong>{{ session.liveRoomTitle }}</strong>
|
||||
<span><PlatformMark :name="session.platformName" /> · {{ session.segmentCount }} 个分片</span>
|
||||
</span>
|
||||
<time>{{ formatTimer(session.durationSeconds) }}</time>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="live-hero__empty">
|
||||
{{ data.activeRecordingCount > 0 ? "活动会话将在下一次状态刷新后显示" : "当前没有正在录制的直播间" }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-card class="surface-card process-card" shadow="never">
|
||||
<div class="panel-heading panel-heading--bordered">
|
||||
<div><h2>处理概况</h2><p>录制后的自动化流水线</p></div>
|
||||
<el-button link @click="router.push({ name: 'transcode-tasks' })">查看队列</el-button>
|
||||
</div>
|
||||
<div class="process-list">
|
||||
<button class="process-row" type="button" @click="router.push({ name: 'transcode-tasks' })">
|
||||
<span class="process-row__icon"><el-icon><Film /></el-icon></span>
|
||||
<span><strong>等待转码</strong><small>{{ formatRelativeHint(data.oldestTranscodeUpdatedAt) }}</small></span>
|
||||
<b>{{ data.pendingTranscodeCount }}</b>
|
||||
</button>
|
||||
<button class="process-row" type="button" @click="router.push({ name: 'upload-tasks' })">
|
||||
<span class="process-row__icon"><el-icon><Upload /></el-icon></span>
|
||||
<span><strong>等待上传</strong><small>{{ data.stalledUploadCount ?? 0 }} 个停滞 · {{ data.uploadCleanupFailureCount ?? 0 }} 个待清理</small></span>
|
||||
<b>{{ data.pendingUploadCount }}</b>
|
||||
</button>
|
||||
<button class="process-row" type="button" @click="router.push({ name: 'media-browser' })">
|
||||
<span class="process-row__icon"><el-icon><FolderOpened /></el-icon></span>
|
||||
<span><strong>今日产出</strong><small>{{ formatDuration(data.todayRecordingSeconds) }} · {{ data.todayDanmakuCount.toLocaleString() }} 条弹幕</small></span>
|
||||
<b>{{ formatDataSize(data.todayDataBytes) }}</b>
|
||||
</button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="secondary-grid">
|
||||
<el-card class="surface-card storage-card" shadow="never">
|
||||
<div class="panel-heading panel-heading--bordered">
|
||||
<div><h2>存储容量</h2><p>录制目录与保护阈值</p></div>
|
||||
</div>
|
||||
<StorageCapacity :status="data.storageStatus" />
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card activity-card" shadow="never">
|
||||
<div class="panel-heading panel-heading--bordered">
|
||||
<div><h2>最近动态</h2><p>最新创建的录制会话</p></div>
|
||||
<el-button link @click="router.push({ name: 'record-tasks' })">全部会话</el-button>
|
||||
</div>
|
||||
<EmptyState v-if="data.recentSessions.length === 0" title="暂无录制会话" description="直播开始录制后会出现在这里" />
|
||||
<button
|
||||
v-for="session in data.recentSessions.slice(0, 4)"
|
||||
v-else
|
||||
:key="session.id"
|
||||
class="activity-row"
|
||||
type="button"
|
||||
@click="router.push({ name: 'record-session-detail', params: { id: session.id } })"
|
||||
>
|
||||
<i class="activity-row__dot" :class="{ 'is-error': session.status === 5, 'is-warn': [0, 1, 3, 7].includes(session.status) }" />
|
||||
<span class="activity-row__main"><strong>{{ session.liveRoomTitle }}</strong><small>{{ formatDate(session.startedAt) }} · {{ session.segmentCount }} 个分片</small></span>
|
||||
<StatusBadge :label="sessionStatus(session)" :status="session.status" />
|
||||
</button>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card ranking-card" shadow="never">
|
||||
<div class="panel-heading panel-heading--bordered">
|
||||
<div><h2>今日录制排行</h2><p>按录制时长排序的直播间</p></div>
|
||||
<el-button link @click="router.push({ name: 'live-rooms' })">直播间 <el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button>
|
||||
</div>
|
||||
<EmptyState v-if="data.topRooms.length === 0" title="今日暂无录制" description="完成录制后会生成今日排行" />
|
||||
<div v-else class="ranking-list">
|
||||
<button v-for="(room, index) in data.topRooms" :key="room.liveRoomId" class="ranking-row" type="button" @click="router.push({ name: 'live-rooms' })">
|
||||
<span class="rank" :class="`rank--${index + 1}`">{{ index + 1 }}</span>
|
||||
<span class="activity-row__main">
|
||||
<strong>{{ room.title || room.anchorName || room.roomId }}</strong>
|
||||
<small><PlatformMark :name="room.platformName" /> · {{ room.sessionCount }} 个会话</small>
|
||||
</span>
|
||||
<span class="ranking-row__duration">{{ formatDuration(room.totalDurationSeconds) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page { gap: 14px; }
|
||||
.attention-bar { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; padding: 13px 15px; border: 1px solid color-mix(in srgb, var(--warning) 27%, var(--border-subtle)); border-radius: 11px; background: var(--warning-soft); }
|
||||
.attention-bar__icon { width: 32px; height: 32px; display: grid; place-items: center; overflow: visible; border-radius: 9px; color: var(--warning); background: var(--surface); }
|
||||
.attention-bar__icon .el-icon { display: grid; place-items: center; }
|
||||
.attention-bar__copy { display: grid; min-width: 0; gap: 2px; }
|
||||
.attention-bar__copy strong { font-size: 12.5px; }
|
||||
.attention-bar__copy > span { color: var(--text-secondary); font-size: 11px; line-height: 1.55; }
|
||||
.history-note { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 12px; border-radius: var(--radius-sm); background: var(--surface-subtle); color: var(--text-muted); font-size: 11.5px; line-height: 1.5; }
|
||||
.primary-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(290px, .72fr); gap: 14px; }
|
||||
.secondary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
|
||||
.panel-heading--bordered { margin: -18px -18px 0; padding: 17px 18px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.panel-heading h2 { margin: 0; font-size: 14px; letter-spacing: -.01em; }
|
||||
.panel-heading p { margin: 4px 0 0; color: var(--text-muted); font-size: 11px; }
|
||||
.live-hero { position: relative; min-height: 282px; overflow: hidden; padding: 20px; border-radius: var(--radius-md); color: #f7f9ff; background: radial-gradient(circle at 88% 8%, rgba(104, 220, 235, .22), transparent 31%), linear-gradient(135deg, #185ecf, #3f67d8 54%, #6658ce); box-shadow: 0 16px 38px rgba(24, 94, 207, .22); }
|
||||
:global(html[data-theme="dark"]) .live-hero { background: radial-gradient(circle at 88% 8%, rgba(104, 220, 235, .16), transparent 31%), linear-gradient(135deg, #17498f, #304f9d 54%, #56469f); box-shadow: 0 16px 38px rgba(0, 0, 0, .28); }
|
||||
.live-hero::after { content: ""; position: absolute; right: -65px; top: -90px; width: 260px; height: 260px; border: 45px solid rgba(255,255,255,.07); border-radius: 50%; pointer-events: none; }
|
||||
.live-hero__top { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.live-hero__label { display: flex; align-items: center; gap: 8px; color: #d7e3ff; font-size: 11px; font-weight: 700; }
|
||||
.live-pulse { width: 8px; height: 8px; border-radius: 50%; background: #ff8b94; box-shadow: 0 0 0 5px rgba(255, 139, 148, .18); }
|
||||
.live-hero__badge { padding: 4px 9px; border-radius: 99px; color: #ffe8ec; background: rgba(255, 139, 148, .18); font-size: 11px; font-weight: 700; }
|
||||
.live-hero__count { position: relative; z-index: 1; margin-top: 15px; font-size: 38px; line-height: 1; font-weight: 800; letter-spacing: -.05em; }
|
||||
.live-hero__count small { margin-left: 7px; color: #d7e3ff; font-size: 12px; font-weight: 500; letter-spacing: 0; }
|
||||
.active-session-list { position: relative; z-index: 1; display: grid; }
|
||||
.active-session { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; width: 100%; padding: 15px 0; border: 0; border-top: 1px solid rgba(255,255,255,.22); color: inherit; background: transparent; text-align: left; }
|
||||
.active-session:first-child { margin-top: 18px; }
|
||||
.active-session:hover strong { text-decoration: underline; text-underline-offset: 3px; }
|
||||
.active-session__avatar { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; color: #244fa9; background: #e8efff; font-size: 13px; font-weight: 800; }
|
||||
.active-session__copy { display: grid; min-width: 0; gap: 4px; }
|
||||
.active-session__copy strong { overflow: hidden; color: #f7f9ff; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.active-session__copy > span { display: flex; align-items: center; color: #d7e3ff; font-size: 10px; }
|
||||
.active-session__copy :deep(.platform-mark) { color: inherit; }
|
||||
.active-session time { color: #f7f9ff; font-family: var(--font-mono); font-size: 15px; font-weight: 700; }
|
||||
.live-hero__empty { position: relative; z-index: 1; margin-top: 32px; padding-top: 20px; border-top: 1px solid rgba(255,255,255,.22); color: #d7e3ff; font-size: 12px; }
|
||||
.process-card :deep(.el-card__body), .activity-card :deep(.el-card__body), .ranking-card :deep(.el-card__body), .storage-card :deep(.el-card__body) { display: grid; }
|
||||
.process-list { display: grid; }
|
||||
.process-row { display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 11px; width: 100%; padding: 14px 0; border: 0; border-bottom: 1px solid var(--border-subtle); color: var(--text-primary); background: transparent; text-align: left; }
|
||||
.process-row:last-child { border-bottom: 0; }
|
||||
.process-row__icon { width: 34px; height: 34px; display: grid; place-items: center; overflow: visible; border-radius: 9px; color: var(--accent); background: var(--accent-soft); }
|
||||
.process-row:nth-child(2) .process-row__icon { color: var(--purple); background: var(--purple-soft); }
|
||||
.process-row:nth-child(3) .process-row__icon { color: var(--cyan); background: var(--cyan-soft); }
|
||||
.process-row__icon .el-icon { display: grid; place-items: center; }
|
||||
.process-row > span:nth-child(2) { display: grid; min-width: 0; gap: 2px; }
|
||||
.process-row strong { font-size: 12px; }
|
||||
.process-row small { overflow: hidden; color: var(--text-muted); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.process-row b { max-width: 96px; overflow: hidden; font-size: 18px; font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.storage-card :deep(.storage-capacity) { padding-top: 18px; }
|
||||
.activity-row, .ranking-row { display: flex; align-items: center; width: 100%; gap: 10px; padding: 12px 0; border: 0; border-bottom: 1px solid var(--border-subtle); background: transparent; color: var(--text-primary); text-align: left; }
|
||||
.activity-row:last-child, .ranking-row:last-child { border-bottom: 0; }
|
||||
.activity-row:hover, .ranking-row:hover, .process-row:hover { color: var(--accent); }
|
||||
.activity-row__dot { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--success); }
|
||||
.activity-row__dot.is-warn { background: var(--warning); }
|
||||
.activity-row__dot.is-error { background: var(--danger); }
|
||||
.activity-row__main { display: grid; flex: 1; min-width: 0; gap: 4px; }
|
||||
.activity-row__main strong { overflow: hidden; color: var(--text-primary); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.activity-row__main small { display: flex; align-items: center; min-width: 0; overflow: hidden; color: var(--text-muted); font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ranking-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; }
|
||||
.rank { width: 26px; height: 26px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 7px; color: var(--text-muted); background: var(--surface-muted); font-size: 11px; font-weight: 800; }
|
||||
.rank--1 { color: #8a5700; background: #fff1cb; }
|
||||
.rank--2 { color: var(--accent); background: var(--accent-soft); }
|
||||
.rank--3 { color: #8b4d2e; background: #f9e7dd; }
|
||||
.ranking-row__duration { flex: 0 0 auto; color: var(--text-secondary); font-size: 11.5px; font-weight: 700; }
|
||||
@media (max-width: 1100px) {
|
||||
.primary-grid { grid-template-columns: 1fr; }
|
||||
.live-hero { min-height: auto; }
|
||||
.ranking-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.secondary-grid { grid-template-columns: 1fr; }
|
||||
.attention-bar { grid-template-columns: auto minmax(0, 1fr); align-items: start; }
|
||||
.attention-bar .el-button { grid-column: 1 / -1; width: 100%; }
|
||||
.history-note { align-items: flex-start; flex-direction: column; }
|
||||
.live-hero { padding: 17px; }
|
||||
.live-hero__count { font-size: 34px; }
|
||||
.active-session time { font-size: 12px; }
|
||||
}
|
||||
@media (max-width: 420px) {
|
||||
.header-actions { width: 100%; }
|
||||
.header-actions .el-button { flex: 1; margin-left: 0; }
|
||||
.active-session { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.active-session time { grid-column: 2; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useRoute } from "vue-router";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||||
import RightDrawer from "@/components/ui/RightDrawer.vue";
|
||||
import SafeAvatar from "@/components/ui/SafeAvatar.vue";
|
||||
import StatusBadge from "@/components/ui/StatusBadge.vue";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import type { BatchLiveRoomsResult, ImportLiveRoomsRequest, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types";
|
||||
import {
|
||||
autoStartDecisionLabelMap,
|
||||
formatAutoStartDecisionSummary,
|
||||
formatQualityLabel,
|
||||
availabilityLabelMap,
|
||||
currentRecordingStateLabelMap,
|
||||
@@ -23,6 +27,7 @@ import { House, RefreshRight, SwitchButton, VideoCamera } from "@element-plus/ic
|
||||
|
||||
const inheritValue = "__inherit__";
|
||||
const AUTO_REFRESH_INTERVAL_MS = 15000;
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
@@ -47,10 +52,11 @@ const loadError = ref("");
|
||||
const roomDetailVisible = ref(false);
|
||||
const activeRoom = ref<LiveRoom | null>(null);
|
||||
const { isMobile } = useViewport();
|
||||
const roomsTableShellRef = ref<HTMLElement | null>(null);
|
||||
const roomsTableProxyRef = ref<HTMLElement | null>(null);
|
||||
const showRoomsTableProxyScroll = ref(false);
|
||||
const roomsTableProxyInnerWidth = ref(0);
|
||||
const roomSearch = ref("");
|
||||
const roomState = ref("all");
|
||||
const mobileRoomPage = ref(1);
|
||||
const mobileRoomPageSize = 12;
|
||||
const failedAvatarUrls = ref<Set<string>>(new Set());
|
||||
|
||||
const createForm = reactive({
|
||||
url: "",
|
||||
@@ -113,12 +119,36 @@ const selectedRoomIds = computed(() => selectedRooms.value.map((item) => item.id
|
||||
const selectedRoomCount = computed(() => selectedRooms.value.length);
|
||||
const hasSelectedRooms = computed(() => selectedRoomCount.value > 0);
|
||||
const pendingDeleteCount = computed(() => pendingDeleteRoom.value ? 1 : pendingDeleteRoomIds.value.length);
|
||||
const totalRooms = computed(() => rooms.value.length);
|
||||
const enabledRooms = computed(() => rooms.value.filter((item) => item.isEnabled).length);
|
||||
const disabledRooms = computed(() => rooms.value.filter((item) => !item.isEnabled).length);
|
||||
const liveRooms = computed(() => rooms.value.filter((item) => item.availabilityStatus === 2).length);
|
||||
const recordingRooms = computed(() => rooms.value.filter((item) => item.currentRecordingState === 2).length);
|
||||
const priorityRooms = computed(() => rooms.value.filter((item) => item.isPriority).length);
|
||||
const filteredRooms = computed(() => {
|
||||
const keyword = roomSearch.value.trim().toLowerCase();
|
||||
return rooms.value.filter((room) => {
|
||||
const matchesKeyword = !keyword || [room.title, room.anchorName, room.alias, room.roomId, room.platformName]
|
||||
.some((value) => String(value || "").toLowerCase().includes(keyword));
|
||||
const matchesState = roomState.value === "all" ||
|
||||
(roomState.value === "live" && room.availabilityStatus === 2) ||
|
||||
(roomState.value === "recording" && room.currentRecordingState === 2) ||
|
||||
(roomState.value === "enabled" && room.isEnabled) ||
|
||||
(roomState.value === "disabled" && !room.isEnabled);
|
||||
return matchesKeyword && matchesState;
|
||||
});
|
||||
});
|
||||
const mobileRoomPageCount = computed(() =>
|
||||
Math.max(1, Math.ceil(filteredRooms.value.length / mobileRoomPageSize))
|
||||
);
|
||||
const paginatedMobileRooms = computed(() => {
|
||||
const start = (mobileRoomPage.value - 1) * mobileRoomPageSize;
|
||||
return filteredRooms.value.slice(start, start + mobileRoomPageSize);
|
||||
});
|
||||
watch([roomSearch, roomState], () => {
|
||||
mobileRoomPage.value = 1;
|
||||
});
|
||||
watch(mobileRoomPageCount, (pageCount) => {
|
||||
mobileRoomPage.value = Math.min(mobileRoomPage.value, pageCount);
|
||||
});
|
||||
const activeRoomSubtitle = computed(() => {
|
||||
if (!activeRoom.value) {
|
||||
return "";
|
||||
@@ -126,182 +156,16 @@ const activeRoomSubtitle = computed(() => {
|
||||
|
||||
return `${activeRoom.value.platformName || "--"} · ${activeRoom.value.roomId || "--"}`;
|
||||
});
|
||||
const tableHeight = computed(() => (isMobile.value ? undefined : 700));
|
||||
function clearRoomFilters() {
|
||||
roomSearch.value = "";
|
||||
roomState.value = "all";
|
||||
}
|
||||
const createDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "560px"));
|
||||
const importDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 720px)" : "720px"));
|
||||
const recordDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 440px)" : "440px"));
|
||||
const settingsDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 760px)" : "840px"));
|
||||
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 46vw, 560px)"));
|
||||
let autoRefreshTimer: number | null = null;
|
||||
let roomsTableScrollWrap: HTMLElement | null = null;
|
||||
let roomsTableResizeObserver: ResizeObserver | null = null;
|
||||
let observedRoomsTableShell: HTMLElement | null = null;
|
||||
let observedRoomsTableWrap: HTMLElement | null = null;
|
||||
let syncingRoomsTableProxy = false;
|
||||
let syncingRoomsTableBody = false;
|
||||
|
||||
function cleanupRoomsTableScrollSync() {
|
||||
if (roomsTableScrollWrap) {
|
||||
roomsTableScrollWrap.removeEventListener("scroll", handleRoomsTableBodyScroll);
|
||||
roomsTableScrollWrap = null;
|
||||
}
|
||||
|
||||
roomsTableResizeObserver?.disconnect();
|
||||
roomsTableResizeObserver = null;
|
||||
observedRoomsTableShell = null;
|
||||
observedRoomsTableWrap = null;
|
||||
}
|
||||
|
||||
function getRoomsTableScrollWrap() {
|
||||
const shell = roomsTableShellRef.value;
|
||||
|
||||
if (!shell) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return shell.querySelector<HTMLElement>(".el-table__body-wrapper .el-scrollbar__wrap") ??
|
||||
shell.querySelector<HTMLElement>(".el-scrollbar__wrap") ??
|
||||
shell.querySelector<HTMLElement>(".el-table__body-wrapper");
|
||||
}
|
||||
|
||||
function getRoomsTableContentWidth(wrap: HTMLElement) {
|
||||
const shell = roomsTableShellRef.value;
|
||||
|
||||
if (!shell) {
|
||||
return wrap.scrollWidth;
|
||||
}
|
||||
|
||||
const widths = [
|
||||
wrap.scrollWidth,
|
||||
wrap.firstElementChild?.scrollWidth ?? 0,
|
||||
shell.querySelector<HTMLElement>(".el-table__body table")?.scrollWidth ?? 0,
|
||||
shell.querySelector<HTMLElement>(".el-table__header table")?.scrollWidth ?? 0
|
||||
];
|
||||
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
let maxRight = 0;
|
||||
|
||||
shell.querySelectorAll<HTMLElement>(
|
||||
".el-table__header th, .el-table__body td, .room-actions-cell, .config-summary, .auto-start-cell"
|
||||
).forEach((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
maxRight = Math.max(maxRight, rect.right - wrapRect.left + wrap.scrollLeft);
|
||||
});
|
||||
|
||||
widths.push(Math.ceil(maxRight) + 24);
|
||||
|
||||
return Math.max(...widths);
|
||||
}
|
||||
|
||||
function handleRoomsTableBodyScroll() {
|
||||
if (syncingRoomsTableProxy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const proxy = roomsTableProxyRef.value;
|
||||
|
||||
if (!proxy || !roomsTableScrollWrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncingRoomsTableBody = true;
|
||||
proxy.scrollLeft = roomsTableScrollWrap.scrollLeft;
|
||||
window.requestAnimationFrame(() => {
|
||||
syncingRoomsTableBody = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleRoomsTableProxyScroll() {
|
||||
if (syncingRoomsTableBody || !roomsTableScrollWrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
const proxy = roomsTableProxyRef.value;
|
||||
|
||||
if (!proxy) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncingRoomsTableProxy = true;
|
||||
roomsTableScrollWrap.scrollLeft = proxy.scrollLeft;
|
||||
window.requestAnimationFrame(() => {
|
||||
syncingRoomsTableProxy = false;
|
||||
});
|
||||
}
|
||||
|
||||
function ensureRoomsTableResizeObserver() {
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const shell = roomsTableShellRef.value;
|
||||
const wrap = roomsTableScrollWrap;
|
||||
|
||||
if (roomsTableResizeObserver && observedRoomsTableShell === shell && observedRoomsTableWrap === wrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
roomsTableResizeObserver?.disconnect();
|
||||
roomsTableResizeObserver = new ResizeObserver(() => {
|
||||
void syncRoomsTableProxyScroll();
|
||||
});
|
||||
|
||||
if (shell) {
|
||||
roomsTableResizeObserver.observe(shell);
|
||||
}
|
||||
|
||||
if (wrap) {
|
||||
roomsTableResizeObserver.observe(wrap);
|
||||
}
|
||||
|
||||
observedRoomsTableShell = shell;
|
||||
observedRoomsTableWrap = wrap;
|
||||
}
|
||||
|
||||
async function syncRoomsTableProxyScroll() {
|
||||
await nextTick();
|
||||
|
||||
if (isMobile.value) {
|
||||
showRoomsTableProxyScroll.value = false;
|
||||
roomsTableProxyInnerWidth.value = 0;
|
||||
cleanupRoomsTableScrollSync();
|
||||
return;
|
||||
}
|
||||
|
||||
const wrap = getRoomsTableScrollWrap();
|
||||
|
||||
if (roomsTableScrollWrap !== wrap) {
|
||||
roomsTableScrollWrap?.removeEventListener("scroll", handleRoomsTableBodyScroll);
|
||||
roomsTableScrollWrap = wrap;
|
||||
roomsTableScrollWrap?.addEventListener("scroll", handleRoomsTableBodyScroll, { passive: true });
|
||||
}
|
||||
|
||||
const proxy = roomsTableProxyRef.value;
|
||||
|
||||
if (!wrap) {
|
||||
showRoomsTableProxyScroll.value = false;
|
||||
roomsTableProxyInnerWidth.value = 0;
|
||||
ensureRoomsTableResizeObserver();
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollWidth = getRoomsTableContentWidth(wrap);
|
||||
const clientWidth = wrap.clientWidth;
|
||||
const shellClientWidth = roomsTableShellRef.value?.clientWidth ?? clientWidth;
|
||||
const proxyViewportWidth = proxy?.clientWidth ?? shellClientWidth;
|
||||
const canScrollHorizontally = scrollWidth > clientWidth + 1;
|
||||
const proxyContentWidth = scrollWidth + Math.max(0, proxyViewportWidth - clientWidth);
|
||||
|
||||
roomsTableProxyInnerWidth.value = canScrollHorizontally ? Math.ceil(proxyContentWidth) : 0;
|
||||
showRoomsTableProxyScroll.value = canScrollHorizontally;
|
||||
|
||||
if (canScrollHorizontally && proxy && Math.abs(proxy.scrollLeft - wrap.scrollLeft) > 1) {
|
||||
proxy.scrollLeft = wrap.scrollLeft;
|
||||
}
|
||||
|
||||
ensureRoomsTableResizeObserver();
|
||||
}
|
||||
|
||||
async function loadRooms() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
@@ -309,7 +173,6 @@ async function loadRooms() {
|
||||
try {
|
||||
const { data } = await apiClient.get<LiveRoom[]>("/live-rooms");
|
||||
rooms.value = data;
|
||||
void syncRoomsTableProxyScroll();
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorMessage(error, "直播间列表加载失败,请稍后重试。");
|
||||
} finally {
|
||||
@@ -344,7 +207,6 @@ async function autoRefreshRooms() {
|
||||
const { data } = await apiClient.get<LiveRoom[]>("/live-rooms");
|
||||
rooms.value = data;
|
||||
loadError.value = "";
|
||||
void syncRoomsTableProxyScroll();
|
||||
} catch {
|
||||
// Keep the current view stable; the background poller will try again.
|
||||
}
|
||||
@@ -711,12 +573,26 @@ function getRoomAvatarText(room: LiveRoom) {
|
||||
return source.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
function getRoomAvatarUrl(room: LiveRoom) {
|
||||
const url = room.avatarUrl || room.coverUrl || "";
|
||||
return url && !failedAvatarUrls.value.has(url) ? url : "";
|
||||
}
|
||||
|
||||
function markRoomAvatarFailed(room: LiveRoom) {
|
||||
const url = room.avatarUrl || room.coverUrl || "";
|
||||
if (!url || failedAvatarUrls.value.has(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
failedAvatarUrls.value = new Set([...failedAvatarUrls.value, url]);
|
||||
}
|
||||
|
||||
function roomAvailabilityLabel(room: LiveRoom) {
|
||||
return availabilityLabelMap[room.availabilityStatus] ?? "--";
|
||||
}
|
||||
|
||||
function latestEventLabel(room: LiveRoom) {
|
||||
return room.lastAutoStartDecisionSummary || "暂无事件";
|
||||
return formatAutoStartDecisionSummary(room.lastAutoStartDecisionCode, room.lastAutoStartDecisionSummary || "暂无事件");
|
||||
}
|
||||
|
||||
function getQualityLabel(quality?: string | null) {
|
||||
@@ -814,17 +690,14 @@ function nullableStringFromSelect(value: string | number) {
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRooms();
|
||||
void syncRoomsTableProxyScroll();
|
||||
if (route.query.action === "create") openCreateDialog();
|
||||
startAutoRefresh();
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("resize", syncRoomsTableProxyScroll);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopAutoRefresh();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("resize", syncRoomsTableProxyScroll);
|
||||
cleanupRoomsTableScrollSync();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -857,36 +730,13 @@ onBeforeUnmount(() => {
|
||||
<MetricCard label="重点监控" :value="priorityRooms" description="标记为重点巡检与优先关注的房间" :icon="RefreshRight" />
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card focus-card" shadow="never">
|
||||
<div class="focus-card__header">
|
||||
<div>
|
||||
<div class="focus-card__eyebrow">监控概览</div>
|
||||
<h3 class="section-title">直播采集态势</h3>
|
||||
</div>
|
||||
<StatusBadge
|
||||
:label="recordingRooms > 0 ? '录制通道活跃' : '等待开播'"
|
||||
:status="recordingRooms > 0"
|
||||
context="boolean"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="focus-card__description">
|
||||
当前共接入 {{ totalRooms }} 个直播间,其中 {{ enabledRooms }} 个处于自动巡检范围,{{ disabledRooms }} 个处于保留但停用状态。
|
||||
</p>
|
||||
|
||||
<div class="focus-card__chips">
|
||||
<span class="info-pill">自动开录复用现有后端策略</span>
|
||||
<span class="info-pill">单房间配置优先于全局设置</span>
|
||||
<span class="info-pill">无实时吞吐字段时统一显示占位</span>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card table-card" shadow="never" v-loading="loading">
|
||||
<div class="toolbar-row">
|
||||
<div>
|
||||
<h3 class="section-title">直播间列表</h3>
|
||||
<p class="section-subtitle">继续使用真实接口数据,统一强化为监控控制台视图,支持查看详情、手动录制和单房间配置覆盖。</p>
|
||||
<p class="section-subtitle">搜索主播或房间,并按运行状态快速筛选。</p>
|
||||
</div>
|
||||
<el-space v-if="!isMobile" wrap class="batch-actions">
|
||||
<span class="batch-actions__count">已选 {{ selectedRoomCount }} 个</span>
|
||||
@@ -898,20 +748,32 @@ onBeforeUnmount(() => {
|
||||
</el-space>
|
||||
</div>
|
||||
|
||||
<div class="list-filterbar">
|
||||
<el-input v-model="roomSearch" clearable placeholder="搜索主播、标题、平台或 Room ID" />
|
||||
<el-select v-model="roomState" aria-label="直播间状态筛选">
|
||||
<el-option label="全部状态" value="all" />
|
||||
<el-option label="正在直播" value="live" />
|
||||
<el-option label="正在录制" value="recording" />
|
||||
<el-option label="已启用" value="enabled" />
|
||||
<el-option label="已停用" value="disabled" />
|
||||
</el-select>
|
||||
<span class="list-filterbar__count">{{ filteredRooms.length }} / {{ rooms.length }}</span>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="!loading && rooms.length === 0"
|
||||
title="暂无数据"
|
||||
description="当前筛选条件下没有可展示内容"
|
||||
action-text="刷新列表"
|
||||
@action="loadRooms"
|
||||
v-if="!loading && filteredRooms.length === 0"
|
||||
:title="rooms.length === 0 ? '尚未添加直播间' : '没有匹配的直播间'"
|
||||
:description="rooms.length === 0 ? '添加直播间后即可开始自动巡检和录制' : '请调整搜索词或状态筛选'"
|
||||
:action-text="rooms.length === 0 ? '刷新列表' : '清除筛选'"
|
||||
@action="rooms.length === 0 ? loadRooms() : clearRoomFilters()"
|
||||
/>
|
||||
|
||||
<div v-else-if="isMobile" class="data-card-list room-card-list">
|
||||
<article v-for="row in rooms" :key="row.id" class="data-card room-card">
|
||||
<article v-for="row in paginatedMobileRooms" :key="row.id" class="data-card room-card">
|
||||
<div class="data-card__header">
|
||||
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
|
||||
<SafeAvatar :src="getRoomAvatarUrl(row)" :alt="row.anchorName || row.title" :size="52" class="room-avatar" @error="markRoomAvatarFailed(row)">
|
||||
{{ getRoomAvatarText(row) }}
|
||||
</el-avatar>
|
||||
</SafeAvatar>
|
||||
|
||||
<div>
|
||||
<div class="data-card__title">{{ row.title || row.anchorName || row.roomId }}</div>
|
||||
@@ -929,7 +791,7 @@ onBeforeUnmount(() => {
|
||||
:status="row.currentRecordingState"
|
||||
context="recording"
|
||||
/>
|
||||
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
|
||||
<PlatformMark :name="row.platformName" />
|
||||
<StatusBadge v-if="row.isPinned" label="置顶" status="completed" size="sm" />
|
||||
<StatusBadge v-if="row.isPriority" label="重点" status="retrying" size="sm" />
|
||||
</div>
|
||||
@@ -987,32 +849,36 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="data-card__actions">
|
||||
<el-button size="small" @click="openRoomDetails(row)">查看详情</el-button>
|
||||
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
|
||||
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
|
||||
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
|
||||
<el-button size="small" @click="openOriginalRoom(row)">打开原房间</el-button>
|
||||
<el-button size="small" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
|
||||
录制
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="openDeleteDialog(row)">删除</el-button>
|
||||
<el-button size="small" type="primary" @click="openRoomDetails(row)">查看详情</el-button>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button size="small">更多</el-button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item :disabled="!row.isEnabled" @click="openRecordDialog(row)">开始录制</el-dropdown-item>
|
||||
<el-dropdown-item @click="refreshRoom(row)">刷新状态</el-dropdown-item>
|
||||
<el-dropdown-item @click="openSettingsDialog(row)">房间配置</el-dropdown-item>
|
||||
<el-dropdown-item @click="copyRoomLink(row)">复制链接</el-dropdown-item>
|
||||
<el-dropdown-item @click="openOriginalRoom(row)">打开原房间</el-dropdown-item>
|
||||
<el-dropdown-item divided class="danger-menu-item" @click="openDeleteDialog(row)">删除</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="mobileRoomPage"
|
||||
class="mobile-room-pagination"
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:page-size="mobileRoomPageSize"
|
||||
:pager-count="5"
|
||||
:total="filteredRooms.length"
|
||||
hide-on-single-page
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else ref="roomsTableShellRef" class="table-scroll-shell rooms-table-shell">
|
||||
<div
|
||||
v-if="showRoomsTableProxyScroll"
|
||||
ref="roomsTableProxyRef"
|
||||
class="rooms-table-proxy-scroll"
|
||||
@scroll.passive="handleRoomsTableProxyScroll"
|
||||
>
|
||||
<div class="rooms-table-proxy-scroll__inner" :style="{ width: `${roomsTableProxyInnerWidth}px` }"></div>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-scroll-shell rooms-table-shell" data-testid="rooms-table-scroll">
|
||||
<el-table
|
||||
:data="rooms"
|
||||
:height="tableHeight"
|
||||
:data="filteredRooms"
|
||||
class="premium-table rooms-table"
|
||||
table-layout="fixed"
|
||||
row-key="id"
|
||||
@@ -1023,14 +889,14 @@ onBeforeUnmount(() => {
|
||||
<el-table-column label="直播间" min-width="340">
|
||||
<template #default="{ row }">
|
||||
<div class="room-summary-cell">
|
||||
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
|
||||
<SafeAvatar :src="getRoomAvatarUrl(row)" :alt="row.anchorName || row.title" :size="52" class="room-avatar" @error="markRoomAvatarFailed(row)">
|
||||
{{ getRoomAvatarText(row) }}
|
||||
</el-avatar>
|
||||
</SafeAvatar>
|
||||
<div class="room-summary-cell__copy">
|
||||
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
|
||||
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
|
||||
<div class="config-summary">
|
||||
<span>{{ row.platformName || "--" }}</span>
|
||||
<PlatformMark :name="row.platformName" />
|
||||
<span>{{ row.roomId || "--" }}</span>
|
||||
<span>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</span>
|
||||
</div>
|
||||
@@ -1112,34 +978,27 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="监控字段" width="168">
|
||||
<template #default="{ row }">
|
||||
<div class="table-placeholder-grid">
|
||||
<span>在线人数 --</span>
|
||||
<span>码率 --</span>
|
||||
<span>采集账号 --</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="最近检测" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="table-date-text">{{ formatDate(row.lastCheckedAt) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="380">
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="room-actions-cell">
|
||||
<el-button size="small" @click="openRoomDetails(row)">查看</el-button>
|
||||
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
|
||||
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
|
||||
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
|
||||
<el-button size="small" @click="openOriginalRoom(row)">打开原房间</el-button>
|
||||
<el-button size="small" type="primary" :disabled="!row.isEnabled" @click="openRecordDialog(row)">
|
||||
开始录制
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain @click="openDeleteDialog(row)">删除</el-button>
|
||||
<el-button size="small" type="primary" @click="openRoomDetails(row)">查看</el-button>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button size="small">更多</el-button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item :disabled="!row.isEnabled" @click="openRecordDialog(row)">开始录制</el-dropdown-item>
|
||||
<el-dropdown-item @click="refreshRoom(row)">刷新状态</el-dropdown-item>
|
||||
<el-dropdown-item @click="openSettingsDialog(row)">房间配置</el-dropdown-item>
|
||||
<el-dropdown-item @click="copyRoomLink(row)">复制链接</el-dropdown-item>
|
||||
<el-dropdown-item @click="openOriginalRoom(row)">打开原房间</el-dropdown-item>
|
||||
<el-dropdown-item divided class="danger-menu-item" @click="openDeleteDialog(row)">删除</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -1154,9 +1013,9 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div v-if="activeRoom" class="detail-panel">
|
||||
<div class="detail-panel__hero">
|
||||
<el-avatar :src="activeRoom.avatarUrl || activeRoom.coverUrl" :size="64" class="room-avatar">
|
||||
<SafeAvatar :src="getRoomAvatarUrl(activeRoom)" :alt="activeRoom.anchorName || activeRoom.title" :size="64" class="room-avatar" @error="markRoomAvatarFailed(activeRoom)">
|
||||
{{ getRoomAvatarText(activeRoom) }}
|
||||
</el-avatar>
|
||||
</SafeAvatar>
|
||||
<div>
|
||||
<div class="detail-panel__title">{{ activeRoom.anchorName || "未知主播" }}</div>
|
||||
<div class="detail-panel__meta">{{ activeRoom.originalLiveRoomUrl || activeRoom.sourceUrl || "--" }}</div>
|
||||
@@ -1170,7 +1029,7 @@ onBeforeUnmount(() => {
|
||||
:status="activeRoom.currentRecordingState"
|
||||
context="recording"
|
||||
/>
|
||||
<StatusBadge :label="activeRoom.platformName || '--'" :status="activeRoom.platformName || 'unknown'" />
|
||||
<PlatformMark :name="activeRoom.platformName" />
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="1" border class="detail-panel__descriptions">
|
||||
@@ -1178,7 +1037,7 @@ onBeforeUnmount(() => {
|
||||
{{ activeRoom.title || activeRoom.anchorName || activeRoom.roomId || "--" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="平台 + Room ID">
|
||||
{{ activeRoom.platformName || "--" }} · {{ activeRoom.roomId || "--" }}
|
||||
<span class="platform-room-id"><PlatformMark :name="activeRoom.platformName" /><span>· {{ activeRoom.roomId || "--" }}</span></span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="直播状态">
|
||||
{{ roomAvailabilityLabel(activeRoom) }}
|
||||
@@ -1598,6 +1457,7 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.platform-room-id { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
@@ -1674,28 +1534,18 @@ onBeforeUnmount(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.list-filterbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) 180px auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.list-filterbar__count { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.rooms-table-shell {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rooms-table-proxy-scroll {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 6px;
|
||||
background: var(--surface);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.rooms-table-proxy-scroll__inner {
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.rooms-table {
|
||||
min-width: 1920px;
|
||||
}
|
||||
|
||||
.rooms-table :deep(.el-table__cell) {
|
||||
@@ -1724,6 +1574,11 @@ onBeforeUnmount(() => {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.mobile-room-pagination {
|
||||
justify-content: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.room-card__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1841,6 +1696,10 @@ onBeforeUnmount(() => {
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.detail-panel__hero > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-panel__title {
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
@@ -2040,6 +1899,9 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-filterbar { grid-template-columns: 1fr 160px; }
|
||||
.list-filterbar__count { grid-column: 1 / -1; }
|
||||
|
||||
.batch-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
@@ -2063,7 +1925,23 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.list-filterbar { grid-template-columns: 1fr; }
|
||||
.list-filterbar__count { grid-column: auto; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.detail-panel__hero {
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.detail-panel__descriptions :deep(.el-descriptions__label) {
|
||||
width: 104px;
|
||||
min-width: 104px;
|
||||
}
|
||||
|
||||
.header-actions :deep(.el-space__item) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
+104
-235
@@ -2,7 +2,7 @@
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Lock, Monitor, Moon, Sunny, User } from "@element-plus/icons-vue";
|
||||
import { ArrowRight, Lock, Monitor, Moon, Sunny, User, VideoCamera } from "@element-plus/icons-vue";
|
||||
import { getApiErrorMessage, isBackendUnavailableError } from "@/api/client";
|
||||
import { useBackendStatus } from "@/composables/useBackendStatus";
|
||||
import { useUiPreferences } from "@/composables/useUiPreferences";
|
||||
@@ -12,11 +12,12 @@ const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const loading = ref(false);
|
||||
const { backendUnavailable, backendMessage } = useBackendStatus();
|
||||
const { themeMode, resolvedTheme } = useUiPreferences();
|
||||
const { themeMode } = useUiPreferences();
|
||||
|
||||
const form = reactive({
|
||||
username: "admin",
|
||||
password: "Admin@123"
|
||||
password: "",
|
||||
rememberMe: false
|
||||
});
|
||||
|
||||
const currentThemeIcon = computed(() => {
|
||||
@@ -35,9 +36,9 @@ async function handleLogin() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await authStore.login(form.username, form.password);
|
||||
await authStore.login(form.username, form.password, form.rememberMe);
|
||||
ElMessage.success("登录成功");
|
||||
await router.push({ name: "live-rooms" });
|
||||
await router.push({ name: "dashboard" });
|
||||
} catch (error) {
|
||||
ElMessage.error(
|
||||
isBackendUnavailableError(error)
|
||||
@@ -52,42 +53,22 @@ async function handleLogin() {
|
||||
|
||||
<template>
|
||||
<div class="login-screen">
|
||||
<section class="login-hero">
|
||||
<div class="login-hero__eyebrow">Live Recorder</div>
|
||||
<h1 class="login-hero__title">把直播录制做成可长期维护的专业控制台。</h1>
|
||||
<p class="login-hero__subtitle">
|
||||
统一管理直播间、自动开录、恢复流程、系统日志、事件脚本和日报回顾,让录制系统像真正的运维平台一样稳定工作。
|
||||
</p>
|
||||
|
||||
<div class="login-hero__grid">
|
||||
<article class="login-hero__tile">
|
||||
<strong>实时监控</strong>
|
||||
<span>直播间、自动开录决策、会话与分片状态集中可见。</span>
|
||||
</article>
|
||||
<article class="login-hero__tile">
|
||||
<strong>恢复能力</strong>
|
||||
<span>中断转码、暂停录制、重启恢复都有统一入口。</span>
|
||||
</article>
|
||||
<article class="login-hero__tile">
|
||||
<strong>自动化</strong>
|
||||
<span>邮件、Webhook、事件脚本与自定义日志全部贯通。</span>
|
||||
</article>
|
||||
<article class="login-hero__tile">
|
||||
<strong>回顾分析</strong>
|
||||
<span>时间轴和日报帮助我们快速复盘每一场直播。</span>
|
||||
</article>
|
||||
<section class="login-brand" aria-label="Live Recorder 产品介绍">
|
||||
<div class="login-brand__logo">
|
||||
<span><el-icon :size="22"><VideoCamera /></el-icon></span>
|
||||
<strong>Live Recorder</strong>
|
||||
</div>
|
||||
<div class="login-brand__message">
|
||||
<span class="login-brand__status"><i />本地服务运行正常</span>
|
||||
<h1>让每一次开播,<br>都有迹可循。</h1>
|
||||
<p>集中监控直播状态、自动完成录制与归档,并在异常发生时第一时间告诉你。</p>
|
||||
</div>
|
||||
<div class="login-brand__meta">SELF-HOSTED · PRIVATE · RELIABLE</div>
|
||||
</section>
|
||||
|
||||
<section class="login-panel surface-card">
|
||||
<div class="login-panel__header">
|
||||
<div>
|
||||
<div class="login-panel__kicker">Sign In</div>
|
||||
<h2 class="login-panel__title">进入控制台</h2>
|
||||
<p class="login-panel__subtitle">默认账户为 <span class="monospace">admin / Admin@123</span></p>
|
||||
</div>
|
||||
|
||||
<el-select v-model="themeMode" size="small" class="login-panel__theme-select">
|
||||
<section class="login-panel">
|
||||
<div class="login-panel__toolbar">
|
||||
<el-select v-model="themeMode" size="small" aria-label="界面主题" class="login-panel__theme-select">
|
||||
<template #prefix>
|
||||
<el-icon><component :is="currentThemeIcon" /></el-icon>
|
||||
</template>
|
||||
@@ -97,39 +78,54 @@ async function handleLogin() {
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="backendUnavailable"
|
||||
class="login-panel__alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="后端服务暂时不可用"
|
||||
:description="backendMessage"
|
||||
/>
|
||||
<div class="login-card surface-card">
|
||||
<div class="login-card__header">
|
||||
<div class="login-panel__eyebrow">LIVE RECORDER</div>
|
||||
<h2 class="login-panel__title">欢迎回来</h2>
|
||||
<p>登录后进入 Live Recorder 运行中心</p>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" class="login-form" @submit.prevent="handleLogin">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" :prefix-icon="User" />
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
v-if="backendUnavailable"
|
||||
class="login-panel__alert"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="后端服务暂时不可用"
|
||||
:description="backendMessage"
|
||||
/>
|
||||
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
:prefix-icon="Lock"
|
||||
type="password"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form label-position="top" class="login-form" autocomplete="on" @submit.prevent="handleLogin">
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
id="username"
|
||||
name="username"
|
||||
:prefix-icon="User"
|
||||
autocomplete="username"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-button class="login-form__submit" type="primary" :loading="loading" @click="handleLogin">
|
||||
登录控制台
|
||||
</el-button>
|
||||
</el-form>
|
||||
<el-form-item label="密码">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
id="password"
|
||||
name="password"
|
||||
:prefix-icon="Lock"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
@keyup.enter="handleLogin"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="login-panel__footer">
|
||||
<span>当前主题:{{ resolvedTheme === "dark" ? "深色" : "浅色" }}</span>
|
||||
<span>双端适配:Web / Mobile</span>
|
||||
<el-checkbox v-model="form.rememberMe" name="rememberMe">记住登录状态(30 天)</el-checkbox>
|
||||
|
||||
<el-button class="login-form__submit" native-type="submit" type="primary" :loading="loading">
|
||||
登录控制台 <el-icon class="el-icon--right"><ArrowRight /></el-icon>
|
||||
</el-button>
|
||||
</el-form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -139,175 +135,48 @@ async function handleLogin() {
|
||||
.login-screen {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 440px);
|
||||
gap: 48px;
|
||||
padding: 48px 56px;
|
||||
grid-template-columns: minmax(420px, .95fr) minmax(480px, 1.05fr);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 24px;
|
||||
.login-brand { position: relative; display: flex; flex-direction: column; min-height: 100vh; overflow: hidden; padding: clamp(32px, 5vw, 68px); border-right: 1px solid var(--border-subtle); background: radial-gradient(circle at 12% 8%, rgba(86,137,255,.18), transparent 34%), radial-gradient(circle at 92% 88%, rgba(146,119,255,.16), transparent 36%), linear-gradient(145deg, var(--accent-soft-2), var(--surface) 58%, var(--purple-soft)); }
|
||||
.login-brand::after { content: ""; position: absolute; right: -170px; bottom: -180px; width: 480px; height: 480px; border: 75px solid color-mix(in srgb, var(--accent) 7%, transparent); border-radius: 50%; }
|
||||
.login-brand__logo { position: relative; z-index: 1; display: flex; align-items: center; gap: 12px; color: var(--text-primary); font-size: 15px; }
|
||||
.login-brand__logo > span { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; color: #fff; background: var(--action); box-shadow: 0 9px 24px color-mix(in srgb, var(--action) 26%, transparent); }
|
||||
.login-brand__message { position: relative; z-index: 1; width: min(100%, 570px); margin: auto 0; }
|
||||
.login-brand__status { display: inline-flex; align-items: center; gap: 9px; color: var(--success); font-size: 11px; font-weight: 750; }
|
||||
.login-brand__status i { width: 8px; height: 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 5px color-mix(in srgb, var(--success) 14%, transparent); }
|
||||
.login-brand__message h1 { margin: 24px 0 0; color: var(--text-primary); font-size: clamp(40px, 5vw, 66px); font-weight: 800; letter-spacing: -.065em; line-height: 1.08; }
|
||||
.login-brand__message p { max-width: 500px; margin: 24px 0 0; color: var(--text-secondary); font-size: 14px; line-height: 1.8; }
|
||||
.login-brand__meta { position: relative; z-index: 1; color: var(--text-muted); font-size: 9px; font-weight: 750; letter-spacing: .18em; }
|
||||
.login-panel { position: relative; display: grid; place-items: center; min-height: 100vh; padding: 70px clamp(24px, 7vw, 96px); background: var(--surface); }
|
||||
.login-panel__toolbar { position: absolute; top: 24px; right: 28px; }
|
||||
.login-card { width: min(100%, 420px); padding: 30px; box-shadow: var(--shadow-sm); }
|
||||
.login-card__header { margin-bottom: 24px; }
|
||||
.login-card__header p { margin: 8px 0 0; color: var(--text-muted); font-size: 12px; }
|
||||
.login-panel__eyebrow { color: var(--accent); font-size: 10px; font-weight: 800; letter-spacing: .12em; }
|
||||
.login-panel__title { margin: 7px 0 0; color: var(--text-primary); font-size: 28px; font-weight: 780; letter-spacing: -.045em; }
|
||||
.login-panel__theme-select { width: 122px; }
|
||||
.login-panel__alert { margin-bottom: 18px; }
|
||||
.login-form__submit { width: 100%; margin-top: 8px; }
|
||||
:global(html[data-theme="dark"]) .login-brand { background: radial-gradient(circle at 12% 8%, rgba(58,112,220,.22), transparent 34%), radial-gradient(circle at 92% 88%, rgba(119,88,207,.19), transparent 36%), linear-gradient(145deg, #111d30, #172235 58%, #1c223b); }
|
||||
@media (max-width: 900px) {
|
||||
.login-screen { grid-template-columns: 1fr; min-height: 100dvh; }
|
||||
.login-brand { min-height: 240px; padding: 26px 24px 30px; }
|
||||
.login-brand__message { margin: 38px 0 0; }
|
||||
.login-brand__message h1 { margin-top: 16px; font-size: 34px; }
|
||||
.login-brand__message p { margin-top: 13px; font-size: 12px; line-height: 1.65; }
|
||||
.login-brand__meta { display: none; }
|
||||
.login-panel { min-height: 0; padding: 70px 18px 28px; }
|
||||
.login-panel__toolbar { top: 18px; right: 18px; }
|
||||
}
|
||||
|
||||
.login-hero__eyebrow,
|
||||
.login-panel__kicker {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-hero__title {
|
||||
margin: 0;
|
||||
max-width: 11ch;
|
||||
color: var(--text-primary);
|
||||
font-size: clamp(44px, 5vw, 72px);
|
||||
font-weight: 780;
|
||||
letter-spacing: -0.065em;
|
||||
line-height: 0.94;
|
||||
}
|
||||
|
||||
.login-hero__subtitle {
|
||||
max-width: 60ch;
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.login-hero__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.login-hero__tile {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
:global(html[data-theme="dark"]) .login-hero__tile {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.login-hero__tile strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-hero__tile span {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
align-self: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-panel__title {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 28px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.login-panel__subtitle {
|
||||
margin: 10px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-panel__theme-select {
|
||||
width: 122px;
|
||||
}
|
||||
|
||||
.login-panel__alert {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-form__submit {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.login-panel__footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.login-screen {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 28px;
|
||||
padding: 28px 20px;
|
||||
}
|
||||
|
||||
.login-hero__grid {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.login-screen {
|
||||
padding: 18px 14px 24px;
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.login-hero__title {
|
||||
max-width: none;
|
||||
font-size: clamp(34px, 12vw, 48px);
|
||||
}
|
||||
|
||||
.login-hero__subtitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.login-panel__header,
|
||||
.login-panel__footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.login-panel__theme-select {
|
||||
width: 100%;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.login-brand { min-height: 218px; padding: 21px 18px 26px; }
|
||||
.login-brand__logo > span { width: 34px; height: 34px; }
|
||||
.login-brand__message { margin-top: 30px; }
|
||||
.login-brand__status { display: none; }
|
||||
.login-brand__message h1 { margin-top: 0; font-size: 29px; }
|
||||
.login-brand__message p { display: none; }
|
||||
.login-panel { padding-inline: 14px; }
|
||||
.login-card { padding: 22px 18px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,9 @@ import { logLevelLabelMap } from "@/types";
|
||||
|
||||
const AUTO_REFRESH_INTERVAL_MS = 15000;
|
||||
|
||||
const loading = ref(false);
|
||||
const initialLoading = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const requestInFlight = ref(false);
|
||||
const logs = ref<SystemLog[]>([]);
|
||||
const loadError = ref("");
|
||||
const { isMobile } = useViewport();
|
||||
@@ -31,13 +33,25 @@ const newestLogTime = computed(() => (logs.value[0] ? formatDate(logs.value[0].c
|
||||
|
||||
let autoRefreshTimer: number | null = null;
|
||||
|
||||
async function loadLogs() {
|
||||
if (loading.value) {
|
||||
function mergeLogs(nextLogs: SystemLog[]) {
|
||||
const previousById = new Map(logs.value.map((item) => [item.id, item]));
|
||||
return nextLogs.map((item) => {
|
||||
const previous = previousById.get(item.id);
|
||||
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLogs(background = false) {
|
||||
if (requestInFlight.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
requestInFlight.value = true;
|
||||
initialLoading.value = !background && logs.value.length === 0;
|
||||
refreshing.value = !initialLoading.value;
|
||||
if (!background) {
|
||||
loadError.value = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<SystemLog[]>("/logs", {
|
||||
@@ -50,11 +64,13 @@ async function loadLogs() {
|
||||
}
|
||||
});
|
||||
|
||||
logs.value = data;
|
||||
logs.value = mergeLogs(data);
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorMessage(error, "系统日志加载失败,请稍后重试。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
requestInFlight.value = false;
|
||||
initialLoading.value = false;
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +79,7 @@ async function autoRefreshLogs() {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadLogs();
|
||||
await loadLogs(true);
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
@@ -126,7 +142,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="page-toolbar">
|
||||
<el-button @click="loadLogs">刷新日志</el-button>
|
||||
<el-button :loading="refreshing" @click="loadLogs(false)">刷新日志</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,7 +203,7 @@ onBeforeUnmount(() => {
|
||||
<el-input-number v-model="filters.take" :min="1" :max="500" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadLogs">应用筛选</el-button>
|
||||
<el-button type="primary" @click="loadLogs(false)">应用筛选</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
@@ -230,8 +246,7 @@ onBeforeUnmount(() => {
|
||||
<div v-else class="table-scroll-shell logs-table-shell">
|
||||
<el-table
|
||||
:data="logs"
|
||||
v-loading="loading"
|
||||
:height="720"
|
||||
v-loading="initialLoading"
|
||||
class="premium-table logs-table"
|
||||
table-layout="auto"
|
||||
>
|
||||
|
||||
@@ -14,15 +14,36 @@ const browser = ref<MediaBrowserResponse | null>(null);
|
||||
const previewVisible = ref(false);
|
||||
const previewTitle = ref("");
|
||||
const previewUrl = ref("");
|
||||
const mediaSearch = ref("");
|
||||
const mediaType = ref("all");
|
||||
|
||||
const currentPathLabel = computed(() => browser.value?.currentPath || "平台目录");
|
||||
const directoryCount = computed(() => browser.value?.items.filter((item) => item.type === "directory").length ?? 0);
|
||||
const mediaFileCount = computed(() => browser.value?.items.filter((item) => item.type !== "directory").length ?? 0);
|
||||
const transcodeReadyCount = computed(() => browser.value?.items.filter((item) => item.canTranscode).length ?? 0);
|
||||
const filteredItems = computed(() => {
|
||||
const keyword = mediaSearch.value.trim().toLowerCase();
|
||||
return (browser.value?.items ?? []).filter((item) => {
|
||||
const matchesKeyword = !keyword || [item.name, item.relativePath].some((value) => value.toLowerCase().includes(keyword));
|
||||
const matchesType = mediaType.value === "all" ||
|
||||
(mediaType.value === "directory" && item.type === "directory") ||
|
||||
(mediaType.value === "video" && (item.type === "mp4" || item.type === "ts")) ||
|
||||
(mediaType.value === "xml" && item.type === "xml") ||
|
||||
(mediaType.value === "other" && !["directory", "mp4", "ts", "xml"].includes(item.type));
|
||||
return matchesKeyword && matchesType;
|
||||
});
|
||||
});
|
||||
|
||||
function clearFilters() {
|
||||
mediaSearch.value = "";
|
||||
mediaType.value = "all";
|
||||
}
|
||||
|
||||
async function loadDirectory(path = "") {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
mediaSearch.value = "";
|
||||
mediaType.value = "all";
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<MediaBrowserResponse>("/media/browser", {
|
||||
@@ -181,18 +202,30 @@ onMounted(() => {
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="list-filterbar media-filterbar">
|
||||
<el-input v-model="mediaSearch" clearable placeholder="搜索文件名或相对路径" />
|
||||
<el-select v-model="mediaType" aria-label="文件类型筛选">
|
||||
<el-option label="全部类型" value="all" />
|
||||
<el-option label="目录" value="directory" />
|
||||
<el-option label="视频" value="video" />
|
||||
<el-option label="XML" value="xml" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
<span class="list-filterbar__count">{{ filteredItems.length }} / {{ browser?.items.length ?? 0 }}</span>
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="loading && !browser" animated :rows="8" />
|
||||
|
||||
<EmptyState
|
||||
v-else-if="browser && browser.items.length === 0"
|
||||
title="暂无数据"
|
||||
description="当前筛选条件下没有可展示内容"
|
||||
action-text="刷新目录"
|
||||
@action="refreshCurrentDirectory"
|
||||
v-else-if="browser && filteredItems.length === 0"
|
||||
:title="browser.items.length === 0 ? '目录为空' : '没有匹配的文件'"
|
||||
:description="browser.items.length === 0 ? '当前目录下没有可展示内容' : '请调整搜索词或文件类型'"
|
||||
:action-text="browser.items.length === 0 ? '刷新目录' : '清除筛选'"
|
||||
@action="browser.items.length === 0 ? refreshCurrentDirectory() : clearFilters()"
|
||||
/>
|
||||
|
||||
<div v-else-if="browser" class="table-scroll-shell">
|
||||
<el-table :data="browser.items" class="premium-table" table-layout="auto">
|
||||
<div v-else-if="browser" class="table-scroll-shell" data-testid="media-table-scroll">
|
||||
<el-table :data="filteredItems" class="premium-table media-table" table-layout="auto">
|
||||
<el-table-column label="名称" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="file-cell">
|
||||
@@ -219,30 +252,24 @@ onMounted(() => {
|
||||
{{ formatDate(row.modifiedAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="320" fixed="right">
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-row">
|
||||
<el-button v-if="row.type === 'directory'" size="small" @click="openDirectory(row.relativePath)">进入目录</el-button>
|
||||
<el-button v-if="row.type === 'mp4'" size="small" @click="previewVideo(row)">预览视频</el-button>
|
||||
<el-button v-if="row.type === 'xml'" size="small" @click="openFile(row)">查看 XML</el-button>
|
||||
<el-button
|
||||
v-if="row.type !== 'directory'"
|
||||
size="small"
|
||||
plain
|
||||
@click="openFile(row, true)"
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.canTranscode"
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="transcodePath === row.relativePath"
|
||||
@click="transcodeFile(row)"
|
||||
>
|
||||
转码为 MP4
|
||||
</el-button>
|
||||
<el-button v-if="row.type === 'directory'" size="small" type="primary" @click="openDirectory(row.relativePath)">进入</el-button>
|
||||
<el-button v-else-if="row.type === 'mp4'" size="small" type="primary" @click="previewVideo(row)">预览</el-button>
|
||||
<el-button v-else-if="row.type === 'xml'" size="small" type="primary" @click="openFile(row)">查看</el-button>
|
||||
<el-button v-else size="small" type="primary" @click="openFile(row, true)">下载</el-button>
|
||||
<el-dropdown v-if="row.type !== 'directory'" trigger="click">
|
||||
<el-button size="small">更多</el-button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item @click="openFile(row, true)">下载文件</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="row.canTranscode"
|
||||
:disabled="transcodePath === row.relativePath"
|
||||
@click="transcodeFile(row)"
|
||||
>转码为 MP4</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import apiClient, { getApiErrorMessage, buildApiUrl } from "@/api/client";
|
||||
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
|
||||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||||
import type {
|
||||
RecordArtifactUploadBatchResult,
|
||||
RecordPreviewTicket,
|
||||
RecordSessionDetail,
|
||||
RecordSessionTimelineEvent,
|
||||
RecordSessionHeatBucket,
|
||||
@@ -28,12 +32,52 @@ const props = defineProps<{
|
||||
const router = useRouter();
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
// Danmaku replay dialog
|
||||
const { danmakuEvents: replayDanmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer();
|
||||
const danmakuDialogVisible = ref(false);
|
||||
const danmakuDialogTitle = ref("");
|
||||
const danmakuPreviewUrl = ref("");
|
||||
const danmakuPreviewLoading = ref(false);
|
||||
const danmakuPreviewMessage = ref("");
|
||||
|
||||
async function openDanmakuReplay(recordTaskId: string, segmentIndex: number) {
|
||||
danmakuDialogVisible.value = true;
|
||||
danmakuDialogTitle.value = `弹幕回放 — 分片 #${segmentIndex}`;
|
||||
danmakuPreviewUrl.value = "";
|
||||
danmakuPreviewMessage.value = "";
|
||||
clearDanmaku();
|
||||
|
||||
// Load preview ticket and danmaku in parallel
|
||||
danmakuPreviewLoading.value = true;
|
||||
try {
|
||||
const [ticketResult] = await Promise.allSettled([
|
||||
apiClient.post<RecordPreviewTicket>(`/record-tasks/${recordTaskId}/preview-ticket`),
|
||||
loadTaskDanmaku(recordTaskId)
|
||||
]);
|
||||
|
||||
if (ticketResult.status === "fulfilled") {
|
||||
danmakuPreviewUrl.value = ticketResult.value.data.url;
|
||||
} else {
|
||||
danmakuPreviewMessage.value = "无法获取视频预览票据,请稍后重试。";
|
||||
}
|
||||
} catch {
|
||||
danmakuPreviewMessage.value = "加载预览资源失败。";
|
||||
} finally {
|
||||
danmakuPreviewLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDanmakuDialog() {
|
||||
danmakuDialogVisible.value = false;
|
||||
danmakuPreviewUrl.value = "";
|
||||
clearDanmaku();
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const uploadLoading = ref(false);
|
||||
const loadError = ref("");
|
||||
const detail = ref<RecordSessionDetail | null>(null);
|
||||
const visibleLayers = ref(["session", "segments", "processing", "danmaku", "automation"]);
|
||||
const logTableHeight = computed(() => (isMobile.value ? undefined : 360));
|
||||
|
||||
const timelineDurationSeconds = computed(() => {
|
||||
const total = detail.value?.timeline.totalDurationSeconds ?? 0;
|
||||
@@ -69,7 +113,8 @@ async function uploadSessionArtifacts() {
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>(`/record-sessions/${props.id}/upload`);
|
||||
const message = `会话上传完成:成功 ${data.successCount},失败 ${data.failedCount}。`;
|
||||
const queued = data.items.some(item => item.uploadStatus === 4 || item.uploadStatus === 5);
|
||||
const message = `${queued ? "会话上传已加入队列" : "会话上传完成"}:已受理 ${data.successCount},失败 ${data.failedCount}。`;
|
||||
ElMessage[data.failedCount === 0 ? "success" : "warning"](message);
|
||||
await loadDetail();
|
||||
} catch (error) {
|
||||
@@ -238,7 +283,7 @@ onMounted(loadDetail);
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<el-space class="header-actions">
|
||||
<el-space wrap class="header-actions">
|
||||
<el-button @click="router.push({ name: 'record-tasks' })">返回列表</el-button>
|
||||
<el-button @click="loadDetail">刷新</el-button>
|
||||
<el-button :loading="uploadLoading" @click="uploadSessionArtifacts">上传会话</el-button>
|
||||
@@ -259,14 +304,20 @@ onMounted(loadDetail);
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="会话状态">
|
||||
<el-tag :type="sessionStatusTagType(detail.session.status)">
|
||||
{{ sessionStatusLabelMap[detail.session.status] }}
|
||||
{{ detail.session.isRecovering ? "恢复中" : sessionStatusLabelMap[detail.session.status] }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.session.isRecovering" label="恢复进度">
|
||||
<span class="recovery-status-copy">{{ detail.session.recoveryReason || "正在等待下一次自动重试" }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="直播间">
|
||||
{{ detail.session.liveRoomTitle }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="主播">
|
||||
{{ detail.session.anchorName || "未知主播" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="平台">
|
||||
{{ platformLabelMap[detail.session.platform] }}
|
||||
<PlatformMark :name="platformLabelMap[detail.session.platform]" />
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Room ID">
|
||||
<span class="monospace">{{ detail.session.roomId }}</span>
|
||||
@@ -468,7 +519,40 @@ onMounted(loadDetail);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll-shell">
|
||||
<div v-if="isMobile" class="segment-card-list">
|
||||
<article v-for="row in detail.timeline.segments" :key="row.recordTaskId" class="segment-card">
|
||||
<div class="segment-card__head">
|
||||
<span>
|
||||
<strong class="monospace">分片 #{{ row.segmentIndex }}</strong>
|
||||
<small>{{ formatDuration(row.durationSeconds) }}</small>
|
||||
</span>
|
||||
<el-tag :type="sessionStatusTagType(row.status)">{{ taskStatusLabelMap[row.status] }}</el-tag>
|
||||
</div>
|
||||
|
||||
<dl class="segment-card__facts">
|
||||
<div class="segment-card__file">
|
||||
<dt>文件</dt>
|
||||
<dd class="monospace">{{ row.label || "-" }}</dd>
|
||||
</div>
|
||||
<div><dt>开始</dt><dd>{{ formatDate(row.startedAt) }}</dd></div>
|
||||
<div><dt>结束</dt><dd>{{ formatDate(row.endedAt) }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="segment-card__actions">
|
||||
<el-button size="small" type="primary" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
plain
|
||||
:disabled="row.status !== 4 && row.status !== 6"
|
||||
@click="openDanmakuReplay(row.recordTaskId, row.segmentIndex)"
|
||||
>
|
||||
弹幕回放
|
||||
</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="table-scroll-shell">
|
||||
<el-table :data="detail.timeline.segments" class="premium-table" table-layout="auto">
|
||||
<el-table-column label="分片" width="90">
|
||||
<template #default="{ row }">
|
||||
@@ -502,9 +586,18 @@ onMounted(loadDetail);
|
||||
{{ formatDuration(row.durationSeconds) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="row.status !== 4 && row.status !== 6"
|
||||
@click="openDanmakuReplay(row.recordTaskId, row.segmentIndex)"
|
||||
>
|
||||
弹幕回放
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -520,7 +613,7 @@ onMounted(loadDetail);
|
||||
</div>
|
||||
|
||||
<div class="table-scroll-shell">
|
||||
<el-table :data="detail.logs" :height="logTableHeight" class="premium-table" table-layout="auto">
|
||||
<el-table :data="detail.logs" class="premium-table detail-logs-table" table-layout="auto">
|
||||
<el-table-column label="级别" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="logTagType(row.level)">
|
||||
@@ -544,6 +637,24 @@ onMounted(loadDetail);
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- Danmaku Replay Dialog -->
|
||||
<el-dialog
|
||||
v-model="danmakuDialogVisible"
|
||||
:title="danmakuDialogTitle"
|
||||
width="90%"
|
||||
:close-on-click-modal="false"
|
||||
@close="closeDanmakuDialog"
|
||||
>
|
||||
<div v-if="danmakuPreviewLoading" class="preview-empty">正在准备预览资源…</div>
|
||||
<div v-else-if="danmakuPreviewMessage" class="preview-empty">{{ danmakuPreviewMessage }}</div>
|
||||
<DanmakuPlayer
|
||||
v-else-if="danmakuPreviewUrl"
|
||||
:video-src="danmakuPreviewUrl"
|
||||
:danmaku-events="replayDanmakuEvents"
|
||||
/>
|
||||
<div v-else class="preview-empty">无法加载该分片的预览。</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -720,6 +831,108 @@ onMounted(loadDetail);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.segment-card-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.segment-card {
|
||||
display: grid;
|
||||
gap: 13px;
|
||||
min-width: 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.segment-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.segment-card__head > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.segment-card__head strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.segment-card__head small {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.segment-card__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.segment-card__facts > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.segment-card__facts dt {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.segment-card__facts dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.segment-card__file {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.segment-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-top: 11px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.segment-card__actions :deep(.el-button) {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.segment-label,
|
||||
.log-detail {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 260px;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--border-base);
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
@@ -730,5 +943,10 @@ onMounted(loadDetail);
|
||||
flex: 1 1 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
min-height: 180px;
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
|
||||
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
|
||||
import type {
|
||||
ManualSegmentCompletedTriggerResult,
|
||||
RecordArtifactUploadItemResult,
|
||||
@@ -35,8 +37,30 @@ const previewUrl = ref("");
|
||||
const previewExpiresAt = ref("");
|
||||
const previewMessage = ref("");
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
// Danmaku replay state
|
||||
const { danmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer();
|
||||
const showDanmaku = ref(false);
|
||||
const danmakuLoaded = ref(false);
|
||||
|
||||
async function toggleDanmaku() {
|
||||
if (showDanmaku.value) {
|
||||
showDanmaku.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!danmakuLoaded.value) {
|
||||
const hasEvents = await loadTaskDanmaku(props.id);
|
||||
if (!hasEvents && danmakuError.value) {
|
||||
ElMessage.warning(danmakuError.value);
|
||||
return;
|
||||
}
|
||||
danmakuLoaded.value = true;
|
||||
}
|
||||
|
||||
showDanmaku.value = true;
|
||||
}
|
||||
const rowGutter = computed(() => (isMobile.value ? 14 : 18));
|
||||
const logTableHeight = computed(() => (isMobile.value ? undefined : 420));
|
||||
const activeTaskStatuses = new Set([1, 2, 3, 7]);
|
||||
const canManualTranscode = computed(() => {
|
||||
const task = detail.value?.task;
|
||||
@@ -234,7 +258,16 @@ function formatProgress(value?: number) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(0)}%` : "-";
|
||||
}
|
||||
|
||||
watch(() => props.id, loadDetailAndPreview);
|
||||
function resetDanmakuState() {
|
||||
showDanmaku.value = false;
|
||||
danmakuLoaded.value = false;
|
||||
clearDanmaku();
|
||||
}
|
||||
|
||||
watch(() => props.id, () => {
|
||||
resetDanmakuState();
|
||||
loadDetailAndPreview();
|
||||
});
|
||||
onMounted(loadDetailAndPreview);
|
||||
</script>
|
||||
|
||||
@@ -248,7 +281,7 @@ onMounted(loadDetailAndPreview);
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<el-space class="header-actions">
|
||||
<el-space wrap class="header-actions">
|
||||
<el-button @click="router.push({ name: 'record-tasks' })">返回列表</el-button>
|
||||
<el-button @click="loadDetailAndPreview">刷新</el-button>
|
||||
<el-button
|
||||
@@ -388,6 +421,14 @@ onMounted(loadDetailAndPreview);
|
||||
<div class="preview-meta" v-if="previewExpiresAt">
|
||||
票据有效至 {{ formatDate(previewExpiresAt) }}
|
||||
</div>
|
||||
<el-button
|
||||
v-if="previewUrl && !previewLoading"
|
||||
:type="showDanmaku ? 'primary' : 'default'"
|
||||
:loading="danmakuLoading"
|
||||
@click="toggleDanmaku"
|
||||
>
|
||||
{{ showDanmaku ? "关闭弹幕" : "弹幕回放" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManualTranscode"
|
||||
type="primary"
|
||||
@@ -400,9 +441,14 @@ onMounted(loadDetailAndPreview);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewLoading" class="preview-empty">正在准备预览资源…</div>
|
||||
<div v-if="previewLoading || danmakuLoading" class="preview-empty">正在准备预览资源…</div>
|
||||
<DanmakuPlayer
|
||||
v-else-if="previewUrl && showDanmaku"
|
||||
:video-src="previewUrl"
|
||||
:danmaku-events="danmakuEvents"
|
||||
/>
|
||||
<video
|
||||
v-else-if="previewUrl"
|
||||
v-else-if="previewUrl && !showDanmaku"
|
||||
:key="previewUrl"
|
||||
class="preview-player"
|
||||
:src="previewUrl"
|
||||
@@ -419,7 +465,7 @@ onMounted(loadDetailAndPreview);
|
||||
<p class="section-subtitle">日志已带上会话与任务关联,可看到 ffmpeg、巡检和弹幕采集的上下文。</p>
|
||||
|
||||
<div class="table-scroll-shell detail-logs-shell">
|
||||
<el-table :data="detail.logs" :height="logTableHeight" class="premium-table detail-logs-table">
|
||||
<el-table :data="detail.logs" class="premium-table detail-logs-table">
|
||||
<el-table-column label="级别" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="logTagType(row.level)">
|
||||
@@ -466,10 +512,6 @@ onMounted(loadDetailAndPreview);
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.detail-logs-table {
|
||||
min-width: 940px;
|
||||
}
|
||||
|
||||
.detail-card :deep(.el-card__body),
|
||||
.preview-card :deep(.el-card__body),
|
||||
.logs-card :deep(.el-card__body) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElNotification } from "element-plus";
|
||||
import { Bell, Connection, VideoCamera } from "@element-plus/icons-vue";
|
||||
import { ArrowLeft, ArrowRight, Bell, Connection, VideoCamera } from "@element-plus/icons-vue";
|
||||
import apiClient, {
|
||||
buildApiUrl,
|
||||
getApiErrorMessage,
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
RecordArtifactUploadBatchResult,
|
||||
RecordArtifactUploadItemResult,
|
||||
RecordSession,
|
||||
RecordSessionListResponse,
|
||||
RecordTask
|
||||
} from "@/types";
|
||||
import {
|
||||
@@ -29,7 +30,8 @@ import {
|
||||
outputFormatLabelMap,
|
||||
saveModeLabelMap,
|
||||
sessionStatusLabelMap,
|
||||
taskStatusLabelMap
|
||||
taskStatusLabelMap,
|
||||
uploadStatusLabelMap
|
||||
} from "@/types";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -42,7 +44,7 @@ const uploadingTaskId = ref<string | null>(null);
|
||||
const triggeringSegmentCompletedTaskId = ref<string | null>(null);
|
||||
const cleanupOperation = ref<CleanupOperation | null>(null);
|
||||
const deleteDialogVisible = ref(false);
|
||||
const deleteDialogMode = ref<"tasks" | "sessions" | "conditional-sessions" | "mixed" | "empty-sessions">("tasks");
|
||||
const deleteDialogMode = ref<"tasks" | "sessions" | "conditional-sessions" | "mixed" | "empty-sessions" | "missing-file-tasks">("tasks");
|
||||
const deleteDialogTaskIds = ref<string[]>([]);
|
||||
const deleteDialogSessionIds = ref<string[]>([]);
|
||||
|
||||
@@ -67,14 +69,26 @@ const selectedTaskMap = ref<Record<string, RecordTask>>({});
|
||||
const realtimeConnected = ref(false);
|
||||
const realtimeError = ref("");
|
||||
const { isMobile } = useViewport();
|
||||
const sessionSearch = ref("");
|
||||
const sessionState = ref("all");
|
||||
const currentPage = ref(1);
|
||||
const pageSize = computed(() => isMobile.value ? 12 : 20);
|
||||
const totalCount = ref(0);
|
||||
const totalSessionCount = ref(0);
|
||||
const activeSessionCount = ref(0);
|
||||
const totalTaskCount = ref(0);
|
||||
const totalDanmakuCount = ref(0);
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)));
|
||||
|
||||
let sessionsEventSource: EventSource | null = null;
|
||||
let cleanupPollTimer: number | null = null;
|
||||
let searchTimer: number | null = null;
|
||||
let activeRequest: AbortController | null = null;
|
||||
let requestSerial = 0;
|
||||
let appMain: HTMLElement | null = null;
|
||||
|
||||
const selectedTasks = computed(() => Object.values(selectedTaskMap.value));
|
||||
const activeSessionCount = computed(() => sessions.value.filter((item) => isActiveStatus(item.status)).length);
|
||||
const totalTaskCount = computed(() => sessions.value.reduce((sum, item) => sum + item.tasks.length, 0));
|
||||
const totalDanmakuCount = computed(() => sessions.value.reduce((sum, item) => sum + item.totalDanmakuMessageCount, 0));
|
||||
const filteredSessions = computed(() => sessions.value);
|
||||
const selectedSessionCount = computed(() => selectedSessionIds.value.length);
|
||||
const mixedSelectionLabel = computed(() => {
|
||||
const parts = [];
|
||||
@@ -174,6 +188,10 @@ const deleteDialogEyebrow = computed(() => {
|
||||
return "空闲会话清理";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||
return "无文件分片清理";
|
||||
}
|
||||
|
||||
return "删除确认";
|
||||
});
|
||||
const deleteDialogTitle = computed(() => {
|
||||
@@ -193,6 +211,10 @@ const deleteDialogTitle = computed(() => {
|
||||
return "清理无分片会话";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||
return "清理无文件分片";
|
||||
}
|
||||
|
||||
return "删除分片任务";
|
||||
});
|
||||
const deleteDialogLead = computed(() => {
|
||||
@@ -212,6 +234,10 @@ const deleteDialogLead = computed(() => {
|
||||
return `将自动找出所有没有任何分片任务的录制会话并批量删除。你也可以选择同时删除可能残留的本地文件。`;
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||
return `将自动找出所有视频文件已丢失的分片任务并批量删除(不限会话,只删命中的分片本身)。删除后若某个会话下不再有任何分片,会话也会一并清理;你也可以选择同时清理残留的弹幕 XML 文件。`;
|
||||
}
|
||||
|
||||
return `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`;
|
||||
});
|
||||
const deleteDialogNote = computed(() => {
|
||||
@@ -227,6 +253,10 @@ const deleteDialogNote = computed(() => {
|
||||
return "仅清理没有任何关联分片的空会话,不影响有录制产物的会话。";
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === "missing-file-tasks") {
|
||||
return "以分片为单位判定:仅当分片的视频文件在磁盘上不存在时才会删除。正在录制或处理中的分片会自动跳过,有视频文件的分片不受影响。";
|
||||
}
|
||||
|
||||
return "记录加文件会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。";
|
||||
});
|
||||
function isActiveStatus(status: number) {
|
||||
@@ -268,23 +298,28 @@ function applySessionsSnapshot(
|
||||
resetSelection?: boolean;
|
||||
}
|
||||
) {
|
||||
const nextSessionIds = new Set(data.map((item) => item.id));
|
||||
const previousById = new Map(sessions.value.map((item) => [item.id, item]));
|
||||
const mergedData = data.map((item) => {
|
||||
const previous = previousById.get(item.id);
|
||||
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
|
||||
});
|
||||
const nextSessionIds = new Set(mergedData.map((item) => item.id));
|
||||
const nextTaskMap = new Map<string, RecordTask>();
|
||||
|
||||
data.forEach((session) => {
|
||||
mergedData.forEach((session) => {
|
||||
session.tasks.forEach((task) => {
|
||||
nextTaskMap.set(task.id, task);
|
||||
});
|
||||
});
|
||||
|
||||
sessions.value = data;
|
||||
sessions.value = mergedData;
|
||||
|
||||
if (options?.resetPanels || activeSessionPanels.value.length === 0) {
|
||||
activeSessionPanels.value = data.slice(0, 4).map((item) => item.id);
|
||||
activeSessionPanels.value = mergedData.slice(0, 4).map((item) => item.id);
|
||||
} else {
|
||||
activeSessionPanels.value = activeSessionPanels.value.filter((sessionId) => nextSessionIds.has(sessionId));
|
||||
if (activeSessionPanels.value.length === 0 && data.length > 0) {
|
||||
activeSessionPanels.value = data.slice(0, 4).map((item) => item.id);
|
||||
if (activeSessionPanels.value.length === 0 && mergedData.length > 0) {
|
||||
activeSessionPanels.value = mergedData.slice(0, 4).map((item) => item.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,30 +341,109 @@ function applySessionsSnapshot(
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSessions(options?: { resetPanels?: boolean; resetSelection?: boolean }) {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
async function loadSessions(options: {
|
||||
resetPanels?: boolean;
|
||||
resetSelection?: boolean;
|
||||
background?: boolean;
|
||||
preserveScroll?: boolean;
|
||||
cancelPrevious?: boolean;
|
||||
} = {}) {
|
||||
if (activeRequest && !options.cancelPrevious) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeRequest?.abort();
|
||||
const controller = new AbortController();
|
||||
activeRequest = controller;
|
||||
const serial = ++requestSerial;
|
||||
const background = options.background === true;
|
||||
const scrollTop = appMain?.scrollTop ?? 0;
|
||||
if (!background) {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<RecordSession[]>("/record-sessions");
|
||||
applySessionsSnapshot(data, {
|
||||
const params: Record<string, string | number> = {
|
||||
skip: (currentPage.value - 1) * pageSize.value,
|
||||
take: pageSize.value,
|
||||
state: sessionState.value
|
||||
};
|
||||
const search = sessionSearch.value.trim();
|
||||
if (search) {
|
||||
params.search = search;
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<RecordSessionListResponse>("/record-sessions/page", {
|
||||
params,
|
||||
signal: controller.signal
|
||||
});
|
||||
if (serial !== requestSerial) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(data.totalCount / pageSize.value));
|
||||
if (currentPage.value > maxPage) {
|
||||
currentPage.value = maxPage;
|
||||
queueMicrotask(() => void loadSessions({ ...options, cancelPrevious: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
applySessionsSnapshot(data.items, {
|
||||
resetPanels: options?.resetPanels ?? true,
|
||||
resetSelection: options?.resetSelection ?? true
|
||||
});
|
||||
totalCount.value = data.totalCount;
|
||||
totalSessionCount.value = data.totalSessionCount;
|
||||
activeSessionCount.value = data.activeSessionCount;
|
||||
totalTaskCount.value = data.totalTaskCount;
|
||||
totalDanmakuCount.value = data.totalDanmakuCount;
|
||||
markBackendAvailable();
|
||||
await nextTick();
|
||||
if (options.preserveScroll && appMain) {
|
||||
appMain.scrollTop = scrollTop;
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
loadError.value = getApiErrorMessage(error, "录制会话加载失败,请稍后重试。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (serial === requestSerial) {
|
||||
activeRequest = null;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page;
|
||||
void loadSessions({ cancelPrevious: true, resetPanels: true, resetSelection: true }).then(() => {
|
||||
document.querySelector(".sessions-card")?.scrollIntoView({ block: "start" });
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleSearchReload() {
|
||||
if (searchTimer !== null) {
|
||||
window.clearTimeout(searchTimer);
|
||||
}
|
||||
|
||||
searchTimer = window.setTimeout(() => {
|
||||
currentPage.value = 1;
|
||||
void loadSessions({ cancelPrevious: true, resetPanels: true, resetSelection: true });
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function connectRealtimeUpdates() {
|
||||
if (typeof window === "undefined" || typeof EventSource === "undefined") {
|
||||
realtimeError.value = "当前浏览器不支持实时更新,仍可手动刷新列表。";
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("live-recorder-token");
|
||||
if (!token) {
|
||||
realtimeError.value = "未检测到登录凭证,实时更新未启用。";
|
||||
@@ -351,18 +465,13 @@ function connectRealtimeUpdates() {
|
||||
markBackendAvailable();
|
||||
};
|
||||
|
||||
sessionsEventSource.addEventListener("sessions", (event) => {
|
||||
try {
|
||||
const nextSessions = JSON.parse((event as MessageEvent<string>).data) as RecordSession[];
|
||||
applySessionsSnapshot(nextSessions, { resetPanels: false, resetSelection: false });
|
||||
realtimeConnected.value = true;
|
||||
realtimeError.value = "";
|
||||
loadError.value = "";
|
||||
markBackendAvailable();
|
||||
} catch {
|
||||
realtimeConnected.value = false;
|
||||
realtimeError.value = "实时更新数据解析失败,已保留手动刷新入口。";
|
||||
}
|
||||
sessionsEventSource.addEventListener("refresh", () => {
|
||||
void loadSessions({
|
||||
background: true,
|
||||
preserveScroll: true,
|
||||
resetPanels: false,
|
||||
resetSelection: false
|
||||
});
|
||||
});
|
||||
|
||||
sessionsEventSource.onerror = () => {
|
||||
@@ -381,6 +490,20 @@ function closeRealtimeUpdates() {
|
||||
sessionsEventSource = null;
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "visible") {
|
||||
void loadSessions({
|
||||
background: true,
|
||||
preserveScroll: true,
|
||||
resetPanels: false,
|
||||
resetSelection: false
|
||||
}).finally(connectRealtimeUpdates);
|
||||
return;
|
||||
}
|
||||
|
||||
closeRealtimeUpdates();
|
||||
}
|
||||
|
||||
function persistCleanupOperationId(id: string | null) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
@@ -499,8 +622,9 @@ async function uploadSession(session: RecordSession) {
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>(`/record-sessions/${session.id}/upload`);
|
||||
const queued = data.items.some(item => item.uploadStatus === 4 || item.uploadStatus === 5);
|
||||
ElMessage[data.failedCount === 0 ? "success" : "warning"](
|
||||
`会话上传完成:成功 ${data.successCount},失败 ${data.failedCount}。`
|
||||
`${queued ? "会话上传已加入队列" : "会话上传完成"}:已受理 ${data.successCount},失败 ${data.failedCount}。`
|
||||
);
|
||||
await loadSessions({ resetPanels: false, resetSelection: false });
|
||||
} catch (error) {
|
||||
@@ -572,6 +696,13 @@ function openDeleteEmptySessionsDialog() {
|
||||
deleteDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openDeleteMissingFileTasksDialog() {
|
||||
deleteDialogMode.value = "missing-file-tasks";
|
||||
deleteDialogSessionIds.value = [];
|
||||
deleteDialogTaskIds.value = [];
|
||||
deleteDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function confirmConditionalDelete() {
|
||||
conditionalDialogVisible.value = false;
|
||||
deleteDialogMode.value = "conditional-sessions";
|
||||
@@ -642,6 +773,7 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
const deletingSessions = currentMode === "sessions";
|
||||
const deletingConditionalSessions = currentMode === "conditional-sessions";
|
||||
const deletingEmptySessions = currentMode === "empty-sessions";
|
||||
const deletingMissingFileTasks = currentMode === "missing-file-tasks";
|
||||
const deletingMixed = currentMode === "mixed";
|
||||
const hasSelection = deletingMixed
|
||||
? deleteDialogSessionIds.value.length > 0 || deleteDialogTaskIds.value.length > 0
|
||||
@@ -649,7 +781,7 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
? deleteDialogSessionIds.value.length > 0
|
||||
: deleteDialogTaskIds.value.length > 0;
|
||||
|
||||
if (!deletingConditionalSessions && !deletingEmptySessions && !hasSelection) {
|
||||
if (!deletingConditionalSessions && !deletingEmptySessions && !deletingMissingFileTasks && !hasSelection) {
|
||||
closeDeleteDialog();
|
||||
return;
|
||||
}
|
||||
@@ -685,6 +817,11 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
deleteFiles
|
||||
});
|
||||
createdCleanupOperation = data;
|
||||
} else if (deletingMissingFileTasks) {
|
||||
const { data } = await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete-missing-files", {
|
||||
deleteFiles
|
||||
});
|
||||
deletedTaskResult = data;
|
||||
} else if (deletingMixed) {
|
||||
if (deleteDialogTaskIds.value.length > 0) {
|
||||
const { data } = await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
|
||||
@@ -723,7 +860,17 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
deleteDialogVisible.value = false;
|
||||
resetDeleteDialogState();
|
||||
|
||||
if (deleteDialogMode.value === "tasks") {
|
||||
if (currentMode === "missing-file-tasks") {
|
||||
ElMessage.success(
|
||||
deletedTaskResult
|
||||
? `已清理 ${deletedTaskResult.deletedTaskIds.length} 个无文件分片。`
|
||||
: "未发现可清理的无文件分片。"
|
||||
);
|
||||
await loadSessions({ resetPanels: false, resetSelection: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentMode === "tasks") {
|
||||
ElMessage.success(
|
||||
deletedTaskResult ? `已删除 ${deletedTaskResult.deletedTaskIds.length} 个分片任务。` : "已删除分片任务。"
|
||||
);
|
||||
@@ -737,11 +884,7 @@ async function confirmDelete(deleteFiles: boolean) {
|
||||
return;
|
||||
}
|
||||
|
||||
ElMessage.success(
|
||||
currentMode === "tasks"
|
||||
? "已删除分片任务。"
|
||||
: "后台清理任务已创建,页面会自动轮询进度。"
|
||||
);
|
||||
ElMessage.success("后台清理任务已创建,页面会自动轮询进度。");
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
@@ -803,8 +946,20 @@ function formatFileSize(bytes?: number) {
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
watch(sessionSearch, scheduleSearchReload);
|
||||
watch(sessionState, () => {
|
||||
currentPage.value = 1;
|
||||
void loadSessions({ cancelPrevious: true, resetPanels: true, resetSelection: true });
|
||||
});
|
||||
watch(pageSize, () => {
|
||||
currentPage.value = 1;
|
||||
void loadSessions({ cancelPrevious: true, resetPanels: true, resetSelection: true });
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSessions();
|
||||
appMain = document.querySelector<HTMLElement>(".app-main");
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
await loadSessions({ cancelPrevious: true });
|
||||
await restoreCleanupTracking();
|
||||
connectRealtimeUpdates();
|
||||
});
|
||||
@@ -812,6 +967,11 @@ onMounted(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
closeRealtimeUpdates();
|
||||
stopCleanupPolling();
|
||||
activeRequest?.abort();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
if (searchTimer !== null) {
|
||||
window.clearTimeout(searchTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -881,29 +1041,12 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div class="record-ops-grid">
|
||||
<div class="stats-grid">
|
||||
<MetricCard label="录制会话" :value="sessions.length" description="按整场直播聚合的录制会话数" :icon="VideoCamera" />
|
||||
<MetricCard label="录制会话" :value="totalSessionCount" description="按整场直播聚合的录制会话数" :icon="VideoCamera" />
|
||||
<MetricCard label="活跃会话" :value="activeSessionCount" description="启动中、录制中、停止中、处理中" :icon="Connection" />
|
||||
<MetricCard label="分片任务" :value="totalTaskCount" description="包含单文件模式下的唯一任务" :icon="Bell" />
|
||||
<MetricCard label="弹幕事件" :value="totalDanmakuCount" description="累计写入 XML 的事件总数" :icon="Bell" />
|
||||
</div>
|
||||
|
||||
<div class="record-feature-strip">
|
||||
<article class="record-feature-card">
|
||||
<div class="record-feature-card__eyebrow">能力说明</div>
|
||||
<div class="record-feature-card__title">开播自动录制</div>
|
||||
<p class="record-feature-card__description">继续复用后端轮询、自动开录和活动会话保护逻辑,不新增任何前端假状态。</p>
|
||||
</article>
|
||||
<article class="record-feature-card">
|
||||
<div class="record-feature-card__eyebrow">能力说明</div>
|
||||
<div class="record-feature-card__title">分片后处理</div>
|
||||
<p class="record-feature-card__description">保留现有转码、分片完成事件和实时进度展示,聚焦运维可读性。</p>
|
||||
</article>
|
||||
<article class="record-feature-card">
|
||||
<div class="record-feature-card__eyebrow">能力说明</div>
|
||||
<div class="record-feature-card__title">上传归档</div>
|
||||
<p class="record-feature-card__description">继续调用真实上传接口,空数据时显示空状态而不是伪造归档数量。</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card sessions-card" shadow="never">
|
||||
@@ -925,34 +1068,50 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
删除已选会话{{ selectedSessionCount > 0 ? `(${selectedSessionCount})` : "" }}
|
||||
</el-button>
|
||||
<el-button plain :loading="deleting" @click="openConditionalDeleteDialog">
|
||||
按条件清理
|
||||
</el-button>
|
||||
<el-button plain :loading="deleting" @click="openDeleteEmptySessionsDialog">
|
||||
清理无分片会话
|
||||
</el-button>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button plain :loading="deleting">清理工具</el-button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item @click="openConditionalDeleteDialog">按条件清理</el-dropdown-item>
|
||||
<el-dropdown-item @click="openDeleteEmptySessionsDialog">清理无分片会话</el-dropdown-item>
|
||||
<el-dropdown-item @click="openDeleteMissingFileTasksDialog">清理无文件分片</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-filterbar">
|
||||
<el-input v-model="sessionSearch" clearable placeholder="搜索直播间、Room ID、会话、任务或文件路径" />
|
||||
<el-select v-model="sessionState" aria-label="录制会话状态筛选">
|
||||
<el-option label="全部状态" value="all" />
|
||||
<el-option label="进行中" value="active" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="失败" value="failed" />
|
||||
<el-option label="已停止" value="stopped" />
|
||||
</el-select>
|
||||
<span class="list-filterbar__count">当前页 {{ filteredSessions.length }} / 共 {{ totalCount }}</span>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="!loading && sessions.length === 0"
|
||||
title="暂无录制任务"
|
||||
description="当前筛选条件下没有可展示内容"
|
||||
v-if="!loading && filteredSessions.length === 0"
|
||||
:title="totalSessionCount === 0 ? '暂无录制任务' : '没有匹配的录制会话'"
|
||||
:description="totalSessionCount === 0 ? '录制开始后,会话会显示在这里。' : '请调整搜索词或状态筛选。'"
|
||||
action-text="刷新列表"
|
||||
@action="loadSessions()"
|
||||
/>
|
||||
|
||||
<div v-else-if="isMobile" class="data-card-list session-card-list">
|
||||
<article v-for="session in sessions" :key="session.id" class="data-card session-card">
|
||||
<article v-for="session in filteredSessions" :key="session.id" class="data-card session-card">
|
||||
<div class="data-card__header">
|
||||
<div>
|
||||
<div class="data-card__title">{{ session.liveRoomTitle }}</div>
|
||||
<div class="data-card__subtitle monospace">{{ session.roomId }}</div>
|
||||
<div class="session-card__anchor">主播 · {{ session.anchorName || "未知主播" }}</div>
|
||||
<div class="data-card__subtitle monospace">Room ID · {{ session.roomId }}</div>
|
||||
</div>
|
||||
<StatusBadge :label="sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
|
||||
<StatusBadge :label="session.isRecovering ? '恢复中' : sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
|
||||
</div>
|
||||
|
||||
<div class="badge-row">
|
||||
<span v-if="session.isRecovering" class="info-pill">自动重试中</span>
|
||||
<span class="info-pill">{{ saveModeLabelMap[session.saveMode] }}</span>
|
||||
<span class="info-pill">{{ outputFormatLabelMap[session.outputFormat] }}</span>
|
||||
<span class="info-pill">分片 {{ session.segmentCount }}</span>
|
||||
@@ -979,23 +1138,15 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="data-card__actions">
|
||||
<el-button size="small" @click.stop="openSessionDetail(session)">查看会话</el-button>
|
||||
<el-button size="small" :loading="uploadingSessionId === session.id" @click.stop="uploadSession(session)">
|
||||
上传会话
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isActiveStatus(session.status)"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="stoppingSessionId === session.id"
|
||||
@click.stop="stopSession(session)"
|
||||
>
|
||||
停止会话
|
||||
</el-button>
|
||||
<el-button size="small" type="danger" plain :loading="deleting" @click.stop="openDeleteSessionDialog([session.id])">
|
||||
删除会话
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" @click.stop="openSessionDetail(session)">查看</el-button>
|
||||
<el-dropdown trigger="click" @click.stop>
|
||||
<el-button size="small">更多</el-button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item :disabled="uploadingSessionId === session.id" @click="uploadSession(session)">上传会话</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isActiveStatus(session.status)" :disabled="stoppingSessionId === session.id" @click="stopSession(session)">停止会话</el-dropdown-item>
|
||||
<el-dropdown-item divided class="danger-menu-item" :disabled="deleting" @click="openDeleteSessionDialog([session.id])">删除会话</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="session-card__tasks">
|
||||
@@ -1005,7 +1156,15 @@ onBeforeUnmount(() => {
|
||||
<strong class="monospace">#{{ task.segmentIndex }}</strong>
|
||||
<div class="cell-subtitle">{{ formatDate(task.startedAt || task.createdAt) }}</div>
|
||||
</div>
|
||||
<StatusBadge :label="taskStatusLabelMap[task.status]" :status="task.status" context="task" />
|
||||
<div class="session-task-card__badges">
|
||||
<StatusBadge :label="taskStatusLabelMap[task.status]" :status="task.status" context="task" />
|
||||
<StatusBadge
|
||||
v-if="task.uploadStatus !== undefined && task.uploadStatus !== null"
|
||||
:label="uploadStatusLabelMap[task.uploadStatus] ?? String(task.uploadStatus)"
|
||||
:status="task.uploadStatus"
|
||||
context="upload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasPostProcess(task)" class="session-task-card__progress">
|
||||
@@ -1036,33 +1195,15 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="data-card__actions">
|
||||
<el-button size="small" @click="openDetail(task)">详情</el-button>
|
||||
<el-button
|
||||
v-if="canTriggerSegmentCompleted(task)"
|
||||
size="small"
|
||||
:loading="triggeringSegmentCompletedTaskId === task.id"
|
||||
@click="triggerSegmentCompleted(task)"
|
||||
>
|
||||
触发完成事件
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDeletableTask(task)"
|
||||
size="small"
|
||||
:loading="uploadingTaskId === task.id"
|
||||
@click="uploadTask(task)"
|
||||
>
|
||||
上传
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDeletableTask(task)"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="deleting"
|
||||
@click="openDeleteTaskDialog([task.id])"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" @click="openDetail(task)">查看</el-button>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button size="small">更多</el-button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item v-if="canTriggerSegmentCompleted(task)" :disabled="triggeringSegmentCompletedTaskId === task.id" @click="triggerSegmentCompleted(task)">触发完成事件</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isDeletableTask(task)" :disabled="uploadingTaskId === task.id" @click="uploadTask(task)">上传</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isDeletableTask(task)" divided class="danger-menu-item" :disabled="deleting" @click="openDeleteTaskDialog([task.id])">删除</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -1071,7 +1212,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<el-collapse v-else v-model="activeSessionPanels" class="session-collapse">
|
||||
<el-collapse-item
|
||||
v-for="session in sessions"
|
||||
v-for="session in filteredSessions"
|
||||
:key="session.id"
|
||||
:name="session.id"
|
||||
class="session-panel"
|
||||
@@ -1086,6 +1227,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="session-title__main">
|
||||
<div class="session-title__name">{{ session.liveRoomTitle }}</div>
|
||||
<div class="session-title__anchor">主播 · {{ session.anchorName || "未知主播" }}</div>
|
||||
<div class="session-title__meta">
|
||||
<span class="monospace">{{ session.roomId }}</span>
|
||||
<span>会话 {{ session.id.slice(0, 8) }}</span>
|
||||
@@ -1093,16 +1235,28 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="session-title__stats">
|
||||
<StatusBadge :label="sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
|
||||
<StatusBadge :label="session.isRecovering ? '恢复中' : sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
|
||||
<span>{{ saveModeLabelMap[session.saveMode] }}</span>
|
||||
<span>{{ outputFormatLabelMap[session.outputFormat] }}</span>
|
||||
<span>分片 {{ session.segmentCount }}</span>
|
||||
<span v-if="session.uploadedSegmentCount > 0 || session.failedUploadSegmentCount > 0 || session.uploadingSegmentCount > 0">
|
||||
上传 {{ session.uploadedSegmentCount }}/{{ session.segmentCount }}
|
||||
</span>
|
||||
<span>{{ formatFileSize(session.totalFileSizeBytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="session-body">
|
||||
<el-alert
|
||||
v-if="session.isRecovering"
|
||||
class="session-recovery-alert"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="录制连接正在自动恢复"
|
||||
:description="session.recoveryReason || '系统会在直播仍在线时持续重试,并保留当前会话。'"
|
||||
/>
|
||||
<div class="session-summary">
|
||||
<div class="session-summary__item">
|
||||
<span class="session-summary__label">开始时间</span>
|
||||
@@ -1123,29 +1277,15 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="session-actions">
|
||||
<el-button @click.stop="openSessionDetail(session)">
|
||||
查看会话
|
||||
</el-button>
|
||||
<el-button :loading="uploadingSessionId === session.id" @click.stop="uploadSession(session)">
|
||||
上传会话
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isActiveStatus(session.status)"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="stoppingSessionId === session.id"
|
||||
@click.stop="stopSession(session)"
|
||||
>
|
||||
停止会话
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
plain
|
||||
:loading="deleting"
|
||||
@click.stop="openDeleteSessionDialog([session.id])"
|
||||
>
|
||||
删除会话
|
||||
</el-button>
|
||||
<el-button type="primary" @click.stop="openSessionDetail(session)">查看会话</el-button>
|
||||
<el-dropdown trigger="click" @click.stop>
|
||||
<el-button>更多操作</el-button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item :disabled="uploadingSessionId === session.id" @click="uploadSession(session)">上传会话</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isActiveStatus(session.status)" :disabled="stoppingSessionId === session.id" @click="stopSession(session)">停止会话</el-dropdown-item>
|
||||
<el-dropdown-item divided class="danger-menu-item" :disabled="deleting" @click="openDeleteSessionDialog([session.id])">删除会话</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<div class="tasks-table-shell">
|
||||
@@ -1183,13 +1323,25 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="上传" width="110">
|
||||
<template #default="{ row }">
|
||||
<StatusBadge
|
||||
v-if="row.uploadStatus !== undefined && row.uploadStatus !== null"
|
||||
:label="uploadStatusLabelMap[row.uploadStatus] ?? String(row.uploadStatus)"
|
||||
:status="row.uploadStatus"
|
||||
context="upload"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="清晰度" width="120">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatQualityLabel(row.preferredQuality) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="输出文件" min-width="320">
|
||||
<el-table-column label="输出文件" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="monospace path-text">{{ row.outputFilePath || "-" }}</div>
|
||||
</template>
|
||||
@@ -1207,46 +1359,19 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="320">
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="task-actions-cell">
|
||||
<el-button size="small" @click="openDetail(row)">详情</el-button>
|
||||
<el-button
|
||||
v-if="canTriggerSegmentCompleted(row)"
|
||||
size="small"
|
||||
:loading="triggeringSegmentCompletedTaskId === row.id"
|
||||
@click="triggerSegmentCompleted(row)"
|
||||
>
|
||||
触发事件
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDeletableTask(row)"
|
||||
size="small"
|
||||
:loading="uploadingTaskId === row.id"
|
||||
@click="uploadTask(row)"
|
||||
>
|
||||
上传
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isActiveStatus(row.status) && session.activeSegmentIndex === row.segmentIndex"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="stoppingSessionId === session.id"
|
||||
@click="stopSession(session)"
|
||||
>
|
||||
停止
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isDeletableTask(row)"
|
||||
size="small"
|
||||
type="danger"
|
||||
plain
|
||||
:loading="deleting"
|
||||
@click="openDeleteTaskDialog([row.id])"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" @click="openDetail(row)">查看</el-button>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button size="small">更多</el-button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item v-if="canTriggerSegmentCompleted(row)" :disabled="triggeringSegmentCompletedTaskId === row.id" @click="triggerSegmentCompleted(row)">触发完成事件</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isDeletableTask(row)" :disabled="uploadingTaskId === row.id" @click="uploadTask(row)">上传</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isActiveStatus(row.status) && session.activeSegmentIndex === row.segmentIndex" :disabled="stoppingSessionId === session.id" @click="stopSession(session)">停止会话</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isDeletableTask(row)" divided class="danger-menu-item" :disabled="deleting" @click="openDeleteTaskDialog([row.id])">删除</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -1255,6 +1380,34 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
|
||||
<div v-if="totalCount > pageSize" class="session-pagination">
|
||||
<div v-if="isMobile" class="session-pagination__mobile" aria-label="会话分页">
|
||||
<el-button
|
||||
class="btn-prev"
|
||||
:icon="ArrowLeft"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="handlePageChange(currentPage - 1)"
|
||||
>上一页</el-button>
|
||||
<span class="session-pagination__position" aria-live="polite">
|
||||
<strong>{{ currentPage }}</strong><span>/ {{ totalPages }}</span>
|
||||
</span>
|
||||
<el-button
|
||||
class="btn-next"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="handlePageChange(currentPage + 1)"
|
||||
>下一页 <el-icon class="el-icon--right"><ArrowRight /></el-icon></el-button>
|
||||
</div>
|
||||
<el-pagination
|
||||
v-else
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-count="totalPages"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog
|
||||
@@ -1382,13 +1535,11 @@ onBeforeUnmount(() => {
|
||||
.record-feature-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
|
||||
var(--surface);
|
||||
box-shadow: var(--shadow-soft);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.record-feature-card__eyebrow {
|
||||
@@ -1487,6 +1638,46 @@ onBeforeUnmount(() => {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.session-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.session-pagination__mobile {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-pagination__mobile :deep(.el-button) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.session-pagination__position {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 54px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-pagination__position strong {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -1614,6 +1805,14 @@ onBeforeUnmount(() => {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.session-card__anchor,
|
||||
.session-title__anchor {
|
||||
margin-top: 4px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.session-card__tasks {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1635,6 +1834,13 @@ onBeforeUnmount(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.session-task-card__badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-task-card__progress {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -1642,7 +1848,6 @@ onBeforeUnmount(() => {
|
||||
|
||||
.nested-table {
|
||||
border-radius: 12px;
|
||||
min-width: 1220px;
|
||||
}
|
||||
|
||||
.nested-table :deep(.el-table__cell) {
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import axios from "axios";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||||
import StatusBadge from "@/components/ui/StatusBadge.vue";
|
||||
import StorageCapacity from "@/components/ui/StorageCapacity.vue";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import type {
|
||||
RecoverableFinalization,
|
||||
RecoverableLiveRoom,
|
||||
RecordingFailureItem,
|
||||
RecordingFailureListResponse,
|
||||
RecoveryActionResult,
|
||||
RecoveryOverview
|
||||
} from "@/types";
|
||||
import { autoStartDecisionLabelMap, taskStatusLabelMap } from "@/types";
|
||||
import { RefreshRight, VideoCamera, WarningFilled } from "@element-plus/icons-vue";
|
||||
import { autoStartDecisionLabelMap, formatAutoStartDecisionSummary, taskStatusLabelMap } from "@/types";
|
||||
import { RefreshRight, VideoCamera } from "@element-plus/icons-vue";
|
||||
|
||||
const loading = ref(false);
|
||||
const retryAllLoading = ref(false);
|
||||
@@ -22,11 +26,29 @@ const resumeAllLoading = ref(false);
|
||||
const runningKey = ref("");
|
||||
const loadError = ref("");
|
||||
const overview = ref<RecoveryOverview | null>(null);
|
||||
const recordingFailures = ref<RecordingFailureItem[]>([]);
|
||||
const recordingFailureTotal = ref(0);
|
||||
const recordingFailurePage = ref(1);
|
||||
const recordingFailureKind = ref("");
|
||||
const recordingFailureLoading = ref(false);
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
const storage = computed(() => overview.value?.storage ?? null);
|
||||
const liveRooms = computed(() => overview.value?.liveRooms ?? []);
|
||||
const finalizations = computed(() => overview.value?.finalizations ?? []);
|
||||
const mergedArtifacts = computed(() => overview.value?.mergedArtifacts ?? []);
|
||||
const recordingFailurePageSize = computed(() => isMobile.value ? 8 : 20);
|
||||
const recordingFailureKinds = [
|
||||
{ label: "全部原因", value: "" },
|
||||
{ label: "异常退出分片", value: "ReadableFragment" },
|
||||
{ label: "短分片", value: "TooShort" },
|
||||
{ label: "媒体不可读", value: "UnreadableMedia" },
|
||||
{ label: "封装或收尾失败", value: "FinalizationFailed" },
|
||||
{ label: "文件缺失", value: "MissingMedia" },
|
||||
{ label: "直播离线", value: "SourceOffline" },
|
||||
{ label: "未取得播放地址", value: "StreamUrlUnavailable" },
|
||||
{ label: "待检测", value: "Unknown" }
|
||||
];
|
||||
|
||||
async function loadOverview() {
|
||||
loading.value = true;
|
||||
@@ -45,6 +67,80 @@ async function loadOverview() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecordingFailures() {
|
||||
recordingFailureLoading.value = true;
|
||||
try {
|
||||
const { data } = await apiClient.get<RecordingFailureListResponse>("/recovery/recording-failures", {
|
||||
params: {
|
||||
failureKind: recordingFailureKind.value || undefined,
|
||||
skip: (recordingFailurePage.value - 1) * recordingFailurePageSize.value,
|
||||
take: recordingFailurePageSize.value
|
||||
}
|
||||
});
|
||||
recordingFailures.value = data.items;
|
||||
recordingFailureTotal.value = data.totalCount;
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "录制失败产物加载失败。"));
|
||||
} finally {
|
||||
recordingFailureLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRecordingFailureFilter() {
|
||||
recordingFailurePage.value = 1;
|
||||
void loadRecordingFailures();
|
||||
}
|
||||
|
||||
function handleRecordingFailurePage(page: number) {
|
||||
recordingFailurePage.value = page;
|
||||
void loadRecordingFailures();
|
||||
}
|
||||
|
||||
async function acceptRecordingArtifact(item: RecordingFailureItem) {
|
||||
let confirmShortArtifact = false;
|
||||
if ((item.durationSeconds ?? 0) < 5) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`该分片只有 ${formatDuration(item.durationSeconds)}。确认后它会转为待上传,但不会自动上传。`,
|
||||
"确认认领短分片",
|
||||
{ confirmButtonText: "确认认领", cancelButtonText: "取消", type: "warning" }
|
||||
);
|
||||
confirmShortArtifact = true;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
runningKey.value = `accept:${item.recordTaskId}`;
|
||||
try {
|
||||
const { data } = await apiClient.post<RecoveryActionResult>(
|
||||
`/recovery/recording-failures/${item.recordTaskId}/accept`,
|
||||
{ confirmShortArtifact }
|
||||
);
|
||||
showActionResult(data);
|
||||
await Promise.all([loadOverview(), loadRecordingFailures()]);
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "分片校验未通过。"));
|
||||
} finally {
|
||||
runningKey.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function repairRecordingArtifact(item: RecordingFailureItem) {
|
||||
runningKey.value = `repair:${item.recordTaskId}`;
|
||||
try {
|
||||
const { data } = await apiClient.post<RecoveryActionResult>(
|
||||
`/recovery/recording-failures/${item.recordTaskId}/repair`
|
||||
);
|
||||
showActionResult(data);
|
||||
await loadRecordingFailures();
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "无法启动非破坏修复。"));
|
||||
} finally {
|
||||
runningKey.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function retryLiveRoom(liveRoomId: string) {
|
||||
runningKey.value = `retry:${liveRoomId}`;
|
||||
|
||||
@@ -113,8 +209,8 @@ function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) {
|
||||
function formatBytes(bytes?: number) {
|
||||
if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
@@ -129,6 +225,13 @@ function formatBytes(bytes: number) {
|
||||
return `${value.toFixed(value >= 100 || index === 0 ? 0 : value >= 10 ? 1 : 2)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number) {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds)) {
|
||||
return "未知时长";
|
||||
}
|
||||
return seconds < 60 ? `${seconds.toFixed(seconds < 10 ? 1 : 0)} 秒` : `${(seconds / 60).toFixed(1)} 分钟`;
|
||||
}
|
||||
|
||||
function autoStartDecisionLabel(code?: string) {
|
||||
if (!code) {
|
||||
return "未记录";
|
||||
@@ -137,6 +240,10 @@ function autoStartDecisionLabel(code?: string) {
|
||||
return autoStartDecisionLabelMap[code] ?? code;
|
||||
}
|
||||
|
||||
function autoStartDecisionSummary(item: RecoverableLiveRoom) {
|
||||
return formatAutoStartDecisionSummary(item.lastAutoStartDecisionCode, item.lastAutoStartDecisionSummary);
|
||||
}
|
||||
|
||||
function autoStartDecisionTagType(code?: string) {
|
||||
if (code === "started") {
|
||||
return "success";
|
||||
@@ -166,7 +273,9 @@ function finalizationTitle(item: RecoverableFinalization) {
|
||||
return item.liveRoomTitle || item.roomId;
|
||||
}
|
||||
|
||||
onMounted(loadOverview);
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadOverview(), loadRecordingFailures()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -188,21 +297,202 @@ onMounted(loadOverview);
|
||||
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
|
||||
|
||||
<div class="stats-grid recovery-metrics" v-loading="loading">
|
||||
<MetricCard
|
||||
label="存储保护"
|
||||
:value="storage?.hasEnoughSpace ? '可恢复' : '受限'"
|
||||
:description="storage?.message || '暂无存储数据'"
|
||||
:icon="WarningFilled"
|
||||
/>
|
||||
<MetricCard label="可重试开录" :value="liveRooms.length" description="在线、启用中且没有活动会话的直播间" :icon="RefreshRight" />
|
||||
<MetricCard label="可恢复转码" :value="finalizations.length" description="等待继续或可手动补转码的 MP4 任务" :icon="VideoCamera" />
|
||||
<MetricCard
|
||||
label="剩余空间"
|
||||
:value="storage ? formatBytes(storage.availableBytes) : '--'"
|
||||
:description="`恢复阈值 ${storage ? formatBytes(storage.requiredBytes) : '--'}`"
|
||||
/>
|
||||
<MetricCard label="已收纳短片" :value="mergedArtifacts.length" description="已合并或转存到恢复目录的源分片" :icon="VideoCamera" />
|
||||
</div>
|
||||
|
||||
<el-card v-if="storage" class="surface-card" shadow="never">
|
||||
<StorageCapacity :status="storage" />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="mergedArtifacts.length > 0" class="surface-card table-card" shadow="never">
|
||||
<div class="toolbar-row">
|
||||
<div>
|
||||
<h3 class="section-title">短分片合并记录</h3>
|
||||
<p class="section-subtitle">源文件不会被覆盖;恢复目录和清单可用于审计或人工还原。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="data-card-list">
|
||||
<article v-for="row in mergedArtifacts" :key="row.sourceRecordTaskId" class="data-card">
|
||||
<div class="data-card__header">
|
||||
<div>
|
||||
<div class="data-card__title">源分片 {{ row.sourceRecordTaskId.slice(0, 8) }}</div>
|
||||
<div class="data-card__subtitle">{{ formatDuration(row.sourceDurationSeconds) }}</div>
|
||||
</div>
|
||||
<StatusBadge :label="row.mergedIntoRecordTaskId ? '已合并' : '仅恢复保留'" status="completed" />
|
||||
</div>
|
||||
<div class="data-card__grid">
|
||||
<div style="grid-column: 1 / -1;"><dt>合并结果</dt><dd class="monospace failure-path">{{ row.mergedVideoPath || "未生成独立结果" }}</dd></div>
|
||||
<div style="grid-column: 1 / -1;"><dt>恢复目录</dt><dd class="monospace failure-path">{{ row.recoveryDirectory || "-" }}</dd></div>
|
||||
<div style="grid-column: 1 / -1;"><dt>恢复清单</dt><dd class="monospace failure-path">{{ row.manifestPath || "-" }}</dd></div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<el-table v-else :data="mergedArtifacts" class="premium-table" table-layout="fixed" row-key="sourceRecordTaskId">
|
||||
<el-table-column label="源分片" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-title monospace">{{ row.sourceRecordTaskId.slice(0, 8) }}</div>
|
||||
<div class="cell-subtitle">{{ formatDuration(row.sourceDurationSeconds) }} · {{ formatDate(row.createdAt) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="合并结果" min-width="280">
|
||||
<template #default="{ row }"><div class="monospace failure-path">{{ row.mergedVideoPath || "仅恢复保留" }}</div></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="恢复目录 / 清单" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<div class="monospace failure-path">{{ row.recoveryDirectory || "-" }}</div>
|
||||
<div class="cell-subtitle monospace failure-path">{{ row.manifestPath || "未找到清单" }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card table-card" shadow="never">
|
||||
<div class="toolbar-row">
|
||||
<div>
|
||||
<h3 class="section-title">录制失败产物</h3>
|
||||
<p class="section-subtitle">先区分可用分片、可修复媒体和不可恢复记录;所有修复都会保留原文件。</p>
|
||||
</div>
|
||||
<div class="recovery-filter-actions">
|
||||
<el-select
|
||||
v-model="recordingFailureKind"
|
||||
aria-label="录制失败原因筛选"
|
||||
@change="handleRecordingFailureFilter"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in recordingFailureKinds"
|
||||
:key="option.value || 'all'"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button :loading="recordingFailureLoading" @click="loadRecordingFailures">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="!recordingFailureLoading && recordingFailures.length === 0"
|
||||
title="没有匹配的录制失败产物"
|
||||
description="当前分类下没有需要人工处理的文件。"
|
||||
action-text="刷新列表"
|
||||
@action="loadRecordingFailures"
|
||||
/>
|
||||
|
||||
<div v-else-if="isMobile" class="data-card-list" v-loading="recordingFailureLoading">
|
||||
<article v-for="row in recordingFailures" :key="row.recordTaskId" class="data-card failure-card">
|
||||
<div class="data-card__header">
|
||||
<div class="failure-card__identity">
|
||||
<div class="data-card__title">{{ row.liveRoomTitle }}</div>
|
||||
<div class="data-card__subtitle platform-line">
|
||||
<PlatformMark :name="row.platformName" /> · 分片 #{{ row.segmentIndex }}
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge :label="row.failureLabel" :status="row.isRepairing ? 'processing' : 'warning'" />
|
||||
</div>
|
||||
|
||||
<p class="failure-card__action">{{ row.recommendedAction }}</p>
|
||||
<div class="data-card__grid">
|
||||
<div><dt>媒体状态</dt><dd>{{ row.fileExists ? "本地文件存在" : "本地文件缺失" }}</dd></div>
|
||||
<div><dt>时长 / 大小</dt><dd>{{ formatDuration(row.durationSeconds) }} · {{ formatBytes(row.fileSizeBytes) }}</dd></div>
|
||||
<div v-if="row.filePath" style="grid-column: 1 / -1;"><dt>文件</dt><dd class="monospace failure-path">{{ row.filePath }}</dd></div>
|
||||
<div v-if="row.errorMessage" style="grid-column: 1 / -1;"><dt>原始错误</dt><dd class="failure-error">{{ row.errorMessage }}</dd></div>
|
||||
</div>
|
||||
|
||||
<div class="data-card__actions failure-actions">
|
||||
<el-button
|
||||
v-if="row.canAccept"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="runningKey === `accept:${row.recordTaskId}`"
|
||||
@click="acceptRecordingArtifact(row)"
|
||||
>确认有效</el-button>
|
||||
<el-button
|
||||
v-if="row.canRepair"
|
||||
size="small"
|
||||
:loading="runningKey === `repair:${row.recordTaskId}`"
|
||||
@click="repairRecordingArtifact(row)"
|
||||
>非破坏修复</el-button>
|
||||
<el-button
|
||||
v-if="row.canRetryRoom"
|
||||
size="small"
|
||||
:loading="runningKey === `retry:${row.liveRoomId}`"
|
||||
@click="retryLiveRoom(row.liveRoomId)"
|
||||
>重新开录</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-else
|
||||
:data="recordingFailures"
|
||||
v-loading="recordingFailureLoading"
|
||||
class="premium-table"
|
||||
table-layout="fixed"
|
||||
row-key="recordTaskId"
|
||||
>
|
||||
<el-table-column label="直播间 / 分片" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-title">{{ row.liveRoomTitle }}</div>
|
||||
<div class="cell-subtitle platform-line"><PlatformMark :name="row.platformName" /> · #{{ row.segmentIndex }}</div>
|
||||
<div class="cell-mono monospace">{{ row.roomId }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原因与建议" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<StatusBadge :label="row.failureLabel" :status="row.isRepairing ? 'processing' : 'warning'" />
|
||||
<div class="cell-subtitle failure-recommendation">{{ row.recommendedAction }}</div>
|
||||
<div v-if="row.errorMessage" class="failure-error line-clamp-2">{{ row.errorMessage }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本地产物" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="monospace failure-path">{{ row.filePath || "-" }}</div>
|
||||
<div class="cell-subtitle">{{ formatDuration(row.durationSeconds) }} · {{ formatBytes(row.fileSizeBytes) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="250" align="right">
|
||||
<template #default="{ row }">
|
||||
<div class="failure-actions failure-actions--desktop">
|
||||
<el-button
|
||||
v-if="row.canAccept"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="runningKey === `accept:${row.recordTaskId}`"
|
||||
@click="acceptRecordingArtifact(row)"
|
||||
>确认有效</el-button>
|
||||
<el-button
|
||||
v-if="row.canRepair"
|
||||
size="small"
|
||||
:loading="runningKey === `repair:${row.recordTaskId}`"
|
||||
@click="repairRecordingArtifact(row)"
|
||||
>修复</el-button>
|
||||
<el-button
|
||||
v-if="row.canRetryRoom"
|
||||
size="small"
|
||||
:loading="runningKey === `retry:${row.liveRoomId}`"
|
||||
@click="retryLiveRoom(row.liveRoomId)"
|
||||
>开录</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="recordingFailureTotal > recordingFailurePageSize" class="pagination-row">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:pager-count="isMobile ? 3 : 7"
|
||||
:page-size="recordingFailurePageSize"
|
||||
:total="recordingFailureTotal"
|
||||
:current-page="recordingFailurePage"
|
||||
@current-change="handleRecordingFailurePage"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="surface-card table-card" shadow="never">
|
||||
<div class="toolbar-row">
|
||||
<div>
|
||||
@@ -235,7 +525,7 @@ onMounted(loadOverview);
|
||||
<div class="data-card__title">{{ liveRoomTitle(row) }}</div>
|
||||
<div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div>
|
||||
</div>
|
||||
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
|
||||
<PlatformMark :name="row.platformName" />
|
||||
</div>
|
||||
|
||||
<div class="badge-row">
|
||||
@@ -254,7 +544,7 @@ onMounted(loadOverview);
|
||||
</div>
|
||||
<div>
|
||||
<dt>最近决策</dt>
|
||||
<dd>{{ row.lastAutoStartDecisionSummary || "暂无摘要" }}</dd>
|
||||
<dd>{{ autoStartDecisionSummary(row) }}</dd>
|
||||
</div>
|
||||
<div style="grid-column: 1 / -1;" v-if="row.lastAutoStartDecisionDetail">
|
||||
<dt>原因详情</dt>
|
||||
@@ -293,7 +583,7 @@ onMounted(loadOverview);
|
||||
|
||||
<el-table-column label="平台" width="120">
|
||||
<template #default="{ row }">
|
||||
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
|
||||
<PlatformMark :name="row.platformName" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -308,7 +598,7 @@ onMounted(loadOverview);
|
||||
/>
|
||||
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
|
||||
</div>
|
||||
<div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div>
|
||||
<div class="cell-subtitle">{{ autoStartDecisionSummary(row) }}</div>
|
||||
<div v-if="row.lastAutoStartDecisionDetail" class="decision-cell__detail">
|
||||
{{ row.lastAutoStartDecisionDetail }}
|
||||
</div>
|
||||
@@ -366,7 +656,7 @@ onMounted(loadOverview);
|
||||
<div class="data-card__header">
|
||||
<div>
|
||||
<div class="data-card__title">{{ finalizationTitle(row) }}</div>
|
||||
<div class="data-card__subtitle">{{ row.platformName }} · Segment #{{ row.segmentIndex }}</div>
|
||||
<div class="data-card__subtitle platform-line"><PlatformMark :name="row.platformName" /> · Segment #{{ row.segmentIndex }}</div>
|
||||
</div>
|
||||
<StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
|
||||
</div>
|
||||
@@ -414,7 +704,7 @@ onMounted(loadOverview);
|
||||
<el-table-column label="任务" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-title">{{ finalizationTitle(row) }}</div>
|
||||
<div class="cell-subtitle">{{ row.platformName }} · Segment #{{ row.segmentIndex }}</div>
|
||||
<div class="cell-subtitle platform-line"><PlatformMark :name="row.platformName" /> · Segment #{{ row.segmentIndex }}</div>
|
||||
<div class="cell-mono monospace">{{ row.roomId }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -479,4 +769,65 @@ onMounted(loadOverview);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.recovery-filter-actions,
|
||||
.failure-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.recovery-filter-actions :deep(.el-select) {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.failure-card__identity {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.failure-card__action,
|
||||
.failure-recommendation {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.failure-card__action {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.failure-path,
|
||||
.failure-error {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.failure-error {
|
||||
margin-top: 6px;
|
||||
color: var(--el-color-danger);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.failure-actions--desktop {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.recovery-filter-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.recovery-filter-actions :deep(.el-select) {
|
||||
flex: 1 1 180px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+942
-340
File diff suppressed because it is too large
Load Diff
@@ -5,34 +5,75 @@ import { ElMessage } from "element-plus";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import type { TranscodeTaskItem } from "@/types";
|
||||
import { formatQualityLabel, outputFormatLabelMap, taskStatusLabelMap } from "@/types";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
|
||||
const router = useRouter();
|
||||
const { isMobile } = useViewport();
|
||||
|
||||
const loading = ref(false);
|
||||
const initialLoading = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const requestInFlight = ref(false);
|
||||
const transcodeStartingTaskId = ref<string | null>(null);
|
||||
const loadError = ref("");
|
||||
const items = ref<TranscodeTaskItem[]>([]);
|
||||
const tableHeight = computed(() => (isMobile.value ? undefined : 560));
|
||||
const taskSearch = ref("");
|
||||
const taskState = ref("all");
|
||||
|
||||
let refreshTimer: number | null = null;
|
||||
|
||||
const activeCount = computed(() => items.value.filter((item) => Boolean(item.task.postProcessStage)).length);
|
||||
const manualCount = computed(() => items.value.filter((item) => item.canManualTranscode).length);
|
||||
const mp4Count = computed(() => items.value.filter((item) => item.task.outputFormat === 0).length);
|
||||
const filteredItems = computed(() => {
|
||||
const keyword = taskSearch.value.trim().toLowerCase();
|
||||
return items.value.filter((item) => {
|
||||
const matchesKeyword = !keyword || [
|
||||
item.task.liveRoomTitle,
|
||||
item.task.recordSessionId,
|
||||
item.task.id,
|
||||
item.sourceFilePath,
|
||||
item.result?.filePath,
|
||||
item.task.outputFilePath
|
||||
].some((value) => String(value || "").toLowerCase().includes(keyword));
|
||||
const matchesState = taskState.value === "all" ||
|
||||
(taskState.value === "processing" && Boolean(item.task.postProcessStage)) ||
|
||||
(taskState.value === "manual" && item.canManualTranscode) ||
|
||||
(taskState.value === "completed" && !item.task.postProcessStage && !item.canManualTranscode);
|
||||
return matchesKeyword && matchesState;
|
||||
});
|
||||
});
|
||||
|
||||
async function loadItems() {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
function clearFilters() {
|
||||
taskSearch.value = "";
|
||||
taskState.value = "all";
|
||||
}
|
||||
|
||||
function mergeItems(nextItems: TranscodeTaskItem[]) {
|
||||
const previousById = new Map(items.value.map((item) => [item.task.id, item]));
|
||||
return nextItems.map((item) => {
|
||||
const previous = previousById.get(item.task.id);
|
||||
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadItems(background = false) {
|
||||
if (requestInFlight.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestInFlight.value = true;
|
||||
initialLoading.value = !background && items.value.length === 0;
|
||||
refreshing.value = !initialLoading.value;
|
||||
if (!background) {
|
||||
loadError.value = "";
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await apiClient.get<TranscodeTaskItem[]>("/transcode-tasks");
|
||||
items.value = data;
|
||||
items.value = mergeItems(data);
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorMessage(error, "转码任务加载失败,请稍后重试。");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
requestInFlight.value = false;
|
||||
initialLoading.value = false;
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +83,7 @@ async function startManualTranscode(taskId: string) {
|
||||
try {
|
||||
await apiClient.post(`/record-tasks/${taskId}/transcode`);
|
||||
ElMessage.success("已开始手动转码,请稍后刷新查看进度。");
|
||||
await loadItems();
|
||||
await loadItems(true);
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "手动转码启动失败。"));
|
||||
} finally {
|
||||
@@ -110,7 +151,7 @@ function setupAutoRefresh() {
|
||||
|
||||
refreshTimer = window.setInterval(() => {
|
||||
if (document.visibilityState === "visible") {
|
||||
loadItems();
|
||||
void loadItems(true);
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
@@ -141,7 +182,7 @@ onBeforeUnmount(() => {
|
||||
<el-space wrap class="header-actions">
|
||||
<el-button @click="router.push({ name: 'record-tasks' })">返回录制任务</el-button>
|
||||
<el-button @click="router.push({ name: 'media-browser' })">录制目录</el-button>
|
||||
<el-button @click="loadItems">刷新列表</el-button>
|
||||
<el-button :loading="refreshing" @click="loadItems(false)">刷新列表</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
|
||||
@@ -178,10 +219,23 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!loading && items.length === 0" description="当前没有需要关注的转码任务" />
|
||||
<div class="list-filterbar">
|
||||
<el-input v-model="taskSearch" clearable placeholder="搜索直播间、会话、任务或文件路径" />
|
||||
<el-select v-model="taskState" aria-label="转码状态筛选">
|
||||
<el-option label="全部状态" value="all" />
|
||||
<el-option label="处理中" value="processing" />
|
||||
<el-option label="可手动转码" value="manual" />
|
||||
<el-option label="已结束" value="completed" />
|
||||
</el-select>
|
||||
<span class="list-filterbar__count">{{ filteredItems.length }} / {{ items.length }}</span>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!initialLoading && filteredItems.length === 0" :description="items.length === 0 ? '当前没有需要关注的转码任务' : '没有匹配的转码任务'">
|
||||
<el-button v-if="items.length > 0" type="primary" @click="clearFilters">清除筛选</el-button>
|
||||
</el-empty>
|
||||
|
||||
<div v-else class="table-scroll-shell">
|
||||
<el-table :data="items" :height="tableHeight" class="premium-table" table-layout="auto">
|
||||
<el-table :data="filteredItems" class="premium-table transcode-table" table-layout="auto">
|
||||
<el-table-column label="直播间 / 分片" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-title">{{ row.task.liveRoomTitle }}</div>
|
||||
@@ -222,21 +276,21 @@ onBeforeUnmount(() => {
|
||||
{{ formatDate(row.task.endedAt || row.task.startedAt || row.task.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-row">
|
||||
<el-button size="small" @click="openTask(row.task.id)">查看分片</el-button>
|
||||
<el-button size="small" @click="openSession(row.task.recordSessionId)">查看会话</el-button>
|
||||
<el-button
|
||||
v-if="row.canManualTranscode"
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
:loading="transcodeStartingTaskId === row.task.id"
|
||||
@click="startManualTranscode(row.task.id)"
|
||||
>
|
||||
手动转码
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" @click="openTask(row.task.id)">查看</el-button>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button size="small">更多</el-button>
|
||||
<template #dropdown><el-dropdown-menu>
|
||||
<el-dropdown-item @click="openSession(row.task.recordSessionId)">查看会话</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="row.canManualTranscode"
|
||||
:disabled="transcodeStartingTaskId === row.task.id"
|
||||
@click="startManualTranscode(row.task.id)"
|
||||
>手动转码</el-dropdown-item>
|
||||
</el-dropdown-menu></template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Bell, CircleCheck, CircleClose, UploadFilled } from "@element-plus/icons-vue";
|
||||
import apiClient, { getApiErrorMessage } from "@/api/client";
|
||||
import EmptyState from "@/components/ui/EmptyState.vue";
|
||||
import MetricCard from "@/components/ui/MetricCard.vue";
|
||||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||||
import StatusBadge from "@/components/ui/StatusBadge.vue";
|
||||
import { useViewport } from "@/composables/useViewport";
|
||||
import type {
|
||||
RecordArtifactUploadBatchResult,
|
||||
RecordArtifactUploadItemResult,
|
||||
UploadQueueHealth,
|
||||
UploadTaskItem,
|
||||
UploadTaskListResponse
|
||||
} from "@/types";
|
||||
import {
|
||||
platformLabelMap,
|
||||
uploadStatusLabelMap
|
||||
} from "@/types";
|
||||
|
||||
const router = useRouter();
|
||||
const { isWideDesktop } = useViewport();
|
||||
const initialLoading = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const loadError = ref("");
|
||||
const items = ref<UploadTaskItem[]>([]);
|
||||
const totalCount = ref(0);
|
||||
const notUploadedCount = ref(0);
|
||||
const failedArtifactCount = ref(0);
|
||||
const succeededCount = ref(0);
|
||||
const failedCount = ref(0);
|
||||
const queuedCount = ref(0);
|
||||
const uploadingCount = ref(0);
|
||||
const waitingRetryCount = ref(0);
|
||||
const matchingRetryableCount = ref(0);
|
||||
const queueHealth = ref<UploadQueueHealth>({ state: "Healthy", isPaused: false });
|
||||
const uploadStatusFilter = ref<number | null>(null);
|
||||
const taskSearch = ref("");
|
||||
const currentPage = ref(1);
|
||||
const pageSize = computed(() => isWideDesktop.value ? 50 : 12);
|
||||
const uploadingTaskId = ref<string | null>(null);
|
||||
const retryingFailed = ref(false);
|
||||
const uploadingAllPending = ref(false);
|
||||
let refreshTimer: number | null = null;
|
||||
let activeRequest: AbortController | null = null;
|
||||
let requestSerial = 0;
|
||||
let appMain: HTMLElement | null = null;
|
||||
let searchTimer: number | null = null;
|
||||
|
||||
const filterOptions = [
|
||||
{ label: "全部", value: null as number | null },
|
||||
{ label: "待上传", value: 0 },
|
||||
{ label: "已上传", value: 1 },
|
||||
{ label: "上传失败", value: 2 },
|
||||
{ label: "上传中", value: 3 },
|
||||
{ label: "排队中", value: 4 },
|
||||
{ label: "等待重试", value: 5 }
|
||||
];
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)));
|
||||
const filteredItems = computed(() => items.value);
|
||||
|
||||
function mergeItems(nextItems: UploadTaskItem[]) {
|
||||
const previousById = new Map(items.value.map((item) => [item.recordTaskId, item]));
|
||||
return nextItems.map((item) => {
|
||||
const previous = previousById.get(item.recordTaskId);
|
||||
return previous && JSON.stringify(previous) === JSON.stringify(item) ? previous : item;
|
||||
});
|
||||
}
|
||||
|
||||
function stopRefreshTimer() {
|
||||
if (refreshTimer !== null) {
|
||||
window.clearTimeout(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRefresh() {
|
||||
stopRefreshTimer();
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasActiveJobs = queuedCount.value + uploadingCount.value + waitingRetryCount.value > 0;
|
||||
refreshTimer = window.setTimeout(async () => {
|
||||
await loadUploadStatus({ background: true, preserveScroll: true });
|
||||
scheduleRefresh();
|
||||
}, hasActiveJobs ? 5000 : 15000);
|
||||
}
|
||||
|
||||
async function loadUploadStatus(options: { background?: boolean; cancelPrevious?: boolean; preserveScroll?: boolean } = {}) {
|
||||
if (activeRequest && !options.cancelPrevious) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeRequest?.abort();
|
||||
const controller = new AbortController();
|
||||
activeRequest = controller;
|
||||
const serial = ++requestSerial;
|
||||
const isInitial = items.value.length === 0 && !options.background;
|
||||
initialLoading.value = isInitial;
|
||||
refreshing.value = !isInitial;
|
||||
if (!options.background) {
|
||||
loadError.value = "";
|
||||
}
|
||||
const scrollTop = appMain?.scrollTop ?? 0;
|
||||
|
||||
try {
|
||||
const params: Record<string, string | number> = {
|
||||
skip: (currentPage.value - 1) * pageSize.value,
|
||||
take: pageSize.value
|
||||
};
|
||||
if (uploadStatusFilter.value !== null) {
|
||||
params.uploadStatus = uploadStatusFilter.value;
|
||||
}
|
||||
if (taskSearch.value.trim()) {
|
||||
params.query = taskSearch.value.trim();
|
||||
}
|
||||
|
||||
const { data } = await apiClient.get<UploadTaskListResponse>("/record-tasks/upload-status", {
|
||||
params,
|
||||
signal: controller.signal
|
||||
});
|
||||
if (serial !== requestSerial) {
|
||||
return;
|
||||
}
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(data.totalCount / pageSize.value));
|
||||
if (currentPage.value > maxPage) {
|
||||
currentPage.value = maxPage;
|
||||
queueMicrotask(() => void loadUploadStatus({ cancelPrevious: true, preserveScroll: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
items.value = mergeItems(data.items);
|
||||
totalCount.value = data.totalCount;
|
||||
notUploadedCount.value = data.notUploadedCount;
|
||||
failedArtifactCount.value = data.failedArtifactCount;
|
||||
succeededCount.value = data.succeededCount;
|
||||
failedCount.value = data.failedCount;
|
||||
queuedCount.value = data.queuedCount;
|
||||
uploadingCount.value = data.uploadingCount;
|
||||
waitingRetryCount.value = data.waitingRetryCount;
|
||||
matchingRetryableCount.value = data.matchingRetryableCount ?? 0;
|
||||
queueHealth.value = data.queueHealth ?? { state: "Healthy", isPaused: false };
|
||||
await nextTick();
|
||||
if (options.preserveScroll !== false && appMain) {
|
||||
appMain.scrollTop = scrollTop;
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
loadError.value = getApiErrorMessage(error, "上传任务列表加载失败,请稍后重试。");
|
||||
} finally {
|
||||
if (serial === requestSerial) {
|
||||
activeRequest = null;
|
||||
initialLoading.value = false;
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilterChange() {
|
||||
currentPage.value = 1;
|
||||
void loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
currentPage.value = page;
|
||||
void loadUploadStatus({ cancelPrevious: true, preserveScroll: false }).then(() => {
|
||||
document.querySelector(".upload-card")?.scrollIntoView({ block: "start" });
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadTask(task: UploadTaskItem) {
|
||||
uploadingTaskId.value = task.recordTaskId;
|
||||
|
||||
try {
|
||||
const retrying = [2, 5].includes(task.uploadStatus);
|
||||
const endpoint = retrying
|
||||
? `/record-tasks/${task.recordTaskId}/upload/retry`
|
||||
: `/record-tasks/${task.recordTaskId}/upload`;
|
||||
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(endpoint);
|
||||
ElMessage[data.success ? "success" : "warning"](data.message);
|
||||
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "上传失败,请稍后重试。"));
|
||||
} finally {
|
||||
uploadingTaskId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function retryAllFailed() {
|
||||
const retryableCount = matchingRetryableCount.value;
|
||||
if (retryableCount === 0) {
|
||||
ElMessage.info("当前筛选条件下没有可重试任务。");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将立即重试全部 ${retryableCount} 个匹配任务,不受当前分页限制。`,
|
||||
"重试全部匹配任务",
|
||||
{ confirmButtonText: "确认重试", cancelButtonText: "取消", type: "warning" }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
retryingFailed.value = true;
|
||||
|
||||
try {
|
||||
const status = [2, 5].includes(uploadStatusFilter.value ?? -1) ? uploadStatusFilter.value : null;
|
||||
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>("/record-tasks/upload/retry", {
|
||||
uploadStatus: status,
|
||||
query: taskSearch.value.trim() || null
|
||||
});
|
||||
ElMessage[data.successCount > 0 ? "success" : "warning"](
|
||||
`重试请求已处理:已受理 ${data.successCount},失败 ${data.failedCount}。`
|
||||
);
|
||||
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
|
||||
} finally {
|
||||
retryingFailed.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadAllPending() {
|
||||
const pendingItems = items.value.filter(item => item.uploadStatus === 0);
|
||||
if (pendingItems.length === 0) {
|
||||
ElMessage.info("当前没有待上传的任务。");
|
||||
return;
|
||||
}
|
||||
|
||||
uploadingAllPending.value = true;
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
try {
|
||||
for (const item of pendingItems) {
|
||||
try {
|
||||
const { data } = await apiClient.post<RecordArtifactUploadItemResult>(`/record-tasks/${item.recordTaskId}/upload`);
|
||||
if (data.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} catch {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage[successCount > 0 ? "success" : "warning"](
|
||||
`批量上传请求已处理:已受理 ${successCount},失败 ${failCount}。`
|
||||
);
|
||||
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
|
||||
} finally {
|
||||
uploadingAllPending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(task: UploadTaskItem) {
|
||||
router.push({ name: "record-task-detail", params: { id: task.recordTaskId } });
|
||||
}
|
||||
|
||||
function canUpload(task: UploadTaskItem) {
|
||||
return ![1, 3, 4].includes(task.uploadStatus);
|
||||
}
|
||||
|
||||
function isUploadInProgress(task: UploadTaskItem) {
|
||||
return [3, 4, 5].includes(task.uploadStatus);
|
||||
}
|
||||
|
||||
function formatDate(value?: string) {
|
||||
return value ? new Date(value).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number) {
|
||||
if (typeof bytes !== "number" || Number.isNaN(bytes)) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024) {
|
||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatProgress(value?: number) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? `${Math.max(0, Math.min(100, value)).toFixed(1)}%`
|
||||
: "-";
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "visible") {
|
||||
void loadUploadStatus({ background: true, preserveScroll: true }).finally(scheduleRefresh);
|
||||
} else {
|
||||
stopRefreshTimer();
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeQueue() {
|
||||
try {
|
||||
const { data } = await apiClient.post<UploadQueueHealth>("/upload-queue/openlist/resume");
|
||||
queueHealth.value = data;
|
||||
ElMessage.success("OpenList 认证成功,上传队列已恢复。");
|
||||
await loadUploadStatus({ cancelPrevious: true, preserveScroll: true });
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, "OpenList 仍不可用,请检查账号、密码和限流状态。"));
|
||||
}
|
||||
}
|
||||
|
||||
watch(isWideDesktop, () => {
|
||||
currentPage.value = 1;
|
||||
void loadUploadStatus({ cancelPrevious: true, preserveScroll: true }).finally(scheduleRefresh);
|
||||
});
|
||||
|
||||
watch(taskSearch, () => {
|
||||
if (searchTimer !== null) {
|
||||
window.clearTimeout(searchTimer);
|
||||
}
|
||||
searchTimer = window.setTimeout(() => {
|
||||
currentPage.value = 1;
|
||||
void loadUploadStatus({ cancelPrevious: true, preserveScroll: true }).finally(scheduleRefresh);
|
||||
}, 350);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
appMain = document.querySelector<HTMLElement>(".app-main");
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
await loadUploadStatus();
|
||||
scheduleRefresh();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopRefreshTimer();
|
||||
if (searchTimer !== null) {
|
||||
window.clearTimeout(searchTimer);
|
||||
}
|
||||
activeRequest?.abort();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-kicker">录制工作台</div>
|
||||
<h1 class="page-title">上传任务</h1>
|
||||
<p class="page-subtitle">
|
||||
查看所有录制分片的上传状态,支持按状态筛选、单任务上传及批量重试失败任务。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="page-toolbar">
|
||||
<el-button @click="router.push({ name: 'record-tasks' })">录制任务</el-button>
|
||||
<el-button :loading="refreshing" @click="loadUploadStatus({ cancelPrevious: true, preserveScroll: true })">刷新列表</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
|
||||
|
||||
<div v-if="queueHealth.isPaused" class="queue-health-banner" role="status">
|
||||
<div>
|
||||
<strong>{{ queueHealth.state === "RateLimited" ? "OpenList 正在限流" : queueHealth.state === "Disabled" ? "上传队列已暂停" : "OpenList 认证需要处理" }}</strong>
|
||||
<p>{{ queueHealth.reason || "队列已停止发起新请求,任务重试次数不会继续消耗。" }}</p>
|
||||
<span v-if="queueHealth.retryAt">预计可重试:{{ formatDate(queueHealth.retryAt) }}</span>
|
||||
</div>
|
||||
<el-button v-if="queueHealth.state !== 'Disabled'" type="primary" @click="resumeQueue">验证并恢复队列</el-button>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<MetricCard label="全部任务" :value="totalCount" :description="`已完成 ${succeededCount} 个`" :icon="Bell" />
|
||||
<MetricCard label="待上传" :value="notUploadedCount" description="尚未进入上传队列" :icon="UploadFilled" />
|
||||
<MetricCard label="队列处理中" :value="queuedCount + uploadingCount" :description="`排队 ${queuedCount} · 上传中 ${uploadingCount}`" :icon="CircleCheck" />
|
||||
<MetricCard
|
||||
label="需要处理"
|
||||
:value="failedCount + failedArtifactCount + waitingRetryCount"
|
||||
:description="`上传失败 ${failedCount} · 失败残片 ${failedArtifactCount} · 等待重试 ${waitingRetryCount}`"
|
||||
:icon="CircleClose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-card class="surface-card upload-card" shadow="never">
|
||||
<div class="toolbar-row">
|
||||
<div>
|
||||
<h3 class="section-title">上传队列</h3>
|
||||
<p class="section-subtitle">按状态和文件信息定位任务,批量重试会处理全部匹配结果。</p>
|
||||
</div>
|
||||
<div class="toolbar-row__actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
:loading="uploadingAllPending"
|
||||
@click="uploadAllPending"
|
||||
>
|
||||
上传本页待上传
|
||||
</el-button>
|
||||
<el-button
|
||||
type="warning"
|
||||
plain
|
||||
:loading="retryingFailed"
|
||||
@click="retryAllFailed"
|
||||
>
|
||||
重试全部匹配
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-filterbar">
|
||||
<el-input v-model="taskSearch" clearable placeholder="搜索直播间、任务、文件或远端路径" />
|
||||
<el-select v-model="uploadStatusFilter" placeholder="全部状态" aria-label="上传状态筛选" @change="handleFilterChange">
|
||||
<el-option v-for="opt in filterOptions" :key="String(opt.value)" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<span class="list-filterbar__count">本页 {{ filteredItems.length }} / 共 {{ totalCount }}</span>
|
||||
</div>
|
||||
|
||||
<el-skeleton v-if="initialLoading && items.length === 0" :rows="6" animated />
|
||||
|
||||
<EmptyState
|
||||
v-else-if="filteredItems.length === 0"
|
||||
:title="items.length === 0 ? '暂无上传任务' : '没有匹配的上传任务'"
|
||||
:description="items.length === 0 ? '当前状态下没有可展示的上传记录。' : '请调整搜索关键词。'"
|
||||
action-text="刷新列表"
|
||||
@action="loadUploadStatus"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="!isWideDesktop" class="upload-card-list">
|
||||
<article v-for="row in filteredItems" :key="row.recordTaskId" class="upload-item-card">
|
||||
<div class="upload-item-card__header">
|
||||
<div class="upload-item-card__identity">
|
||||
<strong>{{ row.liveRoomTitle }}</strong>
|
||||
<span class="platform-line"><PlatformMark :name="platformLabelMap[row.platform] ?? '-'" /> · {{ row.roomId }} · 分片 #{{ row.segmentIndex }}</span>
|
||||
</div>
|
||||
<StatusBadge
|
||||
:label="uploadStatusLabelMap[row.uploadStatus]"
|
||||
:status="row.uploadStatus"
|
||||
context="upload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="upload-item-card__section">
|
||||
<span class="upload-item-card__label">本地文件</span>
|
||||
<span class="monospace path-text">{{ row.filePath || "-" }}</span>
|
||||
<span class="cell-subtitle">{{ formatFileSize(row.fileSizeBytes) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isUploadInProgress(row)" class="upload-item-card__progress">
|
||||
<el-progress :percentage="Math.round(row.uploadProgressPercent || 0)" :stroke-width="7" :show-text="false" />
|
||||
<span>{{ formatProgress(row.uploadProgressPercent) }} · 第 {{ row.uploadAttemptCount || 0 }} 次</span>
|
||||
</div>
|
||||
|
||||
<dl class="upload-item-card__facts">
|
||||
<div><dt>上传方式</dt><dd>{{ row.lastUploadProvider || "-" }}</dd></div>
|
||||
<div><dt>上传时间</dt><dd>{{ formatDate(row.lastUploadedAt) }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div v-if="row.remoteVideoPath" class="upload-item-card__section">
|
||||
<span class="upload-item-card__label">远端路径</span>
|
||||
<span class="monospace path-text">{{ row.remoteVideoPath }}</span>
|
||||
</div>
|
||||
<div v-if="row.uploadErrorMessage" class="upload-item-card__error">{{ row.uploadErrorMessage }}</div>
|
||||
|
||||
<div class="upload-item-card__actions">
|
||||
<el-button type="primary" plain @click="openDetail(row)">查看详情</el-button>
|
||||
<el-button
|
||||
v-if="canUpload(row)"
|
||||
type="primary"
|
||||
:loading="uploadingTaskId === row.recordTaskId"
|
||||
@click="uploadTask(row)"
|
||||
>{{ [2, 5].includes(row.uploadStatus) ? "立即重试" : "立即上传" }}</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="upload-table-shell">
|
||||
<el-table
|
||||
:data="filteredItems"
|
||||
class="premium-table upload-table"
|
||||
table-layout="fixed"
|
||||
row-key="recordTaskId"
|
||||
>
|
||||
<el-table-column label="直播间" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div>
|
||||
<div class="cell-primary">{{ row.liveRoomTitle }}</div>
|
||||
<div class="cell-subtitle platform-line"><PlatformMark :name="platformLabelMap[row.platform] ?? '-'" /> · <span class="monospace">{{ row.roomId }}</span> · #{{ row.segmentIndex }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="文件" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="monospace path-text">{{ row.filePath || "-" }}</div>
|
||||
<div class="cell-subtitle">{{ formatFileSize(row.fileSizeBytes) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态与进度" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<StatusBadge
|
||||
:label="uploadStatusLabelMap[row.uploadStatus]"
|
||||
:status="row.uploadStatus"
|
||||
context="upload"
|
||||
/>
|
||||
<div v-if="isUploadInProgress(row)" class="table-progress">
|
||||
<el-progress
|
||||
:percentage="Math.round(row.uploadProgressPercent || 0)"
|
||||
:stroke-width="6"
|
||||
:show-text="false"
|
||||
/>
|
||||
<div class="cell-subtitle">
|
||||
{{ formatProgress(row.uploadProgressPercent) }} · 第 {{ row.uploadAttemptCount || 0 }} 次
|
||||
</div>
|
||||
<div v-if="row.nextUploadAttemptAt" class="cell-subtitle">
|
||||
下次:{{ formatDate(row.nextUploadAttemptAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="远端结果" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<div class="monospace path-text">{{ row.remoteVideoPath || "-" }}</div>
|
||||
<div class="cell-subtitle">{{ row.lastUploadProvider || "-" }} · {{ formatDate(row.lastUploadedAt) }}</div>
|
||||
<div v-if="row.uploadErrorMessage" class="error-text">{{ row.uploadErrorMessage }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="160" align="right">
|
||||
<template #default="{ row }">
|
||||
<div class="task-actions-cell">
|
||||
<el-button size="small" type="primary" @click="openDetail(row)">查看</el-button>
|
||||
<el-button
|
||||
v-if="canUpload(row)"
|
||||
size="small"
|
||||
:loading="uploadingTaskId === row.recordTaskId"
|
||||
@click="uploadTask(row)"
|
||||
>{{ [2, 5].includes(row.uploadStatus) ? "重试" : "上传" }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="pagination-row">
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:page-size="pageSize"
|
||||
:total="totalCount"
|
||||
:current-page="currentPage"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.page-error-alert {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.queue-health-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid color-mix(in srgb, var(--el-color-warning) 34%, var(--border-subtle));
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--el-color-warning) 9%, var(--surface-card));
|
||||
}
|
||||
|
||||
.queue-health-banner > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.queue-health-banner strong {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.queue-health-banner p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.queue-health-banner span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.queue-health-banner {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-card :deep(.el-card__body) {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
padding-bottom: 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-row__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-table-shell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.upload-table { border-radius: 12px; }
|
||||
|
||||
.table-progress {
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.pagination-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.path-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 12px;
|
||||
color: var(--danger);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.task-actions-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
white-space: nowrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.upload-card-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-item-card {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
.upload-item-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.upload-item-card__identity {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.upload-item-card__identity span,
|
||||
.upload-item-card__label,
|
||||
.upload-item-card__progress span,
|
||||
.upload-item-card__facts dt {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upload-item-card__section {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.upload-item-card__progress {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.upload-item-card__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
.upload-item-card__facts div {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface-muted);
|
||||
}
|
||||
|
||||
.upload-item-card__facts dt,
|
||||
.upload-item-card__facts dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-item-card__facts dd {
|
||||
margin-top: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.upload-item-card__error {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
color: var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 9%, transparent);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.upload-item-card__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.upload-item-card__actions :deep(.el-button) {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-item-card__actions :deep(.el-button:only-child) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.path-text {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-actions-cell :deep(.el-button) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,849 @@
|
||||
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
||||
|
||||
const now = "2026-08-02T12:00:00Z";
|
||||
const room = {
|
||||
id: "room-1",
|
||||
platform: 0,
|
||||
platformName: "哔哩哔哩",
|
||||
sourceUrl: "https://live.example/123",
|
||||
originalLiveRoomUrl: "https://live.example/123",
|
||||
normalizedUrl: "https://live.example/123",
|
||||
roomId: "123456",
|
||||
title: "夏日音乐直播间",
|
||||
anchorName: "示例主播",
|
||||
isPinned: true,
|
||||
isPriority: false,
|
||||
overrides: {},
|
||||
effectiveSettings: {
|
||||
preferredQuality: "origin",
|
||||
outputFormat: 0,
|
||||
saveMode: 1,
|
||||
recordingTemplate: 0,
|
||||
segmentDurationMinutes: 30,
|
||||
enableAutoReconnect: true,
|
||||
reconnectDelayMaxSeconds: 60,
|
||||
readWriteTimeoutMilliseconds: 30000,
|
||||
enableDanmakuRecording: true,
|
||||
danmakuIncludeNonChatEvents: false,
|
||||
danmakuMinPollIntervalMilliseconds: 1000,
|
||||
danmakuRetryDelayMaxSeconds: 30
|
||||
},
|
||||
isEnabled: true,
|
||||
availabilityStatus: 2,
|
||||
currentRecordingState: 2,
|
||||
lastAutoStartDecisionCode: "started",
|
||||
lastAutoStartDecisionSummary: "已自动开始录制",
|
||||
lastAutoStartDecisionDetail: "直播状态确认后创建录制会话。",
|
||||
lastAutoStartDecisionAt: now,
|
||||
lastCheckedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
const task = {
|
||||
id: "task-1",
|
||||
liveRoomId: room.id,
|
||||
recordSessionId: "session-12345678",
|
||||
segmentIndex: 1,
|
||||
liveRoomTitle: room.title,
|
||||
platform: 0,
|
||||
roomId: room.roomId,
|
||||
status: 4,
|
||||
preferredQuality: "origin",
|
||||
outputFormat: 0,
|
||||
outputFilePath: "/volume1/录制/示例主播/2026-08-02/分片-001.mp4",
|
||||
createdAt: now,
|
||||
startedAt: now,
|
||||
endedAt: now,
|
||||
durationSeconds: 1800,
|
||||
uploadStatus: 0
|
||||
};
|
||||
|
||||
const session = {
|
||||
id: "session-12345678",
|
||||
liveRoomId: room.id,
|
||||
liveRoomTitle: room.title,
|
||||
anchorName: room.anchorName,
|
||||
platform: 0,
|
||||
roomId: room.roomId,
|
||||
status: 4,
|
||||
preferredQuality: "origin",
|
||||
outputFormat: 0,
|
||||
saveMode: 1,
|
||||
activeSegmentIndex: 0,
|
||||
segmentCount: 1,
|
||||
createdAt: now,
|
||||
startedAt: now,
|
||||
endedAt: now,
|
||||
totalFileSizeBytes: 8_589_934_592,
|
||||
totalDanmakuMessageCount: 2680,
|
||||
uploadedSegmentCount: 0,
|
||||
failedUploadSegmentCount: 0,
|
||||
uploadingSegmentCount: 0,
|
||||
tasks: [task]
|
||||
};
|
||||
|
||||
const sessionDetail = {
|
||||
session,
|
||||
timeline: {
|
||||
anchorAt: now,
|
||||
totalDurationSeconds: 5400,
|
||||
segments: Array.from({ length: 3 }, (_, index) => ({
|
||||
recordTaskId: `task-${index + 1}`,
|
||||
segmentIndex: index + 1,
|
||||
status: 4,
|
||||
startedAt: new Date(Date.parse(now) + index * 1_800_000).toISOString(),
|
||||
endedAt: new Date(Date.parse(now) + (index + 1) * 1_800_000).toISOString(),
|
||||
offsetSeconds: index * 1800,
|
||||
durationSeconds: 1800,
|
||||
label: `/volume1/录制/示例主播/2026-08-02/分片-${String(index + 1).padStart(3, "0")}.mp4`,
|
||||
detail: "录制完成"
|
||||
})),
|
||||
events: [],
|
||||
heatBuckets: []
|
||||
},
|
||||
logs: []
|
||||
};
|
||||
|
||||
const uploadTask = {
|
||||
recordTaskId: task.id,
|
||||
recordSessionId: session.id,
|
||||
liveRoomId: room.id,
|
||||
liveRoomTitle: room.title,
|
||||
platform: room.platform,
|
||||
roomId: room.roomId,
|
||||
segmentIndex: 1,
|
||||
outputFormat: 0,
|
||||
filePath: task.outputFilePath,
|
||||
fileSizeBytes: 8_589_934_592,
|
||||
uploadStatus: 0,
|
||||
uploadProgressPercent: 0,
|
||||
uploadAttemptCount: 0,
|
||||
createdAt: now
|
||||
};
|
||||
|
||||
const recordingFailure = {
|
||||
recordTaskId: "failed-task-1",
|
||||
recordSessionId: "failed-session-1",
|
||||
liveRoomId: room.id,
|
||||
liveRoomTitle: room.title,
|
||||
roomId: room.roomId,
|
||||
platformName: room.platformName,
|
||||
segmentIndex: 7,
|
||||
failureKind: "ReadableFragment",
|
||||
failureLabel: "异常退出分片",
|
||||
recommendedAction: "文件可以读取。确认内容有效后,将它转为待上传任务。",
|
||||
errorMessage: "FFmpeg 异常退出;原始文件会保留,不会被修复流程覆盖。",
|
||||
filePath: "/volume1/录制/示例主播/2026-08-02/一个用于验证窄屏换行的很长文件名-007.mp4",
|
||||
fileSizeBytes: 4_294_967_296,
|
||||
durationSeconds: 1789,
|
||||
fileExists: true,
|
||||
canAccept: true,
|
||||
canRepair: false,
|
||||
canRetryRoom: false,
|
||||
isRepairing: false,
|
||||
createdAt: now
|
||||
};
|
||||
|
||||
const dashboard = {
|
||||
activeRecordingCount: 1,
|
||||
liveRoomCount: 3,
|
||||
offlineRoomCount: 5,
|
||||
totalRoomCount: 8,
|
||||
todayRecordingSeconds: 12600,
|
||||
todayDataBytes: 32_212_254_720,
|
||||
todayDanmakuCount: 12860,
|
||||
activeSessionCount: 1,
|
||||
recentErrorCount: 2,
|
||||
currentErrorCount: 0,
|
||||
storageStatus: {
|
||||
isEnabled: true,
|
||||
isAvailable: true,
|
||||
hasEnoughSpace: true,
|
||||
message: "空间充足",
|
||||
checkedPath: "/volume1/录制/示例主播/2026-08-02",
|
||||
totalBytes: 1_000_000_000_000,
|
||||
usedBytes: 750_000_000_000,
|
||||
availableBytes: 250_000_000_000,
|
||||
requiredBytes: 100_000_000_000,
|
||||
tier: "Green",
|
||||
usagePercent: 75,
|
||||
freePercent: 25,
|
||||
greenThresholdPercent: 30,
|
||||
redThresholdPercent: 10
|
||||
},
|
||||
recentSessions: [session],
|
||||
topRooms: [{ liveRoomId: room.id, title: room.title, anchorName: room.anchorName, platformName: room.platformName, roomId: room.roomId, sessionCount: 1, totalDurationSeconds: 12600 }],
|
||||
pendingTranscodeCount: 2,
|
||||
pendingUploadCount: 3,
|
||||
queuedDataBytes: 4_294_967_296
|
||||
};
|
||||
|
||||
async function mockApi(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("live-recorder-token", "e2e-token");
|
||||
localStorage.setItem("live-recorder-user", JSON.stringify({
|
||||
userId: "e2e-user",
|
||||
username: "tester",
|
||||
displayName: "界面测试"
|
||||
}));
|
||||
});
|
||||
|
||||
await page.route(/^http:\/\/127\.0\.0\.1:47173\/api\//, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const path = url.pathname;
|
||||
let body: unknown = {};
|
||||
|
||||
if (path === "/api/dashboard") body = dashboard;
|
||||
else if (path === "/api/live-rooms") body = [room];
|
||||
else if (path === "/api/record-sessions/page") body = {
|
||||
items: [session],
|
||||
totalCount: 1,
|
||||
skip: 0,
|
||||
take: 20,
|
||||
totalSessionCount: 1,
|
||||
activeSessionCount: 0,
|
||||
totalTaskCount: 1,
|
||||
totalDanmakuCount: session.totalDanmakuMessageCount
|
||||
};
|
||||
else if (path === `/api/record-sessions/${session.id}`) body = sessionDetail;
|
||||
else if (path === "/api/record-sessions") body = [session];
|
||||
else if (path === "/api/record-tasks/upload-status") body = {
|
||||
items: [uploadTask],
|
||||
totalCount: 1,
|
||||
notUploadedCount: 1,
|
||||
failedArtifactCount: 0,
|
||||
succeededCount: 0,
|
||||
failedCount: 0,
|
||||
queuedCount: 0,
|
||||
uploadingCount: 0,
|
||||
waitingRetryCount: 0,
|
||||
matchingRetryableCount: 0,
|
||||
queueHealth: { state: "Healthy", isPaused: false }
|
||||
};
|
||||
else if (path === "/api/recovery/recording-failures") body = {
|
||||
items: [recordingFailure],
|
||||
totalCount: 1
|
||||
};
|
||||
else if (path === "/api/recovery") body = {
|
||||
storage: dashboard.storageStatus,
|
||||
liveRooms: [],
|
||||
finalizations: []
|
||||
};
|
||||
else if (path === "/api/settings") body = {};
|
||||
else if (path.includes("/record-sessions/stream")) {
|
||||
await route.fulfill({ status: 200, contentType: "text/event-stream", body: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
|
||||
});
|
||||
}
|
||||
|
||||
async function expectNoDocumentOverflow(page: Page) {
|
||||
await expect.poll(() => page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
clientWidth: document.documentElement.clientWidth
|
||||
}))).toEqual(await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.clientWidth,
|
||||
clientWidth: document.documentElement.clientWidth
|
||||
})));
|
||||
}
|
||||
|
||||
async function capture(page: Page, testInfo: TestInfo, name: string) {
|
||||
await page.screenshot({ path: testInfo.outputPath(`${name}.png`), fullPage: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
page.on("pageerror", (error) => console.error("[browser pageerror]", error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") console.error("[browser console]", message.text());
|
||||
});
|
||||
await mockApi(page);
|
||||
});
|
||||
|
||||
test("login preserves the product hierarchy without viewport overflow", async ({ page }, testInfo) => {
|
||||
await page.addInitScript(() => localStorage.clear());
|
||||
await page.goto("/login");
|
||||
await expect(page.getByRole("heading", { name: /让每一次开播/ })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "欢迎回来" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /登录控制台/ })).toBeVisible();
|
||||
await expectNoDocumentOverflow(page);
|
||||
await capture(page, testInfo, "login");
|
||||
});
|
||||
|
||||
test("dashboard keeps storage semantics and the shell within the viewport", async ({ page }, testInfo) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "运行中心" })).toBeVisible();
|
||||
const storage = page.getByTestId("storage-capacity");
|
||||
await expect(storage).toContainText("75.0%");
|
||||
await expect(storage).toContainText("已使用");
|
||||
await expect(storage).toContainText("25.0%");
|
||||
if ((page.viewportSize()?.width ?? 0) <= 768) {
|
||||
const avatarGeometry = await page.locator(".app-user-btn").evaluate((button) => {
|
||||
const avatar = button.querySelector<SVGSVGElement>(".app-user-btn__avatar")!;
|
||||
const circle = avatar.querySelector<SVGCircleElement>("circle")!;
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const avatarRect = avatar.getBoundingClientRect();
|
||||
const circleRect = circle.getBoundingClientRect();
|
||||
return {
|
||||
tagName: avatar.tagName.toLowerCase(),
|
||||
buttonDelta: Math.abs(buttonRect.width - buttonRect.height),
|
||||
avatarDelta: Math.abs(avatarRect.width - avatarRect.height),
|
||||
circleDelta: Math.abs(circleRect.width - circleRect.height)
|
||||
};
|
||||
});
|
||||
expect(avatarGeometry.tagName).toBe("svg");
|
||||
expect(avatarGeometry.buttonDelta).toBeLessThanOrEqual(1);
|
||||
expect(avatarGeometry.avatarDelta).toBeLessThanOrEqual(1);
|
||||
expect(avatarGeometry.circleDelta).toBeLessThanOrEqual(1);
|
||||
}
|
||||
await expectNoDocumentOverflow(page);
|
||||
await capture(page, testInfo, "dashboard");
|
||||
});
|
||||
|
||||
test("mobile navigation animates route and active state without a tap frame", async ({ page }, testInfo) => {
|
||||
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile navigation contract");
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "运行中心" })).toBeVisible();
|
||||
await expect(page.locator(".app-main")).toBeVisible();
|
||||
await expect(page.locator(".app-bottom-nav button[aria-current='page']")).toHaveCount(1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
(window as Window & { __pageSwapObserved?: boolean }).__pageSwapObserved = false;
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
if (mutations.some((mutation) =>
|
||||
mutation.target instanceof HTMLElement && mutation.target.className.includes("page-swap-")
|
||||
)) {
|
||||
(window as Window & { __pageSwapObserved?: boolean }).__pageSwapObserved = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(document.querySelector(".app-main")!, {
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["class"]
|
||||
});
|
||||
});
|
||||
|
||||
const recordNavigation = page.locator(".app-bottom-nav").getByRole("button", { name: "录制", exact: true });
|
||||
const tapHighlight = await recordNavigation.evaluate((element) =>
|
||||
getComputedStyle(element).webkitTapHighlightColor
|
||||
);
|
||||
expect(tapHighlight).toBe("rgba(0, 0, 0, 0)");
|
||||
|
||||
await recordNavigation.click();
|
||||
await expect(page).toHaveURL(/\/live-rooms$/);
|
||||
await expect(recordNavigation).toHaveClass(/is-active/);
|
||||
await expect(page.locator(".app-bottom-nav button[aria-current='page']")).toHaveCount(1);
|
||||
await expect.poll(() => page.evaluate(() =>
|
||||
(window as Window & { __pageSwapObserved?: boolean }).__pageSwapObserved
|
||||
)).toBeTruthy();
|
||||
const activeIndicator = await recordNavigation.locator(".app-bottom-nav__icon").evaluate((element) => ({
|
||||
background: getComputedStyle(element).backgroundColor,
|
||||
duration: getComputedStyle(element).transitionDuration
|
||||
}));
|
||||
expect(activeIndicator.background).not.toBe("rgba(0, 0, 0, 0)");
|
||||
expect(activeIndicator.duration).not.toBe("0s");
|
||||
await capture(page, testInfo, "mobile-navigation-active");
|
||||
});
|
||||
|
||||
test("dark mode teleported overlays inherit dark surfaces and borders", async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem("live-recorder-ui-theme", "dark"));
|
||||
await page.goto("/");
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
|
||||
await page.locator(".app-user-btn").click();
|
||||
const dropdown = page.locator(".el-dropdown__popper:visible").first();
|
||||
await expect(dropdown).toBeVisible();
|
||||
const colors = await dropdown.evaluate((element) => ({
|
||||
background: getComputedStyle(element).backgroundColor,
|
||||
border: getComputedStyle(element).borderTopColor,
|
||||
rootBorder: getComputedStyle(document.documentElement).getPropertyValue("--el-border-color-light").trim()
|
||||
}));
|
||||
expect(colors.background).toBe("rgb(23, 34, 53)");
|
||||
expect(colors.border).toBe("rgb(44, 60, 85)");
|
||||
expect(colors.rootBorder).toBe("#2c3c55");
|
||||
});
|
||||
|
||||
test("live room table, drawer and dialog retain their final actions", async ({ page }, testInfo) => {
|
||||
await page.goto("/live-rooms");
|
||||
await expect(page.getByRole("heading", { name: "直播间列表" })).toBeVisible();
|
||||
await expectNoDocumentOverflow(page);
|
||||
|
||||
if ((page.viewportSize()?.width ?? 0) > 768) {
|
||||
const actionHeader = page.getByRole("columnheader", { name: "操作" }).last();
|
||||
await expect(actionHeader).toBeVisible();
|
||||
const box = await actionHeader.boundingBox();
|
||||
expect(box && box.x + box.width).toBeLessThanOrEqual((page.viewportSize()?.width ?? 0) + 1);
|
||||
const fixedActionCell = page.locator("td.el-table-fixed-column--right").first();
|
||||
await expect(fixedActionCell).toBeVisible();
|
||||
const fixedBackground = await fixedActionCell.evaluate((element) =>
|
||||
window.getComputedStyle(element).backgroundColor
|
||||
);
|
||||
expect(fixedBackground).not.toBe("transparent");
|
||||
expect(fixedBackground).not.toMatch(/rgba\([^)]*,\s*0\s*\)$/);
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: /^查看/ }).first().click();
|
||||
const drawer = page.locator(".right-drawer");
|
||||
const drawerFooter = page.locator(".right-drawer__footer");
|
||||
await expect(drawerFooter).toBeVisible();
|
||||
if ((page.viewportSize()?.width ?? 0) <= 768) {
|
||||
const viewportWidth = page.viewportSize()?.width ?? 0;
|
||||
await expect.poll(async () => {
|
||||
const box = await drawer.boundingBox();
|
||||
return Boolean(box && box.x >= -1 && box.x + box.width <= viewportWidth + 1);
|
||||
}).toBe(true);
|
||||
if (viewportWidth <= 640) {
|
||||
const box = await drawer.boundingBox();
|
||||
expect(Math.round(box?.width ?? 0)).toBe(viewportWidth);
|
||||
}
|
||||
const drawerBody = page.locator(".right-drawer__body");
|
||||
const bodyOverflow = await drawerBody.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
overflowY: getComputedStyle(element).overflowY
|
||||
}));
|
||||
expect(bodyOverflow.scrollWidth).toBeLessThanOrEqual(bodyOverflow.clientWidth);
|
||||
expect(bodyOverflow.overflowY).toBe("auto");
|
||||
await drawerBody.evaluate((element) => { element.scrollTop = element.scrollHeight; });
|
||||
const lastDetailRow = page.locator(".detail-panel__descriptions tr").last();
|
||||
await expect(lastDetailRow).toBeVisible();
|
||||
const lastRowBox = await lastDetailRow.boundingBox();
|
||||
const fixedFooterBox = await drawerFooter.boundingBox();
|
||||
expect(lastRowBox && fixedFooterBox && lastRowBox.y + lastRowBox.height)
|
||||
.toBeLessThanOrEqual((fixedFooterBox?.y ?? 0) + 1);
|
||||
}
|
||||
const footerBox = await drawerFooter.boundingBox();
|
||||
expect(footerBox && footerBox.y + footerBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1);
|
||||
await capture(page, testInfo, "live-room-drawer");
|
||||
await page.getByRole("button", { name: "关闭", exact: true }).click();
|
||||
await expect(drawerFooter).toBeHidden();
|
||||
|
||||
await page.getByRole("button", { name: "新增直播间" }).click();
|
||||
const dialogFooter = page.locator(".el-dialog__footer");
|
||||
await expect(dialogFooter).toBeVisible();
|
||||
const dialogFooterBox = await dialogFooter.boundingBox();
|
||||
expect(dialogFooterBox && dialogFooterBox.y + dialogFooterBox.height).toBeLessThanOrEqual((page.viewportSize()?.height ?? 0) + 1);
|
||||
await page.getByRole("button", { name: "取消", exact: true }).click();
|
||||
await expect(dialogFooter).toBeHidden();
|
||||
await capture(page, testInfo, "live-rooms");
|
||||
});
|
||||
|
||||
test("mobile live room list paginates large collections", async ({ page }) => {
|
||||
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile-only pagination contract");
|
||||
const manyRooms = Array.from({ length: 25 }, (_, index) => ({
|
||||
...room,
|
||||
id: `room-${index + 1}`,
|
||||
roomId: String(100000 + index + 1),
|
||||
title: `分页直播间 ${index + 1}`,
|
||||
anchorName: `分页主播 ${index + 1}`
|
||||
}));
|
||||
await page.route("**/api/live-rooms", async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(manyRooms) });
|
||||
});
|
||||
|
||||
await page.goto("/live-rooms");
|
||||
await expect(page.locator(".room-card")).toHaveCount(12);
|
||||
await expect(page.getByText("分页直播间 1", { exact: true })).toBeVisible();
|
||||
await page.locator(".mobile-room-pagination .btn-next").click();
|
||||
await expect(page.getByText("分页直播间 13", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".room-card")).toHaveCount(12);
|
||||
});
|
||||
|
||||
test("record task cards and tables expose one primary action", async ({ page }, testInfo) => {
|
||||
await page.goto("/record-tasks");
|
||||
await expect(page.getByRole("heading", { name: "录制任务" })).toBeVisible();
|
||||
await expect(page.getByText("主播 · 示例主播", { exact: true })).toBeVisible();
|
||||
if ((page.viewportSize()?.width ?? 0) <= 768) {
|
||||
const tapHighlight = await page.getByRole("button", { name: "刷新列表" }).evaluate((element) =>
|
||||
getComputedStyle(element).webkitTapHighlightColor
|
||||
);
|
||||
expect(tapHighlight).toBe("rgba(0, 0, 0, 0)");
|
||||
}
|
||||
await expect(page.getByPlaceholder(/搜索直播间/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /查看/ }).first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /更多/ }).first()).toBeVisible();
|
||||
await expectNoDocumentOverflow(page);
|
||||
await capture(page, testInfo, "record-tasks");
|
||||
});
|
||||
|
||||
test("record sessions paginate on the server and bound the rendered page", async ({ page }, testInfo) => {
|
||||
const allSessions = Array.from({ length: 200 }, (_, index) => ({
|
||||
...session,
|
||||
id: `session-${index + 1}`,
|
||||
liveRoomTitle: `分页录制会话 ${index + 1}`,
|
||||
roomId: String(200000 + index + 1),
|
||||
tasks: [{ ...task, id: `task-${index + 1}`, recordSessionId: `session-${index + 1}` }]
|
||||
}));
|
||||
const requests: Array<{ skip: number; take: number }> = [];
|
||||
|
||||
await page.route("**/api/record-sessions/page**", async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const skip = Number(url.searchParams.get("skip") || 0);
|
||||
const take = Number(url.searchParams.get("take") || 20);
|
||||
requests.push({ skip, take });
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: allSessions.slice(skip, skip + take),
|
||||
totalCount: allSessions.length,
|
||||
skip,
|
||||
take,
|
||||
totalSessionCount: allSessions.length,
|
||||
activeSessionCount: 0,
|
||||
totalTaskCount: allSessions.length,
|
||||
totalDanmakuCount: allSessions.length * session.totalDanmakuMessageCount
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/record-tasks");
|
||||
const expectedPageSize = (page.viewportSize()?.width ?? 0) <= 768 ? 12 : 20;
|
||||
await expect.poll(() => requests.length).toBeGreaterThan(0);
|
||||
expect(requests[0]).toEqual({ skip: 0, take: expectedPageSize });
|
||||
await expect(page.getByText("当前页 " + expectedPageSize + " / 共 200")).toBeVisible();
|
||||
await expect(page.locator((page.viewportSize()?.width ?? 0) <= 768 ? ".session-card" : ".session-panel"))
|
||||
.toHaveCount(expectedPageSize);
|
||||
const renderedNodeCount = await page.evaluate(() => document.getElementsByTagName("*").length);
|
||||
expect(renderedNodeCount).toBeLessThan(5_000);
|
||||
|
||||
await page.locator(".session-pagination .btn-next").click();
|
||||
await expect.poll(() => requests.some((item) => item.skip === expectedPageSize)).toBeTruthy();
|
||||
await expect(page.getByText(`分页录制会话 ${expectedPageSize + 1}`, { exact: true })).toBeVisible();
|
||||
if ((page.viewportSize()?.width ?? 0) <= 768) {
|
||||
await expect(page.locator(".session-pagination__mobile .el-button")).toHaveCount(2);
|
||||
const pageCount = Math.ceil(allSessions.length / expectedPageSize);
|
||||
const middlePage = Math.ceil(pageCount / 2);
|
||||
for (let pageNumber = 2; pageNumber < middlePage; pageNumber++) {
|
||||
await page.locator(".session-pagination__mobile .btn-next").click();
|
||||
await expect(page.getByText(`分页录制会话 ${pageNumber * expectedPageSize + 1}`, { exact: true })).toBeVisible();
|
||||
}
|
||||
await expect(page.locator(".session-pagination__position")).toContainText(`${middlePage}/ ${pageCount}`);
|
||||
await expect(page.locator(".session-pagination__mobile .el-button")).toHaveCount(2);
|
||||
await expectNoDocumentOverflow(page);
|
||||
await page.locator(".session-pagination__mobile").scrollIntoViewIfNeeded();
|
||||
await capture(page, testInfo, "record-session-middle-page");
|
||||
}
|
||||
});
|
||||
|
||||
test("mobile record session refresh keeps the current page and scroll position", async ({ page }) => {
|
||||
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile refresh contract");
|
||||
|
||||
await page.addInitScript(() => {
|
||||
class MockEventSource {
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
private listeners = new Map<string, Array<(event: Event) => void>>();
|
||||
|
||||
constructor() {
|
||||
(window as any).__recordSessionEventSource = this;
|
||||
window.setTimeout(() => this.onopen?.(new Event("open")), 0);
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject) {
|
||||
const callback = typeof listener === "function"
|
||||
? listener
|
||||
: (event: Event) => listener.handleEvent(event);
|
||||
this.listeners.set(type, [...(this.listeners.get(type) ?? []), callback]);
|
||||
}
|
||||
|
||||
emit(type: string) {
|
||||
this.listeners.get(type)?.forEach((listener) => listener(new MessageEvent(type, { data: "{}" })));
|
||||
}
|
||||
|
||||
close() {}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, "EventSource", { configurable: true, value: MockEventSource });
|
||||
(window as any).__emitRecordSessionRefresh = () =>
|
||||
(window as any).__recordSessionEventSource?.emit("refresh");
|
||||
});
|
||||
|
||||
const allSessions = Array.from({ length: 60 }, (_, index) => ({
|
||||
...session,
|
||||
id: `refresh-session-${index + 1}`,
|
||||
liveRoomTitle: `刷新录制会话 ${index + 1}`,
|
||||
tasks: [{ ...task, id: `refresh-task-${index + 1}`, recordSessionId: `refresh-session-${index + 1}` }]
|
||||
}));
|
||||
let requestCount = 0;
|
||||
await page.route("**/api/record-sessions/page**", async (route) => {
|
||||
requestCount++;
|
||||
const url = new URL(route.request().url());
|
||||
const skip = Number(url.searchParams.get("skip") || 0);
|
||||
const take = Number(url.searchParams.get("take") || 12);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: allSessions.slice(skip, skip + take),
|
||||
totalCount: allSessions.length,
|
||||
skip,
|
||||
take,
|
||||
totalSessionCount: allSessions.length,
|
||||
activeSessionCount: 0,
|
||||
totalTaskCount: allSessions.length,
|
||||
totalDanmakuCount: 0
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/record-tasks");
|
||||
await expect(page.locator(".session-card")).toHaveCount(12);
|
||||
const main = page.locator(".app-main");
|
||||
await main.evaluate((element) => { element.scrollTop = element.scrollHeight; });
|
||||
const scrollTopBefore = await main.evaluate((element) => element.scrollTop);
|
||||
expect(scrollTopBefore).toBeGreaterThan(500);
|
||||
|
||||
await page.evaluate(() => (window as any).__emitRecordSessionRefresh());
|
||||
await expect.poll(() => requestCount).toBeGreaterThan(1);
|
||||
await expect(page.locator(".session-card")).toHaveCount(12);
|
||||
const scrollTopAfter = await main.evaluate((element) => element.scrollTop);
|
||||
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("upload tasks use cards on mobile and never require table horizontal scrolling", async ({ page }, testInfo) => {
|
||||
await page.goto("/upload-tasks");
|
||||
await expect(page.getByRole("heading", { name: "上传任务" })).toBeVisible();
|
||||
await expect(page.getByPlaceholder(/搜索直播间/)).toBeVisible();
|
||||
await expect(page.getByText(task.outputFilePath, { exact: true })).toBeVisible();
|
||||
await expectNoDocumentOverflow(page);
|
||||
|
||||
if ((page.viewportSize()?.width ?? 0) < 1280) {
|
||||
await expect(page.locator(".upload-item-card")).toBeVisible();
|
||||
await expect(page.locator(".upload-table")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "查看详情" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "立即上传" })).toBeVisible();
|
||||
} else {
|
||||
await expect(page.getByRole("columnheader", { name: "操作" })).toBeVisible();
|
||||
const tableScroll = page.locator(".upload-table .el-scrollbar__wrap");
|
||||
await expect(tableScroll).toBeVisible();
|
||||
const dimensions = await tableScroll.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth
|
||||
}));
|
||||
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth + 1);
|
||||
const uploadButton = page.getByRole("button", { name: "上传", exact: true });
|
||||
await expect(uploadButton).toBeVisible();
|
||||
const uploadButtonBox = await uploadButton.boundingBox();
|
||||
expect(uploadButtonBox && uploadButtonBox.x + uploadButtonBox.width)
|
||||
.toBeLessThanOrEqual((page.viewportSize()?.width ?? 0) + 1);
|
||||
}
|
||||
|
||||
await capture(page, testInfo, "upload-tasks");
|
||||
});
|
||||
|
||||
test("paused uploads expose retry controls without overflowing the viewport", async ({ page }, testInfo) => {
|
||||
await page.route("**/api/record-tasks/upload-status**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [{
|
||||
...uploadTask,
|
||||
uploadStatus: 5,
|
||||
uploadAttemptCount: 3,
|
||||
uploadErrorMessage: "OpenList 登录请求过多,队列已全局暂停,当前任务次数不会继续消耗。"
|
||||
}],
|
||||
totalCount: 1,
|
||||
notUploadedCount: 0,
|
||||
failedArtifactCount: 0,
|
||||
succeededCount: 0,
|
||||
failedCount: 0,
|
||||
queuedCount: 0,
|
||||
uploadingCount: 0,
|
||||
waitingRetryCount: 1,
|
||||
matchingRetryableCount: 1,
|
||||
queueHealth: {
|
||||
state: "RateLimited",
|
||||
isPaused: true,
|
||||
reason: "OpenList 登录请求触发限流。队列暂停期间不会发起新的上传请求,也不会消耗单任务重试额度。",
|
||||
retryAt: "2026-08-02T12:15:00Z"
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/upload-tasks");
|
||||
await expect(page.getByText("OpenList 正在限流", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "验证并恢复队列" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "重试全部匹配" })).toBeVisible();
|
||||
const retryLabel = (page.viewportSize()?.width ?? 0) < 1280 ? "立即重试" : "重试";
|
||||
await expect(page.getByRole("button", { name: retryLabel, exact: true })).toBeVisible();
|
||||
await expectNoDocumentOverflow(page);
|
||||
await capture(page, testInfo, "upload-queue-paused");
|
||||
});
|
||||
|
||||
test("recording failure recovery adapts its actions and long paths to each viewport", async ({ page }, testInfo) => {
|
||||
await page.goto("/recovery");
|
||||
await expect(page.getByRole("heading", { name: "恢复中心" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "录制失败产物" })).toBeVisible();
|
||||
await expect(page.getByText(recordingFailure.filePath, { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "确认有效" })).toBeVisible();
|
||||
|
||||
if ((page.viewportSize()?.width ?? 0) <= 768) {
|
||||
await expect(page.locator(".failure-card")).toHaveCount(1);
|
||||
await expect(page.getByRole("columnheader", { name: "操作" })).toHaveCount(0);
|
||||
} else {
|
||||
await expect(page.locator(".failure-card")).toHaveCount(0);
|
||||
await expect(page.getByRole("columnheader", { name: "操作" })).toBeVisible();
|
||||
}
|
||||
|
||||
await expectNoDocumentOverflow(page);
|
||||
await capture(page, testInfo, "recording-failure-recovery");
|
||||
});
|
||||
|
||||
test("mobile session details keep segment actions visible and left aligned", async ({ page }, testInfo) => {
|
||||
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile session detail contract");
|
||||
|
||||
await page.goto("/record-tasks");
|
||||
const sessionCard = page.locator(".session-card").first();
|
||||
await expect(sessionCard).toBeVisible();
|
||||
await sessionCard.locator(":scope > .data-card__actions").getByRole("button", { name: "查看", exact: true }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: "会话详情" })).toBeVisible();
|
||||
await expect(page.locator(".segment-card")).toHaveCount(3);
|
||||
await expect(page.getByRole("columnheader", { name: "操作" })).toHaveCount(0);
|
||||
|
||||
const firstCard = page.locator(".segment-card").first();
|
||||
const firstActions = firstCard.locator(".segment-card__actions");
|
||||
const cardBox = await firstCard.boundingBox();
|
||||
const actionsBox = await firstActions.boundingBox();
|
||||
expect(cardBox).not.toBeNull();
|
||||
expect(actionsBox).not.toBeNull();
|
||||
expect(actionsBox!.x - cardBox!.x).toBeLessThanOrEqual(20);
|
||||
|
||||
const lastActions = page.locator(".segment-card__actions").last();
|
||||
await lastActions.scrollIntoViewIfNeeded();
|
||||
await expect(lastActions.getByRole("button", { name: "查看分片" })).toBeVisible();
|
||||
const actionsBottom = await lastActions.boundingBox();
|
||||
const bottomNav = await page.locator(".app-bottom-nav").boundingBox();
|
||||
expect(actionsBottom).not.toBeNull();
|
||||
expect(bottomNav).not.toBeNull();
|
||||
expect(actionsBottom!.y + actionsBottom!.height).toBeLessThanOrEqual(bottomNav!.y + 1);
|
||||
await capture(page, testInfo, "mobile-session-detail");
|
||||
});
|
||||
|
||||
test("mobile upload polling keeps cards and scroll position while progress updates", async ({ page }) => {
|
||||
test.skip((page.viewportSize()?.width ?? 0) > 768, "mobile polling contract");
|
||||
|
||||
const allItems = Array.from({ length: 60 }, (_, index) => ({
|
||||
...uploadTask,
|
||||
recordTaskId: `upload-task-${index + 1}`,
|
||||
segmentIndex: index + 1,
|
||||
liveRoomTitle: `轮询直播间 ${index + 1}`,
|
||||
uploadStatus: 3,
|
||||
uploadProgressPercent: 10
|
||||
}));
|
||||
let requestCount = 0;
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
|
||||
await page.route("**/api/record-tasks/upload-status**", async (route) => {
|
||||
requestCount++;
|
||||
inFlight++;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
const url = new URL(route.request().url());
|
||||
const skip = Number(url.searchParams.get("skip") || 0);
|
||||
const take = Number(url.searchParams.get("take") || 50);
|
||||
if (requestCount > 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
const progress = requestCount > 1 ? 42 : 10;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: allItems.slice(skip, skip + take).map((item) => ({ ...item, uploadProgressPercent: progress })),
|
||||
totalCount: allItems.length,
|
||||
notUploadedCount: 0,
|
||||
failedArtifactCount: 0,
|
||||
succeededCount: 0,
|
||||
failedCount: 0,
|
||||
queuedCount: 0,
|
||||
uploadingCount: allItems.length,
|
||||
waitingRetryCount: 0,
|
||||
matchingRetryableCount: 0,
|
||||
queueHealth: { state: "Healthy", isPaused: false }
|
||||
})
|
||||
});
|
||||
inFlight--;
|
||||
});
|
||||
|
||||
await page.goto("/upload-tasks");
|
||||
await expect(page.locator(".upload-item-card")).toHaveCount(12);
|
||||
await expect(page.getByText("10.0% · 第 0 次").first()).toBeVisible();
|
||||
|
||||
const main = page.locator(".app-main");
|
||||
await main.evaluate((element) => { element.scrollTop = element.scrollHeight; });
|
||||
const scrollTopBefore = await main.evaluate((element) => element.scrollTop);
|
||||
expect(scrollTopBefore).toBeGreaterThan(500);
|
||||
|
||||
await expect.poll(() => requestCount, { timeout: 10_000 }).toBeGreaterThan(1);
|
||||
await expect(page.locator(".upload-item-card")).toHaveCount(12);
|
||||
await expect(page.locator(".el-skeleton")).toHaveCount(0);
|
||||
await expect(page.getByText("42.0% · 第 0 次").first()).toBeVisible();
|
||||
|
||||
const scrollTopAfter = await main.evaluate((element) => element.scrollTop);
|
||||
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThanOrEqual(2);
|
||||
expect(maxInFlight).toBe(1);
|
||||
});
|
||||
|
||||
test("settings layer advanced controls without clipping the quick bar", async ({ page }, testInfo) => {
|
||||
await page.goto("/settings/recording");
|
||||
await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "保留清理" })).toBeHidden();
|
||||
await page.getByText("高级设置", { exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "保留清理" })).toBeVisible();
|
||||
await expectNoDocumentOverflow(page);
|
||||
await capture(page, testInfo, "settings");
|
||||
});
|
||||
|
||||
test("OpenList test distinguishes login success from a broken destination mount", async ({ page }) => {
|
||||
await page.route("**/api/settings", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
enableFileUpload: true,
|
||||
enableAutoUpload: true,
|
||||
uploadTarget: 3,
|
||||
openListUpload: {
|
||||
baseUrl: "https://openlist.example.com",
|
||||
username: "tester",
|
||||
password: "secret",
|
||||
basePath: "/archive",
|
||||
sourcePath: "/source",
|
||||
destinationPath: "/archive"
|
||||
}
|
||||
})
|
||||
});
|
||||
});
|
||||
await page.route("**/api/settings/openlist/test", async (route) => {
|
||||
const request = route.request().postDataJSON();
|
||||
expect(request.sourcePath).toBe("/source");
|
||||
expect(request.destinationPath).toBe("/archive");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
version: "4.1.10",
|
||||
message: "OpenList 登录成功,但一个或多个配置目录不可用。",
|
||||
sourcePath: { path: "/source", success: true, canWrite: false, message: "目录可访问。" },
|
||||
destinationPath: { path: "/archive", success: false, canWrite: false, message: "provider timeout" }
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/settings/upload");
|
||||
await expect(page.getByRole("heading", { name: "OpenList 服务端复制" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "测试连接" }).click();
|
||||
await expect(page.getByText("配置异常", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("目录可访问。", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("provider timeout", { exact: true })).toBeVisible();
|
||||
await expectNoDocumentOverflow(page);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import path from "node:path";
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
plugins: [
|
||||
...(command === "serve" ? [VueDevTools()] : []),
|
||||
...(command === "serve" && process.env.VITE_DISABLE_DEVTOOLS !== "1" ? [VueDevTools()] : []),
|
||||
vue()
|
||||
],
|
||||
resolve: {
|
||||
@@ -14,6 +14,7 @@ export default defineConfig(({ command }) => ({
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: "es2015",
|
||||
chunkSizeWarningLimit: 900,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.dart_tool/
|
||||
.tmp/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub/
|
||||
build/
|
||||
coverage/
|
||||
android/.gradle/
|
||||
android/local.properties
|
||||
@@ -0,0 +1,24 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "44a626f4f0027bc38a46dc68aed5964b05a83c18"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
|
||||
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
|
||||
- platform: android
|
||||
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
|
||||
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
|
||||
|
||||
unmanaged_files:
|
||||
- "lib/main.dart"
|
||||
- "android/app/src/main/kotlin/com/liverecorder/mobile/MainActivity.kt"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
avoid_print: false
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.liverecorder.mobile"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.liverecorder.mobile"
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="28" />
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="LiveRecorder"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme" />
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package io.flutter.plugins;
|
||||
|
||||
import androidx.annotation.Keep;
|
||||
import androidx.annotation.NonNull;
|
||||
import io.flutter.Log;
|
||||
|
||||
import io.flutter.embedding.engine.FlutterEngine;
|
||||
|
||||
/**
|
||||
* Generated file. Do not edit.
|
||||
* This file is generated by the Flutter tool based on the
|
||||
* plugins that support the Android platform.
|
||||
*/
|
||||
@Keep
|
||||
public final class GeneratedPluginRegistrant {
|
||||
private static final String TAG = "GeneratedPluginRegistrant";
|
||||
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.github.dart_lang.jni.JniPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin jni, com.github.dart_lang.jni.JniPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new com.github.dart_lang.jni_flutter.JniFlutterPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin jni_flutter, com.github.dart_lang.jni_flutter.JniFlutterPlugin", e);
|
||||
}
|
||||
try {
|
||||
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.liverecorder.mobile
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
allprojects {
|
||||
buildscript {
|
||||
repositories {
|
||||
maven("https://maven.aliyun.com/repository/public")
|
||||
maven("https://maven.aliyun.com/repository/google")
|
||||
maven("https://maven.aliyun.com/repository/central")
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
maven("https://maven.aliyun.com/repository/public")
|
||||
maven("https://maven.aliyun.com/repository/google")
|
||||
maven("https://maven.aliyun.com/repository/central")
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
|
||||
Vendored
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn ( ) {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die ( ) {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
||||
function splitJvmOpts() {
|
||||
JVM_OPTS=("$@")
|
||||
}
|
||||
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
||||
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
||||
|
||||
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windowz variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
if "%@eval[2+2]" == "4" goto 4NT_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
goto execute
|
||||
|
||||
:4NT_args
|
||||
@rem Get arguments from the 4NT Shell from JP Software
|
||||
set CMD_LINE_ARGS=%$
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,29 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
maven("https://maven.aliyun.com/repository/gradle-plugin")
|
||||
maven("https://maven.aliyun.com/repository/google")
|
||||
maven("https://maven.aliyun.com/repository/central")
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/app/app_scope.dart';
|
||||
import 'package:live_recorder_mobile/app/app_theme.dart';
|
||||
import 'package:live_recorder_mobile/core/config/api_config.dart';
|
||||
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
|
||||
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/backend_setup_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/login_page.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/mobile_shell_page.dart';
|
||||
|
||||
class LiveRecorderBootstrap extends StatefulWidget {
|
||||
const LiveRecorderBootstrap({
|
||||
super.key,
|
||||
required this.config,
|
||||
});
|
||||
|
||||
final ApiConfig config;
|
||||
|
||||
@override
|
||||
State<LiveRecorderBootstrap> createState() => _LiveRecorderBootstrapState();
|
||||
}
|
||||
|
||||
class _LiveRecorderBootstrapState extends State<LiveRecorderBootstrap> {
|
||||
late final AppBootstrapController<AppDependencies> _bootstrapController =
|
||||
AppBootstrapController<AppDependencies>(
|
||||
config: widget.config,
|
||||
configStorage: AppConfigStorage(),
|
||||
dependenciesFactory: (String baseUrl) => AppDependencies.create(baseUrl: baseUrl),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrapController.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bootstrapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _bootstrapController,
|
||||
builder: (BuildContext context, _) {
|
||||
return AppScope(
|
||||
backendConfig: _bootstrapController,
|
||||
dependencies: _bootstrapController.dependencies,
|
||||
child: MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'LiveRecorder',
|
||||
theme: buildLiveRecorderTheme(),
|
||||
home: _BootstrapHome(
|
||||
bootstrapController: _bootstrapController,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BootstrapHome extends StatelessWidget {
|
||||
const _BootstrapHome({
|
||||
required this.bootstrapController,
|
||||
});
|
||||
|
||||
final AppBootstrapController<AppDependencies> bootstrapController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (bootstrapController.isInitializing) {
|
||||
return const _LoadingSplashPage();
|
||||
}
|
||||
|
||||
if (bootstrapController.initializationErrorMessage != null) {
|
||||
return _BootstrapErrorPage(
|
||||
message: bootstrapController.initializationErrorMessage!,
|
||||
onRetry: bootstrapController.initialize,
|
||||
);
|
||||
}
|
||||
|
||||
if (!bootstrapController.hasConfiguredBackend) {
|
||||
return BackendSetupPage(
|
||||
bootstrapController: bootstrapController,
|
||||
);
|
||||
}
|
||||
|
||||
final dependencies = bootstrapController.dependencies;
|
||||
if (dependencies == null) {
|
||||
return _BootstrapErrorPage(
|
||||
message: '后端配置未能正确加载,请重试',
|
||||
onRetry: bootstrapController.initialize,
|
||||
);
|
||||
}
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: dependencies.sessionController,
|
||||
builder: (BuildContext context, _) {
|
||||
if (dependencies.sessionController.isRestoring) {
|
||||
return const _LoadingSplashPage();
|
||||
}
|
||||
|
||||
if (dependencies.sessionController.isLoggedIn) {
|
||||
return MobileShellPage(
|
||||
dependencies: dependencies,
|
||||
);
|
||||
}
|
||||
|
||||
return LoginPage(
|
||||
sessionController: dependencies.sessionController,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadingSplashPage extends StatelessWidget {
|
||||
const _LoadingSplashPage();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BootstrapErrorPage extends StatelessWidget {
|
||||
const _BootstrapErrorPage({
|
||||
required this.message,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: AppErrorCard(
|
||||
message: message,
|
||||
onRetry: onRetry,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:live_recorder_mobile/app/app_dependencies.dart';
|
||||
import 'package:live_recorder_mobile/core/config/api_config.dart';
|
||||
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
|
||||
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
|
||||
|
||||
typedef AppDependenciesFactory<T extends AppDependencyBundle> = T Function(String baseUrl);
|
||||
|
||||
abstract interface class BackendConfigHandle extends Listenable {
|
||||
String get seedBaseUrl;
|
||||
String? get backendBaseUrl;
|
||||
bool get hasConfiguredBackend;
|
||||
bool get isInitializing;
|
||||
String? get initializationErrorMessage;
|
||||
|
||||
Future<void> initialize();
|
||||
Future<void> saveInitialBackendBaseUrl(String rawValue);
|
||||
Future<bool> updateBackendBaseUrl(String rawValue);
|
||||
}
|
||||
|
||||
class AppBootstrapController<T extends AppDependencyBundle> extends ChangeNotifier
|
||||
implements BackendConfigHandle {
|
||||
AppBootstrapController({
|
||||
required ApiConfig config,
|
||||
required BackendConfigStore configStorage,
|
||||
required AppDependenciesFactory<T> dependenciesFactory,
|
||||
}) : _config = config,
|
||||
_configStorage = configStorage,
|
||||
_dependenciesFactory = dependenciesFactory;
|
||||
|
||||
final ApiConfig _config;
|
||||
final BackendConfigStore _configStorage;
|
||||
final AppDependenciesFactory<T> _dependenciesFactory;
|
||||
|
||||
T? _dependencies;
|
||||
String? _backendBaseUrl;
|
||||
bool _isInitializing = true;
|
||||
String? _initializationErrorMessage;
|
||||
|
||||
T? get dependencies => _dependencies;
|
||||
|
||||
@override
|
||||
String get seedBaseUrl => _config.seedBaseUrl;
|
||||
|
||||
@override
|
||||
String? get backendBaseUrl => _backendBaseUrl;
|
||||
|
||||
@override
|
||||
bool get hasConfiguredBackend => _backendBaseUrl != null && _backendBaseUrl!.isNotEmpty;
|
||||
|
||||
@override
|
||||
bool get isInitializing => _isInitializing;
|
||||
|
||||
@override
|
||||
String? get initializationErrorMessage => _initializationErrorMessage;
|
||||
|
||||
@override
|
||||
Future<void> initialize() async {
|
||||
_setInitializing(true);
|
||||
try {
|
||||
final storedBaseUrl = await _configStorage.readBackendBaseUrl();
|
||||
if (storedBaseUrl == null || storedBaseUrl.trim().isEmpty) {
|
||||
_disposeDependencies();
|
||||
_backendBaseUrl = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final normalizedBaseUrl = normalizeBackendBaseUrl(storedBaseUrl);
|
||||
_backendBaseUrl = normalizedBaseUrl;
|
||||
await _rebuildDependencies(normalizedBaseUrl);
|
||||
} on FormatException {
|
||||
await _configStorage.clear();
|
||||
_disposeDependencies();
|
||||
_backendBaseUrl = null;
|
||||
} catch (_) {
|
||||
_disposeDependencies();
|
||||
_backendBaseUrl = null;
|
||||
_initializationErrorMessage = '读取后端地址失败,请重试';
|
||||
} finally {
|
||||
_setInitializing(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveInitialBackendBaseUrl(String rawValue) async {
|
||||
final normalizedBaseUrl = normalizeBackendBaseUrl(rawValue);
|
||||
await _persistAndApplyBackendBaseUrl(
|
||||
normalizedBaseUrl,
|
||||
clearExistingSession: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> updateBackendBaseUrl(String rawValue) async {
|
||||
final normalizedBaseUrl = normalizeBackendBaseUrl(rawValue);
|
||||
if (normalizedBaseUrl == _backendBaseUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await _persistAndApplyBackendBaseUrl(
|
||||
normalizedBaseUrl,
|
||||
clearExistingSession: true,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _persistAndApplyBackendBaseUrl(
|
||||
String normalizedBaseUrl, {
|
||||
required bool clearExistingSession,
|
||||
}) async {
|
||||
final previousDependencies = _dependencies;
|
||||
_setInitializing(true);
|
||||
|
||||
try {
|
||||
await _configStorage.writeBackendBaseUrl(normalizedBaseUrl);
|
||||
if (clearExistingSession) {
|
||||
await previousDependencies?.sessionController.clearLocalSession();
|
||||
}
|
||||
|
||||
_backendBaseUrl = normalizedBaseUrl;
|
||||
await _rebuildDependencies(normalizedBaseUrl);
|
||||
} catch (error) {
|
||||
if (!identical(previousDependencies, _dependencies)) {
|
||||
_dependencies?.dispose();
|
||||
_dependencies = previousDependencies;
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
_setInitializing(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rebuildDependencies(String baseUrl) async {
|
||||
final nextDependencies = _dependenciesFactory(baseUrl);
|
||||
final previousDependencies = _dependencies;
|
||||
_dependencies = nextDependencies;
|
||||
|
||||
try {
|
||||
await nextDependencies.sessionController.restore();
|
||||
previousDependencies?.dispose();
|
||||
} catch (_) {
|
||||
nextDependencies.dispose();
|
||||
_dependencies = previousDependencies;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
void _setInitializing(bool value) {
|
||||
_isInitializing = value;
|
||||
if (value) {
|
||||
_initializationErrorMessage = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _disposeDependencies() {
|
||||
final dependencies = _dependencies;
|
||||
_dependencies = null;
|
||||
dependencies?.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposeDependencies();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:live_recorder_mobile/core/network/api_client.dart';
|
||||
import 'package:live_recorder_mobile/core/persistence/session_storage.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/auth_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/live_rooms_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/logs_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/media_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recovery_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recordings_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
|
||||
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
|
||||
|
||||
abstract interface class AppDependencyBundle {
|
||||
SessionControllerHandle get sessionController;
|
||||
void dispose();
|
||||
}
|
||||
|
||||
class AppDependencies implements AppDependencyBundle {
|
||||
AppDependencies._({
|
||||
required this.backendBaseUrl,
|
||||
required this.apiClient,
|
||||
required this.sessionStorage,
|
||||
required this.authRepository,
|
||||
required this.liveRoomsRepository,
|
||||
required this.recordingsRepository,
|
||||
required this.recoveryRepository,
|
||||
required this.settingsRepository,
|
||||
required this.logsRepository,
|
||||
required this.mediaRepository,
|
||||
required this.sessionController,
|
||||
});
|
||||
|
||||
factory AppDependencies.create({
|
||||
required String baseUrl,
|
||||
}) {
|
||||
late AppSessionController sessionController;
|
||||
final sessionStorage = SessionStorage();
|
||||
final apiClient = ApiClient(
|
||||
baseUrl: baseUrl,
|
||||
tokenProvider: () => sessionController.token,
|
||||
onUnauthorized: () async => sessionController.handleUnauthorized(),
|
||||
);
|
||||
final authRepository = AuthRepository(apiClient);
|
||||
sessionController = AppSessionController(
|
||||
authRepository: authRepository,
|
||||
sessionStorage: sessionStorage,
|
||||
);
|
||||
|
||||
return AppDependencies._(
|
||||
backendBaseUrl: baseUrl,
|
||||
apiClient: apiClient,
|
||||
sessionStorage: sessionStorage,
|
||||
authRepository: authRepository,
|
||||
liveRoomsRepository: LiveRoomsRepository(apiClient),
|
||||
recordingsRepository: RecordingsRepository(apiClient),
|
||||
recoveryRepository: RecoveryRepository(apiClient),
|
||||
settingsRepository: SettingsRepository(apiClient),
|
||||
logsRepository: LogsRepository(apiClient),
|
||||
mediaRepository: MediaRepository(apiClient),
|
||||
sessionController: sessionController,
|
||||
);
|
||||
}
|
||||
|
||||
final String backendBaseUrl;
|
||||
final ApiClient apiClient;
|
||||
final SessionStorage sessionStorage;
|
||||
final AuthRepository authRepository;
|
||||
final LiveRoomsRepository liveRoomsRepository;
|
||||
final RecordingsRepository recordingsRepository;
|
||||
final RecoveryRepository recoveryRepository;
|
||||
final SettingsRepository settingsRepository;
|
||||
final LogsRepository logsRepository;
|
||||
final MediaRepository mediaRepository;
|
||||
@override
|
||||
final AppSessionController sessionController;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
apiClient.dispose();
|
||||
sessionController.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'app_bootstrap_controller.dart';
|
||||
import 'app_dependencies.dart';
|
||||
|
||||
class AppScope extends InheritedWidget {
|
||||
const AppScope({
|
||||
super.key,
|
||||
required this.backendConfig,
|
||||
required this.dependencies,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
final BackendConfigHandle backendConfig;
|
||||
final AppDependencies? dependencies;
|
||||
|
||||
static AppDependencies of(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'AppScope is not available in this context.');
|
||||
final dependencies = scope!.dependencies;
|
||||
assert(dependencies != null, 'AppDependencies are not available in this context.');
|
||||
return dependencies!;
|
||||
}
|
||||
|
||||
static BackendConfigHandle backendConfigOf(BuildContext context) {
|
||||
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
|
||||
assert(scope != null, 'AppScope is not available in this context.');
|
||||
return scope!.backendConfig;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(AppScope oldWidget) {
|
||||
return dependencies != oldWidget.dependencies || backendConfig != oldWidget.backendConfig;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
ThemeData buildLiveRecorderTheme() {
|
||||
const seed = Color(0xFF2563EB);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
primary: seed,
|
||||
surface: Colors.white,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFFF6F8FB),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
side: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
foregroundColor: Color(0xFF0F172A),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
height: 72,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith<TextStyle?>(
|
||||
(Set<WidgetState> states) {
|
||||
final color = states.contains(WidgetState.selected)
|
||||
? const Color(0xFF2563EB)
|
||||
: const Color(0xFF64748B);
|
||||
return TextStyle(
|
||||
color: color,
|
||||
fontWeight: states.contains(WidgetState.selected) ? FontWeight.w700 : FontWeight.w500,
|
||||
);
|
||||
},
|
||||
),
|
||||
indicatorColor: const Color(0xFFE0ECFF),
|
||||
backgroundColor: Colors.white,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
hintStyle: const TextStyle(color: Color(0xFF64748B)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFF2563EB), width: 1.4),
|
||||
),
|
||||
),
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: Colors.white,
|
||||
selectedColor: const Color(0xFFE0ECFF),
|
||||
side: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
labelStyle: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: Color(0xFFE2E8F0),
|
||||
thickness: 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
class ApiConfig {
|
||||
const ApiConfig({
|
||||
this.seedBaseUrl = '',
|
||||
});
|
||||
|
||||
final String seedBaseUrl;
|
||||
|
||||
static ApiConfig fromEnvironment() {
|
||||
const rawValue = String.fromEnvironment('LIVE_RECORDER_API_BASE_URL');
|
||||
return ApiConfig(seedBaseUrl: rawValue.trim());
|
||||
}
|
||||
|
||||
bool get hasSeedBaseUrl => seedBaseUrl.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import 'api_exception.dart';
|
||||
|
||||
typedef TokenProvider = String? Function();
|
||||
typedef UnauthorizedCallback = Future<void> Function();
|
||||
|
||||
class ApiClient {
|
||||
ApiClient({
|
||||
required String baseUrl,
|
||||
required TokenProvider tokenProvider,
|
||||
required UnauthorizedCallback onUnauthorized,
|
||||
http.Client? client,
|
||||
}) : _baseUri = Uri.parse(baseUrl),
|
||||
_tokenProvider = tokenProvider,
|
||||
_onUnauthorized = onUnauthorized,
|
||||
_client = client ?? http.Client();
|
||||
|
||||
final Uri _baseUri;
|
||||
final TokenProvider _tokenProvider;
|
||||
final UnauthorizedCallback _onUnauthorized;
|
||||
final http.Client _client;
|
||||
|
||||
Uri buildUri(
|
||||
String path, {
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return Uri.parse(path);
|
||||
}
|
||||
|
||||
final normalizedPath = path.startsWith('/') ? path.substring(1) : path;
|
||||
final basePath = _baseUri.path == '/' ? '' : _baseUri.path.replaceAll(RegExp(r'/+$'), '');
|
||||
final resolvedPath = basePath.isEmpty ? '/$normalizedPath' : '$basePath/$normalizedPath';
|
||||
final resolved = _baseUri.replace(path: resolvedPath);
|
||||
if (queryParameters == null || queryParameters.isEmpty) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
return resolved.replace(
|
||||
queryParameters: <String, String>{
|
||||
...resolved.queryParameters,
|
||||
...queryParameters,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> getJson(
|
||||
String path, {
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
return _sendJsonRequest(
|
||||
'GET',
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> postJson(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
return _sendJsonRequest(
|
||||
'POST',
|
||||
path,
|
||||
body: body,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> putJson(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
return _sendJsonRequest(
|
||||
'PUT',
|
||||
path,
|
||||
body: body,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> deleteJson(
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) {
|
||||
return _sendJsonRequest(
|
||||
'DELETE',
|
||||
path,
|
||||
body: body,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _sendJsonRequest(
|
||||
String method,
|
||||
String path, {
|
||||
Object? body,
|
||||
Map<String, String>? queryParameters,
|
||||
}) async {
|
||||
final uri = buildUri(path, queryParameters: queryParameters);
|
||||
final request = http.Request(method, uri);
|
||||
request.headers.addAll(_buildHeaders());
|
||||
|
||||
if (body != null) {
|
||||
request.body = jsonEncode(body);
|
||||
}
|
||||
|
||||
http.StreamedResponse streamedResponse;
|
||||
try {
|
||||
streamedResponse = await _client.send(request);
|
||||
} on Exception catch (error) {
|
||||
throw ApiException(message: '无法连接后端服务', detail: error.toString());
|
||||
}
|
||||
|
||||
final response = await http.Response.fromStream(streamedResponse);
|
||||
return _decodeJsonResponse(response);
|
||||
}
|
||||
|
||||
Future<void> postEmpty(
|
||||
String path, {
|
||||
Object? body,
|
||||
}) async {
|
||||
await postJson(path, body: body);
|
||||
}
|
||||
|
||||
Map<String, String> _buildHeaders() {
|
||||
final headers = <String, String>{
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
final token = _tokenProvider()?.trim();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
dynamic _decodeJsonResponse(http.Response response) {
|
||||
if (response.statusCode == 401) {
|
||||
_onUnauthorized();
|
||||
}
|
||||
|
||||
final bodyText = utf8.decode(response.bodyBytes);
|
||||
final jsonBody = bodyText.trim().isEmpty ? null : jsonDecode(bodyText);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return jsonBody;
|
||||
}
|
||||
|
||||
throw ApiException(
|
||||
message: _resolveErrorMessage(response.statusCode, jsonBody),
|
||||
statusCode: response.statusCode,
|
||||
detail: jsonBody is Map<String, dynamic>
|
||||
? (jsonBody['detail'] ?? jsonBody['error'])?.toString()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveErrorMessage(int statusCode, dynamic body) {
|
||||
if (body is Map<String, dynamic>) {
|
||||
final candidate = <dynamic>[
|
||||
body['message'],
|
||||
body['title'],
|
||||
body['detail'],
|
||||
body['error'],
|
||||
].firstWhere(
|
||||
(value) => value is String && value.trim().isNotEmpty,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (candidate is String) {
|
||||
return candidate;
|
||||
}
|
||||
} else if (body is String && body.trim().isNotEmpty) {
|
||||
return body;
|
||||
}
|
||||
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return '请求参数有误,请检查后重试';
|
||||
case 401:
|
||||
return '登录状态已失效,请重新登录';
|
||||
case 403:
|
||||
return '当前没有权限执行该操作';
|
||||
case 404:
|
||||
return '请求的接口不存在';
|
||||
case 409:
|
||||
return '请求发生冲突,请刷新后重试';
|
||||
case 422:
|
||||
return '提交的数据格式不正确,请检查后重试';
|
||||
case 500:
|
||||
return '后端服务发生内部错误';
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return '后端服务暂时不可用,请稍后重试';
|
||||
default:
|
||||
return '请求失败,请稍后重试';
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_client.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
class ApiException implements Exception {
|
||||
const ApiException({
|
||||
required this.message,
|
||||
this.statusCode,
|
||||
this.detail,
|
||||
});
|
||||
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final String? detail;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final buffer = StringBuffer('ApiException(message: $message');
|
||||
if (statusCode != null) {
|
||||
buffer.write(', statusCode: $statusCode');
|
||||
}
|
||||
if (detail != null && detail!.isNotEmpty) {
|
||||
buffer.write(', detail: $detail');
|
||||
}
|
||||
buffer.write(')');
|
||||
return buffer.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
abstract interface class BackendConfigStore {
|
||||
Future<String?> readBackendBaseUrl();
|
||||
Future<void> writeBackendBaseUrl(String baseUrl);
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
class AppConfigStorage implements BackendConfigStore {
|
||||
@override
|
||||
Future<String?> readBackendBaseUrl() async {
|
||||
final file = await _configFile();
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final content = await file.readAsString();
|
||||
if (content.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final payload = jsonDecode(content);
|
||||
if (payload is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final value = payload['backendBaseUrl']?.toString().trim();
|
||||
if (value == null || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> writeBackendBaseUrl(String baseUrl) async {
|
||||
final file = await _configFile();
|
||||
await file.create(recursive: true);
|
||||
await file.writeAsString(
|
||||
jsonEncode(<String, dynamic>{
|
||||
'backendBaseUrl': baseUrl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
final file = await _configFile();
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<File> _configFile() async {
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
return File('${directory.path}${Platform.pathSeparator}live_recorder_app_config.json');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class SessionStorage {
|
||||
Future<Map<String, dynamic>?> read() async {
|
||||
final file = await _sessionFile();
|
||||
if (!await file.exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final content = await file.readAsString();
|
||||
if (content.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return jsonDecode(content) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
Future<void> write(Map<String, dynamic> payload) async {
|
||||
final file = await _sessionFile();
|
||||
await file.create(recursive: true);
|
||||
await file.writeAsString(jsonEncode(payload));
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final file = await _sessionFile();
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<File> _sessionFile() async {
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
return File('${directory.path}${Platform.pathSeparator}live_recorder_session.json');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'dart:async';
|
||||
|
||||
class PollingController {
|
||||
PollingController({
|
||||
required Duration interval,
|
||||
required Future<void> Function() onTick,
|
||||
}) : _interval = interval,
|
||||
_onTick = onTick;
|
||||
|
||||
final Duration _interval;
|
||||
final Future<void> Function() _onTick;
|
||||
|
||||
Timer? _timer;
|
||||
bool _active = false;
|
||||
bool _busy = false;
|
||||
|
||||
void setActive(bool active) {
|
||||
if (_active == active) {
|
||||
return;
|
||||
}
|
||||
|
||||
_active = active;
|
||||
if (_active) {
|
||||
_schedule();
|
||||
triggerNow();
|
||||
} else {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
void triggerNow() {
|
||||
if (!_active || _busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
_tick();
|
||||
}
|
||||
|
||||
Future<void> _tick() async {
|
||||
_busy = true;
|
||||
try {
|
||||
await _onTick();
|
||||
} finally {
|
||||
_busy = false;
|
||||
_schedule();
|
||||
}
|
||||
}
|
||||
|
||||
void _schedule() {
|
||||
_timer?.cancel();
|
||||
if (!_active) {
|
||||
return;
|
||||
}
|
||||
|
||||
_timer = Timer(_interval, () {
|
||||
if (_active && !_busy) {
|
||||
_tick();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
String normalizeBackendBaseUrl(String rawValue) {
|
||||
final trimmed = rawValue.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw const FormatException('请输入后端地址');
|
||||
}
|
||||
|
||||
final uri = Uri.tryParse(trimmed);
|
||||
if (uri == null ||
|
||||
!uri.hasScheme ||
|
||||
(uri.scheme != 'http' && uri.scheme != 'https') ||
|
||||
uri.host.isEmpty) {
|
||||
throw const FormatException('请输入以 http:// 或 https:// 开头的完整地址');
|
||||
}
|
||||
|
||||
if (uri.query.isNotEmpty || uri.fragment.isNotEmpty) {
|
||||
throw const FormatException('后端地址不能包含查询参数或片段');
|
||||
}
|
||||
|
||||
var normalizedPath = uri.path.replaceAll(RegExp(r'/+$'), '');
|
||||
if (normalizedPath == '/') {
|
||||
normalizedPath = '';
|
||||
}
|
||||
|
||||
return uri.replace(path: normalizedPath).toString();
|
||||
}
|
||||
|
||||
String? validateBackendBaseUrl(String rawValue) {
|
||||
try {
|
||||
normalizeBackendBaseUrl(rawValue);
|
||||
return null;
|
||||
} on FormatException catch (error) {
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
final DateFormat _timeFormat = DateFormat('HH:mm');
|
||||
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
|
||||
|
||||
String formatDateTime(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final dateTime = DateTime.tryParse(value)?.toLocal();
|
||||
if (dateTime == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return _dateTimeFormat.format(dateTime);
|
||||
}
|
||||
|
||||
String formatTime(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final dateTime = DateTime.tryParse(value)?.toLocal();
|
||||
if (dateTime == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return _timeFormat.format(dateTime);
|
||||
}
|
||||
|
||||
String formatDateOnly(String? value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final dateTime = DateTime.tryParse(value)?.toLocal();
|
||||
if (dateTime == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return _dateFormat.format(dateTime);
|
||||
}
|
||||
|
||||
String formatDurationSeconds(num? seconds) {
|
||||
if (seconds == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
final totalSeconds = seconds.round();
|
||||
final hours = totalSeconds ~/ 3600;
|
||||
final minutes = (totalSeconds % 3600) ~/ 60;
|
||||
final remainingSeconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return '${minutes}m ${remainingSeconds}s';
|
||||
}
|
||||
return '${remainingSeconds}s';
|
||||
}
|
||||
|
||||
String formatBytes(num? bytes) {
|
||||
if (bytes == null) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const units = <String>['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
var value = bytes.toDouble();
|
||||
var index = 0;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
final fractionDigits = index == 0 ? 0 : index == 1 ? 1 : 2;
|
||||
return '${value.toStringAsFixed(fractionDigits)} ${units[index]}';
|
||||
}
|
||||
|
||||
String valueOrDash(Object? value) {
|
||||
if (value == null) {
|
||||
return '--';
|
||||
}
|
||||
final text = value.toString().trim();
|
||||
return text.isEmpty ? '--' : text;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user