Author SHA1 Message Date
nanxun 746979bdd5 fix: restore multi-arch api builds 2026-09-19 13:21:42 +08:00
nanxun 8960b68ac8 fix: harden auto uploads and table layouts 2026-09-19 10:25:10 +08:00
nanxun f0fb4db755 ci: bypass proxy for daocloud image layers 2026-09-16 23:40:56 +08:00
nanxun c056a3e741 fix: prevent database circuit failure storm 2026-09-02 20:41:51 +08:00
nanxun 60d1930447 test: make storage guard capacity deterministic 2026-08-18 00:52:42 +08:00
nanxun 4eb2c0724e fix: remove short fragment recovery scan 2026-08-18 00:30:01 +08:00
nanxun 90a67bdf72 fix: harden recording recovery and dashboard metrics 2026-08-14 19:20:24 +08:00
nanxun 506dca898e ci: make release artifacts selectable 2026-08-14 13:31:15 +08:00
nanxun 51e4c41ee2 fix: recover merged uploads and rotate logs 2026-08-14 13:09:49 +08:00
nanxun 7a33b2c6e3 feat: accelerate ffmpeg encoding with intel gpu 2026-08-14 01:46:08 +08:00
nanxun 83b5ea042d fix: keep buildx driver options scalar 2026-08-14 00:49:45 +08:00
nanxun d38c9490ba ci: route remaining package downloads to mirrors 2026-08-13 23:37:21 +08:00
nanxun 96ed3396d9 ci: use domestic mirrors and persistent caches 2026-08-13 23:15:48 +08:00
nanxun fdab9978bf fix: refresh uploads and remember login 2026-08-13 22:25:00 +08:00
nanxun e0bd001541 fix: restore native FLV reconnect with curl fallback 2026-08-12 12:14:43 +08:00
nanxun e6068d93e0 ci: harden registry proxy credentials 2026-08-12 00:01:50 +08:00
nanxun 534f8030c1 fix: keep BuildKit no-proxy driver option scalar 2026-08-11 23:32:44 +08:00
nanxun dc9ab046c5 ci: route BuildKit registry traffic through proxy 2026-08-11 23:04:52 +08:00
nanxun 0eb47e9918 ci: increment fnOS package version per build 2026-08-11 18:03:57 +08:00
nanxun 5e2e0530f1 ci: recover sudo-owned build metadata 2026-08-11 14:26:12 +08:00
nanxun 40acd35ad7 ci: reduce Docker build disk usage 2026-08-11 14:22:44 +08:00
nanxun df75ab272f ci: use ASCII workspace for Buildx sessions 2026-08-11 14:07:42 +08:00
nanxun 713e0111a6 ci: isolate sudo authentication from Buildx stdin 2026-08-11 14:01:18 +08:00
nanxun c3abb1f792 ci: scope Docker privilege to Jenkins credential 2026-08-11 13:54:24 +08:00
nanxun fd552ce2cc ci: expose local build tools to Jenkins agent 2026-08-11 13:43:25 +08:00
nanxun f2e55e46c4 ci: add Jenkins multi-architecture release pipeline 2026-08-11 13:34:58 +08:00
nanxun 19d358b312 feat: improve recording recovery and upload workflow 2026-08-10 11:14:34 +08:00
nanxun e0d3969e46 refactor: move PostgreSQL shared service to dedicated repository 2026-08-03 23:59:25 +08:00
nanxun ecc737f0bd feat: harden recording lifecycle and refresh fnOS UI 2026-08-03 23:45:26 +08:00
nanxun e5b50ea85c feat: add shared PostgreSQL fnOS service and refresh UI 2026-08-02 20:14:39 +08:00
nanxun de9f5ae110 fix: rebuild native fnOS package with fnpack 2026-08-02 17:18:02 +08:00
nanxun e8772a39b5 feat: add native fnOS package 2026-08-02 14:43:37 +08:00
nanxun e62dfd2de1 fix: create OpenList target directories before verification 2026-08-01 23:27:02 +08:00
nanxun 9091c1abc7 feat: add reliable OpenList segment uploads 2026-08-01 19:27:56 +08:00
nanxun 12ba62e2a0 fix: skip reconnect options when using curl pipe input
Reconnect options (-reconnect, -reconnect_streamed, etc.) only apply to
network inputs and cause 'Option reconnect not found' errors when the
input is pipe:0. Curl already handles connection resilience.
2026-07-09 12:30:11 +08:00
nanxun ad86c080e3 fix: use curl pipe to bypass FFmpeg HTTP header buffer limit
The previous -max_alloc approach does not fix the 'overlong headers' error
because FFmpeg 5.1 (Debian bookworm) uses a compile-time stack-allocated
buffer (MAX_URL_SIZE=4096) for HTTP response headers, which -max_alloc
cannot change.

Instead, when the stream URL is HTTP/HTTPS, launch curl to handle the
HTTP connection and pipe its stdout to FFmpeg via stdin (pipe:0). Curl
does not have the 4096-byte header limit, so it handles oversized CDN
response headers from Douyin without error.

Additional changes:
- RequestStopAsync kills curl first (instead of sending 'q' to FFmpeg),
  causing the pipe to close and FFmpeg to exit gracefully on EOF.
- SessionProcessRuntime tracks the curl process for cleanup.
2026-07-09 12:19:34 +08:00
nanxun 8a079b4698 fix: add max_alloc to prevent overlong headers error and throttle startup failure notifications
- Add -max_alloc 100000000 to FFmpeg arguments for HTTP inputs to avoid
  'overlong headers' error when CDN (e.g. Douyin) returns oversized HTTP
  response headers exceeding FFmpeg's default 4096-byte buffer.

- Add exponential backoff for repeated startup failures (30s → 15min cap)
  to break the tight fail→retry→re-poll loop that floods notifications.

- Throttle startup failure notifications to at most one per 30 minutes per
  room to prevent email/webhook storms during persistent failures.

- Reset backoff counter when a session successfully opens its first segment.
2026-07-09 11:51:52 +08:00
nanxun 48da49ac72 fix: invert reversed storage guard condition in MP4 finalization
The ternary in GetLowStoragePauseMessageAsync had its branches swapped:
when storage was healthy it returned the pause error, and when storage
was critically low it returned null (allowing finalization to proceed).
This caused MP4 finalization to always pause with a misleading 'storage
is Red' warning even when storage protection was disabled.
2026-07-05 18:10:49 +08:00
nanxunandClaude Opus 4.8 cdea5e8732 fix: switch docker-compose from local build to pre-built Harbor images
API and nginx services now pull from reg.nxsir.cn/live_recorder
instead of building locally. The 'build' sections referenced the old
Dockerfiles with WSL-specific binfmt/proxy workarounds; the CI pipeline
in Jenkinsfile now handles multi-arch builds and pushes.

Uses patchable REGISTRY_URL env var (defaults to reg.nxsir.cn).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:11:52 +08:00
nanxunandClaude Opus 4.8 5d57dff482 fix: declare proxy ARGs in frontend Dockerfile for npm ci
The frontend Dockerfile was missing HTTP_PROXY/HTTPS_PROXY ARG declarations
in the build stage, so npm ci could not reach the npm registry through the
builder's proxy. Same fix pattern as the API Dockerfile.

This explains why the Web image has never been successfully built — the
pipeline always skips 'Build Web' after 'Build API' fails, but once API
build succeeds, Web build would hit the same npm registry issue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:04:53 +08:00
nanxunandClaude Opus 4.8 68ab2c773a fix: merge restore+publish+RID into one RUN so shell var persists
The case statement that sets RID based on TARGETARCH runs in a /bin/sh
subshell. When it was in a separate RUN from dotnet restore/publish,
the RID variable was not available to the publish command.

Merge the case dispatch, dotnet restore, and dotnet publish into a
single RUN so the RID shell variable stays in scope. Also restores
the COPY . . directive that was accidentally dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 17:22:48 +08:00
nanxunandClaude Opus 4.8 05efe5e125 fix: correct case statement syntax in TARGETARCH -> RID mapping
The previous inline case statement was missing proper variable assignment.
Use 'case' to set DOTNET_RID directly instead of trying to capture output
of a command substitution that contained a multi-branch case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 17:19:53 +08:00
nanxunandClaude Opus 4.8 7a12b929dd fix: use docker TARGETARCH for runtime-specific dotnet publish
Removing RuntimeIdentifier from the csproj caused dotnet publish to produce
ALL platform runtimes (20+) in the output, making runtime loading ambiguous
and causing FileLoadException at startup.

Instead of hardcoding the RID in the csproj, use Docker's built-in TARGETARCH
ARG (injected automatically by buildx for multi-platform builds):
  amd64 -> -r linux-x64
  arm64 -> -r linux-arm64

This ensures native deps (SQLitePCLRaw, EF Core) resolve to exactly one
architecture per image, while keeping the csproj clean for local dev.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 17:17:38 +08:00
nanxunandClaude Opus 4.8 cee591b729 ci: use default docker builder instead of creating new one
docker driver only supports a single instance. Creating another fails with
'additional instances of driver docker cannot be created'. Just use the
default builder that ships with the docker daemon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:14:36 +08:00
nanxunandClaude Opus 4.8 4312f23f85 ci: switch buildx back to docker driver to avoid pulling moby/buildkit from Docker Hub
The docker-container driver requires moby/buildkit:buildx-stable-1 from
Docker Hub, which is unreachable from the build network (EOF / timeout).
The docker driver runs buildkit inside the host daemon without needing
a separate container image.

Also explicitly rm + recreate the builder each run so stale
docker-container instances don't linger as the default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:13:01 +08:00
nanxunandClaude Opus 4.8 83f16757e7 ci: add --no-cache to bypass stale build cache causing FileLoadException
Removing <RuntimeIdentifier> from the csproj changed the publish layout,
but the old registry build cache still contains the RID-poisoned layers.
Subsequent builds cached at the COPY/RUN layer boundary reuse those stale
layers, causing FileLoadException at runtime.

