diff --git a/.gitignore b/.gitignore index 35ebb61..b729e8d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ frontend/node_modules/ frontend/dist/ frontend/dist-postgres/ +frontend/test-results/ +frontend/playwright-report/ **/.dotnet-cli-home/ .codex-temp/ .tools/ diff --git a/README.md b/README.md index 8c8bbad..578abd1 100644 --- a/README.md +++ b/README.md @@ -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 | | 保存模式 | 单文件 / 分段 | @@ -257,37 +257,35 @@ docker compose up -d ```bash ./scripts/build-postgresql-fnos-package.sh ./scripts/build-fnos-package.sh -./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk +./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk ./scripts/smoke-fnos-package.sh \ - artifacts/fnos/liverecorder-1.1.0-x86_64.fpk \ - artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk -./scripts/smoke-fnos-migration.sh \ - artifacts/fnos/liverecorder-1.1.0-x86_64.fpk \ - artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk + artifacts/fnos/liverecorder-1.2.13-x86_64.fpk \ + artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk ``` - 使用 fnOS 开发者平台提供的官方 `fnpack` 构建;可通过 `FNPACK=/path/to/fnpack` 指定工具路径 - 两个 FPK 都是 x86_64 原生应用,不依赖 Docker -- 先安装 `nxsir.postgresql`,再安装 Live Recorder;fnOS 会通过 `install_dep_apps` 检查依赖 +- 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 服务的应用接入令牌 -- 从旧 fnOS 包升级时会自动迁移原内置 PostgreSQL;只有自定义转储、SHA-256 和十张业务表行数校验全部成功后才切换 -- 迁移失败会继续使用旧数据库并在下次启动重试;旧数据库和迁移转储不会自动删除 -- Live Recorder 1.1.0 暂时仍携带旧 PG15 运行时,仅用于升级迁移和安全回退,不会在新安装上启动第二个数据库进程 +- 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)。 +更完整的安装、端口与凭据说明见 [docs/postgresql-migration.md](docs/postgresql-migration.md)。 其他 fnOS 应用接入共享数据库时,请直接参考 [docs/fnos-postgresql-client-integration.md](docs/fnos-postgresql-client-integration.md)。 ## 验证 - `dotnet build LiveRecorder.sln --no-restore -m:1` - `npm run build`(frontend 目录) +- `npm run test:e2e`(frontend 目录,覆盖 1920 / 1366 / 1024 / 768 / 390) - Docker Compose 完整部署验证通过 ## 后续规划 diff --git a/docs/postgresql-migration.md b/docs/postgresql-migration.md index a243efc..bb42459 100644 --- a/docs/postgresql-migration.md +++ b/docs/postgresql-migration.md @@ -1,13 +1,15 @@ -# PostgreSQL 共享服务与历史数据迁移 +# PostgreSQL 共享服务部署说明 ## fnOS 原生部署 -fnOS 方案由两个独立 FPK 组成: +fnOS 方案由两个独立 FPK 和一个商店运行时依赖组成: | 应用 | 默认端口 | 持久化内容 | |---|---:|---| | `nxsir.postgresql` | 管理界面 `15433`、数据库 `127.0.0.1:15432` | PostgreSQL 数据、凭据散列、审计日志 | -| `liverecorder` | Web 管理界面 `18080` | 应用日志、签发后的数据库客户端凭据、迁移回退数据 | +| `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 检查并启用两个依赖。 安装顺序: @@ -16,18 +18,9 @@ fnOS 方案由两个独立 FPK 组成: 3. Live Recorder 只通过 `127.0.0.1` 注册。共享服务为它创建独立数据库和 SCRAM 角色,随机密码只在注册响应中返回一次。 4. 注册成功后,接入令牌会从 Live Recorder 持久化目录删除;签发凭据保存在权限为 `0600` 的 `postgres-client.conf`。 -新安装不会启动 Live Recorder 包内的旧 PostgreSQL。录制路径默认是 fnOS 共享目录 `liverecorder/records`,也可以在“设置 → 录制 → 输出根目录”修改;路径模板会继续在该根目录下生成平台、主播、日期等层级,已有目录会直接复用,不会重复嵌套。 +Live Recorder 1.2.5 起只使用共享 PostgreSQL,不再打包、启动或回退到旧内置 PostgreSQL,也不再提供旧内置数据库的自动迁移能力。升级前必须确认 `postgres-client.conf` 有效,或在升级向导填写共享服务应用接入令牌。历史版本留下的 `postgres/` 和 `postgres-migration/` 目录不会被应用读取,也不会自动删除。 -### 从旧 fnOS 版本自动迁移 - -升级包检测到旧 `PG_VERSION` 且尚无迁移标记时会: - -1. 启动旧的私有 PG15,只读导出 custom-format 转储并生成 SHA-256。 -2. 清空新签发的目标 schema,以 `--no-owner --no-acl` 恢复。 -3. 精确比较十张业务表在源库和目标库中的行数。 -4. 全部成功后写入 `shared-database.active` 标记并停止旧 PostgreSQL。 - -任一步失败都会继续使用旧数据库,下次启动再重试。系统不会自动删除旧数据、转储、校验文件;确认新版本稳定并另行备份后再手工清理。迁移标记一旦存在,凭据损坏时应用会拒绝回退到已经过期的旧库,防止录制数据分叉。 +录制路径默认是 fnOS 共享目录 `liverecorder/records`,也可以在“设置 → 录制 → 输出根目录”修改;路径模板会继续在该根目录下生成平台、主播、日期等层级,已有目录会直接复用,不会重复嵌套。 ### 管理与备份 @@ -39,9 +32,8 @@ fnOS 方案由两个独立 FPK 组成: ### fnOS 验证 ```bash -./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk -./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.1.0-x86_64.fpk artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk -./scripts/smoke-fnos-migration.sh artifacts/fnos/liverecorder-1.1.0-x86_64.fpk artifacts/fnos/nxsir-postgresql-15.1.0-x86_64.fpk +./scripts/smoke-postgresql-fnos-package.sh artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk +./scripts/smoke-fnos-package.sh artifacts/fnos/liverecorder-1.2.9-x86_64.fpk artifacts/fnos/nxsir-postgresql-15.1.1-x86_64.fpk ``` 下面保留 Docker/宿主机从旧 SQLite 导入 PostgreSQL 的流程。 diff --git a/fnos-postgresql/manifest b/fnos-postgresql/manifest index 5a4d973..5dbb707 100644 --- a/fnos-postgresql/manifest +++ b/fnos-postgresql/manifest @@ -1,5 +1,5 @@ appname=nxsir.postgresql -version=15.1.0 +version=15.1.1 display_name=PostgreSQL 共享服务 desc=面向 fnOS 应用的原生 PostgreSQL 15 共享数据库服务,包含 pgvector、安全凭据签发、管理面板和手动备份恢复 platform=x86 diff --git a/fnos/cmd/main b/fnos/cmd/main index c965c0a..057b266 100755 --- a/fnos/cmd/main +++ b/fnos/cmd/main @@ -8,29 +8,22 @@ 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" -PG_BIN="$RUNTIME_ROOT/usr/lib/postgresql/15/bin" -PG_SHARE="$RUNTIME_ROOT/usr/share/postgresql/15" -PG_LIB="$RUNTIME_ROOT/usr/lib/postgresql/15/lib" -NODE_BIN="$RUNTIME_ROOT/bin/node" +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" -PG_DATA="$DATA_ROOT/postgres" RUN_ROOT="$DATA_ROOT/run" LOG_ROOT="$DATA_ROOT/log" APP_PID_FILE="$RUN_ROOT/liverecorder.pid" APP_LOG="$LOG_ROOT/liverecorder.log" -PG_LOG="$LOG_ROOT/postgresql.log" 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" -POSTGRES_MIGRATION_ROOT="$DATA_ROOT/postgres-migration" -POSTGRES_MIGRATION_MARKER="$POSTGRES_MIGRATION_ROOT/shared-database.active" -PG_PORT="${LIVE_RECORDER_POSTGRES_PORT:-54329}" 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:$SYSTEM_PATH" -RUNTIME_LIBRARY_PATH="$RUNTIME_ROOT/lib:$PG_LIB" +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="" @@ -54,10 +47,6 @@ app_pid() { return 1 } -run_pg() { - env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$PG_BIN:$RUNTIME_PATH" "$@" -} - run_native() { env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$RUNTIME_PATH" \ SSL_CERT_FILE="$CA_BUNDLE" CURL_CA_BUNDLE="$CA_BUNDLE" "$@" @@ -144,94 +133,11 @@ ensure_shared_credentials() { enroll_shared_database } -run_shared_pg() { - env LD_LIBRARY_PATH="$RUNTIME_LIBRARY_PATH" PATH="$PG_BIN:$RUNTIME_PATH" \ - PGPASSWORD="$SHARED_DB_PASSWORD" "$@" -} - -collect_database_counts() { - mode=$1 - destination=$2 - : >"$destination" - for table_name in AppSettings CleanupOperations LiveRooms RecordResults RecordSessions RecordTasks RecordUploadJobs SystemLogEntries UserAccounts UserSessions; do - if [ "$mode" = "private" ]; then - count=$(run_pg "$PG_BIN/psql" -h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder -d live_recorder -Atqc \ - "SELECT count(*) FROM \"$table_name\"" 2>/dev/null) || return 1 - else - count=$(run_shared_pg "$PG_BIN/psql" -h "$SHARED_DB_HOST" -p "$SHARED_DB_PORT" -U "$SHARED_DB_USER" -d "$SHARED_DB_NAME" -Atqc \ - "SELECT count(*) FROM \"$table_name\"" 2>/dev/null) || return 1 - fi - printf '%s=%s\n' "$table_name" "$count" >>"$destination" - done -} - -migrate_private_postgres() { - mkdir -p "$POSTGRES_MIGRATION_ROOT" - chmod 0700 "$POSTGRES_MIGRATION_ROOT" - dump_file="$POSTGRES_MIGRATION_ROOT/private-postgres-15.dump" - source_counts="$POSTGRES_MIGRATION_ROOT/source-counts.txt" - target_counts="$POSTGRES_MIGRATION_ROOT/target-counts.txt" - start_postgres || return 1 - - log_message "开始导出原内置 PostgreSQL 数据库。" - run_pg "$PG_BIN/pg_dump" \ - -h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder -d live_recorder \ - --format=custom --no-owner --no-acl --file "$dump_file.tmp" >>"$PG_LOG" 2>&1 || return 1 - mv "$dump_file.tmp" "$dump_file" - sha256sum "$dump_file" >"$dump_file.sha256" - collect_database_counts private "$source_counts" || return 1 - - log_message "开始恢复数据到 PostgreSQL 共享服务。" - run_shared_pg "$PG_BIN/psql" \ - -h "$SHARED_DB_HOST" -p "$SHARED_DB_PORT" -U "$SHARED_DB_USER" -d "$SHARED_DB_NAME" \ - -v ON_ERROR_STOP=1 -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public AUTHORIZATION \"$SHARED_DB_USER\"" >>"$PG_LOG" 2>&1 || return 1 - run_shared_pg "$PG_BIN/pg_restore" \ - -h "$SHARED_DB_HOST" -p "$SHARED_DB_PORT" -U "$SHARED_DB_USER" -d "$SHARED_DB_NAME" \ - --no-owner --no-acl --exit-on-error "$dump_file" >>"$PG_LOG" 2>&1 || return 1 - collect_database_counts shared "$target_counts" || return 1 - if ! cmp -s "$source_counts" "$target_counts"; then - log_message "共享数据库行数校验失败,继续使用原数据库。" - return 1 - fi - - umask 077 - { - printf 'migrated_at=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" - printf 'database=%s\n' "$SHARED_DB_NAME" - printf 'dump=%s\n' "$dump_file" - } >"$POSTGRES_MIGRATION_MARKER" - stop_postgres || return 1 - log_message "内置 PostgreSQL 已迁移到共享服务,旧数据和迁移转储已保留。" -} - select_database_connection() { - if ! ensure_shared_credentials; then - if [ -f "$POSTGRES_MIGRATION_MARKER" ]; then - log_message "已完成共享数据库迁移,但当前凭据不可用;为避免使用过期旧库,应用不会启动。" - return 1 - fi - if [ -f "$PG_DATA/PG_VERSION" ]; then - log_message "共享服务暂不可用,本次启动继续使用原内置 PostgreSQL。" - start_postgres || return 1 - DATABASE_CONNECTION_STRING="Host=$RUN_ROOT;Port=$PG_PORT;Database=live_recorder;Username=liverecorder;Timeout=15;Command Timeout=120;Keepalive=30" - return 0 - fi + ensure_shared_credentials || { + log_message "无法连接 PostgreSQL 共享服务或取得数据库凭据,应用不会启动。" return 1 - fi - - if [ -f "$PG_DATA/PG_VERSION" ] && [ ! -f "$POSTGRES_MIGRATION_MARKER" ]; then - if ! migrate_private_postgres; then - log_message "共享数据库迁移失败,本次启动继续使用原内置 PostgreSQL;下次启动会重试迁移。" - start_postgres || return 1 - DATABASE_CONNECTION_STRING="Host=$RUN_ROOT;Port=$PG_PORT;Database=live_recorder;Username=liverecorder;Timeout=15;Command Timeout=120;Keepalive=30" - return 0 - fi - elif [ ! -f "$PG_DATA/PG_VERSION" ] && [ ! -f "$POSTGRES_MIGRATION_MARKER" ]; then - mkdir -p "$POSTGRES_MIGRATION_ROOT" - umask 077 - printf 'fresh_install_at=%s\ndatabase=%s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$SHARED_DB_NAME" >"$POSTGRES_MIGRATION_MARKER" - fi - return 0 + } } system_media_tools_available() { @@ -248,78 +154,6 @@ system_media_tools_available() { return 0 } -postgres_running() { - [ -x "$PG_BIN/pg_ctl" ] || return 1 - [ -f "$PG_DATA/PG_VERSION" ] || return 1 - run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" status >/dev/null 2>&1 -} - -initialize_postgres() { - if [ -f "$PG_DATA/PG_VERSION" ]; then - return 0 - fi - - mkdir -p "$PG_DATA" "$RUN_ROOT" - chmod 0700 "$PG_DATA" "$RUN_ROOT" - if ! run_pg "$PG_BIN/initdb" \ - -D "$PG_DATA" \ - -L "$PG_SHARE" \ - --username=liverecorder \ - --auth-local=trust \ - --auth-host=reject \ - --encoding=UTF8 \ - --no-locale >>"$PG_LOG" 2>&1; then - log_message "PostgreSQL 初始化失败。" - return 1 - fi - - { - printf "listen_addresses = ''\n" - printf "port = %s\n" "$PG_PORT" - printf "unix_socket_directories = '%s'\n" "$RUN_ROOT" - printf "max_connections = 40\n" - printf "shared_buffers = '64MB'\n" - printf "timezone = 'UTC'\n" - printf "log_timezone = 'UTC'\n" - } >>"$PG_DATA/postgresql.conf" - return 0 -} - -start_postgres() { - if postgres_running; then - return 0 - fi - initialize_postgres || return 1 - if ! run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -l "$PG_LOG" -w start; then - log_message "PostgreSQL 启动失败。" - return 1 - fi - - database_exists=$(run_pg "$PG_BIN/psql" \ - -h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder -d postgres -Atqc \ - "SELECT 1 FROM pg_database WHERE datname = 'live_recorder'" 2>/dev/null || true) - if [ "$database_exists" != "1" ]; then - if ! run_pg "$PG_BIN/createdb" \ - -h "$RUN_ROOT" -p "$PG_PORT" -U liverecorder live_recorder >>"$PG_LOG" 2>&1; then - log_message "Live Recorder 数据库创建失败。" - return 1 - fi - fi - return 0 -} - -stop_postgres() { - if postgres_running; then - # A first-run migration can dirty enough pages that a NAS needs more - # than pg_ctl's 60-second default to finish the shutdown checkpoint. - # Do not let restart race a database that is still shutting down. - if ! run_pg "$PG_BIN/pg_ctl" -D "$PG_DATA" -m fast -t 180 -w stop >>"$PG_LOG" 2>&1; then - log_message "PostgreSQL 未能在 180 秒内安全停止,已取消后续重启。" - return 1 - fi - fi -} - launch_app_process() { ( export ASPNETCORE_ENVIRONMENT=Production @@ -354,7 +188,7 @@ start_app() { log_message "应用程序不存在或不可执行:$SERVER" return 1 fi - if [ ! -x "$PG_BIN/postgres" ] || [ ! -x "$NODE_BIN" ] || [ ! -x "$CURL_BIN" ] || [ ! -s "$CA_BUNDLE" ]; then + if [ ! -x "$NODE_BIN" ] || [ ! -x "$CURL_BIN" ] || [ ! -s "$CA_BUNDLE" ]; then log_message "FPK 原生运行环境不完整。" return 1 fi @@ -400,7 +234,7 @@ stop_app() { if pid=$(app_pid); then kill "$pid" 2>/dev/null || true attempt=0 - while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 30 ]; do + while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 50 ]; do sleep 1 attempt=$((attempt + 1)) done @@ -409,7 +243,6 @@ stop_app() { fi fi rm -f "$APP_PID_FILE" - stop_postgres } case "${1:-status}" in diff --git a/fnos/cmd/uninstall_callback b/fnos/cmd/uninstall_callback index 13b74bb..8d38d4e 100755 --- a/fnos/cmd/uninstall_callback +++ b/fnos/cmd/uninstall_callback @@ -1,3 +1,3 @@ #!/bin/bash -# Persistent PostgreSQL data and recordings are preserved by default. +# Persistent application data and recordings are preserved by default. exit 0 diff --git a/fnos/manifest b/fnos/manifest index e6f8bf8..accc667 100644 --- a/fnos/manifest +++ b/fnos/manifest @@ -1,5 +1,5 @@ appname=liverecorder -version=1.1.0 +version=1.2.13 display_name=Live Recorder desc=原生直播录制系统,使用独立 PostgreSQL 共享服务,支持分片录制、弹幕采集与 OpenList 自动上传 platform=x86 @@ -10,4 +10,4 @@ desktop_uidir=ui desktop_applaunchname=liverecorder.Application checkport=true ctl_stop=true -install_dep_apps=nxsir.postgresql +install_dep_apps=nxsir.postgresql:nodejs_v22 diff --git a/fnos/wizard/upgrade b/fnos/wizard/upgrade index 56e95b7..fc44883 100644 --- a/fnos/wizard/upgrade +++ b/fnos/wizard/upgrade @@ -4,13 +4,13 @@ "items": [ { "type": "tips", - "helpText": "本次升级会自动把原内置 PostgreSQL 数据迁移到共享服务。迁移成功前仍可回退到原数据库,旧数据不会自动删除。" + "helpText": "本版本仅使用独立 PostgreSQL 共享服务,不再包含或启动内置数据库。已有共享数据库凭据可继续使用。" }, { "type": "password", "field": "wizard_postgres_enrollment_token", "label": "PostgreSQL 应用接入令牌", - "helpText": "首次迁移需要填写共享服务的应用接入令牌;已经完成迁移的后续升级可以留空。", + "helpText": "尚未取得共享数据库凭据时需要填写应用接入令牌;已有有效凭据的后续升级可以留空。", "rules": [ { "max": 256, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9195244..8cf1fa5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 5ca071e..384a2a8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vue-tsc --noEmit && vite build", "build:postgres-admin": "vite build --config vite.postgres.config.ts", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test" }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", @@ -18,6 +19,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", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..1b1c24c --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from "@playwright/test"; + +const viewports = [ + { name: "desktop-1920", width: 1920, height: 1080 }, + { 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: "VITE_DISABLE_DEVTOOLS=1 npm run dev -- --host 127.0.0.1 --port 47173 --strictPort", + url: "http://127.0.0.1:47173", + reuseExistingServer: false, + timeout: 120_000 + }, + projects: viewports.map(({ name, width, height }) => ({ + name, + use: { viewport: { width, height } } + })) +}); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9f116f0..c8e890f 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -154,7 +154,6 @@ function notifyBackendUnavailable(message: string) { } apiClient.interceptors.request.use((config) => { - console.log("[API DEBUG]", config.method?.toUpperCase(), config.baseURL || "", config.url || "", "→", (config.baseURL || "") + (config.url || "")); const token = localStorage.getItem("live-recorder-token"); if (token) { config.headers.Authorization = `Bearer ${token}`; diff --git a/frontend/src/components/layout/MainLayout.vue b/frontend/src/components/layout/MainLayout.vue index bfe921a..fb8210d 100644 --- a/frontend/src/components/layout/MainLayout.vue +++ b/frontend/src/components/layout/MainLayout.vue @@ -229,7 +229,7 @@ watch(() => route.fullPath, () => { mobileNavVisible.value = false; }); diff --git a/frontend/src/components/ui/SafeAvatar.vue b/frontend/src/components/ui/SafeAvatar.vue new file mode 100644 index 0000000..066818f --- /dev/null +++ b/frontend/src/components/ui/SafeAvatar.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/src/components/ui/StorageCapacity.vue b/frontend/src/components/ui/StorageCapacity.vue new file mode 100644 index 0000000..818a343 --- /dev/null +++ b/frontend/src/components/ui/StorageCapacity.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 5a80151..16dbc76 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -172,12 +172,12 @@ html[data-theme="dark"] #app { /* ============================ Base ============================ */ * { box-sizing: border-box; } -html, body, #app { margin: 0; min-height: 100%; } +html, body, #app { margin: 0; width: 100%; height: 100%; min-height: 100%; } body { color: var(--text-primary); background: var(--bg-base); text-rendering: optimizeLegibility; - overflow-x: hidden; + overflow: hidden; } a { color: inherit; text-decoration: none; } button { font-family: inherit; cursor: pointer; } @@ -190,7 +190,7 @@ button { font-family: inherit; cursor: pointer; } ::selection { background: var(--accent-soft); } /* ============================ Page layout ============================ */ -.page-stack { display: grid; gap: var(--page-gap); width: min(100%, var(--page-max)); margin: 0 auto; } +.page-stack { display: grid; gap: var(--page-gap); width: min(100%, var(--page-max)); margin: 0 auto; padding-bottom: 24px; } .page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; } .page-header > div:first-child { flex: 1; min-width: 0; } .page-title { margin: 0; font-size: 24px; font-weight: 750; letter-spacing: -.02em; line-height: 1.25; color: var(--text-primary); } @@ -204,7 +204,7 @@ button { font-family: inherit; cursor: pointer; } border-radius: var(--radius-md); border: 1px solid var(--border-subtle); background: var(--surface); box-shadow: var(--shadow-xs); } -.surface-card:hover { box-shadow: var(--shadow-sm); } +.surface-card:hover { border-color: var(--border-base); } .surface-card .el-card__body { padding: 18px; } /* stat grid / KPI cards */ @@ -224,9 +224,17 @@ button { font-family: inherit; cursor: pointer; } .section-subtitle { margin: 0; color: var(--text-muted); font-size: 12.5px; line-height: 1.7; } .section-header, .toolbar-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 18px; border-bottom: 1px solid var(--border-subtle); } .toolbar-row { margin-bottom: 18px; } +.list-filterbar { + display: grid; grid-template-columns: minmax(240px, 1fr) 180px auto; align-items: center; + gap: 10px; margin: 0 0 18px; +} +.list-filterbar__count { color: var(--text-muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; } /* table */ -.table-scroll-shell { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; } +.table-scroll-shell { + width: 100%; min-width: 0; overflow-x: auto; overflow-y: visible; + padding-bottom: 2px; scrollbar-gutter: stable; -webkit-overflow-scrolling: touch; +} .cell-title { color: var(--text-primary); font-size: 15px; font-weight: 700; line-height: 1.45; } .cell-subtitle { margin-top: 2px; color: var(--text-muted); font-size: 12px; line-height: 1.6; font-weight: 500; } .cell-mono, .table-date-text, .monospace { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } @@ -326,21 +334,29 @@ html[data-theme="dark"] .el-table th.el-table__cell { background: #16223a; } .el-table tr { background: transparent; } .el-table tbody tr:nth-child(even) { background: rgba(15, 23, 42, 0.018); } html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.02); } -.premium-table { min-width: 920px; } +.premium-table { width: 100%; } /* descriptions */ .el-descriptions { --el-descriptions-table-border: 1px solid var(--border-subtle); } .el-descriptions__body .el-descriptions__table { border-radius: var(--radius-sm); overflow: hidden; } .el-descriptions__body .el-descriptions__label.el-descriptions__cell.is-bordered-label { background: var(--surface-muted); color: var(--text-muted); font-weight: 600; } .el-descriptions__body .el-descriptions__content.el-descriptions__cell.is-bordered-content { background: var(--surface); color: var(--text-primary); } +.el-descriptions__content { min-width: 0; overflow-wrap: anywhere; word-break: break-word; } /* dialogs */ -.el-dialog { border-radius: var(--radius-md); border: 1px solid var(--border-base); background: var(--surface); box-shadow: var(--shadow-float); } +.el-overlay-dialog { display: grid; place-items: center; padding: 16px; overflow: hidden; } +.el-dialog { + display: flex; flex-direction: column; max-width: calc(100vw - 32px); + max-height: calc(100dvh - 32px); margin: 0 !important; + border-radius: var(--radius-md); border: 1px solid var(--border-base); + background: var(--surface); box-shadow: var(--shadow-float); +} .el-message-box, .el-popover.el-popper, .el-select__popper.el-popper, .el-picker__popper.el-popper { border-color: var(--border-subtle); background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow-float); } .el-dropdown__popper.el-popper .el-dropdown-menu { border-color: var(--border-subtle); background: var(--surface); box-shadow: var(--shadow-md); border-radius: var(--radius-sm); } +.el-dropdown-menu__item.danger-menu-item { color: var(--danger); } .el-dialog__header { margin: 0; padding: 20px 24px 10px; } .el-dialog__title { color: var(--text-primary); font-size: 18px; font-weight: 700; letter-spacing: -.03em; } -.el-dialog__body { padding: 12px 24px 6px; } +.el-dialog__body { min-height: 0; padding: 12px 24px 6px; overflow: auto; overscroll-behavior: contain; } .el-dialog__footer { padding: 12px 24px 20px; } .el-empty__description, .el-empty__description p, .el-result__subtitle { color: var(--text-secondary); } @@ -379,11 +395,13 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25 .stat-card__value { font-size: 24px; } .desktop-only { display: none !important; } .mobile-only { display: block !important; } - .el-dialog { width: min(100vw - 16px, 560px) !important; margin: 3vh auto 0 !important; } + .el-overlay-dialog { padding: 8px; } + .el-dialog { width: min(100vw - 16px, 560px) !important; max-height: calc(100dvh - 16px); margin: 0 !important; } .el-dialog__header { padding: 16px 16px 8px; } .el-dialog__body { padding: 10px 16px 4px; } .el-dialog__footer { padding: 10px 16px 16px; } .el-table th.el-table__cell { padding: 10px 12px; font-size: 11px; } .el-table td.el-table__cell { padding: 10px 12px; font-size: 12.5px; line-height: 1.45; } + .list-filterbar { grid-template-columns: 1fr; } + .list-filterbar__count { justify-self: start; } } -@media (max-width: 400px) { .stats-grid { grid-template-columns: 1fr; } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index bb624ba..8c807a1 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -191,6 +191,17 @@ export interface RecordSession { 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; @@ -301,6 +312,7 @@ export interface UploadTaskListResponse { items: UploadTaskItem[]; totalCount: number; notUploadedCount: number; + failedArtifactCount: number; succeededCount: number; failedCount: number; queuedCount: number; @@ -472,6 +484,7 @@ export interface SystemSettings { enableRetentionCleanup: boolean; retentionDays: number; retentionDeleteFiles: boolean; + retentionRequireUploadSuccess: boolean; retentionVideoFileCondition: CleanupVideoFileCondition; retentionTaskStatuses: number[]; enableAutoReconnect: boolean; @@ -659,10 +672,18 @@ export interface RecoveryOverview { export interface StorageGuardStatus { isEnabled: boolean; + isAvailable: boolean; hasEnoughSpace: boolean; checkedPath: string; + totalBytes: number; + usedBytes: number; availableBytes: number; requiredBytes: number; + usagePercent: number; + freePercent: number; + greenThresholdPercent: number; + redThresholdPercent: number; + tier: string; message: string; } @@ -745,7 +766,7 @@ export const availabilityLabelMap: Record = { export const currentRecordingStateLabelMap: Record = { 0: "未开播", - 1: "开播中", + 1: "已开播(未录制)", 2: "录制中" }; @@ -826,6 +847,22 @@ export const autoStartDecisionLabelMap: Record = { poll_failed: "轮询失败" }; +export const autoStartDecisionSummaryMap: Record = { + 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 = { 0: "未知", 1: "Douyin", @@ -899,20 +936,34 @@ export interface DashboardData { 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; + totalBytes: number; + usedBytes: number; availableBytes: number; + requiredBytes: number; tier: string; usagePercent: number; + freePercent: number; + greenThresholdPercent: number; + redThresholdPercent: number; } export interface DashboardRecentSession { diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index 00aaf92..66c62b9 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -1,10 +1,13 @@ diff --git a/frontend/src/views/LiveRoomsView.vue b/frontend/src/views/LiveRoomsView.vue index 8e0afbd..9bcd963 100644 --- a/frontend/src/views/LiveRoomsView.vue +++ b/frontend/src/views/LiveRoomsView.vue @@ -1,15 +1,17 @@ @@ -857,36 +726,13 @@ onBeforeUnmount(() => { - -
-
-
监控概览
-

