Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
|
||||
+12
@@ -5,7 +5,12 @@
|
||||
**/*.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
|
||||
@@ -32,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
+524
-196
@@ -1,244 +1,572 @@
|
||||
pipeline {
|
||||
agent { label '閺嬪嫬缂撻張?' }
|
||||
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'
|
||||
HTTP_PROXY_URL = 'http://192.168.5.200:7890'
|
||||
NO_PROXY_HOSTS = '127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn'
|
||||
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"
|
||||
API_CACHE_IMAGE = "${REGISTRY_URL}/${API_IMAGE_NAME}:buildcache"
|
||||
WEB_CACHE_IMAGE = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:buildcache"
|
||||
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
|
||||
if ! sudo docker buildx inspect --builder ${BUILDER_NAME} >/dev/null 2>&1; then
|
||||
sudo docker buildx create \
|
||||
--name ${BUILDER_NAME} \
|
||||
--driver docker-container \
|
||||
--driver-opt network=host \
|
||||
--driver-opt 'env.HTTP_PROXY=${HTTP_PROXY_URL}' \
|
||||
--driver-opt 'env.HTTPS_PROXY=${HTTP_PROXY_URL}' \
|
||||
--driver-opt 'env.NO_PROXY=reg.nxsir.cn' \
|
||||
--driver-opt 'env.http_proxy=${HTTP_PROXY_URL}' \
|
||||
--driver-opt 'env.https_proxy=${HTTP_PROXY_URL}' \
|
||||
--driver-opt 'env.no_proxy=reg.nxsir.cn' \
|
||||
--use
|
||||
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('Prepare fnOS Dependencies') {
|
||||
when {
|
||||
expression { params.BUILD_FNOS && !params.RUN_TESTS }
|
||||
}
|
||||
steps {
|
||||
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('Prepare Harbor Authentication') {
|
||||
when {
|
||||
expression {
|
||||
params.API_IMAGE_PLATFORMS != 'none' ||
|
||||
params.WEB_IMAGE_PLATFORMS != 'none'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
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 amd64') {
|
||||
when {
|
||||
expression {
|
||||
params.API_IMAGE_PLATFORMS.contains('amd64') ||
|
||||
params.WEB_IMAGE_PLATFORMS.contains('amd64')
|
||||
}
|
||||
}
|
||||
steps {
|
||||
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
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
stage('Login Registry') {
|
||||
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
|
||||
"""
|
||||
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 \
|
||||
-f frontend/Dockerfile -t "$web_ref" --push frontend
|
||||
fi
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Build And Push API Image') {
|
||||
stage('Build And Push arm64') {
|
||||
when {
|
||||
expression {
|
||||
params.API_IMAGE_PLATFORMS.contains('arm64') ||
|
||||
params.WEB_IMAGE_PLATFORMS.contains('arm64')
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh """
|
||||
set -e
|
||||
run_with_heartbeat() {
|
||||
log_file="\$1"
|
||||
build_label="\$2"
|
||||
shift 2
|
||||
rm -f "\$log_file"
|
||||
: > "\$log_file"
|
||||
"\$@" >"\$log_file" 2>&1 &
|
||||
cmd_pid=\$!
|
||||
cmd_status=0
|
||||
last_size=0
|
||||
while kill -0 "\$cmd_pid" >/dev/null 2>&1; do
|
||||
echo "[heartbeat] \${build_label} still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
if [ -f "\$log_file" ]; then
|
||||
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
|
||||
if [ "\$current_size" -gt "\$last_size" ]; then
|
||||
start_byte=\$((last_size + 1))
|
||||
tail -c +"\$start_byte" "\$log_file" || true
|
||||
last_size=\$current_size
|
||||
fi
|
||||
fi
|
||||
sleep 20
|
||||
done
|
||||
wait "\$cmd_pid" || cmd_status=\$?
|
||||
if [ -f "\$log_file" ]; then
|
||||
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
|
||||
if [ "\$current_size" -gt "\$last_size" ]; then
|
||||
start_byte=\$((last_size + 1))
|
||||
tail -c +"\$start_byte" "\$log_file" || true
|
||||
fi
|
||||
fi
|
||||
if [ "\$cmd_status" -ne 0 ]; then
|
||||
echo "[heartbeat] \${build_label} failed with exit code \$cmd_status"
|
||||
fi
|
||||
return "\$cmd_status"
|
||||
}
|
||||
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 "API multi-arch build" \
|
||||
sudo docker buildx build \
|
||||
--builder ${BUILDER_NAME} \
|
||||
--platform ${TARGET_PLATFORMS} \
|
||||
--network host \
|
||||
--progress=plain \
|
||||
--provenance=false \
|
||||
--cache-from type=registry,ref=${API_CACHE_IMAGE} \
|
||||
--cache-to type=registry,ref=${API_CACHE_IMAGE},mode=max \
|
||||
--build-arg HTTP_PROXY=${HTTP_PROXY_URL} \
|
||||
--build-arg HTTPS_PROXY=${HTTP_PROXY_URL} \
|
||||
--build-arg NO_PROXY=${NO_PROXY_HOSTS} \
|
||||
--build-arg http_proxy=${HTTP_PROXY_URL} \
|
||||
--build-arg https_proxy=${HTTP_PROXY_URL} \
|
||||
--build-arg no_proxy=${NO_PROXY_HOSTS} \
|
||||
-f src/LiveRecorder.WebApi/Dockerfile \
|
||||
-t ${API_IMAGE_TAGGED} \
|
||||
-t ${API_IMAGE_LATEST} \
|
||||
--push \
|
||||
.
|
||||
"""
|
||||
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('Build And Push Web Image') {
|
||||
stage('Publish Multi-arch Manifests') {
|
||||
when {
|
||||
expression {
|
||||
params.API_IMAGE_PLATFORMS == 'amd64+arm64' ||
|
||||
params.WEB_IMAGE_PLATFORMS == 'amd64+arm64'
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh """
|
||||
set -e
|
||||
run_with_heartbeat() {
|
||||
log_file="\$1"
|
||||
build_label="\$2"
|
||||
shift 2
|
||||
rm -f "\$log_file"
|
||||
: > "\$log_file"
|
||||
"\$@" >"\$log_file" 2>&1 &
|
||||
cmd_pid=\$!
|
||||
cmd_status=0
|
||||
last_size=0
|
||||
while kill -0 "\$cmd_pid" >/dev/null 2>&1; do
|
||||
echo "[heartbeat] \${build_label} still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
if [ -f "\$log_file" ]; then
|
||||
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
|
||||
if [ "\$current_size" -gt "\$last_size" ]; then
|
||||
start_byte=\$((last_size + 1))
|
||||
tail -c +"\$start_byte" "\$log_file" || true
|
||||
last_size=\$current_size
|
||||
fi
|
||||
fi
|
||||
sleep 20
|
||||
done
|
||||
wait "\$cmd_pid" || cmd_status=\$?
|
||||
if [ -f "\$log_file" ]; then
|
||||
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
|
||||
if [ "\$current_size" -gt "\$last_size" ]; then
|
||||
start_byte=\$((last_size + 1))
|
||||
tail -c +"\$start_byte" "\$log_file" || true
|
||||
fi
|
||||
fi
|
||||
if [ "\$cmd_status" -ne 0 ]; then
|
||||
echo "[heartbeat] \${build_label} failed with exit code \$cmd_status"
|
||||
fi
|
||||
return "\$cmd_status"
|
||||
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"
|
||||
}
|
||||
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 "Web multi-arch build" \
|
||||
sudo docker buildx build \
|
||||
--builder ${BUILDER_NAME} \
|
||||
--platform ${TARGET_PLATFORMS} \
|
||||
--network host \
|
||||
--progress=plain \
|
||||
--provenance=false \
|
||||
--cache-from type=registry,ref=${WEB_CACHE_IMAGE} \
|
||||
--cache-to type=registry,ref=${WEB_CACHE_IMAGE},mode=max \
|
||||
--build-arg NODE_IMAGE=${WEB_NODE_IMAGE} \
|
||||
--build-arg NGINX_IMAGE=${WEB_NGINX_IMAGE} \
|
||||
--build-arg HTTP_PROXY=${HTTP_PROXY_URL} \
|
||||
--build-arg HTTPS_PROXY=${HTTP_PROXY_URL} \
|
||||
--build-arg NO_PROXY=${NO_PROXY_HOSTS} \
|
||||
--build-arg http_proxy=${HTTP_PROXY_URL} \
|
||||
--build-arg https_proxy=${HTTP_PROXY_URL} \
|
||||
--build-arg no_proxy=${NO_PROXY_HOSTS} \
|
||||
-f frontend/Dockerfile \
|
||||
--build-arg VITE_API_BASE_URL=/api \
|
||||
-t ${WEB_IMAGE_TAGGED} \
|
||||
-t ${WEB_IMAGE_LATEST} \
|
||||
--push \
|
||||
frontend
|
||||
"""
|
||||
|
||||
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%;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -16,7 +16,8 @@ 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,14 +53,22 @@ async function handleLogin() {
|
||||
|
||||
<template>
|
||||
<div class="login-screen">
|
||||
<section class="login-panel surface-card">
|
||||
<div class="login-panel__header">
|
||||
<div>
|
||||
<div class="login-panel__eyebrow">Live Recorder</div>
|
||||
<h1 class="login-panel__title">登录</h1>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -69,35 +78,55 @@ 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>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
@@ -106,67 +135,48 @@ async function handleLogin() {
|
||||
.login-screen {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
grid-template-columns: minmax(420px, .95fr) minmax(480px, 1.05fr);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(100%, 420px);
|
||||
padding: 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-panel__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-panel__eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-panel__title {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-primary);
|
||||
font-size: 28px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.045em;
|
||||
}
|
||||
|
||||
.login-panel__theme-select {
|
||||
width: 122px;
|
||||
}
|
||||
|
||||
.login-panel__alert {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.login-form__submit {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.login-screen {
|
||||
padding: 18px 14px 24px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.login-panel__header {
|
||||
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>
|
||||
|
||||
+845
-245
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,44 @@
|
||||
# Live Recorder · 高保真原型
|
||||
|
||||
商业级 SaaS 风格 UI 重设计原型(参考 Ant Design Pro / 飞书 / 云控制台 / Linear)。
|
||||
纯静态 HTML + CSS + 少量 JS,**无需构建**,直接用浏览器打开即可浏览。
|
||||
|
||||
## 如何查看
|
||||
|
||||
直接双击打开 `login.html` 或 `index.html`,通过左侧导航在各页之间跳转。
|
||||
|
||||
> 建议用本地静态服务器以获得最佳体验(可选):
|
||||
> `npx serve prototype` 或 VSCode Live Server。
|
||||
|
||||
## 页面清单
|
||||
|
||||
| 文件 | 页面 | 说明 |
|
||||
|---|---|---|
|
||||
| `login.html` | 登录 | 品牌左栏 + 表单右栏 |
|
||||
| `index.html` | 仪表盘 | KPI + 存储水位 + 队列 + 最近会话/热门房间 |
|
||||
| `live-rooms.html` | 直播间控制台 | 收敛表格 + 分段筛选 + 详情抽屉(**重点重构**) |
|
||||
| `record-tasks.html` | 录制任务 | 实时态 + 折叠会话卡 + 嵌套分片表 |
|
||||
| `session-detail.html` | 会话详情 | 时间轴 + 弹幕热力 + 回放播放器 |
|
||||
| `record-task-detail.html` | 分片详情 | 预览播放器 + 描述列表 + 日志 |
|
||||
| `transcode-tasks.html` | 转码任务 | 进度条 + 分段筛选 |
|
||||
| `upload-tasks.html` | 上传任务 | 上传状态 + 批量重传 |
|
||||
| `media-browser.html` | 文件库 | 面包屑目录浏览 |
|
||||
| `daily-reviews.html` | 回顾日报 | 维度表 + 高光会话 + 高光时刻 |
|
||||
| `logs.html` | 系统日志 | 多维筛选 + 实体下钻 |
|
||||
| `recovery.html` | 恢复中心 | 存储守护 + 可恢复房间/收尾 |
|
||||
| `settings.html` | 系统设置 | 左锚点 + 分组卡片 + 吸底保存栏 |
|
||||
|
||||
## 交互说明
|
||||
|
||||
- **主题切换**:顶栏月亮/太阳图标,切换深/浅色(localStorage 记忆)。
|
||||
- **侧栏折叠**:顶栏菜单图标。
|
||||
- **详情抽屉**:直播间页点行尾「⋯」打开右侧抽屉。
|
||||
- **折叠会话**:录制任务页点会话头部展开/收起分片表。
|
||||
- **设置锚点**:设置页左侧锚点定位到对应分组。
|
||||
|
||||
## 资源
|
||||
|
||||
- `assets/design-system.css` — 设计系统(tokens + 全部组件样式,含暗色/密度)。
|
||||
- `assets/shell.js` — 应用外壳注入(侧栏/顶栏/主题/折叠/抽屉)。
|
||||
|
||||
所有页面共享同一套设计系统与外壳,与第四阶段《设计规范》一致。
|
||||
@@ -0,0 +1,701 @@
|
||||
/* =============================================================
|
||||
Live Recorder · Design System v2 (high-fidelity prototype)
|
||||
商业级 SaaS · 深色侧栏 + 层次投影 + 玻璃磨砂 + 微动画
|
||||
参考 Linear / Vercel / 飞书 / 云控制台 / Ant Design Pro
|
||||
============================================================= */
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--font-sans: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
--font-mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", Menlo, monospace;
|
||||
|
||||
/* neutrals — 冷蓝灰统一基调 */
|
||||
--bg-base: #eef1f6;
|
||||
--bg-subtle: #e7ebf2;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f4f7fb;
|
||||
--surface-hover: #eef2f8;
|
||||
--border-subtle: #e3e7ef;
|
||||
--border-base: #d2d8e3;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #45526a;
|
||||
--text-muted: #6a7689;
|
||||
--text-soft: #97a1b3;
|
||||
|
||||
/* sidebar (light — subtle grey, blends with page) */
|
||||
--side-bg: #f3f5f9;
|
||||
--side-bg-2: #f3f5f9;
|
||||
--side-border: #e3e7ef;
|
||||
--side-title: #7a8699;
|
||||
--side-item: #44526b;
|
||||
--side-item-hover-bg: rgba(26, 86, 219, 0.07);
|
||||
--side-item-hover: #1e40af;
|
||||
--side-active-bg: rgba(26, 86, 219, 0.10);
|
||||
--side-active: #1a56db;
|
||||
|
||||
/* brand */
|
||||
--accent: #1a56db;
|
||||
--accent-strong: #1e40af;
|
||||
--accent-soft: rgba(26, 86, 219, 0.12);
|
||||
--accent-soft-2: rgba(26, 86, 219, 0.07);
|
||||
|
||||
/* semantic */
|
||||
--success: #16a34a;
|
||||
--success-soft: rgba(22, 163, 74, 0.12);
|
||||
--warning: #d97706;
|
||||
--warning-soft: rgba(217, 119, 6, 0.13);
|
||||
--danger: #dc2626;
|
||||
--danger-soft: rgba(220, 38, 38, 0.11);
|
||||
--info: #2563eb;
|
||||
--info-soft: rgba(37, 99, 235, 0.11);
|
||||
--purple: #7c3aed;
|
||||
--purple-soft: rgba(124, 58, 237, 0.11);
|
||||
|
||||
/* elevation — 真正可见的投影 */
|
||||
--shadow-xs: 0 1px 3px rgba(16, 24, 40, 0.09), 0 1px 2px rgba(16, 24, 40, 0.05);
|
||||
--shadow-sm: 0 2px 8px rgba(16, 24, 40, 0.10), 0 1px 3px rgba(16, 24, 40, 0.06);
|
||||
--shadow-md: 0 8px 20px rgba(16, 24, 40, 0.12), 0 2px 6px rgba(16, 24, 40, 0.07);
|
||||
--shadow-lg: 0 16px 40px rgba(16, 24, 40, 0.16), 0 4px 10px rgba(16, 24, 40, 0.08);
|
||||
--shadow-float: 0 24px 56px rgba(16, 24, 40, 0.22);
|
||||
|
||||
/* radius */
|
||||
--radius-xs: 7px;
|
||||
--radius-sm: 9px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 20px;
|
||||
|
||||
/* layout */
|
||||
--sidebar-w: 244px;
|
||||
--sidebar-w-collapsed: 72px;
|
||||
--topbar-h: 62px;
|
||||
--page-max: 1320px;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg-base: #0a1120;
|
||||
--bg-subtle: #0f172a;
|
||||
--surface: #121c30;
|
||||
--surface-muted: #16223899;
|
||||
--surface-hover: #1b2942;
|
||||
--border-subtle: #1f2c44;
|
||||
--border-base: #2c3a55;
|
||||
--text-primary: #e9eefb;
|
||||
--text-secondary: #b6c4dd;
|
||||
--text-muted: #8295b3;
|
||||
--text-soft: #647a9a;
|
||||
|
||||
--side-bg: #0c1424;
|
||||
--side-bg-2: #0a111e;
|
||||
--side-border: #1a2740;
|
||||
--side-title: #4a607d;
|
||||
--side-item: #8295b3;
|
||||
--side-item-hover-bg: rgba(255, 255, 255, 0.05);
|
||||
--side-item-hover: #d4def0;
|
||||
--side-active-bg: rgba(99, 102, 241, 0.32);
|
||||
--side-active: #93c5fd;
|
||||
|
||||
--accent: #60a5fa;
|
||||
--accent-strong: #3b82f6;
|
||||
--accent-soft: rgba(96, 165, 250, 0.18);
|
||||
--accent-soft-2: rgba(96, 165, 250, 0.10);
|
||||
--success: #34d399;
|
||||
--success-soft: rgba(52, 211, 153, 0.15);
|
||||
--warning: #fbbf24;
|
||||
--warning-soft: rgba(251, 191, 36, 0.15);
|
||||
--danger: #f87171;
|
||||
--danger-soft: rgba(248, 113, 113, 0.15);
|
||||
--info: #5b9bff;
|
||||
--info-soft: rgba(91, 155, 255, 0.15);
|
||||
--purple: #a78bfa;
|
||||
--purple-soft: rgba(167, 139, 250, 0.15);
|
||||
|
||||
--shadow-xs: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||
--shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.45);
|
||||
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.6);
|
||||
--shadow-float: 0 24px 56px rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* — dark mode component overrides (brand / health) — */
|
||||
html[data-theme="dark"] .brand__mark { background: #1a2438; box-shadow: 0 2px 8px rgba(0,0,0,.5); }
|
||||
html[data-theme="dark"] .brand__name { color: #eaf0fb; }
|
||||
html[data-theme="dark"] .brand__sub { color: #647a9a; }
|
||||
html[data-theme="dark"] .health { background: rgba(52,211,153,.12); border-color: rgba(52,211,153,.22); }
|
||||
html[data-theme="dark"] .health__dot { background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,.22); }
|
||||
html[data-theme="dark"] .health__txt { color: #6ee7b7; }
|
||||
|
||||
html[data-density="compact"] { --topbar-h: 56px; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-weight: 400;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-feature-settings: "kern" 1, "liga" 1, "calt" 1;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
h1, h2, h3, h4, p { margin: 0; }
|
||||
::selection { background: var(--accent-soft); }
|
||||
|
||||
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
*::-webkit-scrollbar-thumb { background: var(--border-base); border-radius: 99px; border: 3px solid transparent; background-clip: content-box; }
|
||||
*::-webkit-scrollbar-thumb:hover { background: var(--text-soft); background-clip: content-box; }
|
||||
|
||||
/* ============================ Icons ============================ */
|
||||
svg.ic { width: 18px; height: 18px; flex-shrink: 0; display: inline-block; vertical-align: middle; }
|
||||
.empty__ic svg.ic { width: 24px; height: 24px; }
|
||||
|
||||
/* ============================ Motion ============================ */
|
||||
@keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
/* ============================ App Shell ============================ */
|
||||
.app-shell { display: flex; min-height: 100vh; }
|
||||
|
||||
.sidebar {
|
||||
position: fixed; inset: 0 auto 0 0; z-index: 30;
|
||||
width: var(--sidebar-w);
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--side-bg);
|
||||
border-right: 1px solid var(--side-border);
|
||||
transition: width .2s ease;
|
||||
}
|
||||
.sidebar.collapsed { width: var(--sidebar-w-collapsed); }
|
||||
|
||||
.brand {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
height: var(--topbar-h); padding: 0 18px; flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--side-border);
|
||||
}
|
||||
.brand__mark {
|
||||
width: 36px; height: 36px; flex-shrink: 0; border-radius: 8px;
|
||||
display: grid; place-items: center; color: #fff;
|
||||
background: #1a56db;
|
||||
box-shadow: 0 2px 8px rgba(26, 86, 219, 0.25);
|
||||
}
|
||||
.brand__name { font-weight: 800; font-size: 16px; letter-spacing: -.02em; color: var(--text-primary); white-space: nowrap; }
|
||||
.brand__sub { font-size: 11px; color: var(--text-muted); white-space: nowrap; margin-top: 1px; }
|
||||
.sidebar.collapsed .brand__copy { display: none; }
|
||||
|
||||
.nav { flex: 1; overflow-y: auto; padding: 14px 12px 8px; }
|
||||
.nav__group { margin-bottom: 16px; }
|
||||
.nav__title {
|
||||
padding: 6px 10px; font-size: 11px; font-weight: 700; letter-spacing: .09em;
|
||||
text-transform: uppercase; color: var(--side-title);
|
||||
}
|
||||
.sidebar.collapsed .nav__title { font-size: 0; padding: 6px 0; text-align: center; }
|
||||
.sidebar.collapsed .nav__title::after { content: "•"; font-size: 13px; }
|
||||
|
||||
.nav__item {
|
||||
display: flex; align-items: center; gap: 11px;
|
||||
padding: 9px 11px; margin-bottom: 2px; border-radius: 6px;
|
||||
color: var(--side-item); font-weight: 600; font-size: 14px;
|
||||
position: relative; transition: background .15s, color .15s; white-space: nowrap;
|
||||
}
|
||||
.nav__item:hover { background: var(--side-item-hover-bg); color: var(--side-item-hover); }
|
||||
.nav__item.active { background: var(--side-active-bg); color: var(--side-active); }
|
||||
.nav__item.active::before {
|
||||
content: ""; position: absolute; left: -12px; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--side-active);
|
||||
}
|
||||
.nav__item .ic { width: 18px; height: 18px; opacity: .92; }
|
||||
.nav__badge { margin-left: auto; font-size: 11px; font-weight: 700;
|
||||
background: var(--danger); color: #fff; border-radius: 99px; padding: 1px 6px; min-width: 18px; text-align: center; }
|
||||
.sidebar.collapsed .nav__item { justify-content: center; padding: 9px 0; }
|
||||
.sidebar.collapsed .nav__label, .sidebar.collapsed .nav__badge { display: none; }
|
||||
|
||||
.sidebar__foot { padding: 12px; border-top: 1px solid var(--side-border); }
|
||||
.health {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px;
|
||||
background: rgba(22, 163, 74, 0.10); border: 1px solid rgba(22, 163, 74, 0.20);
|
||||
}
|
||||
.health__dot { width: 8px; height: 8px; border-radius: 99px; background: #16a34a; box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.18); }
|
||||
.health__txt { font-size: 12.5px; font-weight: 600; color: #15803d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.sidebar.collapsed .health__txt { display: none; }
|
||||
.sidebar.collapsed .health { justify-content: center; padding: 10px 0; }
|
||||
@media (max-width: 768px) { .sidebar .health { min-width: 0; } .sidebar .health__txt { min-width: 0; flex: 1; } }
|
||||
|
||||
/* main column */
|
||||
.main-col { flex: 1; margin-left: var(--sidebar-w); min-width: 0; transition: margin .2s ease; }
|
||||
.sidebar.collapsed ~ .main-col { margin-left: var(--sidebar-w-collapsed); }
|
||||
|
||||
.topbar {
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
height: var(--topbar-h); display: flex; align-items: center; gap: 14px;
|
||||
padding: 0 28px; background: color-mix(in srgb, var(--surface) 70%, transparent);
|
||||
backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4);
|
||||
border-bottom: 1px solid rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
html[data-theme="dark"] .topbar { border-bottom-color: rgba(255, 255, 255, 0.06); }
|
||||
.icon-btn {
|
||||
width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center;
|
||||
border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent;
|
||||
color: var(--text-secondary); position: relative; transition: background .15s, color .15s;
|
||||
}
|
||||
.icon-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.icon-btn .dot { position: absolute; top: 7px; right: 8px; width: 7px; height: 7px;
|
||||
border-radius: 99px; background: var(--danger); border: 1.5px solid var(--surface); }
|
||||
|
||||
.breadcrumb { display: flex; align-items: center; gap: 7px; font-size: 13.5px; color: var(--text-muted); min-width: 0; }
|
||||
.breadcrumb b { color: var(--text-primary); font-weight: 700; }
|
||||
.breadcrumb .sep { color: var(--text-soft); }
|
||||
|
||||
.searchbar {
|
||||
margin-left: 8px; flex: 1; max-width: 440px; display: flex; align-items: center; gap: 9px;
|
||||
height: 38px; padding: 0 12px; border-radius: var(--radius-sm);
|
||||
background: var(--surface-muted); border: 1px solid var(--border-subtle); color: var(--text-muted);
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
}
|
||||
.searchbar:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.searchbar input { flex: 1; border: 0; background: transparent; outline: none; color: var(--text-primary); font-size: 13.5px; }
|
||||
.searchbar input::placeholder { color: var(--text-soft); }
|
||||
.searchbar kbd { font-family: var(--font-sans); font-size: 11px; color: var(--text-muted);
|
||||
background: var(--surface); border: 1px solid var(--border-base); border-radius: 5px; padding: 1px 6px; }
|
||||
.topbar__right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.user-chip {
|
||||
display: flex; align-items: center; gap: 9px; padding: 4px 10px 4px 4px;
|
||||
border: 1px solid var(--border-subtle); border-radius: 99px; background: var(--surface);
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.user-chip:hover { background: var(--surface-hover); border-color: var(--border-base); }
|
||||
.avatar {
|
||||
width: 30px; height: 30px; flex-shrink: 0; border-radius: 99px; display: grid; place-items: center;
|
||||
color: #fff; font-weight: 700; font-size: 13px; background: #1a56db;
|
||||
}
|
||||
.user-chip__name { font-size: 13px; font-weight: 700; }
|
||||
.user-chip__meta { font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
/* page */
|
||||
.page { max-width: var(--page-max); margin: 0 auto; padding: 28px 32px; display: flex; flex-direction: column; gap: 22px; }
|
||||
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; animation: fadeUp .3s ease both; }
|
||||
.page-head__kicker { font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--accent); margin-bottom: 7px; }
|
||||
.page-head h1 { font-size: 28px; font-weight: 800; letter-spacing: -.025em; line-height: 1.2; }
|
||||
.page-head p { margin-top: 8px; color: var(--text-muted); font-size: 13.5px; max-width: 720px; line-height: 1.6; }
|
||||
.page-head__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
/* ============================ Buttons ============================ */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
height: 38px; padding: 0 15px; border-radius: var(--radius-sm); font-size: 13.5px; font-weight: 700;
|
||||
border: 1px solid var(--border-base); background: var(--surface); color: var(--text-secondary);
|
||||
transition: all .15s ease; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: var(--text-soft); color: var(--text-primary); background: var(--surface); box-shadow: var(--shadow-xs); }
|
||||
.btn .ic { width: 16px; height: 16px; }
|
||||
.btn--primary {
|
||||
background: #1a56db;
|
||||
border-color: #1e40af; color: #fff;
|
||||
box-shadow: 0 1px 3px rgba(26, 86, 219, 0.4), 0 4px 14px rgba(26, 86, 219, 0.2);
|
||||
}
|
||||
.btn--primary:hover { color: #fff; background: #1e40af; border-color: #1e40af; transform: translateY(-1px); box-shadow: 0 2px 5px rgba(26, 86, 219, 0.45), 0 6px 18px rgba(26, 86, 219, 0.28); }
|
||||
.btn--primary:active { transform: translateY(0); }
|
||||
.btn--ghost { border-color: transparent; background: transparent; }
|
||||
.btn--ghost:hover { background: var(--surface-hover); box-shadow: none; }
|
||||
.btn--danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, transparent); background: transparent; }
|
||||
.btn--danger:hover { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); box-shadow: none; }
|
||||
.btn--sm { height: 30px; padding: 0 11px; font-size: 12.5px; border-radius: var(--radius-xs); }
|
||||
.btn--link { border: 0; background: transparent; color: var(--accent); padding: 0 6px; height: auto; font-weight: 600; }
|
||||
.btn--link:hover { background: transparent; color: var(--accent-strong); text-decoration: underline; box-shadow: none; }
|
||||
.btn[disabled] { opacity: .45; pointer-events: none; }
|
||||
|
||||
/* ============================ Cards ============================ */
|
||||
.card { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); transition: box-shadow .18s ease; animation: fadeUp .3s ease both; }
|
||||
.card:hover { box-shadow: var(--shadow-md); }
|
||||
.card__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 24px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.card__title { font-size: 16px; font-weight: 700; letter-spacing: -.01em; }
|
||||
.card__sub { font-size: 12.5px; color: var(--text-muted); margin-top: 3px; font-weight: 500; }
|
||||
.card__body { padding: 24px; }
|
||||
.card__body--flush { padding: 0; }
|
||||
|
||||
/* stat card */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
|
||||
.stat {
|
||||
background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md);
|
||||
padding: 20px; display: flex; flex-direction: column; gap: 11px; box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow .18s, transform .18s; position: relative; overflow: hidden;
|
||||
animation: fadeUp .35s ease both;
|
||||
}
|
||||
.stat:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); }
|
||||
.stat:nth-child(2) { animation-delay: .05s; }
|
||||
.stat:nth-child(3) { animation-delay: .10s; }
|
||||
.stat:nth-child(4) { animation-delay: .15s; }
|
||||
.stat:nth-child(5) { animation-delay: .20s; }
|
||||
.stat:nth-child(6) { animation-delay: .25s; }
|
||||
.stat__top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.stat__label { font-size: 12.5px; font-weight: 600; color: var(--text-muted); }
|
||||
.stat__ic { width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent); }
|
||||
.stat__ic.is-green { background: var(--success-soft); color: var(--success); }
|
||||
.stat__ic.is-amber { background: var(--warning-soft); color: var(--warning); }
|
||||
.stat__ic.is-red { background: var(--danger-soft); color: var(--danger); }
|
||||
.stat__ic.is-purple { background: var(--purple-soft); color: var(--purple); }
|
||||
.stat__value { font-size: 31px; font-weight: 800; letter-spacing: -.04em; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.stat__value small { font-size: 15px; font-weight: 600; color: var(--text-muted); letter-spacing: 0; }
|
||||
.stat__foot { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-muted); font-weight: 500; }
|
||||
.trend { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: 12px; }
|
||||
.trend.up { color: var(--success); }
|
||||
.trend.down { color: var(--danger); }
|
||||
|
||||
/* ============================ Badges / Tags ============================ */
|
||||
.badge { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--text-secondary); }
|
||||
.badge .pip { width: 7px; height: 7px; border-radius: 99px; background: var(--text-soft); }
|
||||
.badge.is-success { color: var(--success); } .badge.is-success .pip { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); }
|
||||
.badge.is-danger { color: var(--danger); } .badge.is-danger .pip { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); }
|
||||
.badge.is-warning { color: var(--warning); } .badge.is-warning .pip { background: var(--warning); box-shadow: 0 0 0 3px var(--warning-soft); }
|
||||
.badge.is-info { color: var(--info); } .badge.is-info .pip { background: var(--info); box-shadow: 0 0 0 3px var(--info-soft); }
|
||||
.badge.live .pip { animation: pulse 1.4s infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
|
||||
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px;
|
||||
border-radius: var(--radius-xs); font-size: 12px; font-weight: 600;
|
||||
background: var(--surface-muted); color: var(--text-secondary); border: 1px solid var(--border-subtle);
|
||||
}
|
||||
.tag.is-blue { background: var(--info-soft); color: var(--info); border-color: transparent; }
|
||||
.tag.is-green { background: var(--success-soft); color: var(--success); border-color: transparent; }
|
||||
.tag.is-amber { background: var(--warning-soft); color: var(--warning); border-color: transparent; }
|
||||
.tag.is-red { background: var(--danger-soft); color: var(--danger); border-color: transparent; }
|
||||
.tag.is-purple { background: var(--purple-soft); color: var(--purple); border-color: transparent; }
|
||||
|
||||
/* ============================ Table ============================ */
|
||||
.table-wrap { width: 100%; overflow-x: auto; }
|
||||
table.tbl { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||
.tbl thead th {
|
||||
position: sticky; top: 0; text-align: left; font-size: 12px; font-weight: 700; letter-spacing: .02em;
|
||||
color: var(--text-muted); text-transform: uppercase; padding: 12px 20px;
|
||||
background: #eef2f8; border-bottom: 2px solid var(--border-base); white-space: nowrap;
|
||||
}
|
||||
html[data-theme="dark"] .tbl thead th { background: #16223a; }
|
||||
.tbl tbody td { padding: 14px 20px; border-bottom: 1px solid var(--border-subtle); vertical-align: middle; }
|
||||
.tbl tbody tr:last-child td { border-bottom: 0; }
|
||||
.tbl tbody tr { transition: background .12s; }
|
||||
.tbl tbody tr:nth-child(even) { background: rgba(15, 23, 42, 0.018); }
|
||||
html[data-theme="dark"] .tbl tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.02); }
|
||||
.tbl tbody tr:hover { background: var(--surface-hover); }
|
||||
.tbl .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.cell-title { font-weight: 700; color: var(--text-primary); }
|
||||
.cell-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; font-weight: 500; }
|
||||
.cell-mono { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.row-actions { display: flex; align-items: center; gap: 4px; justify-content: flex-end; opacity: 0; transition: opacity .15s; }
|
||||
.tbl tbody tr:hover .row-actions { opacity: 1; }
|
||||
@media (hover: none) { .row-actions { opacity: 1; } }
|
||||
|
||||
.checkbox { width: 16px; height: 16px; border: 1.5px solid var(--border-base); border-radius: 4px; display: inline-block; vertical-align: middle; background: var(--surface); }
|
||||
.checkbox.checked { background: var(--accent); border-color: var(--accent); position: relative; }
|
||||
.checkbox.checked::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||
|
||||
.identity { display: flex; align-items: center; gap: 11px; }
|
||||
.identity .avatar { width: 40px; height: 40px; border-radius: 11px; font-size: 15px; }
|
||||
.meta-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; font-size: 12px; color: var(--text-muted); }
|
||||
.meta-row .dot-sep { width: 3px; height: 3px; border-radius: 99px; background: var(--text-soft); }
|
||||
|
||||
/* ============================ Toolbar / filters ============================ */
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 16px 24px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.toolbar__spacer { flex: 1; }
|
||||
.segmented { display: inline-flex; padding: 3px; gap: 2px; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); }
|
||||
.segmented button { border: 0; background: transparent; padding: 6px 13px; border-radius: 6px; font-size: 13px; font-weight: 600; color: var(--text-muted); transition: all .15s; }
|
||||
.segmented button.active { background: var(--surface); color: var(--accent); box-shadow: var(--shadow-xs); }
|
||||
.segmented button:hover:not(.active) { color: var(--text-primary); }
|
||||
.field {
|
||||
display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 12px;
|
||||
background: var(--surface); border: 1px solid var(--border-base); border-radius: var(--radius-sm);
|
||||
font-size: 13px; color: var(--text-secondary);
|
||||
}
|
||||
.field input, .field select { border: 0; background: transparent; outline: none; color: var(--text-primary); font-size: 13px; font-family: inherit; }
|
||||
.field--search { color: var(--text-muted); min-width: 220px; }
|
||||
.field--search input { flex: 1; }
|
||||
.sel-count { font-size: 13px; color: var(--text-muted); font-weight: 600; }
|
||||
.sel-count b { color: var(--accent); }
|
||||
|
||||
/* ============================ Progress ============================ */
|
||||
.bar { height: 8px; border-radius: 99px; background: var(--surface-hover); overflow: hidden; }
|
||||
.bar > span { display: block; height: 100%; border-radius: 99px; background: #1a56db; }
|
||||
.bar > span.is-green { background: #16a34a; }
|
||||
.bar > span.is-amber { background: #d97706; }
|
||||
.bar > span.is-red { background: #dc2626; }
|
||||
|
||||
.ring { --p: 32; width: 92px; height: 92px; border-radius: 99px; display: grid; place-items: center;
|
||||
background: conic-gradient(var(--success) calc(var(--p)*1%), var(--surface-hover) 0); }
|
||||
.ring__inner { width: 70px; height: 70px; border-radius: 99px; background: var(--surface); display: grid; place-items: center; text-align: center; }
|
||||
.ring__num { font-size: 20px; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
.ring__cap { font-size: 10.5px; color: var(--text-muted); margin-top: 2px; }
|
||||
|
||||
/* ============================ Drawer ============================ */
|
||||
.scrim { position: fixed; inset: 0; background: rgba(15,23,42,.5); backdrop-filter: blur(2px); z-index: 40; opacity: 0; pointer-events: none; transition: opacity .2s; }
|
||||
.scrim.open { opacity: 1; pointer-events: auto; }
|
||||
.drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0; width: 448px; max-width: 92vw; z-index: 41;
|
||||
background: var(--surface); border-left: 1px solid var(--border-subtle); box-shadow: var(--shadow-float);
|
||||
transform: translateX(100%); transition: transform .26s cubic-bezier(.32,.72,0,1);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.drawer.open { transform: translateX(0); }
|
||||
.drawer__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px; border-bottom: 1px solid var(--border-subtle); }
|
||||
.drawer__title { font-size: 16px; font-weight: 700; }
|
||||
.drawer__sub { font-size: 12.5px; color: var(--text-muted); margin-top: 3px; }
|
||||
.drawer__body { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.drawer__foot { padding: 14px 20px; border-top: 1px solid var(--border-subtle); display: flex; gap: 8px; justify-content: flex-end; }
|
||||
|
||||
.desc { display: grid; gap: 1px; background: var(--border-subtle); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.desc__row { display: grid; grid-template-columns: 120px 1fr; gap: 1px; }
|
||||
.desc__row dt { background: var(--surface-muted); padding: 10px 12px; font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
|
||||
.desc__row dd { background: var(--surface); padding: 10px 12px; margin: 0; font-size: 13px; color: var(--text-primary); }
|
||||
|
||||
/* ============================ Forms ============================ */
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px 20px; }
|
||||
.form-item { display: flex; flex-direction: column; gap: 7px; }
|
||||
.form-item.full { grid-column: 1 / -1; }
|
||||
.form-item label { font-size: 13px; font-weight: 600; color: var(--text-secondary); }
|
||||
.form-item label .req { color: var(--danger); margin-left: 3px; }
|
||||
.form-item .hint { font-size: 12px; color: var(--text-muted); font-weight: 500; }
|
||||
.input, .select-box, .textarea {
|
||||
height: 38px; padding: 0 12px; border: 1px solid var(--border-base); border-radius: var(--radius-sm);
|
||||
background: var(--surface); color: var(--text-primary); font-size: 13.5px; font-family: inherit; outline: none;
|
||||
transition: border-color .15s, box-shadow .15s; width: 100%;
|
||||
}
|
||||
.input:focus, .select-box:focus, .textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
.textarea { height: auto; padding: 10px 12px; resize: vertical; min-height: 84px; line-height: 1.6; }
|
||||
select.select-box { appearance: none; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236a7689' stroke-width='2'><path d='M6 9l6 6 6-6'/></svg>"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; }
|
||||
|
||||
.switch { width: 40px; height: 22px; border-radius: 99px; background: var(--border-base); position: relative; transition: background .18s; flex-shrink: 0; }
|
||||
.switch.on { background: var(--accent); }
|
||||
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 99px; background: #fff; box-shadow: var(--shadow-sm); transition: left .18s; }
|
||||
.switch.on::after { left: 20px; }
|
||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--surface-muted); }
|
||||
.toggle-row__t { font-size: 13.5px; font-weight: 600; }
|
||||
.toggle-row__h { font-size: 12px; color: var(--text-muted); margin-top: 2px; font-weight: 500; }
|
||||
|
||||
.anchor-layout { display: grid; grid-template-columns: 200px 1fr; gap: 24px; align-items: start; }
|
||||
.anchor { position: sticky; top: calc(var(--topbar-h) + 24px); display: flex; flex-direction: column; gap: 2px; }
|
||||
.anchor a { padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13.5px; font-weight: 600; color: var(--text-muted); border-left: 2px solid transparent; transition: all .15s; }
|
||||
.anchor a:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.anchor a.active { background: var(--accent-soft); color: var(--accent); border-left-color: var(--accent); }
|
||||
|
||||
.savebar {
|
||||
position: sticky; bottom: 16px; margin-top: 4px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||
padding: 13px 18px; border-radius: var(--radius-md); background: var(--surface);
|
||||
border: 1px solid var(--border-base); box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.savebar__t { font-size: 13px; color: var(--text-muted); }
|
||||
.savebar__t b { color: var(--warning); }
|
||||
|
||||
/* ============================ Misc ============================ */
|
||||
.alert { display: flex; gap: 11px; padding: 13px 16px; border-radius: var(--radius-sm); font-size: 13px; align-items: flex-start; }
|
||||
.alert .ic { width: 18px; height: 18px; flex-shrink: 0; margin-top: 1px; }
|
||||
.alert--info { background: var(--info-soft); color: var(--info); }
|
||||
.alert--warn { background: var(--warning-soft); color: var(--warning); }
|
||||
.alert--error { background: var(--danger-soft); color: var(--danger); }
|
||||
.alert--success { background: var(--success-soft); color: var(--success); }
|
||||
.alert b { font-weight: 700; }
|
||||
.alert__body { color: var(--text-secondary); }
|
||||
.alert__body b { color: var(--text-primary); }
|
||||
|
||||
.empty { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 48px 20px; text-align: center; color: var(--text-muted); }
|
||||
.empty__ic { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; background: var(--surface-muted); color: var(--text-soft); }
|
||||
.empty__t { font-size: 14px; font-weight: 700; color: var(--text-secondary); }
|
||||
.empty__d { font-size: 12.5px; }
|
||||
|
||||
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 24px; border-top: 1px solid var(--border-subtle); font-size: 13px; color: var(--text-muted); }
|
||||
.pagination__pages { display: flex; align-items: center; gap: 4px; }
|
||||
.pg { min-width: 32px; height: 32px; padding: 0 8px; display: grid; place-items: center; border-radius: var(--radius-xs); border: 1px solid var(--border-subtle); background: var(--surface); font-size: 13px; font-weight: 600; color: var(--text-secondary); transition: all .15s; }
|
||||
.pg.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.pg:hover:not(.active) { border-color: var(--text-soft); }
|
||||
|
||||
.divider { height: 1px; background: var(--border-subtle); border: 0; margin: 0; }
|
||||
.section-label { font-size: 13px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px; margin: 4px 0; }
|
||||
.section-label::before { content: ""; width: 3px; height: 14px; border-radius: 2px; background: var(--accent); }
|
||||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.grid-12-7-5 { display: grid; grid-template-columns: 7fr 5fr; gap: 20px; }
|
||||
@media (max-width: 1100px) { .grid-2, .grid-12-7-5 { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ≤980px — hide search, tighten page */
|
||||
@media (max-width: 980px) { .searchbar { display: none; } .page { padding: 18px 16px; } }
|
||||
|
||||
/* ≤768px — mobile */
|
||||
@media (max-width: 768px) {
|
||||
:root { --sidebar-w: 0px; --sidebar-w-collapsed: 0px; --topbar-h: 56px; }
|
||||
.sidebar { top: 0; height: 100dvh; transform: translateX(-100%); transition: transform .24s cubic-bezier(.4,0,.2,1); }
|
||||
.sidebar.mobile-open { transform: translateX(0); width: 280px; z-index: 50; top: 0; }
|
||||
.sidebar:not(.mobile-open) .brand { display: none; }
|
||||
.sidebar:not(.mobile-open) .sidebar__foot { display: none; }
|
||||
.sidebar:not(.mobile-open) .nav { padding-top: 0; }
|
||||
.sidebar.mobile-open .brand { height: var(--topbar-h); }
|
||||
.sidebar.mobile-open ~ .scrim { opacity: 1; pointer-events: auto; }
|
||||
.main-col { margin-left: 0 !important; }
|
||||
|
||||
/* page spacing */
|
||||
.page { padding: 12px 12px; gap: 12px; max-width: 100%; }
|
||||
.page-head h1 { font-size: 20px; }
|
||||
.page-head { flex-direction: column; gap: 10px; }
|
||||
.page-head p { font-size: 12.5px; max-width: 100%; word-break: break-word; }
|
||||
.page-head__actions { width: 100%; flex-wrap: wrap; }
|
||||
.page-head__actions .btn { flex: 1; justify-content: center; min-width: 0; }
|
||||
|
||||
/* KPI cards */
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.stat { padding: 12px; gap: 8px; min-width: 0; }
|
||||
.stat__value { font-size: 22px; word-break: break-word; }
|
||||
.stat__value small { font-size: 13px; }
|
||||
.stat__label { font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.stat__foot { font-size: 11px; flex-wrap: wrap; }
|
||||
.stat__ic { width: 28px; height: 28px; border-radius: 6px; }
|
||||
|
||||
/* topbar */
|
||||
.topbar { padding: 0 10px; gap: 6px; min-width: 0; flex-wrap: nowrap; }
|
||||
.topbar > * { min-width: 0; }
|
||||
.topbar__right { gap: 2px; flex-shrink: 0; }
|
||||
.breadcrumb { font-size: 11.5px; min-width: 0; max-width: 130px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; flex-shrink: 1; }
|
||||
.icon-btn { width: 30px; height: 30px; flex-shrink: 0; }
|
||||
.user-chip__copy { display: none; }
|
||||
.user-chip { padding: 2px 6px 2px 2px; }
|
||||
.user-chip .avatar { width: 24px; height: 24px; font-size: 11px; }
|
||||
.user-chip__name { font-size: 11px; }
|
||||
.searchbar { display: none; }
|
||||
|
||||
/* toolbar */
|
||||
.toolbar { padding: 10px 12px; gap: 8px; flex-wrap: wrap; }
|
||||
.toolbar .segmented { width: 100%; }
|
||||
.toolbar .segmented button { flex: 1; text-align: center; font-size: 11.5px; padding: 5px 6px; }
|
||||
.toolbar .field { height: 32px; font-size: 12px; }
|
||||
.toolbar .field--search { min-width: 140px; }
|
||||
.sel-count { font-size: 11px; }
|
||||
|
||||
/* table — force scroll + prevent overflow */
|
||||
.table-wrap { -webkit-overflow-scrolling: touch; }
|
||||
.tbl thead th { padding: 8px 8px; font-size: 10.5px; white-space: nowrap; }
|
||||
.tbl tbody td { padding: 9px 8px; font-size: 12px; line-height: 1.45; }
|
||||
.tbl thead th[style*="width"], .tbl tbody td[style*="width"] { min-width: auto !important; }
|
||||
.cell-title { font-size: 12.5px; word-break: break-word; }
|
||||
.cell-sub { font-size: 11px; }
|
||||
.cell-mono { font-size: 11px; word-break: break-all; }
|
||||
.meta-row { flex-wrap: wrap; gap: 4px; font-size: 11px; }
|
||||
.identity .avatar { width: 32px; height: 32px; border-radius: 8px; font-size: 13px; }
|
||||
|
||||
/* table actions — compact */
|
||||
.row-actions { opacity: 1 !important; flex-wrap: wrap; gap: 2px; }
|
||||
.row-actions .btn--sm { height: 26px; padding: 0 7px; font-size: 11px; }
|
||||
.row-actions .btn--link { padding: 0 4px; font-size: 11px; }
|
||||
|
||||
/* card */
|
||||
.card__head { padding: 12px 14px; gap: 8px; flex-wrap: wrap; }
|
||||
.card__title { font-size: 14px; }
|
||||
.card__body { padding: 14px; }
|
||||
.card__body .session__head + .session__body .tbl tbody td { font-size: 11px; padding: 7px 6px; }
|
||||
|
||||
/* stat card in card context */
|
||||
.card .stat-grid { grid-template-columns: repeat(2, 1fr); gap: 6px; }
|
||||
.card .stat { padding: 10px; }
|
||||
.card .stat .stat__value { font-size: 18px; }
|
||||
|
||||
/* anchor (settings) */
|
||||
.anchor-layout { grid-template-columns: 1fr; }
|
||||
.anchor { flex-direction: row; overflow-x: auto; gap: 2px; position: static; padding-bottom: 4px; }
|
||||
.anchor a { border-left: none; border-bottom: 2px solid transparent; padding: 7px 9px; white-space: nowrap; font-size: 11.5px; }
|
||||
.anchor a.active { border-left: none; border-bottom-color: var(--accent); }
|
||||
|
||||
/* forms */
|
||||
.form-grid { grid-template-columns: 1fr; gap: 12px 14px; }
|
||||
.form-item { gap: 5px; }
|
||||
.toggle-row { padding: 10px 12px; gap: 10px; }
|
||||
.toggle-row__t { font-size: 12.5px; }
|
||||
.toggle-row__h { font-size: 11px; }
|
||||
.desc__row { grid-template-columns: 90px 1fr; }
|
||||
.desc__row dt, .desc__row dd { padding: 7px 8px; font-size: 11.5px; }
|
||||
|
||||
/* drawer */
|
||||
.drawer { width: 100vw; max-width: 100vw; }
|
||||
.drawer__head { padding: 14px; }
|
||||
.drawer__body { padding: 14px; gap: 12px; }
|
||||
.drawer__foot { padding: 10px 14px; }
|
||||
|
||||
/* pagination */
|
||||
.pagination { flex-direction: column; align-items: stretch; gap: 8px; padding: 10px 14px; }
|
||||
|
||||
/* session collapse */
|
||||
.session__head { flex-wrap: wrap; padding: 10px 12px; gap: 8px; }
|
||||
.session__metrics { gap: 12px; margin-left: 0; width: 100%; justify-content: space-between; }
|
||||
.session__metrics .m b { font-size: 13px; }
|
||||
.session__metrics .m span { font-size: 10px; }
|
||||
|
||||
/* misc */
|
||||
.ring { width: 68px; height: 68px; }
|
||||
.ring__inner { width: 50px; height: 50px; }
|
||||
.ring__num { font-size: 16px; }
|
||||
.chip { font-size: 11px; padding: 3px 8px; }
|
||||
.badge { font-size: 11.5px; }
|
||||
.tag { font-size: 11px; height: 20px; padding: 0 6px; }
|
||||
.savebar { flex-direction: column; align-items: stretch; gap: 10px; padding: 10px 14px; }
|
||||
.savebar__t { font-size: 12px; }
|
||||
.alert { font-size: 12px; padding: 10px 12px; }
|
||||
.empty { padding: 32px 16px; }
|
||||
|
||||
/* grid/pair always stack */
|
||||
.grid-2, .grid-12-7-5 { grid-template-columns: 1fr; gap: 12px; }
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
}
|
||||
|
||||
/* very narrow (≤400px) — KPI goes 1-col, even more compact */
|
||||
@media (max-width: 400px) {
|
||||
.stat-grid { grid-template-columns: 1fr; }
|
||||
.page-head__actions { flex-direction: column; }
|
||||
.tbl thead th { font-size: 10px; padding: 6px 5px; }
|
||||
.tbl tbody td { font-size: 11px; padding: 7px 5px; }
|
||||
}
|
||||
|
||||
/* mobile hamburger */
|
||||
.mobile-hamburger { display: none; }
|
||||
@media (max-width: 768px) { .mobile-hamburger { display: grid; } }
|
||||
|
||||
/* mobile sidebar scrim (below drawer scrim 40) */
|
||||
#mobile-scrim { z-index: 39; }
|
||||
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 99px; background: var(--surface-muted); border: 1px solid var(--border-subtle); font-size: 12px; color: var(--text-secondary); font-weight: 600; }
|
||||
|
||||
.timeline-track { position: relative; height: 56px; border-radius: var(--radius-sm); background: var(--surface-muted); overflow: hidden; border: 1px solid var(--border-subtle); }
|
||||
.seg-block { position: absolute; top: 8px; bottom: 8px; border-radius: 5px; background: var(--accent); opacity: .9; }
|
||||
.seg-block.rec { background: #dc2626; }
|
||||
.heat-row { display: flex; gap: 2px; align-items: flex-end; height: 60px; }
|
||||
.heat-bar { flex: 1; border-radius: 3px 3px 0 0; background: var(--accent); opacity: .85; min-height: 3px; }
|
||||
|
||||
.player { aspect-ratio: 16/9; border-radius: var(--radius-md); background: #0f172a; display: grid; place-items: center; color: #cbd5e1; position: relative; overflow: hidden; border: 1px solid var(--border-base); }
|
||||
.player__play { width: 56px; height: 56px; border-radius: 99px; background: rgba(255,255,255,.16); display: grid; place-items: center; backdrop-filter: blur(4px); cursor: pointer; transition: transform .15s, background .15s; }
|
||||
.player__play:hover { transform: scale(1.08); background: rgba(255,255,255,.24); }
|
||||
.player__bar { position: absolute; left: 16px; right: 16px; bottom: 14px; display: flex; align-items: center; gap: 10px; color: #fff; font-size: 12px; }
|
||||
.player__bar .bar { flex: 1; background: rgba(255,255,255,.2); }
|
||||
.player__bar .bar > span { background: #fff; width: 38%; }
|
||||
.danmaku { position: absolute; font-size: 13px; color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.6); white-space: nowrap; font-weight: 600; }
|
||||
|
||||
@media (max-width: 980px) { .searchbar { display: none; } .page { padding: 18px 16px; } }
|
||||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; } }
|
||||
|
||||
/* ============================ Skeleton ============================ */
|
||||
@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } }
|
||||
.skel {
|
||||
background: linear-gradient(90deg, var(--border-subtle) 25%, var(--surface-muted) 37%, var(--border-subtle) 63%);
|
||||
background-size: 800px 100%;
|
||||
animation: shimmer 1.6s ease-in-out infinite;
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.skel--text { height: 14px; width: 100%; }
|
||||
.skel--text.short { width: 60%; }
|
||||
.skel--title { height: 18px; width: 50%; }
|
||||
.skel--stat-value { height: 34px; width: 50%; margin-bottom: 8px; }
|
||||
.skel--stat-label { height: 12px; width: 80%; }
|
||||
.skel--btn { height: 38px; width: 100px; border-radius: var(--radius-sm); }
|
||||
.skel--avatar { width: 40px; height: 40px; border-radius: 99px; flex-shrink: 0; }
|
||||
.skel--pill { height: 22px; width: 56px; border-radius: 99px; }
|
||||
.skel--row { display: flex; align-items: center; gap: 16px; padding: 14px 20px; }
|
||||
.skel--row + .skel--row { border-top: 1px solid var(--border-subtle); }
|
||||
.skel-card { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); overflow: hidden; }
|
||||
.skel-card__body { padding: 24px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.skel-stat { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; box-shadow: var(--shadow-sm); display: flex; flex-direction: column; gap: 12px; }
|
||||
Vendored
+169
@@ -0,0 +1,169 @@
|
||||
/* =============================================================
|
||||
App Shell injector — sidebar + topbar, theme + collapse.
|
||||
Each page: <body data-page="dashboard" data-crumb="监控录制 / 直播间">
|
||||
Content lives in <div id="view"> ... </div>
|
||||
============================================================= */
|
||||
(function () {
|
||||
// ---- inline icon set (lucide-style, 24x24, stroke) ----
|
||||
const I = {
|
||||
gauge: '<path d="M12 14l4-4M4 14a8 8 0 1116 0"/><circle cx="12" cy="14" r="1"/>',
|
||||
home: '<path d="M3 10l9-7 9 7v9a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><path d="M9 21V12h6v9"/>',
|
||||
video: '<rect x="2" y="5" width="14" height="14" rx="2"/><path d="M16 9l6-3v12l-6-3"/>',
|
||||
refresh: '<path d="M21 12a9 9 0 11-3-6.7L21 8"/><path d="M21 3v5h-5"/>',
|
||||
convert: '<path d="M4 7h13l-3-3M20 17H7l3 3"/>',
|
||||
upload: '<path d="M12 16V4M6 10l6-6 6 6"/><path d="M4 20h16"/>',
|
||||
folder: '<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"/>',
|
||||
report: '<path d="M14 3v4a1 1 0 001 1h4"/><path d="M5 3h9l5 5v11a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z"/><path d="M8 13h8M8 17h5"/>',
|
||||
logs: '<path d="M4 5h16M4 12h16M4 19h10"/>',
|
||||
settings: '<circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 00-.1-1l2-1.6-2-3.4-2.4 1a7 7 0 00-1.7-1L14.5 2h-5l-.3 2.4a7 7 0 00-1.7 1l-2.4-1-2 3.4L3 11a7 7 0 000 2l-2 1.6 2 3.4 2.4-1a7 7 0 001.7 1L9.5 22h5l.3-2.4a7 7 0 001.7-1l2.4 1 2-3.4-2-1.6a7 7 0 00.1-1z"/>',
|
||||
search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/>',
|
||||
bell: '<path d="M18 8a6 6 0 10-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 01-3.4 0"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M19 5l-1.5 1.5M6.5 17.5L5 19"/>',
|
||||
moon: '<path d="M21 12.8A9 9 0 1111.2 3 7 7 0 0021 12.8z"/>',
|
||||
menu: '<path d="M3 6h18M3 12h18M3 18h18"/>',
|
||||
chevron: '<path d="M9 18l6-6-6-6"/>',
|
||||
recovery: '<path d="M12 3a9 9 0 100 18 9 9 0 000-18z"/><path d="M12 7v5l3 2"/>',
|
||||
};
|
||||
function svg(name, cls) {
|
||||
return '<svg class="' + (cls || 'ic') + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">' + (I[name] || '') + '</svg>';
|
||||
}
|
||||
|
||||
const NAV = [
|
||||
{ title: '总览', items: [
|
||||
{ id: 'dashboard', label: '仪表盘', icon: 'gauge', href: 'index.html' },
|
||||
]},
|
||||
{ title: '监控录制', items: [
|
||||
{ id: 'live-rooms', label: '直播间', icon: 'home', href: 'live-rooms.html' },
|
||||
{ id: 'record-tasks', label: '录制任务', icon: 'video', href: 'record-tasks.html', badge: '3' },
|
||||
{ id: 'recovery', label: '恢复中心', icon: 'recovery', href: 'recovery.html' },
|
||||
]},
|
||||
{ title: '媒资归档', items: [
|
||||
{ id: 'transcode-tasks', label: '转码任务', icon: 'convert', href: 'transcode-tasks.html' },
|
||||
{ id: 'upload-tasks', label: '上传任务', icon: 'upload', href: 'upload-tasks.html' },
|
||||
{ id: 'media-browser', label: '文件库', icon: 'folder', href: 'media-browser.html' },
|
||||
]},
|
||||
{ title: '分析', items: [
|
||||
{ id: 'daily-reviews', label: '回顾日报', icon: 'report', href: 'daily-reviews.html' },
|
||||
{ id: 'logs', label: '系统日志', icon: 'logs', href: 'logs.html' },
|
||||
]},
|
||||
{ title: '系统', items: [
|
||||
{ id: 'settings', label: '系统设置', icon: 'settings', href: 'settings.html' },
|
||||
]},
|
||||
];
|
||||
|
||||
const page = document.body.dataset.page || 'dashboard';
|
||||
const crumb = document.body.dataset.crumb || '';
|
||||
|
||||
// ---- sidebar ----
|
||||
let navHtml = '';
|
||||
NAV.forEach(g => {
|
||||
navHtml += '<div class="nav__group"><div class="nav__title">' + g.title + '</div>';
|
||||
g.items.forEach(it => {
|
||||
const active = it.id === page ? ' active' : '';
|
||||
navHtml += '<a class="nav__item' + active + '" href="' + it.href + '">' +
|
||||
svg(it.icon) +
|
||||
'<span class="nav__label">' + it.label + '</span>' +
|
||||
(it.badge ? '<span class="nav__badge">' + it.badge + '</span>' : '') +
|
||||
'</a>';
|
||||
});
|
||||
navHtml += '</div>';
|
||||
});
|
||||
|
||||
const sidebar = document.createElement('aside');
|
||||
sidebar.className = 'sidebar';
|
||||
const LOGO =
|
||||
'<svg width="22" height="22" viewBox="0 0 24 24" fill="none">' +
|
||||
'<rect x="1.5" y="6" width="14" height="12" rx="2.4" stroke="#fff" stroke-width="1.8"/>' +
|
||||
'<path d="M15.5 10.2 21 7.2c.5-.3 1.1.06 1.1.64v8.32c0 .58-.6.94-1.1.64l-5.5-3" stroke="#fff" stroke-width="1.8" stroke-linejoin="round"/>' +
|
||||
'<circle cx="6" cy="12" r="2.6" stroke="#fff" stroke-width="1.6"/>' +
|
||||
'<circle cx="11.4" cy="9.2" r="1.5" fill="#ef4444"/>' +
|
||||
'</svg>';
|
||||
sidebar.innerHTML =
|
||||
'<div class="brand">' +
|
||||
'<div class="brand__mark">' + LOGO + '</div>' +
|
||||
'<div class="brand__copy"><div class="brand__name">Live Recorder</div><div class="brand__sub">直播监控录制平台</div></div>' +
|
||||
'</div>' +
|
||||
'<nav class="nav">' + navHtml + '</nav>' +
|
||||
'<div class="sidebar__foot"><div class="health"><span class="health__dot"></span><span class="health__txt">系统状态正常</span></div></div>';
|
||||
|
||||
// ---- topbar ----
|
||||
const crumbHtml = crumb.split('/').map((s, i, a) =>
|
||||
(i === a.length - 1 ? '<b>' + s.trim() + '</b>' : '<span>' + s.trim() + '</span>')
|
||||
).join('<span class="sep">/</span>');
|
||||
|
||||
const topbar = document.createElement('header');
|
||||
topbar.className = 'topbar';
|
||||
topbar.innerHTML =
|
||||
'<button class="icon-btn" id="collapseBtn" title="导航菜单">' + svg('menu') + '</button>' +
|
||||
'<div class="breadcrumb">' + crumbHtml + '</div>' +
|
||||
'<label class="searchbar">' + svg('search') +
|
||||
'<input placeholder="搜索直播间、主播、Room ID、录制文件…"><kbd>⌘K</kbd></label>' +
|
||||
'<div class="topbar__right">' +
|
||||
'<button class="icon-btn" id="themeBtn" title="切换主题">' + svg('moon') + '</button>' +
|
||||
'<button class="icon-btn" title="通知">' + svg('bell') + '<span class="dot"></span></button>' +
|
||||
'<button class="user-chip"><span class="avatar">管</span>' +
|
||||
'<span class="user-chip__copy"><div class="user-chip__name">管理员</div><div class="user-chip__meta">在线</div></span>' +
|
||||
svg('chevron') + '</button>' +
|
||||
'</div>';
|
||||
|
||||
// ---- assemble ----
|
||||
const view = document.getElementById('view');
|
||||
const shell = document.createElement('div');
|
||||
shell.className = 'app-shell';
|
||||
const mainCol = document.createElement('div');
|
||||
mainCol.className = 'main-col';
|
||||
mainCol.appendChild(topbar);
|
||||
if (view) mainCol.appendChild(view);
|
||||
shell.appendChild(sidebar);
|
||||
shell.appendChild(mainCol);
|
||||
document.body.insertBefore(shell, document.body.firstChild);
|
||||
|
||||
// mobile scrim (closes sidebar on tap)
|
||||
const scrim = document.createElement('div');
|
||||
scrim.className = 'scrim';
|
||||
scrim.id = 'mobile-scrim';
|
||||
document.body.appendChild(scrim);
|
||||
|
||||
function closeMobileSidebar() { sidebar.classList.remove('mobile-open'); scrim.classList.remove('open'); }
|
||||
|
||||
// ---- interactions ----
|
||||
const collapseBtn = document.getElementById('collapseBtn');
|
||||
collapseBtn.addEventListener('click', () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
sidebar.classList.toggle('mobile-open');
|
||||
scrim.classList.toggle('open');
|
||||
} else {
|
||||
sidebar.classList.toggle('collapsed');
|
||||
}
|
||||
});
|
||||
scrim.addEventListener('click', closeMobileSidebar);
|
||||
|
||||
// close mobile sidebar on resize back to desktop
|
||||
window.addEventListener('resize', () => { if (window.innerWidth > 768) closeMobileSidebar(); });
|
||||
const themeBtn = document.getElementById('themeBtn');
|
||||
function applyTheme(t) {
|
||||
document.documentElement.dataset.theme = t;
|
||||
themeBtn.innerHTML = svg(t === 'dark' ? 'sun' : 'moon');
|
||||
try { localStorage.setItem('lr-theme', t); } catch (e) {}
|
||||
}
|
||||
themeBtn.addEventListener('click', () => applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'));
|
||||
let saved = 'light';
|
||||
try { saved = localStorage.getItem('lr-theme') || 'light'; } catch (e) {}
|
||||
applyTheme(saved);
|
||||
|
||||
// generic drawer open/close hooks: [data-drawer-open="id"] and .drawer .scrim
|
||||
document.addEventListener('click', e => {
|
||||
const opener = e.target.closest('[data-drawer-open]');
|
||||
if (opener) {
|
||||
const d = document.getElementById(opener.dataset.drawerOpen);
|
||||
const s = document.getElementById(opener.dataset.drawerOpen + '-scrim');
|
||||
if (d) d.classList.add('open');
|
||||
if (s) s.classList.add('open');
|
||||
}
|
||||
const closer = e.target.closest('[data-drawer-close]');
|
||||
if (closer) {
|
||||
document.querySelectorAll('.drawer.open').forEach(d => d.classList.remove('open'));
|
||||
document.querySelectorAll('.scrim.open').forEach(s => s.classList.remove('open'));
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>回顾日报 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="daily-reviews" data-crumb="分析 / 回顾日报">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div><div class="page-head__kicker">回顾分析</div><h1>回顾日报</h1><p>按天聚合录制活跃度、Top 房间、高光会话与弹幕峰值时刻,支持推送到通知渠道。</p></div>
|
||||
<div class="page-head__actions"><span class="field"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" style="color:var(--text-muted)"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M3 10h18M8 2v4M16 2v4"/></svg><input value="2026-06-25" style="width:100px"></span><button class="btn">刷新日报</button><button class="btn btn--primary">推送日报</button></div>
|
||||
</div>
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(130px,1fr))">
|
||||
<div class="stat"><div class="stat__label">活跃房间</div><div class="stat__value">12</div></div>
|
||||
<div class="stat"><div class="stat__label">会话</div><div class="stat__value">28</div></div>
|
||||
<div class="stat"><div class="stat__label">分片</div><div class="stat__value">142</div></div>
|
||||
<div class="stat"><div class="stat__label">时长</div><div class="stat__value">38h</div></div>
|
||||
<div class="stat"><div class="stat__label">弹幕</div><div class="stat__value">86k</div></div>
|
||||
<div class="stat"><div class="stat__label">警告 / 错误</div><div class="stat__value">5 <small>/ 1</small></div></div>
|
||||
</div>
|
||||
<div class="grid-12-7-5">
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">房间维度</div></div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>房间</th><th class="num">会话</th><th class="num">分片</th><th class="num">时长</th><th class="num">弹幕</th><th style="text-align:right"></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><div class="cell-title">熊宇一</div><div class="meta-row"><span class="tag is-red">抖音</span></div></td><td class="num">4</td><td class="num">22</td><td class="num">3h12m</td><td class="num">18,420</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">会话</a></td></tr>
|
||||
<tr><td><div class="cell-title">游戏老王</div><div class="meta-row"><span class="tag is-blue">B站</span></div></td><td class="num">2</td><td class="num">16</td><td class="num">2h40m</td><td class="num">21,003</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">会话</a></td></tr>
|
||||
<tr><td><div class="cell-title">音乐电台</div><div class="meta-row"><span class="tag is-amber">虎牙</span></div></td><td class="num">3</td><td class="num">15</td><td class="num">1h55m</td><td class="num">12,880</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">会话</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">高光会话</div></div>
|
||||
<div class="card__body" style="display:grid;gap:12px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">⏱ 最长会话</div><div class="toggle-row__h">熊宇一 · 才艺专场 · 3h12m</div></div><a class="btn btn--sm" href="session-detail.html">查看</a></div>
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">🔥 最热会话</div><div class="toggle-row__h">游戏老王 · 巅峰赛 · 21,003 弹幕</div></div><a class="btn btn--sm" href="session-detail.html">查看</a></div>
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">⚠ 异常会话</div><div class="toggle-row__h">深夜美食档 · 录制失败 ×1</div></div><a class="btn btn--sm" href="session-detail.html">查看</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card__head"><div><div class="card__title">高光时刻</div><div class="card__sub">弹幕峰值时间段(每 5 分钟桶)</div></div></div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>房间</th><th>时间段</th><th class="num">弹幕峰值</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><div class="cell-title">游戏老王</div></td><td class="cell-mono">21:35 – 21:40</td><td class="num">1,204</td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">查看分片</a></td></tr>
|
||||
<tr><td><div class="cell-title">熊宇一</div></td><td class="cell-mono">10:50 – 10:55</td><td class="num">980</td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">查看分片</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,132 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>仪表盘 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="dashboard" data-crumb="总览 / 仪表盘">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">系统概览</div>
|
||||
<h1>仪表盘</h1>
|
||||
<p>系统运行状态一览:直播间、录制会话、弹幕吞吐与存储队列概况。</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<span class="field"><select class="select-box" style="border:0;height:auto"><option>今日</option><option>近 7 日</option><option>近 30 日</option></select></span>
|
||||
<button class="btn"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M21 12a9 9 0 11-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI -->
|
||||
<div class="stat-grid">
|
||||
<div class="stat">
|
||||
<div class="stat__top"><span class="stat__label">正在录制</span><span class="stat__ic is-red"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><rect x="2" y="5" width="14" height="14" rx="2"/><path d="M16 9l6-3v12l-6-3"/></svg></span></div>
|
||||
<div class="stat__value">3</div>
|
||||
<div class="stat__foot"><span class="badge live is-danger"><span class="pip"></span>录制通道活跃</span></div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__top"><span class="stat__label">直播间</span><span class="stat__ic"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M3 10l9-7 9 7v9a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg></span></div>
|
||||
<div class="stat__value">12 <small>/ 28 离线</small></div>
|
||||
<div class="stat__foot">共接入 40 个直播间</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__top"><span class="stat__label">今日录制时长</span><span class="stat__ic is-purple"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></span></div>
|
||||
<div class="stat__value">6h 20m</div>
|
||||
<div class="stat__foot"><span class="trend up">▲ 12%</span> 较昨日</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__top"><span class="stat__label">今日数据量</span><span class="stat__ic is-green"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M3 3v18h18"/><path d="M7 14l3-3 3 3 5-6"/></svg></span></div>
|
||||
<div class="stat__value">48.2 <small>GB</small></div>
|
||||
<div class="stat__foot">平均码率 4.2 Mbps</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__top"><span class="stat__label">今日弹幕</span><span class="stat__ic is-amber"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg></span></div>
|
||||
<div class="stat__value">12,304</div>
|
||||
<div class="stat__foot"><span class="trend up">▲ 8%</span> 较昨日</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat__top"><span class="stat__label">24h 异常</span><span class="stat__ic is-green"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="9"/></svg></span></div>
|
||||
<div class="stat__value">0</div>
|
||||
<div class="stat__foot"><span class="badge is-success"><span class="pip"></span>系统运行正常</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- storage + queue -->
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card__head"><div><div class="card__title">存储水位</div><div class="card__sub">录制输出路径剩余空间与保护阈值</div></div><span class="tag is-green">正常</span></div>
|
||||
<div class="card__body" style="display:flex;align-items:center;gap:28px">
|
||||
<div class="ring" style="--p:32"><div class="ring__inner"><div><div class="ring__num">32%</div><div class="ring__cap">已使用</div></div></div></div>
|
||||
<div style="flex:1;display:grid;gap:14px">
|
||||
<div><div class="cell-sub">可用空间</div><div style="font-size:18px;font-weight:700;font-variant-numeric:tabular-nums">412 GB <span style="font-size:13px;font-weight:500;color:var(--text-muted)">/ 600 GB</span></div></div>
|
||||
<div class="bar"><span class="is-green" style="width:32%"></span></div>
|
||||
<div style="display:flex;gap:24px">
|
||||
<div><div class="cell-sub">挂载路径</div><div class="cell-mono">/records</div></div>
|
||||
<div><div class="cell-sub">暂停阈值</div><div style="font-weight:650">1 GB</div></div>
|
||||
<div><div class="cell-sub">恢复阈值</div><div style="font-weight:650">4 GB</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card__head"><div><div class="card__title">处理队列</div><div class="card__sub">待转码与待上传的文件积压</div></div><a class="btn--link" href="transcode-tasks.html">去处理 →</a></div>
|
||||
<div class="card__body" style="display:grid;gap:14px">
|
||||
<div class="toggle-row" style="background:var(--warning-soft);border-color:transparent">
|
||||
<div><div class="toggle-row__t">待转码</div><div class="toggle-row__h">FFmpeg 后处理队列</div></div>
|
||||
<div style="font-size:26px;font-weight:760;color:var(--warning);font-variant-numeric:tabular-nums">2</div>
|
||||
</div>
|
||||
<div class="toggle-row" style="background:var(--warning-soft);border-color:transparent">
|
||||
<div><div class="toggle-row__t">待上传</div><div class="toggle-row__h">WebDAV / S3 / OpenList</div></div>
|
||||
<div style="font-size:26px;font-weight:760;color:var(--warning);font-variant-numeric:tabular-nums">5</div>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<div><div class="toggle-row__t">积压数据量</div><div class="toggle-row__h">等待归档的本地文件总量</div></div>
|
||||
<div style="font-size:26px;font-weight:760;font-variant-numeric:tabular-nums">18.4 <span style="font-size:14px;color:var(--text-muted)">GB</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- recent sessions + top rooms -->
|
||||
<div class="grid-2">
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">最近会话</div><a class="btn--link" href="record-tasks.html">全部 →</a></div>
|
||||
<div class="card__body--flush">
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>直播间</th><th>状态</th><th class="num">分片</th><th>开始时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="cell-title">熊宇一 · 才艺专场</td><td><span class="badge live is-danger"><span class="pip"></span>录制中</span></td><td class="num">3</td><td class="cell-mono">10:24:05</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">游戏老王 · 巅峰赛</td><td><span class="badge is-info"><span class="pip"></span>处理中</span></td><td class="num">8</td><td class="cell-mono">09:12:40</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">音乐电台 LIVE</td><td><span class="badge"><span class="pip"></span>已完成</span></td><td class="num">5</td><td class="cell-mono">02:00:11</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">深夜美食档</td><td><span class="badge is-danger"><span class="pip"></span>失败</span></td><td class="num">1</td><td class="cell-mono">01:31:55</td><td class="row-actions"><a class="btn btn--link" href="session-detail.html">查看</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">今日热门直播间</div><span class="chip">Top 5 · 按时长</span></div>
|
||||
<div class="card__body--flush">
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>直播间</th><th>平台</th><th class="num">会话</th><th class="num">时长</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="cell-title">熊宇一</td><td><span class="tag is-red">抖音</span></td><td class="num">4</td><td class="num">3h 12m</td><td class="row-actions"><a class="btn btn--link" href="live-rooms.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">游戏老王</td><td><span class="tag is-blue">B站</span></td><td class="num">2</td><td class="num">2h 40m</td><td class="row-actions"><a class="btn btn--link" href="live-rooms.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">音乐电台</td><td><span class="tag is-amber">虎牙</span></td><td class="num">3</td><td class="num">1h 55m</td><td class="row-actions"><a class="btn btn--link" href="live-rooms.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">户外阿强</td><td><span class="tag is-purple">快手</span></td><td class="num">1</td><td class="num">1h 08m</td><td class="row-actions"><a class="btn btn--link" href="live-rooms.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">英文角 Talk</td><td><span class="tag is-blue">YouTube</span></td><td class="num">1</td><td class="num">52m</td><td class="row-actions"><a class="btn btn--link" href="live-rooms.html">查看</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>直播间 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="live-rooms" data-crumb="监控录制 / 直播间">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">直播监控</div>
|
||||
<h1>直播间控制台</h1>
|
||||
<p>聚合直播状态、自动开录决策与单房间录制配置,面向开播检测、异常巡检与手动介入。</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<button class="btn">刷新状态</button>
|
||||
<button class="btn">批量导入</button>
|
||||
<button class="btn">导出列表</button>
|
||||
<button class="btn btn--primary"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>新增直播间</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI -->
|
||||
<div class="stat-grid">
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">在线直播间</span><span class="stat__ic is-green"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M3 10l9-7 9 7v9a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg></span></div><div class="stat__value">12</div><div class="stat__foot">最近巡检识别为开播中</div></div>
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">录制中任务</span><span class="stat__ic is-red"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><rect x="2" y="5" width="14" height="14" rx="2"/><path d="M16 9l6-3v12l-6-3"/></svg></span></div><div class="stat__value">3</div><div class="stat__foot">当前处于录制状态</div></div>
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">启用房间</span><span class="stat__ic"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="9"/></svg></span></div><div class="stat__value">34</div><div class="stat__foot">参与自动检测与录制</div></div>
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">重点监控</span><span class="stat__ic is-amber"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M12 2l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 15.4 6.8 18l1-5.8L3.5 8.2l5.9-.9z"/></svg></span></div><div class="stat__value">6</div><div class="stat__foot">优先巡检与关注</div></div>
|
||||
</div>
|
||||
|
||||
<!-- table card -->
|
||||
<div class="card">
|
||||
<!-- toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="segmented">
|
||||
<button class="active">全部 40</button>
|
||||
<button>在线 12</button>
|
||||
<button>录制中 3</button>
|
||||
<button>已停用 6</button>
|
||||
</div>
|
||||
<span class="field"><select class="select-box" style="border:0;height:auto;padding:0;background-position:right center"><option>全部平台</option><option>抖音</option><option>B站</option><option>虎牙</option><option>快手</option></select></span>
|
||||
<label class="field field--search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="房间 / 主播 / Room ID"></label>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<span class="sel-count">已选 <b>2</b> 项</span>
|
||||
<button class="btn btn--sm">批量启用</button>
|
||||
<button class="btn btn--sm">批量停用</button>
|
||||
<button class="btn btn--sm btn--danger">批量删除</button>
|
||||
<button class="btn btn--sm" title="列设置"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>
|
||||
</div>
|
||||
|
||||
<!-- table -->
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr>
|
||||
<th style="width:40px"><span class="checkbox"></span></th>
|
||||
<th>直播间</th>
|
||||
<th style="width:110px">直播状态</th>
|
||||
<th style="width:110px">录制状态</th>
|
||||
<th>最近事件</th>
|
||||
<th style="width:170px">房间配置</th>
|
||||
<th style="width:80px">开关</th>
|
||||
<th style="width:150px;text-align:right">操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="checkbox checked"></span></td>
|
||||
<td><div class="identity"><span class="avatar" style="background:#dc2626">熊</span><div>
|
||||
<div class="cell-title">熊宇一 · 才艺专场 <span class="tag is-amber" style="height:18px">重点</span></div>
|
||||
<div class="meta-row"><span class="tag is-red">抖音</span><span class="cell-mono">845878323112</span></div>
|
||||
</div></div></td>
|
||||
<td><span class="badge is-success"><span class="pip"></span>开播中</span></td>
|
||||
<td><span class="badge live is-danger"><span class="pip"></span>录制中</span></td>
|
||||
<td><div><span class="tag is-green" style="height:18px">自动开录 ✓</span> <span class="cell-mono">10:24:05</span></div><div class="cell-sub">检测到开播,已启动录制任务</div></td>
|
||||
<td><div class="cell-sub" style="line-height:1.8">原画 · MP4 · 分段<br>弹幕开 · 30min</div></td>
|
||||
<td><span class="switch on"></span></td>
|
||||
<td class="row-actions"><button class="btn btn--sm btn--primary">录制中</button><button class="btn btn--sm" data-drawer-open="room-drawer">⋯</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="checkbox checked"></span></td>
|
||||
<td><div class="identity"><span class="avatar" style="background:#2563eb">王</span><div>
|
||||
<div class="cell-title">游戏老王 · 巅峰赛</div>
|
||||
<div class="meta-row"><span class="tag is-blue">B站</span><span class="cell-mono">123456</span></div>
|
||||
</div></div></td>
|
||||
<td><span class="badge is-success"><span class="pip"></span>开播中</span></td>
|
||||
<td><span class="badge is-info"><span class="pip"></span>处理中</span></td>
|
||||
<td><div><span class="tag is-green" style="height:18px">自动开录 ✓</span> <span class="cell-mono">09:12:40</span></div><div class="cell-sub">分片收尾中(MP4 finalize)</div></td>
|
||||
<td><div class="cell-sub" style="line-height:1.8">超清 · TS · 分段<br>弹幕开 · 60min</div></td>
|
||||
<td><span class="switch on"></span></td>
|
||||
<td class="row-actions"><button class="btn btn--sm btn--primary">录制</button><button class="btn btn--sm" data-drawer-open="room-drawer">⋯</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="checkbox"></span></td>
|
||||
<td><div class="identity"><span class="avatar" style="background:#d97706">音</span><div>
|
||||
<div class="cell-title">音乐电台 LIVE</div>
|
||||
<div class="meta-row"><span class="tag is-amber">虎牙</span><span class="cell-mono">998877</span></div>
|
||||
</div></div></td>
|
||||
<td><span class="badge"><span class="pip"></span>未开播</span></td>
|
||||
<td><span class="badge"><span class="pip"></span>未录制</span></td>
|
||||
<td><div><span class="tag" style="height:18px">已禁用</span> <span class="cell-mono">08:00:00</span></div><div class="cell-sub">房间未开播,跳过自动开录</div></td>
|
||||
<td><div class="cell-sub" style="line-height:1.8">高清 · MP4 · 单文件<br>弹幕关 · —</div></td>
|
||||
<td><span class="switch on"></span></td>
|
||||
<td class="row-actions"><button class="btn btn--sm btn--primary">录制</button><button class="btn btn--sm" data-drawer-open="room-drawer">⋯</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="checkbox"></span></td>
|
||||
<td><div class="identity"><span class="avatar" style="background:#7c3aed">强</span><div>
|
||||
<div class="cell-title">户外阿强 <span class="tag" style="height:18px">置顶</span></div>
|
||||
<div class="meta-row"><span class="tag is-purple">快手</span><span class="cell-mono">556677</span></div>
|
||||
</div></div></td>
|
||||
<td><span class="badge"><span class="pip"></span>未开播</span></td>
|
||||
<td><span class="badge"><span class="pip"></span>未录制</span></td>
|
||||
<td><div><span class="tag is-amber" style="height:18px">存储不足</span> <span class="cell-mono">07:45:00</span></div><div class="cell-sub">磁盘低于暂停阈值,跳过开录</div></td>
|
||||
<td><div class="cell-sub" style="line-height:1.8">原画 · MP4 · 分段<br>弹幕开 · 30min</div></td>
|
||||
<td><span class="switch"></span></td>
|
||||
<td class="row-actions"><button class="btn btn--sm btn--primary" disabled>录制</button><button class="btn btn--sm" data-drawer-open="room-drawer">⋯</button></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="checkbox"></span></td>
|
||||
<td><div class="identity"><span class="avatar">T</span><div>
|
||||
<div class="cell-title">英文角 Talk</div>
|
||||
<div class="meta-row"><span class="tag is-blue">YouTube</span><span class="cell-mono">dQw4w9</span></div>
|
||||
</div></div></td>
|
||||
<td><span class="badge is-warning"><span class="pip"></span>未知</span></td>
|
||||
<td><span class="badge"><span class="pip"></span>未录制</span></td>
|
||||
<td><div><span class="tag is-red" style="height:18px">轮询失败</span> <span class="cell-mono">06:30:00</span></div><div class="cell-sub">远端请求超时,将自动重试</div></td>
|
||||
<td><div class="cell-sub" style="line-height:1.8">高清 · MP4 · 单文件<br>弹幕关 · —</div></td>
|
||||
<td><span class="switch on"></span></td>
|
||||
<td class="row-actions"><button class="btn btn--sm btn--primary">录制</button><button class="btn btn--sm" data-drawer-open="room-drawer">⋯</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
<!-- pagination -->
|
||||
<div class="pagination">
|
||||
<span>共 40 个直播间</span>
|
||||
<div class="pagination__pages">
|
||||
<span class="pg">‹</span><span class="pg active">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg">›</span>
|
||||
<span class="field" style="height:32px;margin-left:8px"><select class="select-box" style="border:0;height:auto;padding:0"><option>20 / 页</option><option>50 / 页</option></select></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- detail drawer -->
|
||||
<div class="scrim" id="room-drawer-scrim" data-drawer-close></div>
|
||||
<aside class="drawer" id="room-drawer">
|
||||
<div class="drawer__head">
|
||||
<div style="display:flex;gap:12px;align-items:center">
|
||||
<span class="avatar" style="width:46px;height:46px;border-radius:13px;font-size:17px;background:#dc2626">熊</span>
|
||||
<div><div class="drawer__title">熊宇一 · 才艺专场</div><div class="drawer__sub">抖音 · 845878323112</div></div>
|
||||
</div>
|
||||
<button class="icon-btn" data-drawer-close><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<div class="drawer__body">
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<span class="badge is-success"><span class="pip"></span>开播中</span>
|
||||
<span class="badge live is-danger"><span class="pip"></span>录制中</span>
|
||||
<span class="tag is-red">抖音</span>
|
||||
<span class="tag is-amber">重点监控</span>
|
||||
</div>
|
||||
|
||||
<!-- mini tabs -->
|
||||
<div class="segmented" style="width:100%">
|
||||
<button class="active" style="flex:1">概览</button>
|
||||
<button style="flex:1">配置</button>
|
||||
<button style="flex:1">最近事件</button>
|
||||
</div>
|
||||
|
||||
<dl class="desc">
|
||||
<div class="desc__row"><dt>直播间名称</dt><dd>熊宇一 · 才艺专场</dd></div>
|
||||
<div class="desc__row"><dt>主播</dt><dd>熊宇一</dd></div>
|
||||
<div class="desc__row"><dt>平台 · Room</dt><dd>抖音 · 845878323112</dd></div>
|
||||
<div class="desc__row"><dt>直播状态</dt><dd>开播中</dd></div>
|
||||
<div class="desc__row"><dt>录制状态</dt><dd>录制中(会话 #3 · 已 1h 02m)</dd></div>
|
||||
<div class="desc__row"><dt>录制时长</dt><dd>1h 02m</dd></div>
|
||||
<div class="desc__row"><dt>最近事件</dt><dd>检测到开播,已启动录制任务</dd></div>
|
||||
<div class="desc__row"><dt>最近巡检</dt><dd>2026-06-25 10:24:05</dd></div>
|
||||
<div class="desc__row"><dt>房间配置</dt><dd>原画 · MP4 · 分段 30min · 弹幕开</dd></div>
|
||||
<div class="desc__row"><dt>检测间隔</dt><dd>跟随全局(30 秒)</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="alert alert--info"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="12" cy="12" r="9"/><path d="M12 8h.01M11 12h1v4h1"/></svg><div class="alert__body">自动开录复用现有后端策略;<b>单房间配置优先于全局设置</b>。</div></div>
|
||||
</div>
|
||||
<div class="drawer__foot">
|
||||
<button class="btn" data-drawer-close>关闭</button>
|
||||
<button class="btn">配置</button>
|
||||
<button class="btn btn--primary">▶ 开始录制</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body>
|
||||
<div style="min-height:100vh;display:grid;grid-template-columns:1.1fr 0.9fr">
|
||||
<!-- brand panel -->
|
||||
<div style="background:#1e3a8a;color:#fff;padding:56px;display:flex;flex-direction:column;justify-content:space-between" class="login-hero">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<div class="brand__mark" style="width:42px;height:42px;background:rgba(255,255,255,.12);box-shadow:none">
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="1.5" y="6" width="14" height="12" rx="2.4" stroke="#fff" stroke-width="1.8"/>
|
||||
<path d="M15.5 10.2 21 7.2c.5-.3 1.1.06 1.1.64v8.32c0 .58-.6.94-1.1.64l-5.5-3" stroke="#fff" stroke-width="1.8" stroke-linejoin="round"/>
|
||||
<circle cx="6" cy="12" r="2.6" stroke="#fff" stroke-width="1.6"/>
|
||||
<circle cx="11.4" cy="9.2" r="1.5" fill="#ef4444"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div><div style="font-weight:750;font-size:17px">Live Recorder</div><div style="font-size:12px;opacity:.8">直播监控录制平台</div></div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 style="font-size:34px;font-weight:760;letter-spacing:-.03em;line-height:1.25">监控 · 录制 · 归档<br>一体化直播采集平台</h1>
|
||||
<p style="margin-top:18px;opacity:.85;font-size:15px;line-height:1.8;max-width:420px">支持抖音、B站、虎牙、快手、YouTube 等多平台开播检测、自动录制、弹幕采集、转码与云端归档。</p>
|
||||
<div style="display:flex;gap:10px;margin-top:24px;flex-wrap:wrap">
|
||||
<span class="chip" style="background:rgba(255,255,255,.12);border-color:rgba(255,255,255,.2);color:#fff">自动开录</span>
|
||||
<span class="chip" style="background:rgba(255,255,255,.12);border-color:rgba(255,255,255,.2);color:#fff">弹幕回放</span>
|
||||
<span class="chip" style="background:rgba(255,255,255,.12);border-color:rgba(255,255,255,.2);color:#fff">存储守护</span>
|
||||
<span class="chip" style="background:rgba(255,255,255,.12);border-color:rgba(255,255,255,.2);color:#fff">云端归档</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:12px;opacity:.6">© 2026 Live Recorder</div>
|
||||
</div>
|
||||
<!-- form panel -->
|
||||
<div style="display:grid;place-items:center;padding:40px">
|
||||
<div style="width:100%;max-width:360px">
|
||||
<h2 style="font-size:24px;font-weight:750">登录</h2>
|
||||
<p style="color:var(--text-muted);margin-top:8px;margin-bottom:28px">欢迎回来,请使用管理员账号登录控制台。</p>
|
||||
<div style="display:grid;gap:18px">
|
||||
<div class="form-item"><label>用户名</label><input class="input" value="admin"></div>
|
||||
<div class="form-item"><label>密码</label><input class="input" type="password" value="123456"></div>
|
||||
<a class="btn btn--primary" href="index.html" style="height:42px;font-size:14px">登录</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>@media(max-width:860px){.login-hero{display:none!important}body>div{grid-template-columns:1fr!important}}</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>系统日志 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="logs" data-crumb="分析 / 系统日志">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">排障中心</div>
|
||||
<h1>系统日志</h1>
|
||||
<p>贯穿巡检、录制、转码、上传的全链路日志,支持按级别、分类与关键词过滤并下钻关联实体。</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<button class="btn">刷新</button>
|
||||
<button class="btn">导出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<span class="field"><select class="select-box" style="border:0;height:auto;padding:0"><option>全部级别</option><option>错误</option><option>警告</option><option>信息</option><option>跟踪</option></select></span>
|
||||
<span class="field"><select class="select-box" style="border:0;height:auto;padding:0"><option>全部分类</option><option>录制</option><option>巡检</option><option>转码</option><option>上传</option></select></span>
|
||||
<span class="field"><select class="select-box" style="border:0;height:auto;padding:0"><option>近 24 小时</option><option>近 7 天</option><option>自定义</option></select></span>
|
||||
<label class="field field--search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="搜索消息关键词"></label>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--sm btn--primary">应用筛选</button>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:160px">时间</th><th style="width:90px">级别</th><th style="width:90px">分类</th><th>消息</th><th style="width:180px">关联实体</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="cell-mono">10:24:05</td><td><span class="tag is-red">错误</span></td><td>录制</td><td>FFmpeg 进程异常退出,退出码 1:Connection reset by peer<div class="cell-sub">▸ 展开堆栈详情</div></td><td><a class="btn btn--link" href="session-detail.html">会话↗</a><a class="btn btn--link" href="record-task-detail.html">分片↗</a></td></tr>
|
||||
<tr><td class="cell-mono">10:23:50</td><td><span class="tag is-amber">警告</span></td><td>巡检</td><td>抖音房间 845878 轮询超时,将在 8s 后重试(第 2 次)</td><td><a class="btn btn--link" href="live-rooms.html">房间↗</a></td></tr>
|
||||
<tr><td class="cell-mono">10:24:05</td><td><span class="tag is-blue">信息</span></td><td>录制</td><td>检测到开播,已启动录制任务(会话 #3)</td><td><a class="btn btn--link" href="session-detail.html">会话↗</a></td></tr>
|
||||
<tr><td class="cell-mono">10:18:11</td><td><span class="tag is-blue">信息</span></td><td>上传</td><td>分片 #1 已上传至 OpenList:/archive/2026/06/25/熊宇一/…</td><td><a class="btn btn--link" href="upload-tasks.html">上传↗</a></td></tr>
|
||||
<tr><td class="cell-mono">10:15:02</td><td><span class="tag is-amber">警告</span></td><td>存储</td><td>磁盘可用空间 980MB 低于暂停阈值 1024MB,已暂停 2 个录制任务</td><td><a class="btn btn--link" href="recovery.html">恢复↗</a></td></tr>
|
||||
<tr><td class="cell-mono">10:12:40</td><td><span class="tag is-green">跟踪</span></td><td>转码</td><td>分片 #4 转码完成,耗时 42s,输出 410MB</td><td><a class="btn btn--link" href="transcode-tasks.html">转码↗</a></td></tr>
|
||||
<tr><td class="cell-mono">10:08:33</td><td><span class="tag is-blue">信息</span></td><td>巡检</td><td>本轮巡检完成:40 个房间,开播 12,耗时 3.2s</td><td><span class="cell-sub">—</span></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
|
||||
<div class="pagination">
|
||||
<span>共 1,284 条</span>
|
||||
<div class="pagination__pages"><span class="pg">‹</span><span class="pg active">1</span><span class="pg">2</span><span class="pg">3</span><span class="pg">…</span><span class="pg">65</span><span class="pg">›</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>文件库 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="media-browser" data-crumb="媒资归档 / 文件库">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div><div class="page-head__kicker">文件浏览</div><h1>文件库</h1><p>浏览录制输出目录,预览视频、查看弹幕 XML,或对源文件发起转码。</p></div>
|
||||
<div class="page-head__actions"><button class="btn">刷新目录</button></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<div class="breadcrumb" style="font-size:14px"><span>records</span><span class="sep">/</span><span>抖音</span><span class="sep">/</span><span>2026</span><span class="sep">/</span><span>06</span><span class="sep">/</span><b>25</b></div>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<label class="field field--search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="筛选当前目录"></label>
|
||||
</div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>名称</th><th style="width:100px">类型</th><th class="num" style="width:120px">大小</th><th style="width:160px">修改时间</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><div style="display:flex;align-items:center;gap:10px"><span style="color:var(--warning)">📁</span><span class="cell-title">熊宇一</span></div></td><td><span class="tag">目录</span></td><td class="num">—</td><td class="cell-mono">10:24</td><td class="row-actions"><button class="btn btn--sm">进入目录</button></td></tr>
|
||||
<tr><td><div style="display:flex;align-items:center;gap:10px"><span>🎬</span><span class="cell-title">103505_原画_熊宇一_才艺_01.mp4</span></div></td><td><span class="tag is-blue">视频</span></td><td class="num">420 MB</td><td class="cell-mono">10:54</td><td class="row-actions"><button class="btn btn--sm">预览</button><button class="btn btn--sm">转码</button></td></tr>
|
||||
<tr><td><div style="display:flex;align-items:center;gap:10px"><span>📄</span><span class="cell-title">103505_原画_熊宇一_才艺_01.xml</span></div></td><td><span class="tag is-purple">弹幕</span></td><td class="num">1.2 MB</td><td class="cell-mono">10:54</td><td class="row-actions"><button class="btn btn--sm">查看 XML</button></td></tr>
|
||||
<tr><td><div style="display:flex;align-items:center;gap:10px"><span>🎬</span><span class="cell-title">105405_原画_熊宇一_才艺_02.mp4</span></div></td><td><span class="tag is-blue">视频</span></td><td class="num">440 MB</td><td class="cell-mono">11:24</td><td class="row-actions"><button class="btn btn--sm">预览</button><button class="btn btn--sm">转码</button></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>分片详情 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="record-tasks" data-crumb="监控录制 / 录制任务 / 会话 #3 / 分片 #1">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div><div class="page-head__kicker">分片任务详情</div><h1>分片 #1 · 熊宇一 才艺专场</h1><p>抖音 · 845878323112 · 会话 #3 · 10:24:05 – 10:54:05</p></div>
|
||||
<div class="page-head__actions"><a class="btn" href="session-detail.html">返回会话</a><button class="btn">刷新</button><button class="btn btn--primary">上传文件</button></div>
|
||||
</div>
|
||||
<div class="grid-12-7-5">
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">视频预览</div><span class="tag is-green">已完成</span></div>
|
||||
<div class="card__body">
|
||||
<div class="player">
|
||||
<div class="player__play"><svg width="22" height="22" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg></div>
|
||||
<div class="player__bar"><span>00:00</span><div class="bar"><span></span></div><span>30:00</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">分片信息</div></div>
|
||||
<div class="card__body">
|
||||
<dl class="desc">
|
||||
<div class="desc__row"><dt>状态</dt><dd>已完成</dd></div>
|
||||
<div class="desc__row"><dt>时长</dt><dd>30m 00s</dd></div>
|
||||
<div class="desc__row"><dt>文件大小</dt><dd>420 MB</dd></div>
|
||||
<div class="desc__row"><dt>画质 / 格式</dt><dd>原画 · MP4</dd></div>
|
||||
<div class="desc__row"><dt>分辨率</dt><dd>1920 × 1080 · 30fps</dd></div>
|
||||
<div class="desc__row"><dt>编码</dt><dd>H.264 / AAC</dd></div>
|
||||
<div class="desc__row"><dt>弹幕数</dt><dd>2,940</dd></div>
|
||||
<div class="desc__row"><dt>上传状态</dt><dd>已上传 · OpenList</dd></div>
|
||||
<div class="desc__row"><dt>文件路径</dt><dd class="cell-mono" style="word-break:break-all">/records/抖音/2026/06/25/熊宇一/103505_…_01.mp4</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">分片日志</div></div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:160px">时间</th><th style="width:90px">级别</th><th>消息</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="cell-mono">10:54:05</td><td><span class="tag is-green">跟踪</span></td><td>MP4 finalize 成功,moov 写入完成</td></tr>
|
||||
<tr><td class="cell-mono">10:54:02</td><td><span class="tag is-blue">信息</span></td><td>分片达到分段时长,开始收尾</td></tr>
|
||||
<tr><td class="cell-mono">10:24:05</td><td><span class="tag is-blue">信息</span></td><td>分片开始录制,拉流成功(origin)</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>录制任务 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
<style>
|
||||
.session { border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); overflow:hidden; }
|
||||
.session + .session { margin-top:14px; }
|
||||
.session__head { display:flex; align-items:center; gap:14px; padding:16px 18px; cursor:pointer; }
|
||||
.session__head:hover { background:var(--surface-hover); }
|
||||
.session__chev { transition:transform .2s; color:var(--text-soft); }
|
||||
.session.open .session__chev { transform:rotate(90deg); }
|
||||
.session__body { border-top:1px solid var(--border-subtle); display:none; }
|
||||
.session.open .session__body { display:block; }
|
||||
.session__metrics { display:flex; gap:22px; margin-left:auto; }
|
||||
.session__metrics .m { text-align:right; }
|
||||
.session__metrics .m b { font-size:15px; font-weight:700; font-variant-numeric:tabular-nums; }
|
||||
.session__metrics .m span { display:block; font-size:11px; color:var(--text-muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="record-tasks" data-crumb="监控录制 / 录制任务">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">录制工作台</div>
|
||||
<h1>录制任务</h1>
|
||||
<p>按会话聚合录制分片,支持实时刷新、停止录制、上传归档与条件化清理。</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<span class="badge is-success" style="margin-right:6px"><span class="pip"></span>实时已连接</span>
|
||||
<button class="btn">刷新</button>
|
||||
<button class="btn">清理 ▾</button>
|
||||
<button class="btn btn--danger">条件删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(160px,1fr))">
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">活动会话</span></div><div class="stat__value">3</div><div class="stat__foot">正在录制 / 处理中</div></div>
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">总分片</span></div><div class="stat__value">142</div><div class="stat__foot">全部会话累计</div></div>
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">总弹幕</span></div><div class="stat__value">86,420</div><div class="stat__foot">已采集消息数</div></div>
|
||||
<div class="stat"><div class="stat__top"><span class="stat__label">待上传分片</span></div><div class="stat__value" style="color:var(--warning)">5</div><div class="stat__foot">等待归档</div></div>
|
||||
</div>
|
||||
|
||||
<!-- cleanup progress (running) -->
|
||||
<div class="card"><div class="card__body" style="display:flex;align-items:center;gap:16px;padding:16px 20px">
|
||||
<span class="badge is-info"><span class="pip"></span>清理进行中</span>
|
||||
<div style="flex:1"><div class="bar"><span style="width:30%"></span></div></div>
|
||||
<span class="cell-sub">已处理 <b style="color:var(--text-primary)">12 / 40</b> 会话 · 删除文件 8 · 释放 6.2 GB</span>
|
||||
<button class="btn btn--sm">查看详情</button>
|
||||
</div></div>
|
||||
|
||||
<!-- toolbar -->
|
||||
<div class="card"><div class="toolbar">
|
||||
<div class="segmented">
|
||||
<button class="active">全部</button><button>进行中</button><button>已完成</button><button>失败</button>
|
||||
</div>
|
||||
<label class="field field--search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="搜索直播间 / Room ID"></label>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<span class="sel-count">已选 <b>0</b> 项</span>
|
||||
<button class="btn btn--sm">停止</button>
|
||||
<button class="btn btn--sm">上传</button>
|
||||
<button class="btn btn--sm btn--danger">删除</button>
|
||||
</div>
|
||||
|
||||
<div class="card__body" style="background:var(--bg-base)">
|
||||
<!-- session 1 -->
|
||||
<div class="session open">
|
||||
<div class="session__head" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="checkbox"></span>
|
||||
<svg class="ic session__chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
<span class="avatar" style="background:#dc2626">熊</span>
|
||||
<div>
|
||||
<div class="cell-title">熊宇一 · 才艺专场 <span class="badge live is-danger" style="margin-left:6px"><span class="pip"></span>录制中</span></div>
|
||||
<div class="meta-row"><span class="tag is-red">抖音</span><span class="cell-mono">845878323112</span><span class="dot-sep"></span>会话 #3 · 开始 10:24</div>
|
||||
</div>
|
||||
<div class="session__metrics">
|
||||
<div class="m"><b>3</b><span>分片</span></div>
|
||||
<div class="m"><b>1.2 GB</b><span>数据量</span></div>
|
||||
<div class="m"><b>8,420</b><span>弹幕</span></div>
|
||||
</div>
|
||||
<div class="row-actions" style="margin-left:18px" onclick="event.stopPropagation()">
|
||||
<button class="btn btn--sm btn--danger">停止</button>
|
||||
<button class="btn btn--sm">上传</button>
|
||||
<a class="btn btn--sm" href="session-detail.html">详情</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="session__body">
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:40px"></th><th>分片</th><th>状态</th><th>时间段</th><th class="num">时长</th><th class="num">大小</th><th>上传</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="checkbox"></span></td><td class="cell-title">#1</td><td><span class="badge"><span class="pip"></span>已完成</span></td><td class="cell-mono">10:24:05 – 10:54:05</td><td class="num">30m</td><td class="num">420 MB</td><td><span class="tag is-green">已上传</span></td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">详情</a></td></tr>
|
||||
<tr><td><span class="checkbox"></span></td><td class="cell-title">#2</td><td><span class="badge"><span class="pip"></span>已完成</span></td><td class="cell-mono">10:54:05 – 11:24:05</td><td class="num">30m</td><td class="num">440 MB</td><td><span class="tag is-amber">未上传</span></td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">详情</a><button class="btn btn--link">上传</button></td></tr>
|
||||
<tr><td><span class="checkbox"></span></td><td class="cell-title">#3</td><td><span class="badge live is-danger"><span class="pip"></span>录制中</span></td><td class="cell-mono">11:24:05 – …</td><td class="num">进行中</td><td class="num">340 MB</td><td><span class="tag">—</span></td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">详情</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- session 2 -->
|
||||
<div class="session">
|
||||
<div class="session__head" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="checkbox"></span>
|
||||
<svg class="ic session__chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
<span class="avatar" style="background:#2563eb">王</span>
|
||||
<div>
|
||||
<div class="cell-title">游戏老王 · 巅峰赛 <span class="badge is-info" style="margin-left:6px"><span class="pip"></span>处理中</span></div>
|
||||
<div class="meta-row"><span class="tag is-blue">B站</span><span class="cell-mono">123456</span><span class="dot-sep"></span>会话 #7 · 开始 09:12</div>
|
||||
</div>
|
||||
<div class="session__metrics">
|
||||
<div class="m"><b>8</b><span>分片</span></div>
|
||||
<div class="m"><b>3.4 GB</b><span>数据量</span></div>
|
||||
<div class="m"><b>21,003</b><span>弹幕</span></div>
|
||||
</div>
|
||||
<div class="row-actions" style="margin-left:18px" onclick="event.stopPropagation()">
|
||||
<button class="btn btn--sm">上传</button>
|
||||
<a class="btn btn--sm" href="session-detail.html">详情</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- session 3 -->
|
||||
<div class="session">
|
||||
<div class="session__head" onclick="this.parentElement.classList.toggle('open')">
|
||||
<span class="checkbox"></span>
|
||||
<svg class="ic session__chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
<span class="avatar" style="background:#d97706">音</span>
|
||||
<div>
|
||||
<div class="cell-title">音乐电台 LIVE <span class="badge" style="margin-left:6px"><span class="pip"></span>已完成</span></div>
|
||||
<div class="meta-row"><span class="tag is-amber">虎牙</span><span class="cell-mono">998877</span><span class="dot-sep"></span>会话 #5 · 02:00 – 03:55</div>
|
||||
</div>
|
||||
<div class="session__metrics">
|
||||
<div class="m"><b>5</b><span>分片</span></div>
|
||||
<div class="m"><b>2.1 GB</b><span>数据量</span></div>
|
||||
<div class="m"><b>12,880</b><span>弹幕</span></div>
|
||||
</div>
|
||||
<div class="row-actions" style="margin-left:18px" onclick="event.stopPropagation()">
|
||||
<button class="btn btn--sm">上传</button>
|
||||
<a class="btn btn--sm" href="session-detail.html">详情</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>恢复中心 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="recovery" data-crumb="监控录制 / 恢复中心">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div><div class="page-head__kicker">恢复处理</div><h1>恢复中心</h1><p>存储守护状态、可恢复直播间与未完成 MP4 收尾的一键恢复。</p></div>
|
||||
<div class="page-head__actions"><button class="btn">刷新总览</button></div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert--success" style="padding:16px 18px"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="9"/></svg><div class="alert__body"><b>存储守护正常</b> · 可用 412 GB,需求 1 GB,检测路径 <span class="cell-mono">/records</span></div></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card__head"><div><div class="card__title">可恢复直播间</div><div class="card__sub">因异常中断、可重新触发自动开录的房间</div></div><div style="display:flex;gap:8px"><button class="btn btn--sm">恢复选中</button><button class="btn btn--sm btn--primary">全部恢复</button></div></div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:40px"><span class="checkbox"></span></th><th>直播间</th><th>最近决策</th><th>最近巡检</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="checkbox"></span></td><td><div class="cell-title">户外阿强</div><div class="meta-row"><span class="tag is-purple">快手</span><span class="cell-mono">556677</span></div></td><td><span class="tag is-amber">存储不足</span></td><td class="cell-mono">07:45:00</td><td class="row-actions"><button class="btn btn--sm btn--primary">恢复</button></td></tr>
|
||||
<tr><td><span class="checkbox"></span></td><td><div class="cell-title">英文角 Talk</div><div class="meta-row"><span class="tag is-blue">YouTube</span><span class="cell-mono">dQw4w9</span></div></td><td><span class="tag is-red">轮询失败</span></td><td class="cell-mono">06:30:00</td><td class="row-actions"><button class="btn btn--sm btn--primary">恢复</button></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card__head"><div><div class="card__title">未完成收尾</div><div class="card__sub">中断时未完成 MP4 finalize 的分片,可重新收尾</div></div><button class="btn btn--sm btn--primary">恢复选中收尾</button></div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:40px"><span class="checkbox"></span></th><th>分片</th><th>直播间</th><th>原因</th><th>结束时间</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="checkbox"></span></td><td class="cell-title">会话#7 · #8</td><td>游戏老王 <span class="tag is-blue">B站</span></td><td class="cell-sub">进程被强制结束,MP4 索引未写入</td><td class="cell-mono">09:58:02</td><td class="row-actions"><button class="btn btn--sm btn--primary">恢复收尾</button></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>会话详情 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="record-tasks" data-crumb="监控录制 / 录制任务 / 会话 #3">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">会话回顾</div>
|
||||
<h1>熊宇一 · 才艺专场 <span class="badge live is-danger" style="font-size:13px;vertical-align:middle"><span class="pip"></span>录制中</span></h1>
|
||||
<p>抖音 · 845878323112 · 会话 #3 · 开始于 2026-06-25 10:24:05</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<a class="btn" href="record-tasks.html">返回列表</a>
|
||||
<button class="btn">刷新</button>
|
||||
<button class="btn btn--primary">上传会话</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- overview -->
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(140px,1fr))">
|
||||
<div class="stat"><div class="stat__label">分片数</div><div class="stat__value">3</div></div>
|
||||
<div class="stat"><div class="stat__label">总时长</div><div class="stat__value">1h 02m</div></div>
|
||||
<div class="stat"><div class="stat__label">数据量</div><div class="stat__value">1.2 <small>GB</small></div></div>
|
||||
<div class="stat"><div class="stat__label">弹幕</div><div class="stat__value">8,420</div></div>
|
||||
<div class="stat"><div class="stat__label">已上传 / 失败</div><div class="stat__value">1 <small>/ 0</small></div></div>
|
||||
</div>
|
||||
|
||||
<!-- timeline -->
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">录制时间轴</div><div style="display:flex;gap:8px"><span class="chip"><span class="badge is-info"><span class="pip"></span></span>分片</span><span class="chip"><span class="badge is-danger"><span class="pip"></span></span>录制中</span><span class="chip">▲ 事件</span></div></div>
|
||||
<div class="card__body" style="display:grid;gap:14px">
|
||||
<div class="timeline-track">
|
||||
<div class="seg-block" style="left:0%;width:32%"></div>
|
||||
<div class="seg-block" style="left:33%;width:32%"></div>
|
||||
<div class="seg-block rec" style="left:66%;width:30%"></div>
|
||||
<div style="position:absolute;top:0;left:34%;color:var(--warning);font-size:11px">▲</div>
|
||||
<div style="position:absolute;top:0;left:67%;color:var(--danger);font-size:11px">▲</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text-muted)" class="cell-mono"><span>10:24</span><span>10:54</span><span>11:24</span><span>现在</span></div>
|
||||
<div>
|
||||
<div class="cell-sub" style="margin-bottom:6px">弹幕热力(每 5 分钟)</div>
|
||||
<div class="heat-row">
|
||||
<div class="heat-bar" style="height:30%"></div><div class="heat-bar" style="height:48%"></div><div class="heat-bar" style="height:65%"></div>
|
||||
<div class="heat-bar" style="height:40%"></div><div class="heat-bar" style="height:88%;background:var(--warning)"></div><div class="heat-bar" style="height:72%"></div>
|
||||
<div class="heat-bar" style="height:55%"></div><div class="heat-bar" style="height:35%"></div><div class="heat-bar" style="height:60%"></div>
|
||||
<div class="heat-bar" style="height:78%"></div><div class="heat-bar" style="height:92%;background:var(--warning)"></div><div class="heat-bar" style="height:50%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- player + segments -->
|
||||
<div class="grid-12-7-5">
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">弹幕回放</div><span class="chip">分片 #1</span></div>
|
||||
<div class="card__body">
|
||||
<div class="player">
|
||||
<div class="danmaku" style="top:14%;right:8%">主播好强!!</div>
|
||||
<div class="danmaku" style="top:32%;right:24%">2333333</div>
|
||||
<div class="danmaku" style="top:56%;right:14%">送上小心心 ♥</div>
|
||||
<div class="player__play"><svg width="22" height="22" viewBox="0 0 24 24" fill="#fff"><path d="M8 5v14l11-7z"/></svg></div>
|
||||
<div class="player__bar"><span>10:35</span><div class="bar"><span></span></div><span>30:00</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">分片列表</div></div>
|
||||
<div class="card__body--flush"><div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>分片</th><th>状态</th><th class="num">时长</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="cell-title">#1</td><td><span class="badge"><span class="pip"></span>已完成</span></td><td class="num">30m</td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">#2</td><td><span class="badge"><span class="pip"></span>已完成</span></td><td class="num">30m</td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">查看</a></td></tr>
|
||||
<tr><td class="cell-title">#3</td><td><span class="badge live is-danger"><span class="pip"></span>录制中</span></td><td class="num">—</td><td class="row-actions"><a class="btn btn--link" href="record-task-detail.html">查看</a></td></tr>
|
||||
</tbody>
|
||||
</table></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- session logs -->
|
||||
<div class="card">
|
||||
<div class="card__head"><div class="card__title">会话日志</div></div>
|
||||
<div class="card__body--flush"><div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:160px">时间</th><th style="width:90px">级别</th><th>消息</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="cell-mono">11:24:05</td><td><span class="tag is-blue">信息</span></td><td>分片 #3 开始录制</td></tr>
|
||||
<tr><td class="cell-mono">10:54:05</td><td><span class="tag is-green">跟踪</span></td><td>分片 #2 收尾完成,MP4 finalize 成功</td></tr>
|
||||
<tr><td class="cell-mono">10:39:12</td><td><span class="tag is-amber">警告</span></td><td>弹幕轮询短暂超时,已自动恢复</td></tr>
|
||||
<tr><td class="cell-mono">10:24:05</td><td><span class="tag is-blue">信息</span></td><td>检测到开播,已启动录制会话 #3</td></tr>
|
||||
</tbody>
|
||||
</table></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>系统设置 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="settings" data-crumb="系统 / 系统设置">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">系统设置</div>
|
||||
<h1>系统设置</h1>
|
||||
<p>录制、存储、弹幕、上传归档、巡检、事件脚本与通知的全局默认配置。</p>
|
||||
</div>
|
||||
<div class="page-head__actions">
|
||||
<button class="btn">导入配置</button>
|
||||
<button class="btn">导出配置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- account + display preference -->
|
||||
<div class="grid-2">
|
||||
<div class="card"><div class="card__body" style="display:flex;align-items:center;gap:14px">
|
||||
<span class="avatar" style="width:48px;height:48px;border-radius:13px;font-size:18px">管</span>
|
||||
<div style="flex:1"><div class="cell-title" style="font-size:15px">管理员</div><div class="cell-sub">admin · 账号到期 2027-01-01</div></div>
|
||||
<button class="btn btn--sm">账号安全</button>
|
||||
</div></div>
|
||||
<div class="card"><div class="card__body" style="display:flex;align-items:center;gap:20px;flex-wrap:wrap">
|
||||
<div><div class="cell-sub" style="margin-bottom:6px">主题</div><div class="segmented"><button class="active">浅色</button><button>深色</button><button>跟随系统</button></div></div>
|
||||
<div><div class="cell-sub" style="margin-bottom:6px">密度</div><div class="segmented"><button class="active">标准</button><button>紧凑</button></div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- anchor + forms -->
|
||||
<div class="anchor-layout">
|
||||
<nav class="anchor">
|
||||
<a class="active" href="#s-record">录制基础</a>
|
||||
<a href="#s-storage">存储保护</a>
|
||||
<a href="#s-clean">保留清理</a>
|
||||
<a href="#s-danmaku">弹幕录制</a>
|
||||
<a href="#s-path">路径模板</a>
|
||||
<a href="#s-poll">巡检与上传</a>
|
||||
<a href="#s-proxy">平台代理</a>
|
||||
<a href="#s-script">事件脚本</a>
|
||||
<a href="#s-notify">通知</a>
|
||||
</nav>
|
||||
|
||||
<div style="display:flex;flex-direction:column;gap:20px;min-width:0">
|
||||
<!-- 录制基础 -->
|
||||
<div class="card" id="s-record">
|
||||
<div class="card__head"><div class="card__title">录制基础</div></div>
|
||||
<div class="card__body">
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>FFmpeg 路径</label><input class="input" value="ffmpeg"></div>
|
||||
<div class="form-item"><label>输出根目录</label><input class="input" value="/records"></div>
|
||||
<div class="form-item"><label>默认画质</label><select class="select-box"><option>原画</option><option>超清</option><option>高清</option><option>标清</option></select></div>
|
||||
<div class="form-item"><label>默认输出格式</label><select class="select-box"><option>MP4</option><option>TS</option></select></div>
|
||||
<div class="form-item"><label>保存模式</label><select class="select-box"><option>分段</option><option>单文件</option></select></div>
|
||||
<div class="form-item"><label>录制模板</label><select class="select-box"><option>均衡 MP4</option><option>直接封装</option><option>归档 TS</option></select></div>
|
||||
<div class="form-item"><label>分段时长(分钟)</label><input class="input" value="30"></div>
|
||||
<div class="form-item"><label>最大并发转码任务</label><input class="input" value="1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 存储保护 -->
|
||||
<div class="card" id="s-storage">
|
||||
<div class="card__head"><div class="card__title">存储保护</div><div class="card__sub">磁盘不足时自动暂停录制</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">启用存储守护</div><div class="toggle-row__h">低于阈值时暂停录制,恢复后自动继续</div></div><span class="switch on"></span></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>暂停阈值(MB)</label><input class="input" value="1024"></div>
|
||||
<div class="form-item"><label>恢复阈值(MB)</label><input class="input" value="4096"></div>
|
||||
<div class="form-item"><label>绿色水位(%)</label><input class="input" value="30"></div>
|
||||
<div class="form-item"><label>红色水位(%)</label><input class="input" value="10"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保留清理 -->
|
||||
<div class="card" id="s-clean">
|
||||
<div class="card__head"><div class="card__title">保留清理</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">启用保留清理</div><div class="toggle-row__h">按天数自动清理过期录制</div></div><span class="switch"></span></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>保留天数</label><input class="input" value="30"></div>
|
||||
<div class="form-item"><label>视频文件条件</label><select class="select-box"><option>不限文件状态</option><option>全部不存在</option><option>全部存在</option></select></div>
|
||||
<div class="form-item full"><label>同时删除本地文件</label><div class="toggle-row" style="background:transparent"><span class="toggle-row__h">关闭时仅删除数据库记录,保留磁盘文件</span><span class="switch"></span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹幕 -->
|
||||
<div class="card" id="s-danmaku">
|
||||
<div class="card__head"><div class="card__title">弹幕录制</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">启用弹幕录制</div><div class="toggle-row__h">录制时同步抓取弹幕到 XML</div></div><span class="switch on"></span></div>
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">记录非聊天事件</div><div class="toggle-row__h">礼物、进场、点赞等系统事件</div></div><span class="switch on"></span></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>最小轮询间隔(毫秒)</label><input class="input" value="2000"></div>
|
||||
<div class="form-item"><label>最大重试退避(秒)</label><input class="input" value="30"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 路径模板 -->
|
||||
<div class="card" id="s-path">
|
||||
<div class="card__head"><div class="card__title">路径模板</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="form-item full"><label>输出目录模板</label><input class="input cell-mono" value="{platform}/{yyyy}/{MM}/{dd}/{anchor}"><div class="hint">可用变量:{platform} {yyyy} {MM} {dd} {anchor} {roomId}</div></div>
|
||||
<div class="form-item full"><label>输出文件名模板</label><input class="input cell-mono" value="{HHmmss}_{quality}_{anchor}_{title}_{roomId}{segmentSuffix}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 巡检与上传 -->
|
||||
<div class="card" id="s-poll">
|
||||
<div class="card__head"><div class="card__title">巡检与上传归档</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">启用后台巡检</div><div class="toggle-row__h">周期性检测开播状态</div></div><span class="switch on"></span></div>
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">开播自动录制</div><div class="toggle-row__h">检测到开播立即启动录制任务</div></div><span class="switch on"></span></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>巡检间隔(秒)</label><input class="input" value="30"></div>
|
||||
<div class="form-item"><label>上传目标</label><select class="select-box"><option>OpenList</option><option>WebDAV</option><option>S3</option><option>不上传</option></select></div>
|
||||
</div>
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">上传后删除本地文件</div><div class="toggle-row__h">归档成功后释放磁盘空间</div></div><span class="switch"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 平台代理 -->
|
||||
<div class="card" id="s-proxy">
|
||||
<div class="card__head"><div class="card__title">平台代理与请求头</div><span class="chip">抖音 / B站 / 虎牙 …</span></div>
|
||||
<div class="card__body">
|
||||
<div class="alert alert--info" style="margin-bottom:16px"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="12" cy="12" r="9"/><path d="M12 8h.01M11 12h1v4h1"/></svg><div class="alert__body">为各平台单独配置 Cookie、UA、Referer 与代理,提升抓流成功率。</div></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>平台</label><select class="select-box"><option>抖音 Douyin</option><option>B站 Bilibili</option><option>虎牙 Huya</option></select></div>
|
||||
<div class="form-item"><label>代理地址</label><input class="input" placeholder="http://127.0.0.1:7890"></div>
|
||||
<div class="form-item full"><label>Cookie</label><textarea class="textarea" placeholder="粘贴平台 Cookie…"></textarea></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 事件脚本 -->
|
||||
<div class="card" id="s-script">
|
||||
<div class="card__head"><div class="card__title">事件脚本</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">启用事件脚本</div><div class="toggle-row__h">开播 / 结束 / 分片完成时触发自定义脚本</div></div><span class="switch on"></span></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item"><label>脚本超时(秒)</label><input class="input" value="60"></div>
|
||||
<div class="form-item"><label>重试次数</label><input class="input" value="2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 通知 -->
|
||||
<div class="card" id="s-notify">
|
||||
<div class="card__head"><div class="card__title">通知</div></div>
|
||||
<div class="card__body" style="display:grid;gap:16px">
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">Webhook 通知</div><div class="toggle-row__h">开播与异常时推送到 Webhook</div></div><span class="switch on"></span></div>
|
||||
<div class="toggle-row"><div><div class="toggle-row__t">邮件通知</div><div class="toggle-row__h">SMTP 邮件提醒</div></div><span class="switch"></span></div>
|
||||
<div class="form-grid">
|
||||
<div class="form-item full"><label>Webhook URL</label><input class="input cell-mono" placeholder="https://…"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- savebar -->
|
||||
<div class="savebar">
|
||||
<div class="savebar__t">检测到 <b>3 项</b>未保存的修改</div>
|
||||
<div style="display:flex;gap:8px"><button class="btn">重置</button><button class="btn btn--primary">保存设置</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
<script>
|
||||
// anchor active state
|
||||
document.querySelectorAll('.anchor a').forEach(a => a.addEventListener('click', e => {
|
||||
document.querySelectorAll('.anchor a').forEach(x => x.classList.remove('active'));
|
||||
a.classList.add('active');
|
||||
}));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,122 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Skeleton 加载态 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="dashboard" data-crumb="总览 / 仪表盘">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<div class="page-head__kicker">系统概览</div>
|
||||
<h1>仪表盘</h1>
|
||||
<p style="display:flex;align-items:center;gap:8px">
|
||||
<span class="skel skel--text short" style="width:280px;display:inline-block"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-head__actions" style="display:flex;gap:8px;align-items:center">
|
||||
<span class="skel skel--btn" style="width:90px"></span>
|
||||
<span class="skel skel--btn" style="width:72px"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI skeleton -->
|
||||
<div class="stat-grid">
|
||||
<div class="skel-stat"><span class="skel skel--stat-value"></span><span class="skel skel--stat-label" style="width:65%"></span></div>
|
||||
<div class="skel-stat"><span class="skel skel--stat-value" style="width:40%"></span><span class="skel skel--stat-label"></span></div>
|
||||
<div class="skel-stat"><span class="skel skel--stat-value" style="width:55%"></span><span class="skel skel--stat-label" style="width:70%"></span></div>
|
||||
<div class="skel-stat"><span class="skel skel--stat-value" style="width:45%"></span><span class="skel skel--stat-label" style="width:60%"></span></div>
|
||||
<div class="skel-stat"><span class="skel skel--stat-value" style="width:48%"></span><span class="skel skel--stat-label" style="width:75%"></span></div>
|
||||
<div class="skel-stat"><span class="skel skel--stat-value" style="width:20%"></span><span class="skel skel--stat-label" style="width:50%"></span></div>
|
||||
</div>
|
||||
|
||||
<!-- storage + queue skeleton -->
|
||||
<div class="grid-2">
|
||||
<div class="skel-card">
|
||||
<div class="skel-card__body">
|
||||
<span class="skel skel--title" style="width:30%"></span>
|
||||
<div style="display:flex;align-items:center;gap:24px">
|
||||
<span class="skel" style="width:84px;height:84px;border-radius:99px;flex-shrink:0"></span>
|
||||
<div style="flex:1;display:grid;gap:12px">
|
||||
<span class="skel skel--text"></span>
|
||||
<span class="skel" style="height:8px;border-radius:99px"></span>
|
||||
<span class="skel skel--text short"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skel-card">
|
||||
<div class="skel-card__body">
|
||||
<span class="skel skel--title" style="width:30%"></span>
|
||||
<span class="skel skel--text"></span>
|
||||
<span class="skel skel--text short" style="width:50%"></span>
|
||||
<span class="skel skel--text short" style="width:40%"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- table skeleton -->
|
||||
<div class="grid-2">
|
||||
<div class="skel-card">
|
||||
<div class="skel-card__body" style="padding:20px;gap:0">
|
||||
<span class="skel skel--title" style="width:28%;margin-bottom:16px"></span>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skel-card">
|
||||
<div class="skel-card__body" style="padding:20px;gap:0">
|
||||
<span class="skel skel--title" style="width:28%;margin-bottom:16px"></span>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
<div class="skel--row"><span class="skel skel--text" style="flex:1"></span><span class="skel skel--pill" style="width:50px"></span><span class="skel skel--text" style="width:40px"></span><span class="skel skel--text" style="width:100px"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- live rooms table skeleton (dense) -->
|
||||
<div class="skel-card">
|
||||
<div class="skel-card__body" style="padding:20px;gap:0">
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px">
|
||||
<span class="skel skel--title" style="width:200px"></span>
|
||||
<span class="skel skel--text short" style="width:120px"></span>
|
||||
</div>
|
||||
<div class="skel--row">
|
||||
<span class="skel skel--avatar"></span>
|
||||
<span class="skel skel--text" style="flex:1"></span>
|
||||
<span class="skel skel--pill" style="width:60px"></span>
|
||||
<span class="skel skel--pill" style="width:60px"></span>
|
||||
<span class="skel skel--text" style="width:120px"></span>
|
||||
<span class="skel" style="width:36px;height:22px;border-radius:99px"></span>
|
||||
<span class="skel skel--btn" style="width:60px;height:30px"></span>
|
||||
</div>
|
||||
<div class="skel--row">
|
||||
<span class="skel skel--avatar"></span>
|
||||
<span class="skel skel--text" style="flex:1"></span>
|
||||
<span class="skel skel--pill" style="width:60px"></span>
|
||||
<span class="skel skel--pill" style="width:60px"></span>
|
||||
<span class="skel skel--text" style="width:120px"></span>
|
||||
<span class="skel" style="width:36px;height:22px;border-radius:99px"></span>
|
||||
<span class="skel skel--btn" style="width:60px;height:30px"></span>
|
||||
</div>
|
||||
<div class="skel--row">
|
||||
<span class="skel skel--avatar"></span>
|
||||
<span class="skel skel--text" style="flex:1"></span>
|
||||
<span class="skel skel--pill" style="width:60px"></span>
|
||||
<span class="skel skel--pill" style="width:60px"></span>
|
||||
<span class="skel skel--text" style="width:120px"></span>
|
||||
<span class="skel" style="width:36px;height:22px;border-radius:99px"></span>
|
||||
<span class="skel skel--btn" style="width:60px;height:30px"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>转码任务 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="transcode-tasks" data-crumb="媒资归档 / 转码任务">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div><div class="page-head__kicker">后处理工作台</div><h1>转码任务</h1><p>录制产物的 FFmpeg 后处理队列,支持手动转码与失败重试。</p></div>
|
||||
<div class="page-head__actions"><a class="btn" href="media-browser.html">文件库</a><a class="btn" href="record-tasks.html">返回录制</a><button class="btn">刷新列表</button></div>
|
||||
</div>
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(160px,1fr))">
|
||||
<div class="stat"><div class="stat__label">待转码</div><div class="stat__value" style="color:var(--warning)">2</div></div>
|
||||
<div class="stat"><div class="stat__label">转码中</div><div class="stat__value" style="color:var(--info)">1</div></div>
|
||||
<div class="stat"><div class="stat__label">已完成</div><div class="stat__value" style="color:var(--success)">88</div></div>
|
||||
<div class="stat"><div class="stat__label">失败</div><div class="stat__value" style="color:var(--danger)">0</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<div class="segmented"><button class="active">全部</button><button>待转码 2</button><button>转码中 1</button><button>已完成 88</button></div>
|
||||
<label class="field field--search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="搜索源文件"></label>
|
||||
</div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th>分片</th><th>源文件</th><th class="num">大小</th><th>状态</th><th style="width:160px">进度</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><div class="cell-title">游戏老王 · #6</div><div class="meta-row"><span class="tag is-blue">B站</span></div></td><td class="cell-mono">…_老王_06.ts</td><td class="num">520 MB</td><td><span class="badge is-info live"><span class="pip"></span>转码中</span></td><td><div class="bar"><span style="width:62%"></span></div><div class="cell-sub" style="margin-top:4px">62% · 预计 18s</div></td><td class="row-actions"><a class="btn btn--sm" href="record-task-detail.html">查分片</a></td></tr>
|
||||
<tr><td><div class="cell-title">音乐电台 · #4</div><div class="meta-row"><span class="tag is-amber">虎牙</span></div></td><td class="cell-mono">…_电台_04.ts</td><td class="num">410 MB</td><td><span class="badge is-warning"><span class="pip"></span>待转码</span></td><td><span class="cell-sub">排队中</span></td><td class="row-actions"><button class="btn btn--sm btn--primary">转码</button><a class="btn btn--sm" href="record-task-detail.html">查分片</a></td></tr>
|
||||
<tr><td><div class="cell-title">户外阿强 · #1</div><div class="meta-row"><span class="tag is-purple">快手</span></div></td><td class="cell-mono">…_阿强_01.ts</td><td class="num">300 MB</td><td><span class="badge is-warning"><span class="pip"></span>待转码</span></td><td><span class="cell-sub">排队中</span></td><td class="row-actions"><button class="btn btn--sm btn--primary">转码</button><a class="btn btn--sm" href="record-task-detail.html">查分片</a></td></tr>
|
||||
<tr><td><div class="cell-title">熊宇一 · #1</div><div class="meta-row"><span class="tag is-red">抖音</span></div></td><td class="cell-mono">…_熊宇一_01.ts</td><td class="num">420 MB</td><td><span class="badge"><span class="pip"></span>已完成</span></td><td><div class="bar"><span class="is-green" style="width:100%"></span></div><div class="cell-sub" style="margin-top:4px">耗时 42s</div></td><td class="row-actions"><a class="btn btn--sm" href="record-task-detail.html">查分片</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>上传任务 · Live Recorder</title>
|
||||
<link rel="stylesheet" href="assets/design-system.css">
|
||||
</head>
|
||||
<body data-page="upload-tasks" data-crumb="媒资归档 / 上传任务">
|
||||
<div id="view">
|
||||
<div class="page">
|
||||
<div class="page-head">
|
||||
<div><div class="page-head__kicker">录制工作台</div><h1>上传任务</h1><p>录制产物的归档上传状态,支持手动重传与跳转分片 / 会话。</p></div>
|
||||
<div class="page-head__actions"><a class="btn" href="record-tasks.html">录制任务</a><button class="btn">刷新列表</button></div>
|
||||
</div>
|
||||
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(160px,1fr))">
|
||||
<div class="stat"><div class="stat__label">总计</div><div class="stat__value">142</div></div>
|
||||
<div class="stat"><div class="stat__label">未上传</div><div class="stat__value" style="color:var(--warning)">5</div></div>
|
||||
<div class="stat"><div class="stat__label">已上传</div><div class="stat__value" style="color:var(--success)">135</div></div>
|
||||
<div class="stat"><div class="stat__label">上传失败</div><div class="stat__value" style="color:var(--danger)">2</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="toolbar">
|
||||
<div class="segmented"><button class="active">全部</button><button>未上传 5</button><button>已上传 135</button><button>失败 2</button></div>
|
||||
<label class="field field--search"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input placeholder="搜索文件 / 直播间"></label>
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--sm">批量上传</button>
|
||||
<button class="btn btn--sm btn--primary">上传全部未上传</button>
|
||||
</div>
|
||||
<div class="table-wrap"><table class="tbl">
|
||||
<thead><tr><th style="width:40px"><span class="checkbox"></span></th><th>直播间 / 分片</th><th>文件</th><th class="num">大小</th><th>状态</th><th>目标</th><th style="text-align:right">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="checkbox"></span></td><td><div class="cell-title">熊宇一 · #2</div><div class="meta-row"><span class="tag is-red">抖音</span><span class="cell-mono">845878</span></div></td><td class="cell-mono">…_熊宇一_才艺_02.mp4</td><td class="num">440 MB</td><td><span class="tag is-amber">未上传</span></td><td>OpenList</td><td class="row-actions"><button class="btn btn--sm btn--primary">上传</button><a class="btn btn--sm" href="record-task-detail.html">详情</a></td></tr>
|
||||
<tr><td><span class="checkbox"></span></td><td><div class="cell-title">游戏老王 · #5</div><div class="meta-row"><span class="tag is-blue">B站</span><span class="cell-mono">123456</span></div></td><td class="cell-mono">…_老王_巅峰赛_05.ts</td><td class="num">512 MB</td><td><span class="tag is-red">上传失败</span></td><td>OpenList</td><td class="row-actions"><button class="btn btn--sm btn--primary">重传</button><a class="btn btn--sm" href="record-task-detail.html">详情</a></td></tr>
|
||||
<tr><td><span class="checkbox"></span></td><td><div class="cell-title">熊宇一 · #1</div><div class="meta-row"><span class="tag is-red">抖音</span><span class="cell-mono">845878</span></div></td><td class="cell-mono">…_熊宇一_才艺_01.mp4</td><td class="num">420 MB</td><td><span class="tag is-green">已上传</span></td><td>OpenList</td><td class="row-actions"><a class="btn btn--sm" href="record-task-detail.html">详情</a></td></tr>
|
||||
<tr><td><span class="checkbox"></span></td><td><div class="cell-title">音乐电台 · #3</div><div class="meta-row"><span class="tag is-amber">虎牙</span><span class="cell-mono">998877</span></div></td><td class="cell-mono">…_电台_LIVE_03.mp4</td><td class="num">388 MB</td><td><span class="tag is-green">已上传</span></td><td>OpenList</td><td class="row-actions"><a class="btn btn--sm" href="record-task-detail.html">详情</a></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="pagination"><span>共 142 条</span><div class="pagination__pages"><span class="pg">‹</span><span class="pg active">1</span><span class="pg">2</span><span class="pg">›</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="assets/shell.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build both the LiveRecorder WebApi and Frontend (nginx) images for
|
||||
# linux/arm64 and export them as a single gzip-compressed `docker load`-able
|
||||
# archive.
|
||||
#
|
||||
# The docker-compose.yml uses a split architecture:
|
||||
# nginx (frontend) — Vue SPA served by nginx, proxies /api/* to api:8080
|
||||
# api (backend) — .NET 8 WebApi
|
||||
#
|
||||
# Both images must be ARM64 for a full deployment on an ARM64 host.
|
||||
#
|
||||
# Target environment: Windows + Docker Desktop (WSL2 backend) + a host HTTP proxy.
|
||||
# It encodes the workarounds discovered while building from a network where the
|
||||
# Docker daemon cannot reliably reach upstream MCR / Docker Hub:
|
||||
#
|
||||
# 1. ARM64 QEMU emulation is registered via binfmt_misc with the `F`
|
||||
# (fix-binary) flag so the kernel-held fd works inside build containers.
|
||||
# 2. Base images are pre-pulled into the local cache (resumable retries),
|
||||
# then the build runs with `--pull=false`.
|
||||
# 3. `dotnet restore` goes through the host proxy; apt uses the in-Dockerfile
|
||||
# Tsinghua mirror directly (NO_PROXY).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/build-arm64-image.sh
|
||||
#
|
||||
# Override defaults via env vars:
|
||||
# PROXY=http://127.0.0.1:10808
|
||||
# OUTPUT=live-recorder-arm64.tar.gz
|
||||
# WSL_DISTRO=Ubuntu
|
||||
set -euo pipefail
|
||||
|
||||
# --- Resolve repo root (script lives in <root>/scripts) ---
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
# --- Config ---
|
||||
PROXY="${PROXY:-http://127.0.0.1:10808}"
|
||||
PROXY_HOST="${PROXY_HOST:-http://host.docker.internal:${PROXY##*:}}" # container-visible proxy
|
||||
API_IMAGE_TAG="${API_IMAGE_TAG:-live-recorder-webapi:arm64}"
|
||||
FRONTEND_IMAGE_TAG="${FRONTEND_IMAGE_TAG:-live-recorder-frontend:arm64}"
|
||||
OUTPUT="${OUTPUT:-live-recorder-arm64.tar.gz}"
|
||||
WSL_DISTRO="${WSL_DISTRO:-Ubuntu}"
|
||||
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}"
|
||||
NGINX_IMAGE="${NGINX_IMAGE:-docker.m.daocloud.io/library/nginx:1.27-alpine}"
|
||||
NO_PROXY_HOSTS="mirrors.tuna.tsinghua.edu.cn,.tsinghua.edu.cn,mirrors.huaweicloud.com,.huaweicloud.com,mcr.m.daocloud.io,docker.m.daocloud.io,.m.daocloud.io,localhost,127.0.0.1"
|
||||
|
||||
log() { printf '\n\033[1;36m[%s] %s\033[0m\n' "$(date +%H:%M:%S)" "$*"; }
|
||||
die() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
# --- 1. Ensure ARM64 emulation works -----------------------------------------
|
||||
ensure_arm64_emulation() {
|
||||
if docker run --rm --platform linux/arm64 "$DOTNET_RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64; then
|
||||
log "ARM64 emulation already works — skipping binfmt registration."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Registering qemu-aarch64 emulation via the '$WSL_DISTRO' WSL distro..."
|
||||
wsl.exe -d "$WSL_DISTRO" -u root -- bash -lc '
|
||||
set -e
|
||||
if [ ! -x /usr/bin/qemu-aarch64-static ]; then
|
||||
for source_file in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources; do
|
||||
[ -f "$source_file" ] || continue
|
||||
sed -i \
|
||||
-e "s|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g" \
|
||||
-e "s|security.debian.org/debian-security|mirrors.tuna.tsinghua.edu.cn/debian-security|g" \
|
||||
-e "s|archive.ubuntu.com|mirrors.tuna.tsinghua.edu.cn|g" \
|
||||
-e "s|security.ubuntu.com|mirrors.tuna.tsinghua.edu.cn|g" \
|
||||
"$source_file"
|
||||
done
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq qemu-user-static
|
||||
fi
|
||||
for name in qemu-aarch64 qemu-aarch64-static; do
|
||||
[ -e "/proc/sys/fs/binfmt_misc/$name" ] && echo -1 > "/proc/sys/fs/binfmt_misc/$name" || true
|
||||
done
|
||||
printf ":qemu-aarch64-static:M::\x7f\x45\x4c\x46\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xb7\x00:\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff:/usr/bin/qemu-aarch64-static:F" \
|
||||
> /proc/sys/fs/binfmt_misc/register
|
||||
' || die "binfmt registration failed (need a Debian/Ubuntu WSL distro named '$WSL_DISTRO' with apt)."
|
||||
|
||||
docker run --rm --platform linux/arm64 "$DOTNET_RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64 \
|
||||
|| die "ARM64 emulation still not working after registration."
|
||||
log "ARM64 emulation registered and verified."
|
||||
}
|
||||
|
||||
# --- 2. Pre-pull base images (resumable retries) -----------------------------
|
||||
pull_with_retry() {
|
||||
local img="$1" i
|
||||
for i in $(seq 1 8); do
|
||||
if docker image inspect "$img" >/dev/null 2>&1; then return 0; fi
|
||||
log "Pulling $img (attempt $i/8) ..."
|
||||
docker pull --platform linux/arm64 "$img" >/dev/null 2>&1 || true
|
||||
docker image inspect "$img" >/dev/null 2>&1 && return 0
|
||||
sleep 3
|
||||
done
|
||||
docker image inspect "$img" >/dev/null 2>&1 || die "Could not pull $img (network)."
|
||||
}
|
||||
|
||||
# --- 3. Build API image ------------------------------------------------------
|
||||
build_api_image() {
|
||||
log "Building $API_IMAGE_TAG for linux/arm64 (restore via proxy, apt via mirror)..."
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
-f src/LiveRecorder.WebApi/Dockerfile \
|
||||
-t "$API_IMAGE_TAG" \
|
||||
--build-arg "HTTP_PROXY=$PROXY_HOST" \
|
||||
--build-arg "HTTPS_PROXY=$PROXY_HOST" \
|
||||
--build-arg "NO_PROXY=$NO_PROXY_HOSTS" \
|
||||
--build-arg "DOTNET_SDK_IMAGE=$DOTNET_SDK_IMAGE" \
|
||||
--build-arg "DOTNET_RUNTIME_IMAGE=$DOTNET_RUNTIME_IMAGE" \
|
||||
--pull=false \
|
||||
--load \
|
||||
.
|
||||
}
|
||||
|
||||
# --- 4. Build Frontend (nginx) image -----------------------------------------
|
||||
# Frontend dist is pre-built locally (npm run build inside frontend/) and copied
|
||||
# directly — avoids running Node.js under QEMU ARM64 emulation which is prohibitively
|
||||
# slow. Build context is frontend/ (not repo root) to bypass .dockerignore rules.
|
||||
build_frontend_image() {
|
||||
log "Building $FRONTEND_IMAGE_TAG for linux/arm64 (pre-built dist -> nginx)..."
|
||||
docker buildx build \
|
||||
--platform linux/arm64 \
|
||||
-f frontend/Dockerfile.arm64 \
|
||||
-t "$FRONTEND_IMAGE_TAG" \
|
||||
--pull=false \
|
||||
--load \
|
||||
frontend
|
||||
}
|
||||
|
||||
# --- 5. Export both images as a single archive -------------------------------
|
||||
export_images() {
|
||||
log "Exporting $API_IMAGE_TAG + $FRONTEND_IMAGE_TAG -> $OUTPUT ..."
|
||||
local tar="${OUTPUT%.gz}"
|
||||
docker save "$API_IMAGE_TAG" "$FRONTEND_IMAGE_TAG" -o "$tar"
|
||||
gzip -f "$tar"
|
||||
log "Done: $(ls -lh "$OUTPUT" | awk '{print $5, $9}')"
|
||||
log "Load on an arm64 host with: docker load < $OUTPUT"
|
||||
log "Then start with: docker compose up -d"
|
||||
}
|
||||
|
||||
ensure_arm64_emulation
|
||||
pull_with_retry "$DOTNET_SDK_IMAGE"
|
||||
pull_with_retry "$DOTNET_RUNTIME_IMAGE"
|
||||
pull_with_retry "$NGINX_IMAGE"
|
||||
build_api_image
|
||||
build_frontend_image
|
||||
export_images
|
||||
Executable
+248
@@ -0,0 +1,248 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
BASE_VERSION=$(sed -n 's/^version[[:space:]]*=[[:space:]]*//p' "$ROOT_DIR/fnos/manifest" | head -n 1 | tr -d '\r')
|
||||
PACKAGE_VERSION="${PACKAGE_VERSION:-$BASE_VERSION}"
|
||||
case "$PACKAGE_VERSION" in
|
||||
''|*[!0-9.]*|.*|*..*|*.)
|
||||
printf 'invalid fnOS package version: %s\n' "$PACKAGE_VERSION" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
[ "$(printf '%s' "$PACKAGE_VERSION" | awk -F. '{ print NF }')" -eq 3 ] || {
|
||||
printf 'fnOS package version must contain exactly three numeric components: %s\n' "$PACKAGE_VERSION" >&2
|
||||
exit 1
|
||||
}
|
||||
OUTPUT="${1:-$ROOT_DIR/artifacts/fnos/liverecorder-${PACKAGE_VERSION}-x86_64.fpk}"
|
||||
WORKSPACE_CACHE=$(CDPATH= cd -- "$ROOT_DIR/.." && pwd)
|
||||
if [ -n "${DOTNET:-}" ]; then
|
||||
DOTNET_BIN="$DOTNET"
|
||||
elif [ -x "$WORKSPACE_CACHE/.dotnet8/dotnet" ]; then
|
||||
DOTNET_BIN="$WORKSPACE_CACHE/.dotnet8/dotnet"
|
||||
else
|
||||
DOTNET_BIN=$(command -v dotnet 2>/dev/null || true)
|
||||
fi
|
||||
NUGET_FEED="${LIVERECORDER_NUGET_FEED:-$WORKSPACE_CACHE/.nuget-feed}"
|
||||
NUGET_PACKAGES="${NUGET_PACKAGES:-$WORKSPACE_CACHE/.nuget-packages}"
|
||||
DOTNET_CLI_HOME="${DOTNET_CLI_HOME:-$WORKSPACE_CACHE/.dotnet-cli-home}"
|
||||
BUILD_TMP_ROOT="${LIVERECORDER_BUILD_TMPDIR:-$WORKSPACE_CACHE/.fnos-build-tmp}"
|
||||
|
||||
if [ -n "${FNPACK:-}" ]; then
|
||||
FNPACK_BIN="$FNPACK"
|
||||
elif [ -x "$ROOT_DIR/.tools/fnpack" ]; then
|
||||
FNPACK_BIN="$ROOT_DIR/.tools/fnpack"
|
||||
else
|
||||
FNPACK_BIN=fnpack
|
||||
fi
|
||||
|
||||
FNPACK_BIN=$(command -v "$FNPACK_BIN") || {
|
||||
printf 'fnpack is required; install it from the fnOS developer portal or set FNPACK.\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for command_name in npm apt-get curl dpkg-deb readelf realpath find install sha256sum tar node; do
|
||||
command -v "$command_name" >/dev/null 2>&1 || {
|
||||
printf 'required build command is missing: %s\n' "$command_name" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
test -n "$DOTNET_BIN" && test -x "$DOTNET_BIN" || {
|
||||
printf 'missing .NET SDK; set DOTNET or install dotnet in PATH\n' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p "$BUILD_TMP_ROOT" "$(dirname -- "$OUTPUT")"
|
||||
WORK_DIR=$(mktemp -d "${BUILD_TMP_ROOT%/}/liverecorder-fnos-build.XXXXXX")
|
||||
trap 'rm -rf -- "$WORK_DIR"' EXIT
|
||||
STAGE="$WORK_DIR/stage"
|
||||
PACKED_ROOT="$WORK_DIR/packed"
|
||||
FNPACK_TMP_ROOT="$WORK_DIR/fnpack-tmp"
|
||||
EXTRACT_ROOT="$WORK_DIR/debian-root"
|
||||
RUNTIME_ROOT="$STAGE/app/runtime"
|
||||
|
||||
mkdir -p "$STAGE/app/server" "$RUNTIME_ROOT/bin" "$RUNTIME_ROOT/lib" \
|
||||
"$RUNTIME_ROOT/etc/ssl/certs" \
|
||||
"$PACKED_ROOT" "$FNPACK_TMP_ROOT" "$EXTRACT_ROOT"
|
||||
cp -a "$ROOT_DIR/fnos/." "$STAGE/"
|
||||
sed -i -E "s/^version[[:space:]]*=.*/version=${PACKAGE_VERSION}/" "$STAGE/manifest"
|
||||
|
||||
printf 'Building frontend...\n'
|
||||
if [ "${LIVERECORDER_SKIP_NPM_CI:-0}" != "1" ]; then
|
||||
npm ci --prefix "$ROOT_DIR/frontend"
|
||||
fi
|
||||
npm run build --prefix "$ROOT_DIR/frontend"
|
||||
|
||||
printf 'Publishing self-contained .NET application...\n'
|
||||
export NUGET_PACKAGES DOTNET_CLI_HOME
|
||||
RESTORE_SOURCES=(--source "https://mirrors.huaweicloud.com/repository/nuget/v3/index.json")
|
||||
if [ -d "$NUGET_FEED" ]; then
|
||||
RESTORE_SOURCES=(--source "$NUGET_FEED" "${RESTORE_SOURCES[@]}")
|
||||
fi
|
||||
"$DOTNET_BIN" restore "$ROOT_DIR/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" \
|
||||
-r linux-x64 \
|
||||
"${RESTORE_SOURCES[@]}" \
|
||||
--disable-parallel
|
||||
"$DOTNET_BIN" publish "$ROOT_DIR/src/LiveRecorder.WebApi/LiveRecorder.WebApi.csproj" \
|
||||
-c Release \
|
||||
-r linux-x64 \
|
||||
--self-contained true \
|
||||
--no-restore \
|
||||
-p:DebugType=None \
|
||||
-p:DebugSymbols=false \
|
||||
-p:PublishSingleFile=false \
|
||||
-p:PublishReadyToRun=false \
|
||||
-o "$STAGE/app/server" \
|
||||
/maxcpucount:1
|
||||
rm -f "$STAGE/app/server/"*.pdb
|
||||
mkdir -p "$STAGE/app/server/wwwroot"
|
||||
cp -a "$ROOT_DIR/frontend/dist/." "$STAGE/app/server/wwwroot/"
|
||||
|
||||
printf 'Downloading pinned Debian Bookworm native runtime packages...\n'
|
||||
APT_ROOT="$WORK_DIR/apt"
|
||||
mkdir -p \
|
||||
"$APT_ROOT/etc/apt" \
|
||||
"$APT_ROOT/var/lib/apt/lists/partial" \
|
||||
"$APT_ROOT/var/lib/dpkg" \
|
||||
"$APT_ROOT/var/cache/apt/archives/partial"
|
||||
cp "$ROOT_DIR/scripts/fnos-bookworm.sources.list" "$APT_ROOT/etc/apt/sources.list"
|
||||
touch "$APT_ROOT/var/lib/dpkg/status"
|
||||
|
||||
APT_OPTIONS=(
|
||||
-o "Dir::Etc::sourcelist=$APT_ROOT/etc/apt/sources.list"
|
||||
-o "Dir::Etc::sourceparts=-"
|
||||
-o "Dir::State::status=$APT_ROOT/var/lib/dpkg/status"
|
||||
-o "Dir::State::lists=$APT_ROOT/var/lib/apt/lists"
|
||||
-o "Dir::Cache=$APT_ROOT/var/cache/apt"
|
||||
-o "Dir::Cache::archives=$APT_ROOT/var/cache/apt/archives"
|
||||
-o "Debug::NoLocking=1"
|
||||
-o "APT::Architecture=amd64"
|
||||
-o "Acquire::Languages=none"
|
||||
)
|
||||
apt-get "${APT_OPTIONS[@]}" update
|
||||
apt-get "${APT_OPTIONS[@]}" \
|
||||
--download-only \
|
||||
--no-install-recommends \
|
||||
--yes \
|
||||
install \
|
||||
curl \
|
||||
ca-certificates
|
||||
|
||||
shopt -s nullglob
|
||||
packages=("$APT_ROOT"/var/cache/apt/archives/*.deb)
|
||||
test "${#packages[@]}" -gt 0 || { printf 'APT did not download runtime packages\n' >&2; exit 1; }
|
||||
for package_file in "${packages[@]}"; do
|
||||
dpkg-deb -x "$package_file" "$EXTRACT_ROOT"
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
resolve_extracted_file() {
|
||||
local current=$1 target normalized depth=0
|
||||
while [ -L "$current" ]; do
|
||||
depth=$((depth + 1))
|
||||
[ "$depth" -le 32 ] || { printf 'too many symlink levels: %s\n' "$1" >&2; return 1; }
|
||||
target=$(readlink -- "$current")
|
||||
case "$target" in
|
||||
/*) current="$EXTRACT_ROOT$target" ;;
|
||||
*) current="$(dirname -- "$current")/$target" ;;
|
||||
esac
|
||||
normalized=$(realpath -m -- "$current")
|
||||
case "$normalized" in
|
||||
"$EXTRACT_ROOT"/*) current="$normalized" ;;
|
||||
*) printf 'Debian package link escapes extraction root: %s\n' "$1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
[ -f "$current" ] || { printf 'missing extracted runtime file: %s\n' "$1" >&2; return 1; }
|
||||
printf '%s\n' "$current"
|
||||
}
|
||||
|
||||
copy_extracted_file() {
|
||||
local source resolved destination=$2 mode=${3:-0644}
|
||||
source=$1
|
||||
resolved=$(resolve_extracted_file "$source")
|
||||
mkdir -p "$(dirname -- "$destination")"
|
||||
install -m "$mode" "$resolved" "$destination"
|
||||
}
|
||||
|
||||
printf 'Assembling minimal relocatable runtime...\n'
|
||||
copy_extracted_file "$EXTRACT_ROOT/usr/bin/curl" "$RUNTIME_ROOT/bin/curl" 0755
|
||||
|
||||
CA_CONFIG="$EXTRACT_ROOT/etc/ca-certificates.conf"
|
||||
CA_SOURCE_ROOT="$EXTRACT_ROOT/usr/share/ca-certificates"
|
||||
CA_BUNDLE="$RUNTIME_ROOT/etc/ssl/certs/ca-certificates.crt"
|
||||
: >"$CA_BUNDLE"
|
||||
if [ -s "$CA_CONFIG" ]; then
|
||||
while IFS= read -r certificate; do
|
||||
case "$certificate" in
|
||||
''|'#'*|'!'*) continue ;;
|
||||
esac
|
||||
[ -f "$CA_SOURCE_ROOT/$certificate" ] || continue
|
||||
sed -e '$a\' "$CA_SOURCE_ROOT/$certificate" >>"$CA_BUNDLE"
|
||||
done <"$CA_CONFIG"
|
||||
else
|
||||
while IFS= read -r -d '' certificate; do
|
||||
sed -e '$a\' "$certificate" >>"$CA_BUNDLE"
|
||||
done < <(find "$CA_SOURCE_ROOT" -type f -name '*.crt' -print0 | sort -z)
|
||||
fi
|
||||
[ -s "$CA_BUNDLE" ] || { printf 'unable to assemble CA certificate bundle\n' >&2; exit 1; }
|
||||
chmod 0644 "$CA_BUNDLE"
|
||||
|
||||
declare -A COPIED_LIBRARIES=()
|
||||
ELF_QUEUE=()
|
||||
while IFS= read -r -d '' elf_file; do
|
||||
if readelf -h "$elf_file" >/dev/null 2>&1; then
|
||||
ELF_QUEUE+=("$elf_file")
|
||||
fi
|
||||
done < <(find "$RUNTIME_ROOT/bin" -type f -print0)
|
||||
|
||||
is_system_glibc_library() {
|
||||
case "$1" in
|
||||
ld-linux-*.so.*|libc.so.*|libBrokenLocale.so.*|libanl.so.*|libdl.so.*|libm.so.*|libmvec.so.*|libnss_compat.so.*|libnss_dns.so.*|libnss_files.so.*|libnss_hesiod.so.*|libpthread.so.*|libresolv.so.*|librt.so.*|libthread_db.so.*|libutil.so.*)
|
||||
return 0
|
||||
;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
queue_index=0
|
||||
while [ "$queue_index" -lt "${#ELF_QUEUE[@]}" ]; do
|
||||
elf_file=${ELF_QUEUE[$queue_index]}
|
||||
queue_index=$((queue_index + 1))
|
||||
while IFS= read -r needed; do
|
||||
[ -n "$needed" ] || continue
|
||||
is_system_glibc_library "$needed" && continue
|
||||
[ -z "${COPIED_LIBRARIES[$needed]:-}" ] || continue
|
||||
source=$(find "$EXTRACT_ROOT" \( -type f -o -type l \) -name "$needed" -print -quit)
|
||||
[ -n "$source" ] || {
|
||||
printf 'unable to resolve native dependency %s required by %s\n' "$needed" "$elf_file" >&2
|
||||
exit 1
|
||||
}
|
||||
destination="$RUNTIME_ROOT/lib/$needed"
|
||||
copy_extracted_file "$source" "$destination" 0755
|
||||
COPIED_LIBRARIES[$needed]=1
|
||||
ELF_QUEUE+=("$destination")
|
||||
done < <(readelf -d "$elf_file" 2>/dev/null | sed -n 's/.*Shared library: \[\([^]]*\)\].*/\1/p')
|
||||
done
|
||||
|
||||
for required_file in \
|
||||
"$RUNTIME_ROOT/bin/curl"; do
|
||||
test -x "$required_file" || { printf 'native runtime file is missing: %s\n' "$required_file" >&2; exit 1; }
|
||||
done
|
||||
|
||||
node "$ROOT_DIR/scripts/generate-fnos-icons.mjs" "$STAGE" "$STAGE/app/ui/images"
|
||||
chmod 0755 "$STAGE/cmd/"*
|
||||
|
||||
printf 'Packing with official fnOS fnpack...\n'
|
||||
(
|
||||
cd "$PACKED_ROOT"
|
||||
TMPDIR="$FNPACK_TMP_ROOT" "$FNPACK_BIN" build --directory "$STAGE"
|
||||
)
|
||||
mv "$PACKED_ROOT/liverecorder.fpk" "$OUTPUT"
|
||||
(
|
||||
cd "$(dirname -- "$OUTPUT")"
|
||||
sha256sum "$(basename -- "$OUTPUT")" >"$(basename -- "$OUTPUT").sha256"
|
||||
)
|
||||
|
||||
LIVERECORDER_VERIFY_TMPDIR="${LIVERECORDER_VERIFY_TMPDIR:-$BUILD_TMP_ROOT}" \
|
||||
"$ROOT_DIR/scripts/verify-fnos-package.sh" "$OUTPUT" "$PACKAGE_VERSION"
|
||||
printf 'Built %s\n' "$OUTPUT"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
: "${JENKINS_NODE_PASSWORD:?JENKINS_NODE_PASSWORD is required}"
|
||||
|
||||
printf '%s\n' "$JENKINS_NODE_PASSWORD" | sudo -S -p '' -v
|
||||
|
||||
sudo -n -- \
|
||||
/usr/bin/env "DOCKER_CONFIG=${DOCKER_CONFIG:-}" \
|
||||
/usr/bin/docker "$@"
|
||||
@@ -0,0 +1,3 @@
|
||||
deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm main
|
||||
deb https://mirrors.tuna.tsinghua.edu.cn/debian bookworm-updates main
|
||||
deb https://mirrors.tuna.tsinghua.edu.cn/debian-security bookworm-security main
|
||||
@@ -0,0 +1,131 @@
|
||||
import { deflateSync } from "node:zlib";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const [packageRoot, uiImageRoot] = process.argv.slice(2);
|
||||
if (!packageRoot || !uiImageRoot) {
|
||||
throw new Error("usage: node generate-fnos-icons.mjs package-root ui-image-root");
|
||||
}
|
||||
|
||||
const crcTable = new Uint32Array(256);
|
||||
for (let index = 0; index < 256; index++) {
|
||||
let value = index;
|
||||
for (let bit = 0; bit < 8; bit++) {
|
||||
value = (value & 1) !== 0 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||
}
|
||||
crcTable[index] = value >>> 0;
|
||||
}
|
||||
|
||||
function crc32(buffer) {
|
||||
let value = 0xffffffff;
|
||||
for (const byte of buffer) {
|
||||
value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
|
||||
}
|
||||
return (value ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const typeBuffer = Buffer.from(type, "ascii");
|
||||
const length = Buffer.alloc(4);
|
||||
length.writeUInt32BE(data.length);
|
||||
const checksum = Buffer.alloc(4);
|
||||
checksum.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])));
|
||||
return Buffer.concat([length, typeBuffer, data, checksum]);
|
||||
}
|
||||
|
||||
function roundedRectangleDistance(x, y, left, top, right, bottom, radius) {
|
||||
const centerX = (left + right) / 2;
|
||||
const centerY = (top + bottom) / 2;
|
||||
const halfWidth = (right - left) / 2 - radius;
|
||||
const halfHeight = (bottom - top) / 2 - radius;
|
||||
const dx = Math.max(Math.abs(x - centerX) - halfWidth, 0);
|
||||
const dy = Math.max(Math.abs(y - centerY) - halfHeight, 0);
|
||||
return Math.hypot(dx, dy) - radius;
|
||||
}
|
||||
|
||||
function render(size) {
|
||||
const pixels = Buffer.alloc(size * size * 4);
|
||||
const samples = 3;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
const rgba = [0, 0, 0, 0];
|
||||
for (let sy = 0; sy < samples; sy++) {
|
||||
for (let sx = 0; sx < samples; sx++) {
|
||||
const px = x + (sx + 0.5) / samples;
|
||||
const py = y + (sy + 0.5) / samples;
|
||||
const scale = size / 256;
|
||||
let color = [0, 0, 0, 0];
|
||||
const background = roundedRectangleDistance(px, py, 12 * scale, 12 * scale, 244 * scale, 244 * scale, 50 * scale);
|
||||
if (background <= 0) {
|
||||
const mix = Math.min(1, Math.max(0, (px + py) / (512 * scale)));
|
||||
color = [Math.round(27 + 33 * mix), Math.round(94 + 66 * mix), Math.round(180 + 49 * mix), 255];
|
||||
}
|
||||
|
||||
const body = roundedRectangleDistance(px, py, 51 * scale, 75 * scale, 190 * scale, 185 * scale, 22 * scale);
|
||||
if (body <= 0) {
|
||||
color = [245, 249, 255, 255];
|
||||
}
|
||||
|
||||
const lensDistance = Math.hypot(px - 120 * scale, py - 130 * scale);
|
||||
if (lensDistance <= 36 * scale) {
|
||||
color = lensDistance <= 23 * scale ? [42, 109, 196, 255] : [128, 185, 239, 255];
|
||||
}
|
||||
|
||||
const viewfinder = roundedRectangleDistance(px, py, 73 * scale, 56 * scale, 122 * scale, 86 * scale, 9 * scale);
|
||||
if (viewfinder <= 0) {
|
||||
color = [232, 241, 253, 255];
|
||||
}
|
||||
|
||||
if (px >= 190 * scale && px <= 222 * scale && py >= 101 * scale && py <= 159 * scale) {
|
||||
const edge = Math.abs(py - 130 * scale) / (29 * scale);
|
||||
const leftEdge = 190 * scale + edge * 16 * scale;
|
||||
if (px >= leftEdge) {
|
||||
color = [237, 244, 253, 255];
|
||||
}
|
||||
}
|
||||
|
||||
const statusDistance = Math.hypot(px - 165 * scale, py - 98 * scale);
|
||||
if (statusDistance <= 9 * scale) {
|
||||
color = [244, 85, 93, 255];
|
||||
}
|
||||
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
rgba[channel] += color[channel];
|
||||
}
|
||||
}
|
||||
}
|
||||
const offset = (y * size + x) * 4;
|
||||
for (let channel = 0; channel < 4; channel++) {
|
||||
pixels[offset + channel] = Math.round(rgba[channel] / (samples * samples));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scanlines = Buffer.alloc((size * 4 + 1) * size);
|
||||
for (let row = 0; row < size; row++) {
|
||||
const target = row * (size * 4 + 1);
|
||||
scanlines[target] = 0;
|
||||
pixels.copy(scanlines, target + 1, row * size * 4, (row + 1) * size * 4);
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(size, 0);
|
||||
header.writeUInt32BE(size, 4);
|
||||
header[8] = 8;
|
||||
header[9] = 6;
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
chunk("IHDR", header),
|
||||
chunk("IDAT", deflateSync(scanlines, { level: 9 })),
|
||||
chunk("IEND", Buffer.alloc(0))
|
||||
]);
|
||||
}
|
||||
|
||||
mkdirSync(packageRoot, { recursive: true });
|
||||
mkdirSync(uiImageRoot, { recursive: true });
|
||||
const icon64 = render(64);
|
||||
const icon256 = render(256);
|
||||
writeFileSync(join(packageRoot, "ICON.PNG"), icon64);
|
||||
writeFileSync(join(packageRoot, "ICON_256.PNG"), icon256);
|
||||
writeFileSync(join(uiImageRoot, "icon_64.png"), icon64);
|
||||
writeFileSync(join(uiImageRoot, "icon_256.png"), icon256);
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Deprecated compatibility script. Prefer the built-in persistent OpenList upload
|
||||
# queue configured in the Web admin. No endpoint or credentials are stored here.
|
||||
|
||||
OPENLIST_BASE_URL="${OPENLIST_BASE_URL:-}"
|
||||
OPENLIST_USERNAME="${OPENLIST_USERNAME:-}"
|
||||
OPENLIST_PASSWORD="${OPENLIST_PASSWORD:-}"
|
||||
OPENLIST_SOURCE_ROOT="${OPENLIST_SOURCE_ROOT:-}"
|
||||
OPENLIST_DESTINATION_ROOT="${OPENLIST_DESTINATION_ROOT:-}"
|
||||
|
||||
segment_path="$LIVE_RECORDER_SEGMENT_FILE_PATH"
|
||||
danmaku_path="$LIVE_RECORDER_DANMAKU_FILE_PATH"
|
||||
platform="$LIVE_RECORDER_PLATFORM"
|
||||
|
||||
log_file="$LIVE_RECORDER_SCRIPT_LOG_PATH"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$log_file"
|
||||
}
|
||||
|
||||
# =========================
|
||||
# 参数检查
|
||||
# =========================
|
||||
|
||||
if [ -z "$OPENLIST_BASE_URL" ] || [ -z "$OPENLIST_USERNAME" ] || [ -z "$OPENLIST_PASSWORD" ]; then
|
||||
echo "错误:请通过环境变量配置 OPENLIST_BASE_URL、OPENLIST_USERNAME 和 OPENLIST_PASSWORD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$OPENLIST_SOURCE_ROOT" ] || [ -z "$OPENLIST_DESTINATION_ROOT" ]; then
|
||||
echo "错误:请通过环境变量配置 OPENLIST_SOURCE_ROOT 和 OPENLIST_DESTINATION_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$segment_path" ]; then
|
||||
log "错误:LIVE_RECORDER_SEGMENT_FILE_PATH 为空"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$danmaku_path" ]; then
|
||||
log "错误:LIVE_RECORDER_DANMAKU_FILE_PATH 为空"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$platform" ]; then
|
||||
log "错误:LIVE_RECORDER_PLATFORM 为空"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =========================
|
||||
# 简单 JSON 字符串转义
|
||||
# =========================
|
||||
|
||||
json_escape() {
|
||||
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
# 从响应里抽取业务 code
|
||||
extract_code() {
|
||||
printf '%s' "$1" | sed -n 's/.*"code"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p'
|
||||
}
|
||||
|
||||
# =========================
|
||||
# 1. 获取主播名和日期
|
||||
# =========================
|
||||
|
||||
anchor_name=$(printf '%s\n' "$segment_path" | awk -F'/' '{print $(NF-2)}')
|
||||
record_date=$(printf '%s\n' "$segment_path" | awk -F'/' '{print $(NF-1)}')
|
||||
|
||||
video_filename=$(basename "$segment_path")
|
||||
danmaku_filename=$(basename "$danmaku_path")
|
||||
|
||||
log "主播名: $anchor_name"
|
||||
log "日期: $record_date"
|
||||
log "视频文件名: $video_filename"
|
||||
log "弹幕文件名: $danmaku_filename"
|
||||
|
||||
remote_dir="${OPENLIST_DESTINATION_ROOT%/}/$platform/$anchor_name/$record_date"
|
||||
src_dir="${OPENLIST_SOURCE_ROOT%/}/$platform/$anchor_name/$record_date"
|
||||
|
||||
log "OpenList 源目录: $src_dir"
|
||||
log "OpenList 目标目录: $remote_dir"
|
||||
|
||||
# 转义后用于 JSON
|
||||
username_json=$(json_escape "$OPENLIST_USERNAME")
|
||||
password_json=$(json_escape "$OPENLIST_PASSWORD")
|
||||
remote_dir_json=$(json_escape "$remote_dir")
|
||||
src_dir_json=$(json_escape "$src_dir")
|
||||
video_filename_json=$(json_escape "$video_filename")
|
||||
danmaku_filename_json=$(json_escape "$danmaku_filename")
|
||||
|
||||
# =========================
|
||||
# 2. 登录获取 token
|
||||
# =========================
|
||||
|
||||
login_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/auth/login" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "{\"username\":\"$username_json\",\"password\":\"$password_json\"}")
|
||||
|
||||
login_code=$(extract_code "$login_resp")
|
||||
token=$(printf '%s' "$login_resp" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||
|
||||
if [ "$login_code" != "200" ] || [ -z "$token" ]; then
|
||||
log "错误:登录失败,响应: $login_resp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "OpenList 登录成功"
|
||||
|
||||
# =========================
|
||||
# 3. 递归创建目标目录
|
||||
# OpenList 的 /api/fs/mkdir 在部分网盘驱动(如移动云盘)上
|
||||
# 不会自动创建父目录,父目录缺失时会报
|
||||
# "failed to get parent dir [...]: object not found"。
|
||||
# 因此这里从根开始逐级 list + mkdir,确保每一层父目录都已存在。
|
||||
# =========================
|
||||
|
||||
# 先判断完整目标目录是否已存在,存在则直接跳过创建
|
||||
list_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/list" \
|
||||
--header "Authorization: $token" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "{\"path\":\"$remote_dir_json\",\"password\":\"\",\"refresh\":false,\"page\":1,\"per_page\":1}")
|
||||
list_code=$(extract_code "$list_resp")
|
||||
|
||||
if [ "$list_code" = "200" ]; then
|
||||
log "目标目录已存在: $remote_dir"
|
||||
else
|
||||
log "目标目录不存在,逐级创建: $remote_dir"
|
||||
|
||||
accum=""
|
||||
old_ifs="$IFS"
|
||||
IFS='/'
|
||||
for seg in $remote_dir; do
|
||||
[ -z "$seg" ] && continue
|
||||
accum="$accum/$seg"
|
||||
accum_json=$(json_escape "$accum")
|
||||
|
||||
# 该层是否已存在?存在则跳过,避免无谓 mkdir 报错
|
||||
seg_list=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/list" \
|
||||
--header "Authorization: $token" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "{\"path\":\"$accum_json\",\"password\":\"\",\"refresh\":false,\"page\":1,\"per_page\":1}")
|
||||
seg_code=$(extract_code "$seg_list")
|
||||
if [ "$seg_code" = "200" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
mk_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/mkdir" \
|
||||
--header "Authorization: $token" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "{\"path\":\"$accum_json\"}")
|
||||
mk_code=$(extract_code "$mk_resp")
|
||||
if [ "$mk_code" != "200" ]; then
|
||||
IFS="$old_ifs"
|
||||
log "错误:创建目录失败 ($accum),响应: $mk_resp"
|
||||
exit 1
|
||||
fi
|
||||
log "已创建: $accum"
|
||||
done
|
||||
IFS="$old_ifs"
|
||||
|
||||
log "目标目录创建成功: $remote_dir"
|
||||
fi
|
||||
|
||||
# =========================
|
||||
# 4. 移动文件到目标目录
|
||||
# =========================
|
||||
|
||||
move_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/move" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--header "Authorization: $token" \
|
||||
--data-raw "{\"src_dir\":\"$src_dir_json\",\"dst_dir\":\"$remote_dir_json\",\"names\":[\"$danmaku_filename_json\",\"$video_filename_json\"]}")
|
||||
|
||||
move_code=$(extract_code "$move_resp")
|
||||
|
||||
if [ "$move_code" = "200" ]; then
|
||||
log "文件移动成功: $danmaku_filename, $video_filename -> $remote_dir"
|
||||
else
|
||||
log "错误:文件移动失败,响应: $move_resp"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+177
@@ -0,0 +1,177 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
LIVE_PACKAGE=${1:?usage: smoke-fnos-package.sh liverecorder.fpk postgresql-service.fpk [temporary-directory]}
|
||||
POSTGRES_PACKAGE=${2:?usage: smoke-fnos-package.sh liverecorder.fpk postgresql-service.fpk [temporary-directory]}
|
||||
SMOKE_TMP_ROOT="${3:-${LIVERECORDER_SMOKE_TMPDIR:-${TMPDIR:-/tmp}}}"
|
||||
mkdir -p "$SMOKE_TMP_ROOT"
|
||||
SMOKE_TMP_ROOT=$(CDPATH= cd -- "$SMOKE_TMP_ROOT" && pwd)
|
||||
WORK_DIR=$(mktemp -d "${SMOKE_TMP_ROOT%/}/liverecorder-shared-stack-smoke.XXXXXX")
|
||||
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/liverecorder-stack-state.XXXXXX")
|
||||
|
||||
LIVE_PACKAGE_ROOT="$WORK_DIR/live-package"
|
||||
LIVE_APP_ROOT="$WORK_DIR/live-app"
|
||||
LIVE_DATA_ROOT="$STATE_DIR/live-var"
|
||||
LIVE_VOLUME_ROOT="$WORK_DIR/live-volume"
|
||||
PG_PACKAGE_ROOT="$WORK_DIR/postgres-package"
|
||||
PG_APP_ROOT="$WORK_DIR/postgres-app"
|
||||
PG_DATA_ROOT="$STATE_DIR/postgres-var"
|
||||
PG_VOLUME_ROOT="$WORK_DIR/postgres-volume"
|
||||
LIVE_PORT=${LIVERECORDER_SMOKE_PORT:-19580}
|
||||
PG_API_PORT=${POSTGRES_SERVICE_SMOKE_API_PORT:-19533}
|
||||
PG_PORT=${POSTGRES_SERVICE_SMOKE_PG_PORT:-19532}
|
||||
LIVE_CONTROL="$LIVE_PACKAGE_ROOT/cmd/main"
|
||||
PG_CONTROL="$PG_PACKAGE_ROOT/cmd/main"
|
||||
ADMIN_PASSWORD='LiveRecorder-Smoke-2026!'
|
||||
PG_ADMIN_PASSWORD='Postgres-Admin-Smoke-2026!'
|
||||
ENROLLMENT_TOKEN='Postgres-Enrollment-Smoke-2026!'
|
||||
MEDIA_PATH="${PATH:-/usr/local/bin:/usr/bin:/bin}"
|
||||
NODEJS_ROOT="$WORK_DIR/nodejs-v22-target"
|
||||
NODEJS_BIN="${NODEJS_SMOKE_BIN:-$(type -P node)}"
|
||||
|
||||
mkdir -p "$NODEJS_ROOT/bin"
|
||||
ln -s "$NODEJS_BIN" "$NODEJS_ROOT/bin/node"
|
||||
|
||||
if ! PATH="$MEDIA_PATH" command -v ffmpeg >/dev/null 2>&1 || \
|
||||
! PATH="$MEDIA_PATH" command -v ffprobe >/dev/null 2>&1; then
|
||||
mkdir -p "$WORK_DIR/system-media-stubs"
|
||||
ln -s "$(type -P true)" "$WORK_DIR/system-media-stubs/ffmpeg"
|
||||
ln -s "$(type -P true)" "$WORK_DIR/system-media-stubs/ffprobe"
|
||||
MEDIA_PATH="$WORK_DIR/system-media-stubs:$MEDIA_PATH"
|
||||
fi
|
||||
|
||||
run_live_control() {
|
||||
TRIM_APPDEST="$LIVE_APP_ROOT" TRIM_PKGVAR="$LIVE_DATA_ROOT" \
|
||||
TRIM_APPDEST_VOL="$LIVE_VOLUME_ROOT" TRIM_SERVICE_PORT="$LIVE_PORT" \
|
||||
POSTGRES_SERVICE_API="http://127.0.0.1:$PG_API_PORT" NODEJS_ROOT="$NODEJS_ROOT" \
|
||||
PATH="$MEDIA_PATH" "$LIVE_CONTROL" "$@"
|
||||
}
|
||||
|
||||
run_postgres_control() {
|
||||
TRIM_APPDEST="$PG_APP_ROOT" TRIM_PKGVAR="$PG_DATA_ROOT" \
|
||||
TRIM_APPDEST_VOL="$PG_VOLUME_ROOT" TRIM_SERVICE_PORT="$PG_API_PORT" \
|
||||
POSTGRES_SERVICE_PORT="$PG_PORT" "$PG_CONTROL" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
status=$?
|
||||
if [ -x "$LIVE_CONTROL" ]; then run_live_control stop >/dev/null 2>&1 || true; fi
|
||||
if [ -x "$PG_CONTROL" ]; then run_postgres_control stop >/dev/null 2>&1 || true; fi
|
||||
if [ "$status" -ne 0 ]; then
|
||||
printf '%s\n' 'fnOS shared-stack smoke test failed; service logs follow:' >&2
|
||||
for log_file in \
|
||||
"$PG_DATA_ROOT/log/postgresql.log" "$PG_DATA_ROOT/log/postgres-service.log" \
|
||||
"$LIVE_DATA_ROOT/log/liverecorder.log"; do
|
||||
if [ -f "$log_file" ]; then
|
||||
printf '%s\n' "--- $log_file ---" >&2
|
||||
tail -n 160 "$log_file" >&2 || true
|
||||
fi
|
||||
done
|
||||
fi
|
||||
rm -rf -- "$WORK_DIR"
|
||||
rm -rf -- "$STATE_DIR"
|
||||
return "$status"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
mkdir -p "$LIVE_PACKAGE_ROOT" "$LIVE_APP_ROOT" "$LIVE_VOLUME_ROOT" \
|
||||
"$PG_PACKAGE_ROOT" "$PG_APP_ROOT" "$PG_VOLUME_ROOT"
|
||||
tar -xzf "$POSTGRES_PACKAGE" -C "$PG_PACKAGE_ROOT"
|
||||
tar -xzf "$PG_PACKAGE_ROOT/app.tgz" -C "$PG_APP_ROOT"
|
||||
rm -f "$PG_PACKAGE_ROOT/app.tgz"
|
||||
tar -xzf "$LIVE_PACKAGE" -C "$LIVE_PACKAGE_ROOT"
|
||||
tar -xzf "$LIVE_PACKAGE_ROOT/app.tgz" -C "$LIVE_APP_ROOT"
|
||||
rm -f "$LIVE_PACKAGE_ROOT/app.tgz"
|
||||
|
||||
test ! -e "$LIVE_APP_ROOT/runtime/usr/lib/postgresql"
|
||||
test ! -e "$LIVE_APP_ROOT/runtime/usr/share/postgresql"
|
||||
test ! -e "$LIVE_APP_ROOT/runtime/lib/libLLVM-14.so.1"
|
||||
test ! -e "$LIVE_APP_ROOT/runtime/lib/libz3.so.4"
|
||||
test ! -e "$LIVE_APP_ROOT/runtime/bin/node"
|
||||
|
||||
TRIM_PKGVAR="$PG_DATA_ROOT" \
|
||||
wizard_postgres_admin_password="$PG_ADMIN_PASSWORD" \
|
||||
wizard_postgres_admin_password_confirm="$PG_ADMIN_PASSWORD" \
|
||||
wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \
|
||||
wizard_postgres_enrollment_token_confirm="$ENROLLMENT_TOKEN" \
|
||||
"$PG_PACKAGE_ROOT/cmd/install_callback"
|
||||
TRIM_PKGVAR="$LIVE_DATA_ROOT" \
|
||||
wizard_admin_password="$ADMIN_PASSWORD" \
|
||||
wizard_admin_password_confirm="$ADMIN_PASSWORD" \
|
||||
wizard_postgres_enrollment_token="$ENROLLMENT_TOKEN" \
|
||||
"$LIVE_PACKAGE_ROOT/cmd/install_callback"
|
||||
|
||||
run_postgres_control start
|
||||
run_postgres_control status
|
||||
curl -fsS "http://127.0.0.1:$PG_API_PORT/health/ready" | grep -q '"status":"ready"'
|
||||
|
||||
run_live_control start
|
||||
run_live_control status
|
||||
BASE_URL="http://127.0.0.1:$LIVE_PORT"
|
||||
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
|
||||
curl -fsS "$BASE_URL/" | grep -q '<div id="app"></div>'
|
||||
|
||||
CREDENTIALS="$LIVE_DATA_ROOT/postgres-client.conf"
|
||||
test -s "$CREDENTIALS"
|
||||
test "$(stat -c '%a' "$CREDENTIALS")" = "600"
|
||||
grep -q '^host=127\.0\.0\.1$' "$CREDENTIALS"
|
||||
grep -q "^port=$PG_PORT$" "$CREDENTIALS"
|
||||
grep -q '^database=appdb_liverecorder_' "$CREDENTIALS"
|
||||
grep -q '^username=app_liverecorder_' "$CREDENTIALS"
|
||||
test ! -e "$LIVE_DATA_ROOT/postgres"
|
||||
test ! -e "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
|
||||
|
||||
login_response=$(curl -fsS -H 'Content-Type: application/json' \
|
||||
--data "{\"username\":\"admin\",\"password\":\"$ADMIN_PASSWORD\"}" \
|
||||
"$BASE_URL/api/auth/login")
|
||||
token=$(printf '%s' "$login_response" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
|
||||
test -n "$token"
|
||||
settings_response=$(curl -fsS -H "Authorization: Bearer $token" "$BASE_URL/api/settings")
|
||||
expected_record_root="$LIVE_VOLUME_ROOT/@appshare/liverecorder/records"
|
||||
printf '%s' "$settings_response" | grep -Fq "\"outputRoot\":\"$expected_record_root\""
|
||||
session_page_response=$(curl -fsS -H "Authorization: Bearer $token" \
|
||||
"$BASE_URL/api/record-sessions/page?skip=0&take=12&state=all")
|
||||
printf '%s' "$session_page_response" | grep -q '"items":\[\]'
|
||||
printf '%s' "$session_page_response" | grep -q '"totalCount":0'
|
||||
printf '%s' "$session_page_response" | grep -q '"take":12'
|
||||
filtered_session_page_response=$(curl -fsS -H "Authorization: Bearer $token" \
|
||||
"$BASE_URL/api/record-sessions/page?skip=0&take=12&state=active&search=needle")
|
||||
printf '%s' "$filtered_session_page_response" | grep -q '"totalCount":0'
|
||||
|
||||
runtime_libs="$LIVE_APP_ROOT/runtime/lib"
|
||||
ca_bundle="$LIVE_APP_ROOT/runtime/etc/ssl/certs/ca-certificates.crt"
|
||||
node_bin="$NODEJS_ROOT/bin/node"
|
||||
curl_bin="$LIVE_APP_ROOT/runtime/bin/curl"
|
||||
signer="$LIVE_APP_ROOT/server/Platforms/Douyin/Signing/sign-xbogus.js"
|
||||
LD_LIBRARY_PATH="$runtime_libs" "$node_bin" --version | grep -Eq '^v[0-9]+\.'
|
||||
signature=$(LD_LIBRARY_PATH="$runtime_libs" "$node_bin" "$signer" \
|
||||
'aid=6383&device_platform=web&room_id=1' 'Mozilla/5.0 LiveRecorder fnOS shared-stack smoke test')
|
||||
test -n "$signature"
|
||||
LD_LIBRARY_PATH="$runtime_libs" SSL_CERT_FILE="$ca_bundle" CURL_CA_BUNDLE="$ca_bundle" \
|
||||
"$curl_bin" -fsS "$BASE_URL/health/ready" >/dev/null
|
||||
|
||||
run_live_control stop
|
||||
if run_live_control status; then
|
||||
printf '%s\n' 'Live Recorder remained running after stop' >&2
|
||||
exit 1
|
||||
fi
|
||||
run_postgres_control status
|
||||
curl -fsS "http://127.0.0.1:$PG_API_PORT/health/ready" | grep -q '"status":"ready"'
|
||||
|
||||
run_live_control start
|
||||
run_live_control status
|
||||
curl -fsS "$BASE_URL/health/ready" | grep -q '"status":"ready"'
|
||||
|
||||
run_live_control stop
|
||||
run_postgres_control stop
|
||||
rm -f "$CREDENTIALS" "$LIVE_DATA_ROOT/postgres-enrollment-token.seed"
|
||||
mkdir -p "$LIVE_DATA_ROOT/postgres"
|
||||
printf '15\n' >"$LIVE_DATA_ROOT/postgres/PG_VERSION"
|
||||
if run_live_control start; then
|
||||
printf '%s\n' 'Live Recorder unexpectedly fell back to a legacy private PostgreSQL database' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q '无法连接 PostgreSQL 共享服务或取得数据库凭据,应用不会启动' \
|
||||
"$LIVE_DATA_ROOT/log/liverecorder.log"
|
||||
|
||||
printf '%s\n' 'fnOS shared-stack smoke test passed: independent PostgreSQL stayed running, Live Recorder enrolled, persisted credentials and restarted without Docker'
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
command -v ffmpeg >/dev/null || { echo "ffmpeg is required" >&2; exit 127; }
|
||||
command -v ffprobe >/dev/null || { echo "ffprobe is required" >&2; exit 127; }
|
||||
|
||||
test_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
|
||||
make_sample() {
|
||||
local output="$1"
|
||||
local duration="$2"
|
||||
ffmpeg -hide_banner -loglevel error \
|
||||
-f lavfi -i "testsrc2=size=320x180:rate=25" \
|
||||
-f lavfi -i "sine=frequency=1000:sample_rate=48000" \
|
||||
-t "$duration" -c:v libx264 -preset ultrafast -pix_fmt yuv420p \
|
||||
-c:a aac -b:a 96k -movflags +faststart -y "$output"
|
||||
}
|
||||
|
||||
make_sample "$test_dir/short-a.mp4" 1.2
|
||||
make_sample "$test_dir/short-b.mp4" 2.1
|
||||
make_sample "$test_dir/normal.mp4" 6.4
|
||||
|
||||
run_case() {
|
||||
local name="$1"
|
||||
local minimum_duration="$2"
|
||||
shift 2
|
||||
local list="$test_dir/$name.txt"
|
||||
local output="$test_dir/$name.mp4"
|
||||
: > "$list"
|
||||
for source in "$@"; do
|
||||
printf "file '%s'\n" "$source" >> "$list"
|
||||
done
|
||||
|
||||
ffmpeg -hide_banner -loglevel error -f concat -safe 0 -i "$list" \
|
||||
-map 0 -c copy -movflags +faststart -y "$output"
|
||||
|
||||
local duration
|
||||
duration="$(ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 "$output")"
|
||||
local video_streams
|
||||
video_streams="$(ffprobe -v error -select_streams v:0 -show_entries stream=index -of csv=p=0 "$output" | wc -l)"
|
||||
awk -v actual="$duration" -v minimum="$minimum_duration" 'BEGIN { exit !(actual >= minimum) }'
|
||||
test "$video_streams" -ge 1
|
||||
echo "$name ok: duration=${duration}s"
|
||||
}
|
||||
|
||||
run_case short_then_normal 7.0 "$test_dir/short-a.mp4" "$test_dir/normal.mp4"
|
||||
run_case normal_then_short 7.0 "$test_dir/normal.mp4" "$test_dir/short-a.mp4"
|
||||
run_case consecutive_shorts 9.0 "$test_dir/short-a.mp4" "$test_dir/short-b.mp4" "$test_dir/normal.mp4"
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
FILE=${1:?usage: upload-openlist-artifact.sh file [remote-directory]}
|
||||
REMOTE_DIR=${2:-/yidongpan/构建产物/liverecorder}
|
||||
OPENLIST_BASE_URL=${OPENLIST_BASE_URL:-https://openlist.nxsir.cn}
|
||||
OPENLIST_USERNAME=${OPENLIST_USERNAME:?OPENLIST_USERNAME is required}
|
||||
OPENLIST_PASSWORD=${OPENLIST_PASSWORD:?OPENLIST_PASSWORD is required}
|
||||
OPENLIST_UPLOAD_ATTEMPTS=${OPENLIST_UPLOAD_ATTEMPTS:-6}
|
||||
|
||||
test -f "$FILE" || { printf 'artifact does not exist: %s\n' "$FILE" >&2; exit 1; }
|
||||
for required_command in curl python3 stat basename sleep; do
|
||||
command -v "$required_command" >/dev/null 2>&1 || {
|
||||
printf 'required upload command is missing: %s\n' "$required_command" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
OPENLIST_BASE_URL=${OPENLIST_BASE_URL%/}
|
||||
REMOTE_DIR="/${REMOTE_DIR#/}"
|
||||
REMOTE_DIR=${REMOTE_DIR%/}
|
||||
FILE_NAME=$(basename -- "$FILE")
|
||||
BUILD_MARKER=${BUILD_TAG:-${BUILD_ID:-$$}}
|
||||
BUILD_MARKER=$(printf '%s' "$BUILD_MARKER" | tr -c 'A-Za-z0-9._-' '-')
|
||||
TEMP_NAME=".${FILE_NAME}.uploading-${BUILD_MARKER}"
|
||||
TEMP_PATH="$REMOTE_DIR/$TEMP_NAME"
|
||||
FINAL_PATH="$REMOTE_DIR/$FILE_NAME"
|
||||
LOCAL_SIZE=$(stat -c '%s' "$FILE")
|
||||
TOKEN=
|
||||
|
||||
json_string() {
|
||||
python3 -c 'import json,sys; print(json.dumps(sys.argv[1], ensure_ascii=False))' "$1"
|
||||
}
|
||||
|
||||
envelope_code() {
|
||||
python3 -c 'import json,sys
|
||||
try:
|
||||
value=json.load(sys.stdin)
|
||||
print(value.get("code", -1))
|
||||
except Exception:
|
||||
print(-1)'
|
||||
}
|
||||
|
||||
envelope_message() {
|
||||
python3 -c 'import json,sys
|
||||
try:
|
||||
value=json.load(sys.stdin)
|
||||
print(value.get("message", "unknown OpenList response"))
|
||||
except Exception:
|
||||
print("invalid OpenList response")'
|
||||
}
|
||||
|
||||
login() {
|
||||
local payload response code
|
||||
payload=$(printf '{"username":%s,"password":%s}' \
|
||||
"$(json_string "$OPENLIST_USERNAME")" \
|
||||
"$(json_string "$OPENLIST_PASSWORD")")
|
||||
response=$(curl --silent --show-error --location --fail-with-body \
|
||||
--connect-timeout 30 --max-time 120 \
|
||||
--request POST "$OPENLIST_BASE_URL/api/auth/login" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "$payload") || return 1
|
||||
code=$(printf '%s' "$response" | envelope_code)
|
||||
[ "$code" = "200" ] || {
|
||||
printf 'OpenList login failed: %s\n' "$(printf '%s' "$response" | envelope_message)" >&2
|
||||
return 1
|
||||
}
|
||||
TOKEN=$(printf '%s' "$response" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("data") or {}).get("token", ""))')
|
||||
[ -n "$TOKEN" ]
|
||||
}
|
||||
|
||||
post_json() {
|
||||
local endpoint=$1 payload=$2
|
||||
curl --silent --show-error --location --fail-with-body \
|
||||
--connect-timeout 30 --max-time 180 \
|
||||
--request POST "$OPENLIST_BASE_URL$endpoint" \
|
||||
--header "Authorization: $TOKEN" \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw "$payload"
|
||||
}
|
||||
|
||||
remote_size() {
|
||||
local path=$1 response code
|
||||
response=$(post_json /api/fs/get "{\"path\":$(json_string "$path"),\"password\":\"\"}") || return 1
|
||||
code=$(printf '%s' "$response" | envelope_code)
|
||||
[ "$code" = "200" ] || return 1
|
||||
printf '%s' "$response" | python3 -c 'import json,sys; print((json.load(sys.stdin).get("data") or {}).get("size", -1))'
|
||||
}
|
||||
|
||||
ensure_remote_directory() {
|
||||
local current= segment response code
|
||||
while IFS= read -r segment; do
|
||||
[ -n "$segment" ] || continue
|
||||
current="$current/$segment"
|
||||
response=$(post_json /api/fs/list "{\"path\":$(json_string "$current"),\"password\":\"\",\"refresh\":false,\"page\":1,\"per_page\":1}" || true)
|
||||
code=$(printf '%s' "$response" | envelope_code)
|
||||
if [ "$code" = "200" ]; then
|
||||
continue
|
||||
fi
|
||||
response=$(post_json /api/fs/mkdir "{\"path\":$(json_string "$current")}") || return 1
|
||||
code=$(printf '%s' "$response" | envelope_code)
|
||||
[ "$code" = "200" ] || {
|
||||
printf 'OpenList mkdir failed for %s: %s\n' "$current" "$(printf '%s' "$response" | envelope_message)" >&2
|
||||
return 1
|
||||
}
|
||||
done < <(python3 -c 'import sys; print("\n".join(part for part in sys.argv[1].split("/") if part))' "$REMOTE_DIR")
|
||||
}
|
||||
|
||||
remove_temporary_file() {
|
||||
local response
|
||||
[ -n "$TOKEN" ] || login >/dev/null 2>&1 || return 0
|
||||
response=$(post_json /api/fs/remove "{\"dir\":$(json_string "$REMOTE_DIR"),\"names\":[$(json_string "$TEMP_NAME")]}" 2>/dev/null || true)
|
||||
[ "$(printf '%s' "$response" | envelope_code)" = "200" ] || true
|
||||
}
|
||||
|
||||
publish_temporary_file() {
|
||||
local response code
|
||||
response=$(post_json /api/fs/rename "{\"path\":$(json_string "$TEMP_PATH"),\"name\":$(json_string "$FILE_NAME")}") || return 1
|
||||
code=$(printf '%s' "$response" | envelope_code)
|
||||
[ "$code" = "200" ] || {
|
||||
printf 'OpenList rename failed: %s\n' "$(printf '%s' "$response" | envelope_message)" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
upload_once() {
|
||||
local encoded_path response code uploaded_size
|
||||
encoded_path=$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$TEMP_PATH")
|
||||
response=$(curl --silent --show-error --location --fail-with-body \
|
||||
--connect-timeout 30 --max-time 3600 --speed-limit 1024 --speed-time 120 \
|
||||
--request PUT "$OPENLIST_BASE_URL/api/fs/put" \
|
||||
--header "Authorization: $TOKEN" \
|
||||
--header "File-Path: $encoded_path" \
|
||||
--header 'As-Task: false' \
|
||||
--header 'Content-Type: application/octet-stream' \
|
||||
--header 'Expect:' \
|
||||
--data-binary "@$FILE") || return 1
|
||||
code=$(printf '%s' "$response" | envelope_code)
|
||||
[ "$code" = "200" ] || {
|
||||
printf 'OpenList upload failed: %s\n' "$(printf '%s' "$response" | envelope_message)" >&2
|
||||
return 1
|
||||
}
|
||||
uploaded_size=$(remote_size "$TEMP_PATH") || return 1
|
||||
[ "$uploaded_size" = "$LOCAL_SIZE" ] || {
|
||||
printf 'OpenList size mismatch for temporary upload: local=%s remote=%s\n' "$LOCAL_SIZE" "$uploaded_size" >&2
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
printf 'Uploading %s (%s bytes) to OpenList %s\n' "$FILE_NAME" "$LOCAL_SIZE" "$REMOTE_DIR"
|
||||
|
||||
for attempt in $(seq 1 "$OPENLIST_UPLOAD_ATTEMPTS"); do
|
||||
TOKEN=
|
||||
upload_ready=1
|
||||
if login && ensure_remote_directory; then
|
||||
existing_size=$(remote_size "$FINAL_PATH" 2>/dev/null || true)
|
||||
if [ "$existing_size" = "$LOCAL_SIZE" ]; then
|
||||
printf 'OpenList artifact already exists with matching size: %s\n' "$FINAL_PATH"
|
||||
exit 0
|
||||
fi
|
||||
if [ -n "$existing_size" ]; then
|
||||
remove_response=$(post_json /api/fs/remove "{\"dir\":$(json_string "$REMOTE_DIR"),\"names\":[$(json_string "$FILE_NAME")]}" || true)
|
||||
if [ "$(printf '%s' "$remove_response" | envelope_code)" != "200" ]; then
|
||||
printf 'Unable to remove mismatched OpenList artifact before retry: %s\n' "$FINAL_PATH" >&2
|
||||
upload_ready=0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$upload_ready" = "1" ] && upload_once && publish_temporary_file; then
|
||||
final_size=$(remote_size "$FINAL_PATH") || final_size=-1
|
||||
if [ "$final_size" = "$LOCAL_SIZE" ]; then
|
||||
printf 'OpenList upload verified: %s (%s bytes)\n' "$FINAL_PATH" "$final_size"
|
||||
exit 0
|
||||
fi
|
||||
printf 'OpenList final size mismatch: local=%s remote=%s\n' "$LOCAL_SIZE" "$final_size" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$attempt" -lt "$OPENLIST_UPLOAD_ATTEMPTS" ]; then
|
||||
delay=$((5 * (1 << (attempt - 1))))
|
||||
[ "$delay" -le 120 ] || delay=120
|
||||
printf 'OpenList upload attempt %s/%s failed; retrying in %ss\n' "$attempt" "$OPENLIST_UPLOAD_ATTEMPTS" "$delay" >&2
|
||||
sleep "$delay"
|
||||
fi
|
||||
done
|
||||
|
||||
remove_temporary_file
|
||||
printf 'OpenList upload failed after %s attempts: %s\n' "$OPENLIST_UPLOAD_ATTEMPTS" "$FILE_NAME" >&2
|
||||
exit 1
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
PACKAGE=${1:?usage: verify-fnos-package.sh package.fpk [expected-version]}
|
||||
EXPECTED_VERSION=${2:-}
|
||||
VERIFY_TMP_ROOT="${LIVERECORDER_VERIFY_TMPDIR:-${TMPDIR:-/tmp}}"
|
||||
MAX_APP_UNCOMPRESSED_BYTES=$((512 * 1024 * 1024))
|
||||
mkdir -p "$VERIFY_TMP_ROOT"
|
||||
WORK_DIR=$(mktemp -d "${VERIFY_TMP_ROOT%/}/fnos-package-verify.XXXXXX")
|
||||
trap 'rm -rf -- "$WORK_DIR"' EXIT
|
||||
|
||||
manifest_value() {
|
||||
sed -n "s/^$1[[:space:]]*=[[:space:]]*//p" "$WORK_DIR/manifest" | head -n 1 | tr -d '\r'
|
||||
}
|
||||
|
||||
tar -xzf "$PACKAGE" -C "$WORK_DIR"
|
||||
appname=$(manifest_value appname)
|
||||
version=$(manifest_value version)
|
||||
test "$appname" = "liverecorder" || { printf 'unexpected fnOS appname: %s\n' "$appname" >&2; exit 1; }
|
||||
test -n "$version"
|
||||
if [ -n "$EXPECTED_VERSION" ] && [ "$version" != "$EXPECTED_VERSION" ]; then
|
||||
printf 'unexpected fnOS version: expected %s, got %s\n' "$EXPECTED_VERSION" "$version" >&2
|
||||
exit 1
|
||||
fi
|
||||
test "$(manifest_value platform)" = "x86"
|
||||
test -x "$WORK_DIR/cmd/main"
|
||||
test -x "$WORK_DIR/cmd/install_callback"
|
||||
test -s "$WORK_DIR/wizard/install"
|
||||
test -s "$WORK_DIR/wizard/upgrade"
|
||||
test -s "$WORK_DIR/ICON.PNG"
|
||||
test -s "$WORK_DIR/ICON_256.PNG"
|
||||
|
||||
expected=$(manifest_value checksum)
|
||||
actual=$(md5sum "$WORK_DIR/app.tgz" | cut -d' ' -f1)
|
||||
test -n "$expected"
|
||||
test "$expected" = "$actual"
|
||||
gzip -t "$WORK_DIR/app.tgz"
|
||||
|
||||
gzip -dc "$WORK_DIR/app.tgz" >"$WORK_DIR/app.tar"
|
||||
uncompressed_bytes=$(wc -c <"$WORK_DIR/app.tar")
|
||||
if [ "$uncompressed_bytes" -gt "$MAX_APP_UNCOMPRESSED_BYTES" ]; then
|
||||
printf 'app.tgz expands to %s bytes; limit is %s bytes\n' \
|
||||
"$uncompressed_bytes" "$MAX_APP_UNCOMPRESSED_BYTES" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tar -tf "$WORK_DIR/app.tar" >"$WORK_DIR/app-files.txt"
|
||||
if awk '/^\// || /(^|\/)\.\.($|\/)/ { unsafe = 1; exit } END { exit unsafe ? 0 : 1 }' "$WORK_DIR/app-files.txt"; then
|
||||
printf 'app.tgz contains an unsafe member path\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tar -tvf "$WORK_DIR/app.tar" >"$WORK_DIR/app-metadata.txt"
|
||||
if awk '
|
||||
/^l/ {
|
||||
marker = " -> "
|
||||
offset = index($0, marker)
|
||||
if (offset > 0) {
|
||||
target = substr($0, offset + length(marker))
|
||||
if (target ~ /^\//) { unsafe = 1; exit }
|
||||
}
|
||||
}
|
||||
/^h/ {
|
||||
marker = " link to "
|
||||
offset = index($0, marker)
|
||||
if (offset > 0) {
|
||||
target = substr($0, offset + length(marker))
|
||||
if (target ~ /^\//) { unsafe = 1; exit }
|
||||
}
|
||||
}
|
||||
END { exit unsafe ? 0 : 1 }
|
||||
' "$WORK_DIR/app-metadata.txt"; then
|
||||
printf 'app.tgz contains an unsafe symbolic or hard link\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
grep -q '^server/wwwroot/index.html$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^ui/config$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^ui/images/icon_64.png$' "$WORK_DIR/app-files.txt"
|
||||
|
||||
grep -q '^server/LiveRecorder.WebApi$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^server/Platforms/Douyin/Signing/sign-xbogus.js$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/bin/curl$' "$WORK_DIR/app-files.txt"
|
||||
grep -q '^runtime/etc/ssl/certs/ca-certificates.crt$' "$WORK_DIR/app-files.txt"
|
||||
test "$(manifest_value install_dep_apps)" = "nxsir.postgresql:nodejs_v22"
|
||||
if grep -Eq '^runtime/(bin/node|usr/(lib|share)/postgresql/|lib/(libLLVM|libz3))' "$WORK_DIR/app-files.txt"; then
|
||||
printf 'Live Recorder must use shared PostgreSQL and the fnOS nodejs_v22 dependency\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Eq '^runtime/.*/(ffmpeg|ffprobe)$' "$WORK_DIR/app-files.txt"; then
|
||||
printf 'ffmpeg and ffprobe must come from the fnOS system environment\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Eq '^runtime/lib/(ld-linux-.*|libc\.so\..*|libBrokenLocale\.so\..*|libanl\.so\..*|libdl\.so\..*|libm(vec)?\.so\..*|libnss_(compat|dns|files|hesiod)\.so\..*|libpthread\.so\..*|libresolv\.so\..*|librt\.so\..*|libthread_db\.so\..*|libutil\.so\..*)$' "$WORK_DIR/app-files.txt"; then
|
||||
printf 'the FPK must use the fnOS glibc and matching system loader\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Eq '^(data|records|postgres|log|var)/' "$WORK_DIR/app-files.txt"; then
|
||||
printf 'persistent data must not be included in app.tgz\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$WORK_DIR/app"
|
||||
tar -xf "$WORK_DIR/app.tar" -C "$WORK_DIR/app"
|
||||
while IFS= read -r -d '' link_path; do
|
||||
target=$(readlink -- "$link_path")
|
||||
case "$target" in
|
||||
/*) printf 'absolute symbolic link in app.tgz: %s -> %s\n' "$link_path" "$target" >&2; exit 1 ;;
|
||||
esac
|
||||
resolved=$(realpath -m -- "$(dirname -- "$link_path")/$target")
|
||||
case "$resolved" in
|
||||
"$WORK_DIR/app"/*) ;;
|
||||
*) printf 'escaping symbolic link in app.tgz: %s -> %s\n' "$link_path" "$target" >&2; exit 1 ;;
|
||||
esac
|
||||
done < <(find "$WORK_DIR/app" -type l -print0)
|
||||
|
||||
printf 'fnOS package verified: %s (%s bytes uncompressed)\n' "$PACKAGE" "$uncompressed_bytes"
|
||||
@@ -25,6 +25,10 @@ public interface ILiveRoomRepository
|
||||
|
||||
Task<IReadOnlyList<LiveRoom>> ListAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountByAvailabilityAsync(LiveRoomAvailabilityStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(LiveRoom liveRoom, CancellationToken cancellationToken = default);
|
||||
|
||||
void Remove(LiveRoom liveRoom);
|
||||
@@ -42,6 +46,10 @@ public interface IRecordTaskRepository
|
||||
|
||||
Task<RecordTask?> GetRunningByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<double> SumDurationSecondsAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountByStatusAsync(RecordTaskStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordTask recordTask, CancellationToken cancellationToken = default);
|
||||
|
||||
void Remove(RecordTask recordTask);
|
||||
@@ -53,21 +61,77 @@ public interface IRecordSessionRepository
|
||||
{
|
||||
Task<RecordSession?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> GetByIdsAsync(
|
||||
IReadOnlyCollection<Guid> ids,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListOverviewAsync(Guid? liveRoomId, int take, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(IReadOnlyList<RecordSession> Items, int TotalCount)> ListPageAsync(
|
||||
Guid? liveRoomId,
|
||||
IReadOnlyCollection<RecordSessionStatus>? statuses,
|
||||
string? search,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecordSessionOverviewTotals> GetOverviewTotalsAsync(
|
||||
Guid? liveRoomId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyCollection<Guid>> ListActiveIdsAsync(Guid? liveRoomId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyCollection<Guid>> ListActiveLiveRoomIdsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<RecordSession?> GetActiveByLiveRoomIdAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountByStatusAsync(RecordSessionStatus status, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountActiveAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListRecentAsync(int take, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<RecordSession>> ListInDateRangeAsync(DateTimeOffset startedFrom, DateTimeOffset startedTo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordSession recordSession, CancellationToken cancellationToken = default);
|
||||
|
||||
void Remove(RecordSession recordSession);
|
||||
}
|
||||
|
||||
public sealed record RecordSessionOverviewTotals(
|
||||
int TotalSessionCount,
|
||||
int ActiveSessionCount,
|
||||
int TotalTaskCount,
|
||||
int TotalDanmakuCount);
|
||||
|
||||
public interface IRecordResultRepository
|
||||
{
|
||||
Task<RecordResult?> GetByTaskIdAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<(long TotalBytes, int TotalDanmaku)> GetTodayAggregateAsync(DateTimeOffset createdFrom, DateTimeOffset createdTo, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountPendingUploadAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<long> SumPendingUploadBytesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<PendingUploadMetrics> GetPendingUploadMetricsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountFailedArtifactAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<List<(RecordResult Result, RecordTask Task)>> ListUploadStatusAsync(
|
||||
RecordArtifactUploadStatus? uploadStatusFilter,
|
||||
string? query,
|
||||
int skip,
|
||||
int take,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountUploadStatusAsync(
|
||||
RecordArtifactUploadStatus? uploadStatusFilter,
|
||||
string? query,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default);
|
||||
|
||||
void Update(RecordResult recordResult);
|
||||
@@ -100,11 +164,26 @@ public interface ISystemLogRepository
|
||||
int take = 200,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> CountRecentErrorsAsync(DateTimeOffset since, CancellationToken cancellationToken = default);
|
||||
|
||||
void RemoveRange(IEnumerable<SystemLogEntry> entries);
|
||||
|
||||
Task<IReadOnlyList<Guid>> ListSessionIdsWithoutTasksAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IOperationsMetricsRepository
|
||||
{
|
||||
Task<OperationsMetricsSnapshot> GetAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record OperationsMetricsSnapshot(
|
||||
DateTimeOffset? OldestTranscodeUpdatedAt,
|
||||
DateTimeOffset? OldestUploadProgressAt,
|
||||
int StalledUploadCount,
|
||||
int CleanupFailureCount,
|
||||
DateTimeOffset? OldestActionableUploadAt,
|
||||
int PendingUploadRecoveryCount);
|
||||
|
||||
public interface IUserAccountRepository
|
||||
{
|
||||
Task<UserAccount?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
@@ -122,9 +201,13 @@ public interface IUserSessionRepository
|
||||
|
||||
Task AddAsync(UserSession session, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyList<UserSession>> ListActiveByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
void Update(UserSession session);
|
||||
}
|
||||
|
||||
public sealed record PendingUploadMetrics(int Count, long TotalBytes);
|
||||
|
||||
public interface IUnitOfWork
|
||||
{
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using LiveRecorder.Application.Models.RecordTasks;
|
||||
|
||||
namespace LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
/// <summary>
|
||||
/// Service for reading and parsing danmaku XML files produced by the recording system.
|
||||
/// </summary>
|
||||
public interface IDanmakuService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns parsed danmaku events for a single recording task (one segment).
|
||||
/// Returns null if the task does not exist or has no danmaku file.
|
||||
/// </summary>
|
||||
Task<DanmakuResponseDto?> GetTaskDanmakuAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns aggregated danmaku events for all tasks in a recording session.
|
||||
/// Returns null if the session does not exist or has no tasks with danmaku.
|
||||
/// Offsets for segments beyond the first are adjusted so they are relative to the session start.
|
||||
/// </summary>
|
||||
Task<SessionDanmakuResponseDto?> GetSessionDanmakuAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -28,8 +28,19 @@ public interface IFfmpegService
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> StopAllAndWaitAsync(
|
||||
TimeSpan gracefulTimeout,
|
||||
TimeSpan forceKillTimeout,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> TryReconcileInactiveSessionAsync(Guid recordSessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> RecoverOrphanedTerminalSessionTasksAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> RecoverStalePendingSessionsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> TryRecoverOrphanedTerminalTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> StartManualFinalizeTaskAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LiveRecorder.Application.Models.Media.TranscodeMediaFileResultDto> StartManualFinalizeFileAsync(
|
||||
@@ -38,6 +49,12 @@ public interface IFfmpegService
|
||||
|
||||
Task<int> ResumePausedFinalizationsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> StartArtifactRepairAsync(Guid recordTaskId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> ResumeArtifactRepairsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<int> ResumeRecoveringSessionsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
bool IsRunning(Guid recordSessionId);
|
||||
|
||||
IReadOnlyDictionary<Guid, RecordTaskRuntimeState> GetTaskRuntimeStates(IReadOnlyCollection<Guid> recordTaskIds);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
public interface IRecordingStartLock
|
||||
{
|
||||
Task<IAsyncDisposable> AcquireAsync(Guid liveRoomId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace LiveRecorder.Application.Abstractions.Recording;
|
||||
|
||||
/// <summary>
|
||||
/// Service for extracting video metadata and generating thumbnails using ffmpeg/ffprobe.
|
||||
/// </summary>
|
||||
public interface IVideoMetadataService
|
||||
{
|
||||
Task<VideoMetadata?> ExtractMetadataAsync(string filePath, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<string?> GenerateThumbnailAsync(string filePath, string outputDir, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record VideoMetadata(
|
||||
double? DurationSeconds,
|
||||
int? Width,
|
||||
int? Height,
|
||||
string? VideoCodec,
|
||||
string? AudioCodec,
|
||||
double? FrameRate,
|
||||
long? BitRate);
|
||||
|
||||
public sealed record VideoMetadataDto(
|
||||
double? DurationSeconds,
|
||||
int? Width,
|
||||
int? Height,
|
||||
string? VideoCodec,
|
||||
string? AudioCodec,
|
||||
double? FrameRate,
|
||||
long? BitRate);
|
||||
@@ -23,6 +23,7 @@ public interface IEventScriptService
|
||||
string segmentFilePath,
|
||||
DateTimeOffset occurredAt,
|
||||
bool forceRun = false,
|
||||
Guid? eventId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<EventScriptTestResultDto> TestAsync(
|
||||
|
||||
@@ -2,18 +2,78 @@ using LiveRecorder.Application.Models.Settings;
|
||||
|
||||
namespace LiveRecorder.Application.Abstractions.Storage;
|
||||
|
||||
public enum StorageTier
|
||||
{
|
||||
/// <summary>Disk has sufficient free space for normal operation.</summary>
|
||||
Green = 0,
|
||||
|
||||
/// <summary>Disk space is low. Deny new recordings but allow existing to finish.</summary>
|
||||
Yellow = 1,
|
||||
|
||||
/// <summary>Disk space is critically low. Deny new recordings and pause active ones.</summary>
|
||||
Red = 2
|
||||
}
|
||||
|
||||
public interface IStorageGuardService
|
||||
{
|
||||
StorageGuardResult CheckCanStartOrResume(SystemSettingsDto settings, long additionalRequiredBytes = 0);
|
||||
|
||||
StorageGuardResult CheckShouldPause(SystemSettingsDto settings);
|
||||
|
||||
StorageGuardResult CheckCanFinalize(SystemSettingsDto settings, long estimatedTemporaryBytes);
|
||||
}
|
||||
|
||||
public interface IStorageCapacityProvider
|
||||
{
|
||||
StorageVolumeCapacity GetCapacity(string outputPath);
|
||||
}
|
||||
|
||||
public sealed record StorageVolumeCapacity(
|
||||
string VolumeRoot,
|
||||
long TotalBytes,
|
||||
long AvailableBytes);
|
||||
|
||||
public sealed record StorageGuardResult(
|
||||
bool IsEnabled,
|
||||
bool HasEnoughSpace,
|
||||
string CheckedPath,
|
||||
long AvailableBytes,
|
||||
long RequiredBytes,
|
||||
string Message);
|
||||
string Message)
|
||||
{
|
||||
public bool IsAvailable { get; init; }
|
||||
|
||||
public string VolumeRoot { get; init; } = string.Empty;
|
||||
|
||||
public long TotalBytes { get; init; }
|
||||
|
||||
public long UsedBytes { get; init; }
|
||||
|
||||
public double FreePercent { get; init; }
|
||||
|
||||
public double GreenThresholdPercent { get; init; }
|
||||
|
||||
public double RedThresholdPercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current storage tier (Green/Yellow/Red).
|
||||
/// </summary>
|
||||
public StorageTier Tier { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Disk usage percentage (0-100).
|
||||
/// </summary>
|
||||
public double UsagePercent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True if new recordings can be started. Both the percentage and absolute
|
||||
/// resume thresholds must be satisfied.
|
||||
/// </summary>
|
||||
public bool CanStartNewRecording => !IsEnabled || Tier == StorageTier.Green;
|
||||
|
||||
/// <summary>
|
||||
/// True if active recordings should be paused. Either the percentage or
|
||||
/// absolute pause threshold can put storage into the Red tier.
|
||||
/// </summary>
|
||||
public bool ShouldPauseActive => IsEnabled && Tier == StorageTier.Red;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ public sealed class LoginRequest
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
public string Password { get; set; } = string.Empty;
|
||||
|
||||
public bool RememberMe { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AuthenticatedUser
|
||||
|
||||
@@ -22,6 +22,16 @@ public sealed class MediaBrowserItemDto
|
||||
public bool CanTranscode { get; init; }
|
||||
|
||||
public bool CanPreview { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Video metadata (only populated when includeMetadata is requested and item is a video file).
|
||||
/// </summary>
|
||||
public Abstractions.Recording.VideoMetadataDto? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Thumbnail URL relative path (only populated when includeMetadata is requested and item is a video file).
|
||||
/// </summary>
|
||||
public string? ThumbnailUrl { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MediaBrowserResponseDto
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace LiveRecorder.Application.Models.RecordTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Bandwidth summary statistics.
|
||||
/// </summary>
|
||||
public sealed class BandwidthSummaryDto
|
||||
{
|
||||
public double TotalTrafficMB { get; init; }
|
||||
public double AverageBitrateKbps { get; init; }
|
||||
public double PeakBitrateKbps { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bandwidth timeline for a recording session.
|
||||
/// </summary>
|
||||
public sealed class BandwidthTimelineDto
|
||||
{
|
||||
public Guid RecordSessionId { get; init; }
|
||||
public required IReadOnlyList<BandwidthPointDto> Points { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single bandwidth sample point in time.
|
||||
/// </summary>
|
||||
public sealed class BandwidthPointDto
|
||||
{
|
||||
public DateTimeOffset Timestamp { get; init; }
|
||||
public long BytesDownloaded { get; init; }
|
||||
public double? BitrateKbps { get; init; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user