Add --no-cache to both API and Web buildx invocations so every build
produces fresh layers. Once the pipeline stabilizes, we can re-enable
cache-from with the updated cache tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 16:08:41 +08:00
nanxunandClaude Opus 4.8 bcba0d752b fix: add --amend to docker manifest create for :latest tags, remove debug
:latest manifests already exist from the first successful run (build 123),
so subsequent docker manifest create without --amend fails with
'refusing to amend an existing manifest list'. Add --amend so every
pipeline run can update the :latest tag.

Also remove DEBUG config.json prints now that the auth path is verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 14:55:47 +08:00
nanxunandClaude Opus 4.8 1c700f67bc fix: remove hardcoded RuntimeIdentifier from csproj causing FileLoadException
The csproj had <RuntimeIdentifier>linux-arm64</RuntimeIdentifier> baked in,
which forced dotnet publish to produce linux-arm64 native output on EVERY
platform (including amd64). On amd64 containers the runtime could not load
the entry assembly, causing FileLoadException crash loops.

RID is unnecessary for framework-dependent builds (--p:UseAppHost=false
is already in the Dockerfile). Removing it lets each platform produce
its natural runtime output — the base dotnet/aspnet image already has
the correct architecture's runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 14:29:30 +08:00
nanxunandClaude Opus 4.8 af7a3d1276 fix: declare proxy ARGs in build stage so dotnet restore reaches nuget.org
nuget.org is only reachable through the 7890 proxy from the build network
(direct hits return a 302 redirect loop; proxied requests return 200,
verified 8/8). The pipeline passes --build-arg HTTP_PROXY/HTTPS_PROXY, but
Dockerfile did not declare these ARGs in the build stage, so buildkit did
not inject them into the RUN environment and 'dotnet restore' tried nuget.org
directly -> NU1301 'Unable to load the service index'.

Declare HTTP_PROXY/HTTPS_PROXY/NO_PROXY (upper+lower case) as build-stage
ARGs and promote them to ENV so dotnet's HttpClient uses the proxy. Also add
two restore retries (--disable-parallel) to smooth over transient proxy
blips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:39:15 +08:00
nanxunandClaude Opus 4.8 4c6e730a08 fix: correct Harbor project path liverecorder -> live_recorder
The Harbor project is named 'live_recorder' (with underscore), matching
the robot account robot$live_recorder+live. The pipeline was pushing to
'liverecorder' (no underscore) — a different/nonexistent project path —
so the robot's push permission did not apply and every push got 401
despite 'Login Succeeded'.