直播采集态势

-
- -
- -

- 当前共接入 {{ totalRooms }} 个直播间,其中 {{ enabledRooms }} 个处于自动巡检范围,{{ disabledRooms }} 个处于保留但停用状态。 -

- -
- 自动开录复用现有后端策略 - 单房间配置优先于全局设置 - 无实时吞吐字段时统一显示占位 -
-

直播间列表

-

继续使用真实接口数据,统一强化为监控控制台视图,支持查看详情、手动录制和单房间配置覆盖。

+

搜索主播或房间,并按运行状态快速筛选。

已选 {{ selectedRoomCount }} 个 @@ -898,20 +744,32 @@ onBeforeUnmount(() => {
+
+ + + + + + + + + {{ filteredRooms.length }} / {{ rooms.length }} +
+
-
+
- + {{ getRoomAvatarText(row) }} - +
{{ row.title || row.anchorName || row.roomId }}
@@ -987,32 +845,36 @@ onBeforeUnmount(() => {
- 查看详情 - 刷新 - 配置 - 复制链接 - 打开原房间 - - 录制 - - 删除 + 查看详情 + + 更多 + +
+ +
-
-
-
-
- +
{ - - - - - + @@ -1154,9 +1009,9 @@ onBeforeUnmount(() => { >
- + {{ getRoomAvatarText(activeRoom) }} - +
{{ activeRoom.anchorName || "未知主播" }}
{{ activeRoom.originalLiveRoomUrl || activeRoom.sourceUrl || "--" }}
@@ -1674,28 +1529,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 +1569,11 @@ onBeforeUnmount(() => { gap: 16px; } +.mobile-room-pagination { + justify-content: center; + padding-top: 4px; +} + .room-card__toggle { display: flex; align-items: center; @@ -2040,6 +1890,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,6 +1916,11 @@ onBeforeUnmount(() => { } } +@media (max-width: 600px) { + .list-filterbar { grid-template-columns: 1fr; } + .list-filterbar__count { grid-column: auto; } +} + @media (max-width: 640px) { .header-actions :deep(.el-space__item) { width: 100%; diff --git a/frontend/src/views/LogsView.vue b/frontend/src/views/LogsView.vue index 95bc5c7..484bbc3 100644 --- a/frontend/src/views/LogsView.vue +++ b/frontend/src/views/LogsView.vue @@ -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([]); 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("/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(() => {
- 刷新日志 + 刷新日志
@@ -187,7 +203,7 @@ onBeforeUnmount(() => { - 应用筛选 + 应用筛选 @@ -230,8 +246,7 @@ onBeforeUnmount(() => {
diff --git a/frontend/src/views/MediaBrowserView.vue b/frontend/src/views/MediaBrowserView.vue index c320a89..0bb7558 100644 --- a/frontend/src/views/MediaBrowserView.vue +++ b/frontend/src/views/MediaBrowserView.vue @@ -14,15 +14,36 @@ const browser = ref(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("/media/browser", { @@ -181,18 +202,30 @@ onMounted(() => {
+
+ + + + + + + + + {{ filteredItems.length }} / {{ browser?.items.length ?? 0 }} +
+ -
- +
+ - + diff --git a/frontend/src/views/RecordSessionDetailView.vue b/frontend/src/views/RecordSessionDetailView.vue index 4be8f42..63ad6f9 100644 --- a/frontend/src/views/RecordSessionDetailView.vue +++ b/frontend/src/views/RecordSessionDetailView.vue @@ -2,7 +2,6 @@ import { computed, onMounted, ref, watch } from "vue"; import { useRouter } from "vue-router"; import { ElMessage } from "element-plus"; -import { useViewport } from "@/composables/useViewport"; import apiClient, { getApiErrorMessage, buildApiUrl } from "@/api/client"; import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer"; import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue"; @@ -29,7 +28,6 @@ const props = defineProps<{ }>(); const router = useRouter(); -const { isMobile } = useViewport(); // Danmaku replay dialog const { danmakuEvents: replayDanmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer(); @@ -77,7 +75,6 @@ const uploadLoading = ref(false); const loadError = ref(""); const detail = ref(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; @@ -283,7 +280,7 @@ onMounted(loadDetail);

- + 返回列表 刷新 上传会话 @@ -547,7 +544,7 @@ onMounted(loadDetail); {{ formatDuration(row.durationSeconds) }} - +