This was THE root cause of the persistent 401s, not buildx auth
forwarding or token expiry (those were all red herrings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:24:36 +08:00
nanxunandClaude Opus 4.8 e649fb7139 ci: bypass docker login, write base64 auth directly to config.json
Every variant of docker login (--password-stdin, -p) has shown
'Login Succeeded' but docker push consistently gets 401.
Write the base64-encoded user:pass directly into
/root/.docker/config.json, eliminating docker login as a
variable entirely. If push still 401s after this, the problem
is definitively on the Harbor side (robot permissions).

Also fixes broken heredoc escaping from previous commit where
<<'DOCKERCFG' prevented shell variable expansion of AUTH_B64.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:33:05 +08:00
nanxunandClaude Opus 4.8 02a0b1bd87 ci: use docker login -p instead of --password-stdin, add debug
Suspect that echo + pipe to --password-stdin may be mangling the Jenkins
masked password variable. Switch to -p (command-line password) for a
cleaner auth path and add debug output to inspect /root/.docker/config.json
after login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:48:42 +08:00
nanxunandClaude Opus 4.8 06921cbb3e ci: re-login before each docker push, embed withCredentials in build stages
docker push is also getting 401 (not just buildx --push), suggesting
the Harbor login token expires during long builds or there is a
credential propagation gap between the standalone Login stage and the
build stages.

Move withCredentials into each build stage and re-login immediately
before every docker push / manifest push. This gives each push
operation a fresh token.

Pipeline is now: Checkout -> Prepare Buildx -> Build API (login + build
+ push + manifest) -> Build Web (same).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:46:32 +08:00
nanxunandClaude Opus 4.8 761041a70e ci: bypass buildx --push auth via per-platform --load + docker push + manifest
buildx build --push has failed 401 on every attempt. The buildkit auth
forwarding (whether via docker-container or docker driver) does not work
reliably on this builder.

New strategy: build each platform separately with --load (into local
docker, which can read /root/.docker/config.json), then push with
native docker push, then assemble a multi-arch manifest with
docker manifest create/push.

Per-platform tags are pushed as :<BUILD_ID>-amd64 / :<BUILD_ID>-arm64
and the manifest combines them under the canonical :<BUILD_ID> and :latest.

This replaces a single buildx --push call with:
  1. buildx build --platform linux/amd64 --load
  2. docker push (amd64)
  3. docker rmi  (free disk)
  4. buildx build --platform linux/arm64 --load
  5. docker push (arm64)
  6. docker rmi  (free disk)
  7. docker manifest create + push (multi-arch)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:24:27 +08:00
nanxun 75b07bf10e fix: remove stray closing brace from withCredentials cleanup 2026-07-02 20:03:19 +08:00
nanxunandClaude Opus 4.8 affc5adb4a ci: switch buildx to docker driver to fix registry auth
The docker-container driver runs buildkit in a separate container that
cannot reliably forward host Docker registry credentials via the buildx
session mechanism, causing every --push to fail with 401 Unauthorized.
The --auth flag doesn't exist in buildx 0.23.0 on this builder.

Fix: switch to 'docker' driver which runs buildkit inside the host
Docker daemon and naturally shares its registry auth state.

Changes:
- Login Registry stage restored (before Prepare Buildx)
- Prepare Buildx: driver docker (not docker-container), no driver-opts
- Build stages: stripped withCredentials wrappers and --auth flags
- Removed buildkitd.toml max-parallelism config (docker driver doesn't
  support it; swap provides the safety net for OOM)

Pipeline flow: Checkout -> Login -> Prepare Buildx -> Build API -> Build Web

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:01:51 +08:00
nanxunandClaude Opus 4.8 1b3512042c ci: pass registry auth directly to buildx via --auth flag
The docker-container driver's buildkit session mechanism does not reliably
forward host Docker credentials to the buildkit container, causing every
docker buildx build --push to fail with 401 Unauthorized on the manifest
HEAD request.

Fix: remove the standalone Login Registry stage and embed withCredentials
directly into each build stage, passing credentials to buildkit via the
docker buildx build --auth flag:
  --auth 'reg.nxsir.cn=:'

This sends auth directly to buildkit rather than relying on the implicit
docker login -> config.json -> session forwarding chain.

Each stage also does a docker login for the host CLI (needed for
buildx inspect --bootstrap to pull images from the registry, and for
cache-from/cache-to operations).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 19:57:44 +08:00
nanxunandClaude Opus 4.8 fbf6b52a08 ci: drop NO_PROXY from buildkit driver-opt to avoid comma parsing in docker CLI
Docker buildx create --driver-opt splits values on commas as list
separators, causing 'env.NO_PROXY=127.0.0.1,localhost,...' to be parsed
as separate key=value entries and failing with:
  invalid value "localhost", expecting k=v

The NO_PROXY hosts for build containers are already passed via build-arg
in the build stage; the buildkit container itself doesn't need them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 19:38:50 +08:00
nanxunandClaude Opus 4.8 293aa31f60 ci: login before Prepare Buildx to fix 401 Unauthorized push
Move Login Registry stage before Prepare Buildx so Docker credentials are
in /root/.docker/config.json BEFORE the buildx docker-container builder
is created and bootstrapped. Previously the builder started without auth,
causing buildx --push to fail with '401 Unauthorized' on the manifest
HEAD request (blob layers pushed but manifest rejected).

Also expand the NO_PROXY driver-opt from just 'reg.nxsir.cn' to the full
NO_PROXY_HOSTS list (Tsighua, MCR, daocloud) so the buildkit container
itself also bypasses the proxy for these direct-reachable registries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 19:35:23 +08:00
nanxunandClaude Opus 4.8 1cb05292c0 ci: exclude direct-reachable mirrors from HTTP proxy to fix apt 502
The 7890 proxy intermittently returns '502 Bad Gateway' when tunneling to
mirrors.tuna.tsinghua.edu.cn (observed on libglapi-mesa during
apt-get install in the runtime layer). The Tsinghua mirror, MCR, and
daocloud are all directly reachable from this builder, so the proxy adds
no value and only introduces failure modes.

Add the following hosts to NO_PROXY_HOSTS so apt/curl inside the build
containers hit them directly:
  - mirrors.tuna.tsinghua.edu.cn, .tsinghua.edu.cn
  - mcr.microsoft.com
  - docker.m.daocloud.io

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:55:45 +08:00
nanxunandClaude Opus 4.8 fd1121bde0 ci: cap buildkit max-parallelism at 1 to avoid OOM on 4GB builder
Multi-platform buildx (linux/amd64 + linux/arm64) races both platforms in
parallel by default. On the 4GB build agent, QEMU-emulated 'dotnet restore'
for arm64 alone spikes to 2-3GB and racing amd64 apt-installs push us into
the OOM Killer ("cannot allocate memory" at build 4/5).

Fix: write a small buildkitd.toml with max-parallelism = 1 and pass it to
'docker buildx create --config'. buildkit now runs stages sequentially so
the two platforms don't step on each other's RSS.

Recreate the builder every run so the config always takes effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:49:39 +08:00
nanxunandClaude Opus 4.8 8a709bc217 ci: bypass Docker Hub for tonistiigi/binfmt in Prepare Buildx
The build agent cannot reach auth.docker.io from behind the local network,
causing 'docker run tonistiigi/binfmt --install arm64' to fail with a token
fetch timeout.

Fix: three-tier fallback for ARM64 binfmt registration:
  1. Skip if /proc/sys/fs/binfmt_misc/qemu-aarch64 already exists.
  2. Pull tonistiigi/binfmt via docker.m.daocloud.io mirror.
  3. Fall back to qemu-user-static via apt.

Add a post-registration sanity check (arm64 alpine + uname -m) so
misconfigured builders fail fast rather than at buildx invocation time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 18:39:06 +08:00
nanxunandClaude Fable 5 9df174bb0b feat: add OpenList upload support and upload tasks monitoring page
Backend:
- Add OpenListRecordArtifactUploader implementing AList-compatible API upload
- Add Uploading status to RecordArtifactUploadStatus enum
- Add MarkUploadStarted() to RecordResult entity for upload progress tracking
- Add ListUploadStatus API endpoint with pagination and status filtering
- Add UploadTaskItemDto and UploadTaskListResponse models
- Add upload segment count stats (uploaded/failed/uploading) to session DTO
- Add OpenListUploadSettingsDto and upload target type OpenList

Frontend:
- Add UploadTasksView page with route /upload-tasks
- Add upload status labels and UploadTaskItem types
- Refactor MainLayout navigation and clean up main.css
- Polish DashboardView, MetricCard, StatusBadge, RightDrawer components
- Update SettingsView to support OpenList upload configuration

Build:
- Add frontend/Dockerfile.arm64 for ARM64 frontend image
- Update build-arm64-image.sh script

Other:
- Add segment_completed_openlist.sh trigger script
- Add prototype/ directory with UI mockups
- Add frontend .dockerignore refinements

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 21:14:50 +08:00
nanxun f8abae0a7a fix: normalize DateTimeOffset to UTC in DashboardService to fix Npgsql offset rejection
DashboardService was constructing 'today in Beijing' bounds with a +08:00 offset, which Npgsql rejects when binding to a timestamp with time zone column. Added .ToUniversalTime() to normalize to UTC (same instant, offset 0), matching the existing pattern in SessionAnalyticsService.GetUtcWindow.
2026-06-21 00:01:03 +08:00
nanxunandClaude Opus 4.8 f9c7ec5d43 fix: resolve low-storage deadlock by always resuming MP4 finalization
Under the Red storage tier, MP4 finalization (TS->MP4 remux) was being skipped, so tasks never reached Completed and the segment_completed event script — which uploads the file and deletes the local source to free space — never ran. The disk could never recover, deadlocking all recording and transcoding.

Two reversed checks caused this: (1) FfmpegService gated finalization on the legacy HasEnoughSpace MB threshold (effectively 4GB) instead of the tier system, and (2) the polling loop only resumed paused finalizations when NOT in the Red tier. Now finalization is gated solely on ShouldPauseActive (true Red only) and the polling loop always attempts to resume it every cycle, since finalization is the very mechanism that frees space. Once any segment finalizes, the upload+delete script runs and the disk recovers, letting the rest finish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 15:13:07 +08:00
nanxunandClaude Opus 4.8 90fd9f0ba8 build: add one-shot ARM64 image build/export script
scripts/build-arm64-image.sh automates the full linux/arm64 image build on Docker Desktop (WSL2): register qemu-aarch64 binfmt with the F flag so emulation works inside build containers, pre-pull the dotnet base images with resumable retries, build with dotnet restore routed through the host proxy and apt via the in-Dockerfile mirror, then docker save + gzip into a loadable archive. .gitignore now excludes the *.tar/*.tar.gz build artifacts, the qemu-*-static emulator binary, and the stray root package-lock.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 23:04:27 +08:00
nanxunandClaude Opus 4.8 a062bf84bf fix: avoid danmaku event name collision in session detail replay
Alias the replay player's danmakuEvents to replayDanmakuEvents so it no longer shadows another binding in the session detail view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:52:18 +08:00
nanxunandClaude Opus 4.8 df70b64956 build: target linux-arm64 and streamline the Docker image build
Pin the WebApi to RuntimeIdentifier=linux-arm64 (framework-dependent) and rework the Dockerfile for ARM64: copy the full context up front, add QEMU emulation workarounds, and drop debug symbols / cap parallelism during publish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:52:18 +08:00
nanxunandClaude Opus 4.8 5196ffa0f0 feat: add database circuit breaker and health-readiness endpoint
Introduce a process-wide DatabaseCircuitBreaker that fails fast when Postgres is unavailable (e.g. disk full) instead of letting every request burn doomed EF Core retries. CircuitAwareExecutionStrategy derives from NpgsqlRetryingExecutionStrategy and records success/failure around the public Execute/ExecuteAsync seams; background workers skip work and back off while the circuit is open; the exception middleware maps an open circuit (and other DB outages) to 503. Adds /health (liveness) and /health/ready (readiness, reporting circuit state), plus unit tests for the open/half-open transitions and non-transient SQLSTATE detection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:52:18 +08:00
nanxunandClaude Opus 4.8 6da0690fd3 feat: batch-delete recording segments whose files are missing
Add a segment-level cleanup alongside the existing session-level one. DeleteMissingFileTasksAsync scans all non-active record tasks, keeps those whose video file no longer exists on disk, and deletes them individually via DeleteTasksAsync (which also drops any session left empty). Exposed as POST /record-tasks/delete-missing-files and a new "清理无文件分片" action in the record tasks view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 17:52:18 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com e640d5b5cc fix: consolidate storage tier with old MB thresholds into single tier system
- CanStartNewRecording now purely based on Tier==Green (was HasEnoughSpace||Green)
- PollingBackgroundService now uses ShouldPauseActive instead of MB-based CheckShouldPause
- Both pause and start checks consolidated into single guardCheck call
- Old MB pause/resume thresholds still work as secondary safety via hasEnoughSpace

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 11:57:20 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com 0831b33ea0 feat: make storage tier thresholds configurable in system settings
Add StorageGreenThresholdPercent (default 30%) and StorageRedThresholdPercent
(default 10%) to both SystemSettingsDto and the settings UI.

StorageGuardService now reads thresholds from settings instead of hardcoding.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 11:55:08 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com f5ad1dec00 fix: run dashboard queries sequentially to avoid DbContext concurrency
DbContext is not thread-safe. Task.WhenAll caused concurrent access
within the same scoped DbContext, throwing 'A second operation was
started on this context instance' errors on slower machines.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 11:49:48 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com a819322559 fix: hardcode apiBaseUrl to /api instead of relying on import.meta.env
VITE_API_BASE_URL may not be properly resolved during Docker build,
causing axios to construct malformed URLs.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 11:23:23 +08:00
nanxun 2b0345722e fix: add null check to debug log 2026-06-05 11:17:27 +08:00
nanxun 9e5bbbb948 debug: add API URL logging to axios request interceptor 2026-06-05 11:13:18 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com f9016931e8 fix: add build target es2015 for Raspberry Pi Chromium compatibility
Older Chromium on ARM64 doesn't support strict mode arguments.callee
in modern ES module bundles. Downgrading build target to es2015.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 10:47:13 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com 46da2d78f0 perf: increase DB timeout to 120s, reduce retries to 3 with longer delay
Raspberry Pi PostgreSQL is I/O constrained. 60s timeout was too short
for concurrent writes during heavy recording sessions, causing timeout +
retry causing duplicate key violations.

- CommandTimeout: 60s -> 120s
- maxRetryCount: 5 -> 3
- maxRetryDelay: 10s -> 15s

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 01:01:59 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com 524a053263 fix: PlatformHttpClientFactory uses IServiceScopeFactory to avoid disposed DbContext in danmaku retry
PlatformHttpClientFactory was holding a direct ISystemSettingsService reference
(Scoped). When the danmaku connection's request scope ended, retry attempts failed
with ObjectDisposedException on LiveRecorderDbContext.

Changed to use IServiceScopeFactory to create a fresh scope on each CreateAsync call,
so the danmaku retry loop always gets a live DbContext.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-05 00:02:42 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com 24e5cf2a06 feat: add tiered storage guard (Green/Yellow/Red) and dashboard queue monitor
Storage Tier System:
- Add StorageTier enum (Green >30% / Yellow 10-30% / Red <10%)
- Extend StorageGuardResult with Tier, CanStartNewRecording, ShouldPauseActive, UsagePercent
- Yellow tier: deny new recordings but allow existing to finish and upload
- Red tier: deny new recordings and pause active sessions
- Auto-recovery: when disk frees up, polling automatically resumes new recordings
- Update LiveRoomPollingBackgroundService to use tier-based checks
- Expose tier + usage percent in Recovery API

Dashboard Queue Monitor:
- Add pending transcode count, pending upload count, queued data volume to dashboard
- Add storage tier badge (Green/Yellow/Red) with usage percentage
- Add queue monitoring card to dashboard view

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-04 23:13:26 +08:00
nanxunandClaude Opus 4.8 noreply@anthropic.com cbee29bef9 feat: add dashboard, file preview metadata, and bandwidth statistics
Dashboard:
- Add DashboardDto, DashboardService with SQL-level aggregate queries
- Add GET /api/dashboard endpoint with real-time system status
- Add repository aggregate methods (CountByAvailability, SumDuration, etc.)
- Add DashboardView.vue as new landing page with KPI cards, storage status, recent sessions, top rooms
- Update router to make dashboard the new / route, add nav item in sidebar

File Preview:
- Add IVideoMetadataService + FfmpegVideoMetadataService for video metadata extraction
- Extend MediaBrowserItemDto with Metadata and ThumbnailUrl fields
- Add includeMetadata param to media browser API, add thumbnail endpoint
- Add SessionPlaylistDto and GET /api/record-sessions/{id}/playlist for continuous playback

Bandwidth Statistics:
- Add -progress pipe:1 to live recording ffmpeg args for bitrate output
- Parse total_size/bitrate/speed from ffmpeg progress lines during recording
- Write bandwidth samples as SystemLogEntry (Category=Bandwidth) every 30s
- Add BandwidthStatisticsService, BandwidthController with session timeline + daily summary
- Add bandwidth TypeScript types

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
2026-06-04 00:30:02 +08:00
nanxunandClaude Opus 4.8 a5c2cc3202 feat: add danmaku replay player integration
- Add IDanmakuService interface and DanmakuService implementation to parse danmaku XML files
- Add GET /api/record-tasks/{id}/danmaku and GET /api/record-sessions/{id}/danmaku endpoints
- Add DanmakuPlayer Vue component with native video + CSS overlay danmaku rendering
- Add danmakuEngine.ts pure-TypeScript animation loop with binary search, track management, and event notifications
- Add useDanmakuPlayer composable for reusable danmaku data loading
- Integrate danmaku toggle button into RecordTaskDetailView
- Integrate danmaku replay modal dialog into RecordSessionDetailView segment table

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:09:43 +08:00
nanxun b2aaef093d fix: restore settings page chinese text 2026-05-31 20:07:18 +08:00
nanxun f7fa02d13c fix: avoid jenkinsfile label mojibake 2026-05-31 19:53:01 +08:00
nanxun b82110461a feat: add storage and script failure notifications 2026-05-31 19:35:06 +08:00
nanxun fd5be2cc8e ci: stabilize buildx heartbeat and cache 2026-05-31 16:48:35 +08:00
nanxun 78f02978fd fix-settings-i18n-garbled-encoding-and-english-text 2026-05-17 15:58:21 +08:00
nanxun 850c55f5da Merge pull request 'fix: track flutter mobile data layer' (#4) from codex/mobile-data-layer-fix into main
Reviewed-on: #4
2026-05-17 15:27:23 +08:00
nanxun 14773248d9 fix: track flutter mobile data layer 2026-05-17 15:24:28 +08:00
nanxun 5a6c374320 Merge pull request 'ci: restore polling heartbeat for buildx logs' (#3) from codex/ci-heartbeat-fix into main
Reviewed-on: #3
2026-05-15 16:21:39 +08:00
nanxun 9c2767f78c ci: restore polling heartbeat for buildx logs 2026-05-15 16:18:59 +08:00
nanxun db458a9a14 Merge pull request 'feat: add flutter mobile console and refine login ui' (#2) from codex/mobile-console-pr-clean into main
Reviewed-on: #2
2026-05-15 00:31:43 +08:00
nanxun 50b207415a feat: add flutter mobile console and refine login ui 2026-05-15 00:24:58 +08:00
nanxun 4b5077e3da Merge pull request 'codex-live-recorder-console-ui' (#1) from codex-live-recorder-console-ui into main
Reviewed-on: #1
2026-05-13 20:03:13 +08:00
196 changed files with 30293 additions and 3504 deletions
+29
View File
@@ -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"
}
]
}
]
}
}
+2
View File
@@ -7,6 +7,8 @@
.codex-temp/
.codex_tmp_danmaku
.codex_tmp_danmaku/
.ci
.ci/
artifacts
artifacts/
ConsoleApp1
+14
View File
@@ -5,12 +5,19 @@
**/*.suo
frontend/node_modules/
frontend/dist/
frontend/dist-postgres/
frontend/test-results/
frontend/playwright-report/
**/.dotnet-cli-home/
.codex-temp/
.tools/
build.log
webapi-build.log
webapi-build-no-restore.log
artifacts/
data/
!mobile/lib/features/live_recorder/data/
!mobile/lib/features/live_recorder/data/**
records/
docker-data/
src/LiveRecorder.WebApi/data/
@@ -30,3 +37,10 @@ src/LiveRecorder.WebApi/live-recorder.db*
# Frontend source/config TypeScript files must stay trackable.
!frontend/**/*.ts
!frontend/**/*.tsx
# Docker image build artifacts and the ARM64 QEMU emulator binary.
*.tar
*.tar.gz
qemu-*-static
# Stray root lockfile created by running npm in the repo root (frontend has its own).
/package-lock.json
Vendored
+520 -171
View File
@@ -1,223 +1,572 @@
pipeline {
agent { label '构建机1' }
agent {
node {
label '构建机1'
customWorkspace '/home/nanxunai/goujian/workspace/liverecorder-release'
}
}
options {
timestamps()
disableConcurrentBuilds()
skipDefaultCheckout(true)
timeout(time: 180, unit: 'MINUTES')
buildDiscarder(logRotator(daysToKeepStr: '30', numToKeepStr: '20'))
}
parameters {
booleanParam(
name: 'RUN_TESTS',
defaultValue: true,
description: '运行前端构建检查和 .NET 全量测试'
)
booleanParam(
name: 'BUILD_FNOS',
defaultValue: true,
description: '构建 fnOS x86_64 FPK 安装包'
)
booleanParam(
name: 'UPLOAD_FNOS',
defaultValue: true,
description: '将本次构建的 fnOS 包上传到 OpenList(仅 BUILD_FNOS 生效)'
)
choice(
name: 'API_IMAGE_PLATFORMS',
choices: ['amd64+arm64', 'amd64', 'arm64', 'none'],
description: '选择需要构建并推送的 API Docker 镜像架构'
)
choice(
name: 'WEB_IMAGE_PLATFORMS',
choices: ['amd64+arm64', 'amd64', 'arm64', 'none'],
description: '选择需要构建并推送的 Web Docker 镜像架构'
)
}
environment {
REGISTRY_URL = 'reg.nxsir.cn'
API_IMAGE_NAME = 'liverecorder/app-api'
WEB_IMAGE_NAME = 'liverecorder/app-web'
IMAGE_TAG = "${env.BUILD_ID}"
DOCKER_CREDS = 'harbor_key'
TARGET_PLATFORMS = 'linux/amd64,linux/arm64'
BUILDER_NAME = 'liverecorder-buildx'
WEB_NODE_IMAGE = 'docker.m.daocloud.io/library/node:22-alpine'
WEB_NGINX_IMAGE = 'docker.m.daocloud.io/library/nginx:1.27-alpine'
PATH = '/home/nanxunai/.local/bin:/usr/local/bin:/usr/bin:/bin'
REGISTRY_URL = 'reg.nxsir.cn'
API_IMAGE_REPO = 'reg.nxsir.cn/live_recorder/app-api'
WEB_IMAGE_REPO = 'reg.nxsir.cn/live_recorder/app-web'
HARBOR_CREDENTIALS = 'harbor_key'
OPENLIST_CREDENTIALS = 'openlist_key'
NODE_CREDENTIALS = 'bbb939ea-4f01-4b47-aecb-c5ee2a551ef4'
OPENLIST_BASE_URL = 'https://openlist.nxsir.cn'
OPENLIST_REMOTE_DIR = '/yidongpan/构建产物/liverecorder'
GIT_REPO_URL = 'https://gitea.nxsir.cn/nanxun/live_recorder.git'
GIT_BRANCH = 'main'
GIT_CREDS = ''
FNPACK_BIN = '/home/nanxunai/.local/bin/fnpack'
FNPACK_SHA256 = '54b97fa7b70968c4d05c79840f5daeff508957d0bb2062fdb0376d00d9615c93'
DOTNET = '/home/nanxunai/.local/bin/dotnet'
API_IMAGE_TAGGED = "${REGISTRY_URL}/${API_IMAGE_NAME}:${IMAGE_TAG}"
API_IMAGE_LATEST = "${REGISTRY_URL}/${API_IMAGE_NAME}:latest"
WEB_IMAGE_TAGGED = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:${IMAGE_TAG}"
WEB_IMAGE_LATEST = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:latest"
BUILDER_NAME = 'liverecorder-ci'
BUILDKIT_IMAGE = 'docker.m.daocloud.io/moby/buildkit:buildx-stable-1'
BINFMT_IMAGE = 'docker.m.daocloud.io/tonistiigi/binfmt:latest'
ARM_TEST_IMAGE = 'docker.m.daocloud.io/library/alpine:latest'
NODE_BUILD_IMAGE = 'docker.m.daocloud.io/library/node:22-alpine'
NGINX_IMAGE = 'docker.m.daocloud.io/library/nginx:1.27-alpine'
DOTNET_SDK_IMAGE = 'mcr.m.daocloud.io/dotnet/sdk:8.0-bookworm-slim'
DOTNET_RUNTIME_IMAGE = 'mcr.m.daocloud.io/dotnet/aspnet:8.0-bookworm-slim'
HTTP_PROXY_URL = 'http://192.168.5.200:7890'
NO_PROXY_HOSTS = '127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn,openlist.nxsir.cn,mirrors.tuna.tsinghua.edu.cn,.tsinghua.edu.cn,mirrors.huaweicloud.com,.huaweicloud.com,mcr.m.daocloud.io,docker.m.daocloud.io,.m.daocloud.io'
CI_ROOT = "${WORKSPACE}/.ci"
CI_CACHE_ROOT = '/home/nanxunai/.cache/liverecorder-ci'
DOCKER_CONFIG = '/home/nanxunai/.cache/liverecorder-ci/docker'
NUGET_PACKAGES = '/home/nanxunai/.cache/liverecorder-ci/nuget-packages'
NUGET_HTTP_CACHE_PATH = '/home/nanxunai/.cache/liverecorder-ci/nuget-http'
NUGET_PLUGINS_CACHE_PATH = '/home/nanxunai/.cache/liverecorder-ci/nuget-plugins'
DOTNET_CLI_HOME = '/home/nanxunai/.cache/liverecorder-ci/dotnet-home'
NPM_CONFIG_CACHE = '/home/nanxunai/.cache/liverecorder-ci/npm-cache'
LIVERECORDER_NUGET_FEED = '/home/nanxunai/.cache/liverecorder-ci/nuget-feed'
LIVERECORDER_BUILD_TMPDIR = "${WORKSPACE}/.ci/fnos-tmp"
LIVERECORDER_VERIFY_TMPDIR = "${WORKSPACE}/.ci/fnos-verify"
LIVERECORDER_SKIP_NPM_CI = '1'
}
stages {
stage('Checkout') {
steps {
withCredentials([usernamePassword(
credentialsId: "${NODE_CREDENTIALS}",
usernameVariable: 'JENKINS_NODE_USERNAME',
passwordVariable: 'JENKINS_NODE_PASSWORD'
)]) {
sh '''
set -euo pipefail
if [ -d "$WORKSPACE/.ci" ]; then
printf '%s\n' "$JENKINS_NODE_PASSWORD" | \
sudo -S -p '' chown -R -- "$(id -u):$(id -g)" "$WORKSPACE/.ci"
fi
'''
}
deleteDir()
checkout scm
}
}
stage('Metadata And Preflight') {
steps {
script {
def userRemoteConfig = [url: env.GIT_REPO_URL]
if (env.GIT_CREDS?.trim()) {
userRemoteConfig.credentialsId = env.GIT_CREDS.trim()
if (!params.RUN_TESTS &&
!params.BUILD_FNOS &&
params.API_IMAGE_PLATFORMS == 'none' &&
params.WEB_IMAGE_PLATFORMS == 'none') {
error('Select at least one test or build output.')
}
checkout([
$class: 'GitSCM',
branches: [[name: "*/${env.GIT_BRANCH}"]],
userRemoteConfigs: [userRemoteConfig]
])
env.APP_VERSION = sh(
script: "sed -n 's/^version=//p' fnos/manifest | head -n 1",
returnStdout: true
).trim()
env.SHORT_SHA = sh(script: 'git rev-parse --short=8 HEAD', returnStdout: true).trim()
if (!env.APP_VERSION || !env.SHORT_SHA) {
error('Unable to resolve fnOS version or Git commit.')
}
env.FNOS_VERSION = sh(
script: '''
set -euo pipefail
base=$(sed -n 's/^version=//p' fnos/manifest | head -n 1)
major=${base%%.*}
remainder=${base#*.}
minor=${remainder%%.*}
patch=${remainder#*.}
test "$base" = "$major.$minor.$patch" || {
echo "Invalid base version: $base" >&2
exit 1
}
case "$major.$minor.$patch" in
*[!0-9.]*|.*|*..*|*.) echo "Invalid base version: $base" >&2; exit 1 ;;
esac
test "$BUILD_NUMBER" -lt 100000 || {
echo 'BUILD_NUMBER must remain below 100000 for fnOS version ordering.' >&2
exit 1
}
ci_patch=$((patch * 100000 + BUILD_NUMBER))
printf '%s.%s.%s\n' "$major" "$minor" "$ci_patch"
''',
returnStdout: true
).trim()
env.IMMUTABLE_TAG = "${env.APP_VERSION}-b${env.BUILD_NUMBER}-${env.SHORT_SHA}"
env.FPK_BASENAME = "liverecorder-${env.FNOS_VERSION}-x86_64.fpk"
env.FPK_PATH = "${env.WORKSPACE}/artifacts/ci/${env.FPK_BASENAME}"
currentBuild.displayName = "#${env.BUILD_NUMBER} ${env.APP_VERSION} ${env.SHORT_SHA}"
currentBuild.description = "${env.IMMUTABLE_TAG}"
}
withCredentials([usernamePassword(
credentialsId: "${NODE_CREDENTIALS}",
usernameVariable: 'JENKINS_NODE_USERNAME',
passwordVariable: 'JENKINS_NODE_PASSWORD'
)]) {
sh '''
set -euo pipefail
test "$(uname -m)" = x86_64
for command_name in git sudo docker curl python3 npm node sha256sum stat tar; do
command -v "$command_name" >/dev/null
done
test -x "$DOTNET"
test -x "$FNPACK_BIN"
printf '%s %s\n' "$FNPACK_SHA256" "$FNPACK_BIN" | sha256sum --check --status
./scripts/ci-docker.sh version >/dev/null
./scripts/ci-docker.sh buildx version
available_kb=$(df -Pk "$WORKSPACE" | awk 'NR == 2 { print $4 }')
if [ "$available_kb" -lt 2097152 ]; then
echo "At least 2 GiB free workspace disk is required; available KiB: $available_kb" >&2
exit 1
fi
mkdir -p \
"$CI_CACHE_ROOT" \
"$DOCKER_CONFIG" \
"$NUGET_PACKAGES" \
"$NUGET_HTTP_CACHE_PATH" \
"$NUGET_PLUGINS_CACHE_PATH" \
"$DOTNET_CLI_HOME" \
"$NPM_CONFIG_CACHE" \
"$LIVERECORDER_NUGET_FEED" \
"$LIVERECORDER_BUILD_TMPDIR" \
"$LIVERECORDER_VERIFY_TMPDIR" \
"$(dirname -- "$FPK_PATH")"
chmod 700 "$DOCKER_CONFIG"
'''
}
}
}
stage('Prepare Buildx') {
stage('Restore And Test') {
when {
expression { params.RUN_TESTS }
}
steps {
sh """
set -e
sudo docker buildx version
sudo docker run --privileged --rm tonistiigi/binfmt --install arm64
# 强制清理旧的 builder 实例,防止僵尸状态
sudo docker buildx rm ${BUILDER_NAME} || true
"""
sh '''
set -euo pipefail
npm ci --prefix frontend
npm run build --prefix frontend
"$DOTNET" restore LiveRecorder.sln --disable-parallel
"$DOTNET" test LiveRecorder.sln -c Release --no-restore --logger 'console;verbosity=normal' /maxcpucount:1
'''
}
}
stage('Login Registry') {
stage('Prepare fnOS Dependencies') {
when {
expression { params.BUILD_FNOS && !params.RUN_TESTS }
}
steps {
withCredentials([
usernamePassword(
credentialsId: "${DOCKER_CREDS}",
usernameVariable: 'DOCKER_USERNAME',
passwordVariable: 'DOCKER_PASSWORD'
)
]) {
sh """
set -e
echo "\$DOCKER_PASSWORD" | sudo docker login ${REGISTRY_URL} -u "\$DOCKER_USERNAME" --password-stdin
"""
sh '''
set -euo pipefail
npm ci --prefix frontend
'''
}
}
stage('Build fnOS x64') {
when {
expression { params.BUILD_FNOS }
}
steps {
sh '''
set -euo pipefail
PACKAGE_VERSION="$FNOS_VERSION" FNPACK="$FNPACK_BIN" \
./scripts/build-fnos-package.sh "$FPK_PATH"
./scripts/verify-fnos-package.sh "$FPK_PATH" "$FNOS_VERSION"
test -s "$FPK_PATH.sha256"
'''
}
}
stage('Prepare Docker Builder') {
when {
expression {
params.API_IMAGE_PLATFORMS != 'none' ||
params.WEB_IMAGE_PLATFORMS != 'none'
}
}
steps {
withCredentials([usernamePassword(
credentialsId: "${NODE_CREDENTIALS}",
usernameVariable: 'JENKINS_NODE_USERNAME',
passwordVariable: 'JENKINS_NODE_PASSWORD'
)]) {
sh '''
set -euo pipefail
if [ "$API_IMAGE_PLATFORMS" = arm64 ] ||
[ "$API_IMAGE_PLATFORMS" = amd64+arm64 ] ||
[ "$WEB_IMAGE_PLATFORMS" = arm64 ] ||
[ "$WEB_IMAGE_PLATFORMS" = amd64+arm64 ]; then
if ! ./scripts/ci-docker.sh run --rm --platform linux/arm64 "$ARM_TEST_IMAGE" uname -m 2>/dev/null | grep -q aarch64; then
./scripts/ci-docker.sh run --privileged --rm "$BINFMT_IMAGE" --install arm64
fi
./scripts/ci-docker.sh run --rm --platform linux/arm64 "$ARM_TEST_IMAGE" uname -m | grep -q aarch64
fi
if ./scripts/ci-docker.sh buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then
./scripts/ci-docker.sh buildx use "$BUILDER_NAME"
else
./scripts/ci-docker.sh buildx create \
--name "$BUILDER_NAME" \
--driver docker-container \
--driver-opt "image=$BUILDKIT_IMAGE" \
--driver-opt network=host \
--driver-opt "env.http_proxy=$HTTP_PROXY_URL" \
--driver-opt "env.https_proxy=$HTTP_PROXY_URL" \
--driver-opt "env.HTTP_PROXY=$HTTP_PROXY_URL" \
--driver-opt "env.HTTPS_PROXY=$HTTP_PROXY_URL" \
--driver-opt "env.no_proxy=.daocloud.vip" \
--driver-opt "env.NO_PROXY=.daocloud.vip" \
--use
fi
./scripts/ci-docker.sh buildx inspect "$BUILDER_NAME" --bootstrap
'''
}
}
}
stage('Build And Push API Image') {
stage('Prepare Harbor Authentication') {
when {
expression {
params.API_IMAGE_PLATFORMS != 'none' ||
params.WEB_IMAGE_PLATFORMS != 'none'
}
}
steps {
sh """
set -e
run_with_heartbeat() {
log_file="\$1"
shift
rm -f "\$log_file"
: > "\$log_file"
"\$@" >"\$log_file" 2>&1 &
cmd_pid=\$!
cmd_status=0
tail --pid="\$cmd_pid" -n +1 -f "\$log_file" &
tail_pid=\$!
while kill -0 "\$cmd_pid" >/dev/null 2>&1; do
echo "[heartbeat] API multi-arch build still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
sleep 20
done
wait "\$cmd_pid" || cmd_status=\$?
wait "\$tail_pid" >/dev/null 2>&1 || true
if [ "\$cmd_status" -ne 0 ]; then
echo "[heartbeat] API multi-arch build failed with exit code \$cmd_status"
fi
return "\$cmd_status"
}
if ! sudo docker buildx inspect --builder ${BUILDER_NAME} >/dev/null 2>&1; then
sudo docker buildx create \
--name ${BUILDER_NAME} \
--driver docker-container \
--driver-opt network=host \
--driver-opt 'env.HTTP_PROXY=http://192.168.5.200:7890' \
--driver-opt 'env.HTTPS_PROXY=http://192.168.5.200:7890' \
--driver-opt 'env.NO_PROXY=reg.nxsir.cn' \
--driver-opt 'env.http_proxy=http://192.168.5.200:7890' \
--driver-opt 'env.https_proxy=http://192.168.5.200:7890' \
--driver-opt 'env.no_proxy=reg.nxsir.cn' \
--use
fi
sudo docker buildx inspect --builder ${BUILDER_NAME} --bootstrap >/dev/null
echo 'Building and pushing multi-arch API image: ${API_IMAGE_TAGGED}'
run_with_heartbeat /tmp/live-recorder-api-buildx-${IMAGE_TAG}.log \
sudo docker buildx build \
--builder ${BUILDER_NAME} \
--platform ${TARGET_PLATFORMS} \
--network host \
--progress=plain \
--provenance=false \
--build-arg HTTP_PROXY=http://192.168.5.200:7890 \
--build-arg HTTPS_PROXY=http://192.168.5.200:7890 \
--build-arg NO_PROXY=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
--build-arg http_proxy=http://192.168.5.200:7890 \
--build-arg https_proxy=http://192.168.5.200:7890 \
--build-arg no_proxy=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
-f src/LiveRecorder.WebApi/Dockerfile \
-t ${API_IMAGE_TAGGED} \
-t ${API_IMAGE_LATEST} \
--push \
.
"""
withCredentials([usernamePassword(
credentialsId: "${HARBOR_CREDENTIALS}",
usernameVariable: 'HARBOR_USERNAME',
passwordVariable: 'HARBOR_PASSWORD'
)]) {
sh '''
set -euo pipefail
set +x
auth=$(printf '%s:%s' "$HARBOR_USERNAME" "$HARBOR_PASSWORD" | base64 -w0)
printf '{"auths":{"%s":{"auth":"%s"}}}\n' "$REGISTRY_URL" "$auth" >"$DOCKER_CONFIG/config.json"
chmod 600 "$DOCKER_CONFIG/config.json"
unset auth
set -x
'''
}
}
}
stage('Build And Push Web Image') {
stage('Build And Push amd64') {
when {
expression {
params.API_IMAGE_PLATFORMS.contains('amd64') ||
params.WEB_IMAGE_PLATFORMS.contains('amd64')
}
}
steps {
sh """
set -e
run_with_heartbeat() {
log_file="\$1"
shift
rm -f "\$log_file"
: > "\$log_file"
"\$@" >"\$log_file" 2>&1 &
cmd_pid=\$!
cmd_status=0
tail --pid="\$cmd_pid" -n +1 -f "\$log_file" &
tail_pid=\$!
while kill -0 "\$cmd_pid" >/dev/null 2>&1; do
echo "[heartbeat] Web multi-arch build still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
sleep 20
done
wait "\$cmd_pid" || cmd_status=\$?
wait "\$tail_pid" >/dev/null 2>&1 || true
if [ "\$cmd_status" -ne 0 ]; then
echo "[heartbeat] Web multi-arch build failed with exit code \$cmd_status"
fi
return "\$cmd_status"
}
if ! sudo docker buildx inspect --builder ${BUILDER_NAME} >/dev/null 2>&1; then
sudo docker buildx create \
--name ${BUILDER_NAME} \
--driver docker-container \
--driver-opt network=host \
--driver-opt 'env.HTTP_PROXY=http://192.168.5.200:7890' \
--driver-opt 'env.HTTPS_PROXY=http://192.168.5.200:7890' \
--driver-opt 'env.NO_PROXY=reg.nxsir.cn' \
--driver-opt 'env.http_proxy=http://192.168.5.200:7890' \
--driver-opt 'env.https_proxy=http://192.168.5.200:7890' \
--driver-opt 'env.no_proxy=reg.nxsir.cn' \
--use
withCredentials([usernamePassword(
credentialsId: "${NODE_CREDENTIALS}",
usernameVariable: 'JENKINS_NODE_USERNAME',
passwordVariable: 'JENKINS_NODE_PASSWORD'
)]) {
sh '''
set -euo pipefail
arch=amd64
platform=linux/amd64
api_ref="$API_IMAGE_REPO:$IMMUTABLE_TAG-$arch"
web_ref="$WEB_IMAGE_REPO:$IMMUTABLE_TAG-$arch"
if [ "$API_IMAGE_PLATFORMS" = amd64 ] || [ "$API_IMAGE_PLATFORMS" = amd64+arm64 ]; then
./scripts/ci-docker.sh buildx build \
--builder "$BUILDER_NAME" --platform "$platform" --network host \
--progress=plain --provenance=false \
--build-arg DOTNET_SDK_IMAGE="$DOTNET_SDK_IMAGE" \
--build-arg DOTNET_RUNTIME_IMAGE="$DOTNET_RUNTIME_IMAGE" \
--build-arg HTTP_PROXY="$HTTP_PROXY_URL" \
--build-arg HTTPS_PROXY="$HTTP_PROXY_URL" \
--build-arg NO_PROXY="$NO_PROXY_HOSTS" \
--build-arg http_proxy="$HTTP_PROXY_URL" \
--build-arg https_proxy="$HTTP_PROXY_URL" \
--build-arg no_proxy="$NO_PROXY_HOSTS" \
-f src/LiveRecorder.WebApi/Dockerfile -t "$api_ref" --push .
fi
sudo docker buildx inspect --builder ${BUILDER_NAME} --bootstrap >/dev/null
echo 'Building and pushing multi-arch Web image: ${WEB_IMAGE_TAGGED}'
run_with_heartbeat /tmp/live-recorder-web-buildx-${IMAGE_TAG}.log \
sudo docker buildx build \
--builder ${BUILDER_NAME} \
--platform ${TARGET_PLATFORMS} \
--network host \
--progress=plain \
--provenance=false \
--build-arg NODE_IMAGE=${WEB_NODE_IMAGE} \
--build-arg NGINX_IMAGE=${WEB_NGINX_IMAGE} \
--build-arg HTTP_PROXY=http://192.168.5.200:7890 \
--build-arg HTTPS_PROXY=http://192.168.5.200:7890 \
--build-arg NO_PROXY=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
--build-arg http_proxy=http://192.168.5.200:7890 \
--build-arg https_proxy=http://192.168.5.200:7890 \
--build-arg no_proxy=127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn \
-f frontend/Dockerfile \
if [ "$WEB_IMAGE_PLATFORMS" = amd64 ] || [ "$WEB_IMAGE_PLATFORMS" = amd64+arm64 ]; then
./scripts/ci-docker.sh buildx build \
--builder "$BUILDER_NAME" --platform "$platform" --network host \
--progress=plain --provenance=false \
--build-arg NODE_IMAGE="$NODE_BUILD_IMAGE" \
--build-arg NGINX_IMAGE="$NGINX_IMAGE" \
--build-arg HTTP_PROXY="$HTTP_PROXY_URL" \
--build-arg HTTPS_PROXY="$HTTP_PROXY_URL" \
--build-arg NO_PROXY="$NO_PROXY_HOSTS" \
--build-arg http_proxy="$HTTP_PROXY_URL" \
--build-arg https_proxy="$HTTP_PROXY_URL" \
--build-arg no_proxy="$NO_PROXY_HOSTS" \
--build-arg VITE_API_BASE_URL=/api \
-t ${WEB_IMAGE_TAGGED} \
-t ${WEB_IMAGE_LATEST} \
--push \
frontend
"""
-f frontend/Dockerfile -t "$web_ref" --push frontend
fi
'''
}
}
}
stage('Build And Push arm64') {
when {
expression {
params.API_IMAGE_PLATFORMS.contains('arm64') ||
params.WEB_IMAGE_PLATFORMS.contains('arm64')
}
}
steps {
withCredentials([usernamePassword(
credentialsId: "${NODE_CREDENTIALS}",
usernameVariable: 'JENKINS_NODE_USERNAME',
passwordVariable: 'JENKINS_NODE_PASSWORD'
)]) {
sh '''
set -euo pipefail
arch=arm64
platform=linux/arm64
api_ref="$API_IMAGE_REPO:$IMMUTABLE_TAG-$arch"
web_ref="$WEB_IMAGE_REPO:$IMMUTABLE_TAG-$arch"
if [ "$API_IMAGE_PLATFORMS" = arm64 ] || [ "$API_IMAGE_PLATFORMS" = amd64+arm64 ]; then
./scripts/ci-docker.sh buildx build \
--builder "$BUILDER_NAME" --platform "$platform" --network host \
--progress=plain --provenance=false \
--build-arg DOTNET_SDK_IMAGE="$DOTNET_SDK_IMAGE" \
--build-arg DOTNET_RUNTIME_IMAGE="$DOTNET_RUNTIME_IMAGE" \
--build-arg HTTP_PROXY="$HTTP_PROXY_URL" \
--build-arg HTTPS_PROXY="$HTTP_PROXY_URL" \
--build-arg NO_PROXY="$NO_PROXY_HOSTS" \
--build-arg http_proxy="$HTTP_PROXY_URL" \
--build-arg https_proxy="$HTTP_PROXY_URL" \
--build-arg no_proxy="$NO_PROXY_HOSTS" \
-f src/LiveRecorder.WebApi/Dockerfile -t "$api_ref" --push .
fi
if [ "$WEB_IMAGE_PLATFORMS" = arm64 ] || [ "$WEB_IMAGE_PLATFORMS" = amd64+arm64 ]; then
./scripts/ci-docker.sh buildx build \
--builder "$BUILDER_NAME" --platform "$platform" --network host \
--progress=plain --provenance=false \
--build-arg NODE_IMAGE="$NODE_BUILD_IMAGE" \
--build-arg NGINX_IMAGE="$NGINX_IMAGE" \
--build-arg HTTP_PROXY="$HTTP_PROXY_URL" \
--build-arg HTTPS_PROXY="$HTTP_PROXY_URL" \
--build-arg NO_PROXY="$NO_PROXY_HOSTS" \
--build-arg http_proxy="$HTTP_PROXY_URL" \
--build-arg https_proxy="$HTTP_PROXY_URL" \
--build-arg no_proxy="$NO_PROXY_HOSTS" \
--build-arg VITE_API_BASE_URL=/api \
-f frontend/Dockerfile -t "$web_ref" --push frontend
fi
'''
}
}
}
stage('Publish Multi-arch Manifests') {
when {
expression {
params.API_IMAGE_PLATFORMS == 'amd64+arm64' ||
params.WEB_IMAGE_PLATFORMS == 'amd64+arm64'
}
}
steps {
withCredentials([usernamePassword(
credentialsId: "${NODE_CREDENTIALS}",
usernameVariable: 'JENKINS_NODE_USERNAME',
passwordVariable: 'JENKINS_NODE_PASSWORD'
)]) {
sh '''
set -euo pipefail
publish_manifest() {
repository=$1
immutable_ref="$repository:$IMMUTABLE_TAG"
./scripts/ci-docker.sh buildx imagetools create \
--tag "$immutable_ref" \
"$immutable_ref-amd64" "$immutable_ref-arm64"
manifest=$(./scripts/ci-docker.sh buildx imagetools inspect --raw "$immutable_ref")
printf '%s' "$manifest" | grep -q '"architecture"[[:space:]]*:[[:space:]]*"amd64"'
printf '%s' "$manifest" | grep -q '"architecture"[[:space:]]*:[[:space:]]*"arm64"'
./scripts/ci-docker.sh buildx imagetools create \
--tag "$repository:$APP_VERSION" --tag "$repository:latest" \
"$immutable_ref"
}
if [ "$API_IMAGE_PLATFORMS" = amd64+arm64 ]; then
publish_manifest "$API_IMAGE_REPO"
fi
if [ "$WEB_IMAGE_PLATFORMS" = amd64+arm64 ]; then
publish_manifest "$WEB_IMAGE_REPO"
fi
'''
}
}
}
stage('Upload fnOS Artifact') {
when {
expression { params.BUILD_FNOS && params.UPLOAD_FNOS }
}
steps {
withCredentials([usernamePassword(
credentialsId: "${OPENLIST_CREDENTIALS}",
usernameVariable: 'OPENLIST_USERNAME',
passwordVariable: 'OPENLIST_PASSWORD'
)]) {
sh '''
set -euo pipefail
./scripts/upload-openlist-artifact.sh "$FPK_PATH" "$OPENLIST_REMOTE_DIR"
./scripts/upload-openlist-artifact.sh "$FPK_PATH.sha256" "$OPENLIST_REMOTE_DIR"
'''
}
}
}
stage('Build Summary') {
steps {
script {
def summary = [
'Commit: ' + sh(script: 'git rev-parse HEAD', returnStdout: true).trim(),
'Product version: ' + env.APP_VERSION,
'Tests: ' + (params.RUN_TESTS ? 'executed' : 'skipped')
]
if (params.BUILD_FNOS) {
def checksum = sh(
script: "cut -d ' ' -f 1 '" + env.FPK_PATH + ".sha256'",
returnStdout: true
).trim()
summary.addAll([
'fnOS package version: ' + env.FNOS_VERSION,
'fnOS: ' + env.FPK_BASENAME,
'Checksum: ' + checksum
])
if (params.UPLOAD_FNOS) {
summary << 'OpenList: ' + env.OPENLIST_BASE_URL + env.OPENLIST_REMOTE_DIR + '/' + env.FPK_BASENAME
}
}
if (params.API_IMAGE_PLATFORMS != 'none') {
summary << 'API platforms: ' + params.API_IMAGE_PLATFORMS
if (params.API_IMAGE_PLATFORMS.contains('amd64')) {
summary << 'API amd64: ' + env.API_IMAGE_REPO + ':' + env.IMMUTABLE_TAG + '-amd64'
}
if (params.API_IMAGE_PLATFORMS.contains('arm64')) {
summary << 'API arm64: ' + env.API_IMAGE_REPO + ':' + env.IMMUTABLE_TAG + '-arm64'
}
if (params.API_IMAGE_PLATFORMS == 'amd64+arm64') {
summary << 'API multi-arch: ' + env.API_IMAGE_REPO + ':' + env.IMMUTABLE_TAG
}
}
if (params.WEB_IMAGE_PLATFORMS != 'none') {
summary << 'Web platforms: ' + params.WEB_IMAGE_PLATFORMS
if (params.WEB_IMAGE_PLATFORMS.contains('amd64')) {
summary << 'Web amd64: ' + env.WEB_IMAGE_REPO + ':' + env.IMMUTABLE_TAG + '-amd64'
}
if (params.WEB_IMAGE_PLATFORMS.contains('arm64')) {
summary << 'Web arm64: ' + env.WEB_IMAGE_REPO + ':' + env.IMMUTABLE_TAG + '-arm64'
}
if (params.WEB_IMAGE_PLATFORMS == 'amd64+arm64') {
summary << 'Web multi-arch: ' + env.WEB_IMAGE_REPO + ':' + env.IMMUTABLE_TAG
}
}
echo summary.join('\n')
}
}
}
}
post {
success {
echo "Pipeline completed successfully."
echo "LiveRecorder ${env.IMMUTABLE_TAG} selected pipeline completed successfully."
}
failure {
echo "Pipeline failed. Please check the build log."
echo 'LiveRecorder pipeline failed; stable Docker tags were only updated if all architecture builds succeeded.'
}
always {
sh """
set +e
sudo docker logout ${REGISTRY_URL} >/dev/null 2>&1 || true
true
"""
deleteDir()
withCredentials([usernamePassword(
credentialsId: "${NODE_CREDENTIALS}",
usernameVariable: 'JENKINS_NODE_USERNAME',
passwordVariable: 'JENKINS_NODE_PASSWORD'
)]) {
sh '''
set +e
if [ -x ./scripts/ci-docker.sh ] && [ -n "${IMMUTABLE_TAG:-}" ]; then
for repository in "$API_IMAGE_REPO" "$WEB_IMAGE_REPO"; do
for suffix in amd64 arm64; do
./scripts/ci-docker.sh rmi "$repository:$IMMUTABLE_TAG-$suffix" >/dev/null 2>&1 || true
done
./scripts/ci-docker.sh manifest rm "$repository:$IMMUTABLE_TAG" >/dev/null 2>&1 || true
done
fi
if [ -f "$DOCKER_CONFIG/config.json" ]; then
: >"$DOCKER_CONFIG/config.json"
fi
if [ -x ./scripts/ci-docker.sh ] && [ -n "${BUILDER_NAME:-}" ]; then
./scripts/ci-docker.sh buildx prune \
--builder "$BUILDER_NAME" --filter until=168h \
--keep-storage 8gb --force >/dev/null 2>&1 || true
fi
if [ -d "${CI_ROOT:-}" ]; then
printf '%s\n' "$JENKINS_NODE_PASSWORD" | \
sudo -S -p '' chown -R -- "$(id -u):$(id -g)" "$CI_ROOT" || true
fi
'''
}
cleanWs(deleteDirs: true, notFailBuild: true)
}
}
}
+7
View File
@@ -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>
+31 -2
View File
@@ -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
View File
@@ -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:
+42 -1
View File
@@ -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 仅用于一次性历史数据导入。
+13
View File
@@ -0,0 +1,13 @@
{
".url": {
"liverecorder.Application": {
"title": "Live Recorder",
"icon": "images/icon_{0}.png",
"type": "url",
"protocol": "",
"port": "18080",
"url": "/",
"allUsers": false
}
}
}
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
+35
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
exit 0
Executable
+309
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
# Persistent application data and recordings are preserved by default.
exit 0
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -eu
"$(dirname -- "$0")/main" stop || true
exit 0
+15
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
{
"defaults": {
"run-as": "package"
},
"join-groups": ["video"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"data-share": {
"shares": [
{
"name": "liverecorder",
"permission": {
"rw": ["liverecorder"]
}
},
{
"name": "liverecorder/records",
"permission": {
"rw": ["liverecorder"]
}
}
]
}
}
+13
View File
@@ -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
+69
View File
@@ -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 个字符"
}
]
}
]
}
]
+23
View File
@@ -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
View File
@@ -1,3 +1,2 @@
node_modules
dist
npm-debug.log
+3
View File
@@ -0,0 +1,3 @@
registry=https://registry.npmmirror.com/
audit=false
fund=false
+18 -3
View File
@@ -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 . .
+10
View File
@@ -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;"]
+64
View File
@@ -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",
+3 -1
View File
@@ -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",
+35
View File
@@ -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 } }
}))
});
+4 -2
View File
@@ -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;
}
+12 -63
View File
@@ -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>
+64 -4
View File
@@ -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>
+69
View File
@@ -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>
+24 -12
View File
@@ -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 };
}
+3 -2
View File
@@ -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() {
+3 -1
View File
@@ -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;
+10 -2
View File
@@ -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
View File
@@ -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 {
File diff suppressed because it is too large Load Diff
+323 -3
View File
@@ -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;
}
+8 -3
View File
@@ -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 }">
+320
View File
@@ -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>
+163 -285
View File
@@ -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%;
}
+107 -97
View File
@@ -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>
+27 -12
View File
@@ -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"
>
+56 -29
View File
@@ -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>
+227 -9
View File
@@ -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>
+52 -10
View File
@@ -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) {
+391 -186
View File
@@ -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) {
+374 -23
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+82 -28
View File
@@ -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>
+794
View File
@@ -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>
+849
View File
@@ -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);
});
+2 -1
View File
@@ -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: {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class AuthRepository {
const AuthRepository(this._apiClient);
final ApiClient _apiClient;
Future<LoginResponse> login({
required String username,
required String password,
}) async {
final response = await _apiClient.postJson(
'/api/auth/login',
body: <String, dynamic>{
'username': username,
'password': password,
},
);
return LoginResponse.fromJson(response as Map<String, dynamic>);
}
Future<void> logout() {
return _apiClient.postEmpty('/api/auth/logout');
}
Future<void> changePassword({
required String currentPassword,
required String newPassword,
}) {
return _apiClient.postEmpty(
'/api/auth/change-password',
body: <String, dynamic>{
'currentPassword': currentPassword,
'newPassword': newPassword,
},
);
}
}
@@ -0,0 +1,72 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class LiveRoomsRepository {
const LiveRoomsRepository(this._apiClient);
final ApiClient _apiClient;
Future<List<LiveRoom>> listRooms() async {
final response = await _apiClient.getJson('/api/live-rooms') as List<dynamic>;
return response
.map((dynamic item) => LiveRoom.fromJson(item as Map<String, dynamic>))
.toList(growable: false);
}
Future<LiveRoom> getRoom(String roomId) async {
final response = await _apiClient.getJson('/api/live-rooms/$roomId') as Map<String, dynamic>;
return LiveRoom.fromJson(response);
}
Future<LiveRoom> refreshRoom(String roomId) async {
final response = await _apiClient.postJson('/api/live-rooms/$roomId/refresh') as Map<String, dynamic>;
return LiveRoom.fromJson(response);
}
Future<LiveRoom> setRoomEnabled({
required String roomId,
required bool isEnabled,
}) async {
final response = await _apiClient.putJson(
'/api/live-rooms/$roomId/enabled',
body: <String, dynamic>{'isEnabled': isEnabled},
) as Map<String, dynamic>;
return LiveRoom.fromJson(response);
}
Future<LiveRoom> createRoom({
required String url,
int? platformOverride,
}) async {
final response = await _apiClient.postJson(
'/api/live-rooms',
body: <String, dynamic>{
'url': url,
if (platformOverride case final int value) 'platformOverride': value,
},
) as Map<String, dynamic>;
return LiveRoom.fromJson(response);
}
Future<LiveRoom> updateMetadata({
required String roomId,
required Map<String, dynamic> payload,
}) async {
final response = await _apiClient.putJson(
'/api/live-rooms/$roomId/metadata',
body: payload,
) as Map<String, dynamic>;
return LiveRoom.fromJson(response);
}
Future<LiveRoom> updateSettings({
required String roomId,
required Map<String, dynamic> payload,
}) async {
final response = await _apiClient.putJson(
'/api/live-rooms/$roomId/settings',
body: payload,
) as Map<String, dynamic>;
return LiveRoom.fromJson(response);
}
}
@@ -0,0 +1,34 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class LogsRepository {
const LogsRepository(this._apiClient);
final ApiClient _apiClient;
Future<List<SystemLog>> listLogs({
String? liveRoomId,
String? recordSessionId,
String? recordTaskId,
int? level,
String? content,
int take = 200,
}) async {
final response = await _apiClient.getJson(
'/api/logs',
queryParameters: <String, String>{
if (liveRoomId != null && liveRoomId.isNotEmpty) 'liveRoomId': liveRoomId,
if (recordSessionId != null && recordSessionId.isNotEmpty) 'recordSessionId': recordSessionId,
if (recordTaskId != null && recordTaskId.isNotEmpty) 'recordTaskId': recordTaskId,
if (level != null) 'level': '$level',
if (content != null && content.trim().isNotEmpty) 'content': content.trim(),
'take': '$take',
},
) as List<dynamic>;
return response
.map((dynamic item) => SystemLog.fromJson(item as Map<String, dynamic>))
.toList(growable: false);
}
}
@@ -0,0 +1,39 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class MediaRepository {
const MediaRepository(this._apiClient);
final ApiClient _apiClient;
Future<MediaBrowserResponse> browse({String? path}) async {
final response = await _apiClient.getJson(
'/api/media/browser',
queryParameters: <String, String>{
if (path != null && path.isNotEmpty) 'path': path,
},
) as Map<String, dynamic>;
return MediaBrowserResponse.fromJson(response);
}
Uri buildFileUri({
required String relativePath,
bool download = false,
}) {
return _apiClient.buildUri(
'/api/media/file',
queryParameters: <String, String>{
'path': relativePath,
if (download) 'download': 'true',
},
);
}
Future<String> transcodeFile(String relativePath) async {
final response = await _apiClient.postJson(
'/api/media/transcode-file',
body: <String, dynamic>{'relativePath': relativePath},
) as Map<String, dynamic>;
return response['message']?.toString() ?? '转码任务已提交';
}
}
@@ -0,0 +1,82 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class RecordingsRepository {
const RecordingsRepository(this._apiClient);
final ApiClient _apiClient;
Future<List<RecordTask>> listTasks({String? liveRoomId}) async {
final response = await _apiClient.getJson(
'/api/record-tasks',
queryParameters: <String, String>{
if (liveRoomId != null && liveRoomId.isNotEmpty) 'liveRoomId': liveRoomId,
},
) as List<dynamic>;
return response
.map((dynamic item) => RecordTask.fromJson(item as Map<String, dynamic>))
.toList(growable: false);
}
Future<RecordTaskDetail> getTaskDetail(String taskId) async {
final response = await _apiClient.getJson('/api/record-tasks/$taskId') as Map<String, dynamic>;
return RecordTaskDetail.fromJson(response);
}
Future<RecordTask> startRecording({
required String liveRoomId,
required String preferredQuality,
required int outputFormat,
}) async {
final response = await _apiClient.postJson(
'/api/record-tasks/start',
body: <String, dynamic>{
'liveRoomId': liveRoomId,
'preferredQuality': preferredQuality,
'outputFormat': outputFormat,
},
) as Map<String, dynamic>;
return RecordTask.fromJson(response);
}
Future<RecordTask> stopTask(String taskId) async {
final response = await _apiClient.postJson('/api/record-tasks/$taskId/stop') as Map<String, dynamic>;
return RecordTask.fromJson(response);
}
Future<RecordPreviewTicket> createPreviewTicket(String taskId) async {
final response = await _apiClient.postJson('/api/record-tasks/$taskId/preview-ticket') as Map<String, dynamic>;
return RecordPreviewTicket.fromJson(response);
}
Future<void> uploadTask(String taskId) {
return _apiClient.postEmpty('/api/record-tasks/$taskId/upload');
}
Future<List<RecordSession>> listSessions({String? liveRoomId}) async {
final response = await _apiClient.getJson(
'/api/record-sessions',
queryParameters: <String, String>{
if (liveRoomId != null && liveRoomId.isNotEmpty) 'liveRoomId': liveRoomId,
},
) as List<dynamic>;
return response
.map((dynamic item) => RecordSession.fromJson(item as Map<String, dynamic>))
.toList(growable: false);
}
Future<RecordSessionDetail> getSessionDetail(String sessionId) async {
final response = await _apiClient.getJson('/api/record-sessions/$sessionId') as Map<String, dynamic>;
return RecordSessionDetail.fromJson(response);
}
Future<RecordSession> stopSession(String sessionId) async {
final response = await _apiClient.postJson('/api/record-sessions/$sessionId/stop') as Map<String, dynamic>;
return RecordSession.fromJson(response);
}
Future<void> uploadSession(String sessionId) {
return _apiClient.postEmpty('/api/record-sessions/$sessionId/upload');
}
}
@@ -0,0 +1,29 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class RecoveryRepository {
const RecoveryRepository(this._apiClient);
final ApiClient _apiClient;
Future<RecoveryOverview> getOverview() async {
final response = await _apiClient.getJson('/api/recovery') as Map<String, dynamic>;
return RecoveryOverview.fromJson(response);
}
Future<RecoveryActionResult> retryLiveRoom(String roomId) async {
final response = await _apiClient.postJson('/api/recovery/live-rooms/$roomId/retry') as Map<String, dynamic>;
return RecoveryActionResult.fromJson(response);
}
Future<RecoveryActionResult> retryAllLiveRooms() async {
final response = await _apiClient.postJson('/api/recovery/live-rooms/retry-all') as Map<String, dynamic>;
return RecoveryActionResult.fromJson(response);
}
Future<RecoveryActionResult> resumeFinalization(String taskId) async {
final response = await _apiClient.postJson('/api/recovery/finalizations/$taskId/resume') as Map<String, dynamic>;
return RecoveryActionResult.fromJson(response);
}
}
@@ -0,0 +1,27 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class SettingsRepository {
const SettingsRepository(this._apiClient);
final ApiClient _apiClient;
Future<SystemSettings> getSettings() async {
final response = await _apiClient.getJson('/api/settings') as Map<String, dynamic>;
return SystemSettings.fromJson(response);
}
Future<SystemSettings> updateSettings(SystemSettings settings) async {
final response = await _apiClient.putJson(
'/api/settings',
body: settings.toJson(),
) as Map<String, dynamic>;
return SystemSettings.fromJson(response);
}
Future<CleanupOperation> runRetentionCleanup() async {
final response = await _apiClient.postJson('/api/settings/retention/run-now') as Map<String, dynamic>;
return CleanupOperation.fromJson(response);
}
}
+44
View File
@@ -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` — 应用外壳注入(侧栏/顶栏/主题/折叠/抽屉)。
所有页面共享同一套设计系统与外壳,与第四阶段《设计规范》一致。
+701
View File
@@ -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; }
+169
View File
@@ -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'));
}
});
})();
+58
View File
@@ -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>
+132
View File
@@ -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
+196
View File
@@ -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>
+50
View File
@@ -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>
+56
View File
@@ -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 进程异常退出,退出码 1Connection 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>
+35
View File
@@ -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>
+57
View File
@@ -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>
+150
View File
@@ -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>
+42
View File
@@ -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>
+102
View File
@@ -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>
+187
View File
@@ -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>
+122
View File
@@ -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>
+40
View File
@@ -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>
+44
View File
@@ -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>
+155
View File
@@ -0,0 +1,155 @@
#!/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. The SDK is pulled for the builder platform while runtime images are
# pulled for ARM64, then the build runs with `--pull=false`.
# 3. `dotnet restore` goes through the host proxy; apt uses the in-Dockerfile
# Huawei Cloud 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"
BUILD_PLATFORM="${BUILD_PLATFORM:-$(docker version --format '{{.Server.Os}}/{{.Server.Arch}}')}"
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.huaweicloud.com|g" \
-e "s|security.debian.org/debian-security|mirrors.huaweicloud.com/debian-security|g" \
-e "s|archive.ubuntu.com|mirrors.huaweicloud.com|g" \
-e "s|security.ubuntu.com|mirrors.huaweicloud.com|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 platform="$1" img="$2" i
for i in $(seq 1 8); do
if [ "$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$img" 2>/dev/null || true)" = "$platform" ]; then
return 0
fi
log "Pulling $img for $platform (attempt $i/8) ..."
docker pull --platform "$platform" "$img" >/dev/null 2>&1 || true
if [ "$(docker image inspect --format '{{.Os}}/{{.Architecture}}' "$img" 2>/dev/null || true)" = "$platform" ]; then
return 0
fi
sleep 3
done
die "Could not pull $img for $platform (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 "$BUILD_PLATFORM" "$DOTNET_SDK_IMAGE"
pull_with_retry linux/arm64 "$DOTNET_RUNTIME_IMAGE"
pull_with_retry linux/arm64 "$NGINX_IMAGE"
build_api_image
build_frontend_image
export_images
+248
View File
@@ -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"
+10
View File
@@ -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 "$@"
+3
View File
@@ -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
+131
View File
@@ -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);
+184
View File
@@ -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
+177
View File
@@ -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'
+49
View File
@@ -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"
+189
View File
@@ -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
+120
View File
@@ -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);
}

Some files were not shown because too many files have changed in this diff Show More