Author SHA1 Message Date
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
281 changed files with 40077 additions and 3640 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
+15
View File
@@ -5,11 +5,19 @@
**/*.suo
frontend/node_modules/
frontend/dist/
frontend/dist-postgres/
frontend/test-results/
frontend/playwright-report/
**/.dotnet-cli-home/
.codex-temp/
.tools/
build.log
webapi-build.log
webapi-build-no-restore.log
artifacts/
data/
!mobile/lib/features/live_recorder/data/
!mobile/lib/features/live_recorder/data/**
records/
docker-data/
src/LiveRecorder.WebApi/data/
@@ -29,3 +37,10 @@ src/LiveRecorder.WebApi/live-recorder.db*
# Frontend source/config TypeScript files must stay trackable.
!frontend/**/*.ts
!frontend/**/*.tsx
# Docker image build artifacts and the ARM64 QEMU emulator binary.
*.tar
*.tar.gz
qemu-*-static
# Stray root lockfile created by running npm in the repo root (frontend has its own).
/package-lock.json
Vendored
+520 -171
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%;
}
+104 -235
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";
@@ -12,11 +12,12 @@ const router = useRouter();
const authStore = useAuthStore();
const loading = ref(false);
const { backendUnavailable, backendMessage } = useBackendStatus();
const { themeMode, resolvedTheme } = useUiPreferences();
const { themeMode } = useUiPreferences();
const form = reactive({
username: "admin",
password: "Admin@123"
password: "",
rememberMe: false
});
const currentThemeIcon = computed(() => {
@@ -35,9 +36,9 @@ async function handleLogin() {
loading.value = true;
try {
await authStore.login(form.username, form.password);
await authStore.login(form.username, form.password, form.rememberMe);
ElMessage.success("登录成功");
await router.push({ name: "live-rooms" });
await router.push({ name: "dashboard" });
} catch (error) {
ElMessage.error(
isBackendUnavailableError(error)
@@ -52,42 +53,22 @@ async function handleLogin() {
<template>
<div class="login-screen">
<section class="login-hero">
<div class="login-hero__eyebrow">Live Recorder</div>
<h1 class="login-hero__title">把直播录制做成可长期维护的专业控制台</h1>
<p class="login-hero__subtitle">
统一管理直播间自动开录恢复流程系统日志事件脚本和日报回顾让录制系统像真正的运维平台一样稳定工作
</p>
<div class="login-hero__grid">
<article class="login-hero__tile">
<strong>实时监控</strong>
<span>直播间自动开录决策会话与分片状态集中可见</span>
</article>
<article class="login-hero__tile">
<strong>恢复能力</strong>
<span>中断转码暂停录制重启恢复都有统一入口</span>
</article>
<article class="login-hero__tile">
<strong>自动化</strong>
<span>邮件Webhook事件脚本与自定义日志全部贯通</span>
</article>
<article class="login-hero__tile">
<strong>回顾分析</strong>
<span>时间轴和日报帮助我们快速复盘每一场直播</span>
</article>
<section class="login-brand" aria-label="Live Recorder 产品介绍">
<div class="login-brand__logo">
<span><el-icon :size="22"><VideoCamera /></el-icon></span>
<strong>Live Recorder</strong>
</div>
<div class="login-brand__message">
<span class="login-brand__status"><i />本地服务运行正常</span>
<h1>让每一次开播<br>都有迹可循</h1>
<p>集中监控直播状态自动完成录制与归档并在异常发生时第一时间告诉你</p>
</div>
<div class="login-brand__meta">SELF-HOSTED · PRIVATE · RELIABLE</div>
</section>
<section class="login-panel surface-card">
<div class="login-panel__header">
<div>
<div class="login-panel__kicker">Sign In</div>
<h2 class="login-panel__title">进入控制台</h2>
<p class="login-panel__subtitle">默认账户为 <span class="monospace">admin / Admin@123</span></p>
</div>
<el-select v-model="themeMode" size="small" class="login-panel__theme-select">
<section class="login-panel">
<div class="login-panel__toolbar">
<el-select v-model="themeMode" size="small" aria-label="界面主题" class="login-panel__theme-select">
<template #prefix>
<el-icon><component :is="currentThemeIcon" /></el-icon>
</template>
@@ -97,39 +78,54 @@ async function handleLogin() {
</el-select>
</div>
<el-alert
v-if="backendUnavailable"
class="login-panel__alert"
type="error"
:closable="false"
show-icon
title="后端服务暂时不可用"
:description="backendMessage"
/>
<div class="login-card surface-card">
<div class="login-card__header">
<div class="login-panel__eyebrow">LIVE RECORDER</div>
<h2 class="login-panel__title">欢迎回来</h2>
<p>登录后进入 Live Recorder 运行中心</p>
</div>
<el-form label-position="top" class="login-form" @submit.prevent="handleLogin">
<el-form-item label="用户名">
<el-input v-model="form.username" :prefix-icon="User" />
</el-form-item>
<el-alert
v-if="backendUnavailable"
class="login-panel__alert"
type="error"
:closable="false"
show-icon
title="后端服务暂时不可用"
:description="backendMessage"
/>
<el-form-item label="密码">
<el-input
v-model="form.password"
:prefix-icon="Lock"
type="password"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form label-position="top" class="login-form" autocomplete="on" @submit.prevent="handleLogin">
<el-form-item label="用户名">
<el-input
v-model="form.username"
id="username"
name="username"
:prefix-icon="User"
autocomplete="username"
spellcheck="false"
/>
</el-form-item>
<el-button class="login-form__submit" type="primary" :loading="loading" @click="handleLogin">
登录控制台
</el-button>
</el-form>
<el-form-item label="密码">
<el-input
v-model="form.password"
id="password"
name="password"
:prefix-icon="Lock"
type="password"
autocomplete="current-password"
show-password
@keyup.enter="handleLogin"
/>
</el-form-item>
<div class="login-panel__footer">
<span>当前主题{{ resolvedTheme === "dark" ? "深色" : "浅色" }}</span>
<span>双端适配Web / Mobile</span>
<el-checkbox v-model="form.rememberMe" name="rememberMe">记住登录状态30 </el-checkbox>
<el-button class="login-form__submit" native-type="submit" type="primary" :loading="loading">
登录控制台 <el-icon class="el-icon--right"><ArrowRight /></el-icon>
</el-button>
</el-form>
</div>
</section>
</div>
@@ -139,175 +135,48 @@ async function handleLogin() {
.login-screen {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 440px);
gap: 48px;
padding: 48px 56px;
grid-template-columns: minmax(420px, .95fr) minmax(480px, 1.05fr);
background: var(--surface);
}
.login-hero {
display: grid;
align-content: center;
gap: 24px;
.login-brand { position: relative; display: flex; flex-direction: column; min-height: 100vh; overflow: hidden; padding: clamp(32px, 5vw, 68px); border-right: 1px solid var(--border-subtle); background: radial-gradient(circle at 12% 8%, rgba(86,137,255,.18), transparent 34%), radial-gradient(circle at 92% 88%, rgba(146,119,255,.16), transparent 36%), linear-gradient(145deg, var(--accent-soft-2), var(--surface) 58%, var(--purple-soft)); }
.login-brand::after { content: ""; position: absolute; right: -170px; bottom: -180px; width: 480px; height: 480px; border: 75px solid color-mix(in srgb, var(--accent) 7%, transparent); border-radius: 50%; }
.login-brand__logo { position: relative; z-index: 1; display: flex; align-items: center; gap: 12px; color: var(--text-primary); font-size: 15px; }
.login-brand__logo > span { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; color: #fff; background: var(--action); box-shadow: 0 9px 24px color-mix(in srgb, var(--action) 26%, transparent); }
.login-brand__message { position: relative; z-index: 1; width: min(100%, 570px); margin: auto 0; }
.login-brand__status { display: inline-flex; align-items: center; gap: 9px; color: var(--success); font-size: 11px; font-weight: 750; }
.login-brand__status i { width: 8px; height: 8px; border-radius: 50%; background: var(--success); box-shadow: 0 0 0 5px color-mix(in srgb, var(--success) 14%, transparent); }
.login-brand__message h1 { margin: 24px 0 0; color: var(--text-primary); font-size: clamp(40px, 5vw, 66px); font-weight: 800; letter-spacing: -.065em; line-height: 1.08; }
.login-brand__message p { max-width: 500px; margin: 24px 0 0; color: var(--text-secondary); font-size: 14px; line-height: 1.8; }
.login-brand__meta { position: relative; z-index: 1; color: var(--text-muted); font-size: 9px; font-weight: 750; letter-spacing: .18em; }
.login-panel { position: relative; display: grid; place-items: center; min-height: 100vh; padding: 70px clamp(24px, 7vw, 96px); background: var(--surface); }
.login-panel__toolbar { position: absolute; top: 24px; right: 28px; }
.login-card { width: min(100%, 420px); padding: 30px; box-shadow: var(--shadow-sm); }
.login-card__header { margin-bottom: 24px; }
.login-card__header p { margin: 8px 0 0; color: var(--text-muted); font-size: 12px; }
.login-panel__eyebrow { color: var(--accent); font-size: 10px; font-weight: 800; letter-spacing: .12em; }
.login-panel__title { margin: 7px 0 0; color: var(--text-primary); font-size: 28px; font-weight: 780; letter-spacing: -.045em; }
.login-panel__theme-select { width: 122px; }
.login-panel__alert { margin-bottom: 18px; }
.login-form__submit { width: 100%; margin-top: 8px; }
:global(html[data-theme="dark"]) .login-brand { background: radial-gradient(circle at 12% 8%, rgba(58,112,220,.22), transparent 34%), radial-gradient(circle at 92% 88%, rgba(119,88,207,.19), transparent 36%), linear-gradient(145deg, #111d30, #172235 58%, #1c223b); }
@media (max-width: 900px) {
.login-screen { grid-template-columns: 1fr; min-height: 100dvh; }
.login-brand { min-height: 240px; padding: 26px 24px 30px; }
.login-brand__message { margin: 38px 0 0; }
.login-brand__message h1 { margin-top: 16px; font-size: 34px; }
.login-brand__message p { margin-top: 13px; font-size: 12px; line-height: 1.65; }
.login-brand__meta { display: none; }
.login-panel { min-height: 0; padding: 70px 18px 28px; }
.login-panel__toolbar { top: 18px; right: 18px; }
}
.login-hero__eyebrow,
.login-panel__kicker {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.login-hero__title {
margin: 0;
max-width: 11ch;
color: var(--text-primary);
font-size: clamp(44px, 5vw, 72px);
font-weight: 780;
letter-spacing: -0.065em;
line-height: 0.94;
}
.login-hero__subtitle {
max-width: 60ch;
margin: 0;
color: var(--text-secondary);
font-size: 16px;
line-height: 1.85;
}
.login-hero__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
max-width: 720px;
}
.login-hero__tile {
display: grid;
gap: 8px;
padding: 18px;
border-radius: 10px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.42);
box-shadow: var(--shadow-soft);
}
:global(html[data-theme="dark"]) .login-hero__tile {
background: rgba(255, 255, 255, 0.02);
}
.login-hero__tile strong {
color: var(--text-primary);
font-size: 14px;
}
.login-hero__tile span {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.65;
}
.login-panel {
align-self: center;
padding: 24px;
}
.login-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.login-panel__title {
margin: 6px 0 0;
color: var(--text-primary);
font-size: 28px;
font-weight: 750;
letter-spacing: -0.045em;
}
.login-panel__subtitle {
margin: 10px 0 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
}
.login-panel__theme-select {
width: 122px;
}
.login-panel__alert {
margin-bottom: 18px;
}
.login-form__submit {
width: 100%;
margin-top: 8px;
}
.login-panel__footer {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
color: var(--text-muted);
font-size: 12px;
}
@media (max-width: 1100px) {
.login-screen {
grid-template-columns: 1fr;
gap: 28px;
padding: 28px 20px;
}
.login-hero__grid {
grid-template-columns: 1fr;
max-width: none;
}
.login-panel {
width: 100%;
max-width: 480px;
}
}
@media (max-width: 767px) {
.login-screen {
padding: 18px 14px 24px;
}
.login-hero {
gap: 18px;
}
.login-hero__title {
max-width: none;
font-size: clamp(34px, 12vw, 48px);
}
.login-hero__subtitle {
font-size: 14px;
}
.login-panel {
padding: 18px;
}
.login-panel__header,
.login-panel__footer {
flex-direction: column;
}
.login-panel__theme-select {
width: 100%;
}
@media (max-width: 520px) {
.login-brand { min-height: 218px; padding: 21px 18px 26px; }
.login-brand__logo > span { width: 34px; height: 34px; }
.login-brand__message { margin-top: 30px; }
.login-brand__status { display: none; }
.login-brand__message h1 { margin-top: 0; font-size: 29px; }
.login-brand__message p { display: none; }
.login-panel { padding-inline: 14px; }
.login-card { padding: 22px 18px; }
}
</style>
+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: {
+10
View File
@@ -0,0 +1,10 @@
.dart_tool/
.tmp/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub/
build/
coverage/
android/.gradle/
android/local.properties
+24
View File
@@ -0,0 +1,24 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "44a626f4f0027bc38a46dc68aed5964b05a83c18"
channel: "stable"
project_type: app
migration:
platforms:
- platform: root
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
- platform: android
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
unmanaged_files:
- "lib/main.dart"
- "android/app/src/main/kotlin/com/liverecorder/mobile/MainActivity.kt"
+6
View File
@@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
avoid_print: false
+39
View File
@@ -0,0 +1,39 @@
plugins {
id("com.android.application")
id("kotlin-android")
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.liverecorder.mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "com.liverecorder.mobile"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:usesCleartextTraffic="true"
tools:targetApi="28" />
</manifest>
@@ -0,0 +1,34 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="LiveRecorder"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,34 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new com.github.dart_lang.jni.JniPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin jni, com.github.dart_lang.jni.JniPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.github.dart_lang.jni_flutter.JniFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin jni_flutter, com.github.dart_lang.jni_flutter.JniFlutterPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
}
}
}
@@ -0,0 +1,6 @@
package com.liverecorder.mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
+38
View File
@@ -0,0 +1,38 @@
allprojects {
buildscript {
repositories {
maven("https://maven.aliyun.com/repository/public")
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
google()
mavenCentral()
}
}
repositories {
maven("https://maven.aliyun.com/repository/public")
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
Binary file not shown.
@@ -0,0 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
+90
View File
@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+29
View File
@@ -0,0 +1,29 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
maven("https://maven.aliyun.com/repository/gradle-plugin")
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
+161
View File
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/app/app_scope.dart';
import 'package:live_recorder_mobile/app/app_theme.dart';
import 'package:live_recorder_mobile/core/config/api_config.dart';
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/backend_setup_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/login_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/mobile_shell_page.dart';
class LiveRecorderBootstrap extends StatefulWidget {
const LiveRecorderBootstrap({
super.key,
required this.config,
});
final ApiConfig config;
@override
State<LiveRecorderBootstrap> createState() => _LiveRecorderBootstrapState();
}
class _LiveRecorderBootstrapState extends State<LiveRecorderBootstrap> {
late final AppBootstrapController<AppDependencies> _bootstrapController =
AppBootstrapController<AppDependencies>(
config: widget.config,
configStorage: AppConfigStorage(),
dependenciesFactory: (String baseUrl) => AppDependencies.create(baseUrl: baseUrl),
);
@override
void initState() {
super.initState();
_bootstrapController.initialize();
}
@override
void dispose() {
_bootstrapController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _bootstrapController,
builder: (BuildContext context, _) {
return AppScope(
backendConfig: _bootstrapController,
dependencies: _bootstrapController.dependencies,
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'LiveRecorder',
theme: buildLiveRecorderTheme(),
home: _BootstrapHome(
bootstrapController: _bootstrapController,
),
),
);
},
);
}
}
class _BootstrapHome extends StatelessWidget {
const _BootstrapHome({
required this.bootstrapController,
});
final AppBootstrapController<AppDependencies> bootstrapController;
@override
Widget build(BuildContext context) {
if (bootstrapController.isInitializing) {
return const _LoadingSplashPage();
}
if (bootstrapController.initializationErrorMessage != null) {
return _BootstrapErrorPage(
message: bootstrapController.initializationErrorMessage!,
onRetry: bootstrapController.initialize,
);
}
if (!bootstrapController.hasConfiguredBackend) {
return BackendSetupPage(
bootstrapController: bootstrapController,
);
}
final dependencies = bootstrapController.dependencies;
if (dependencies == null) {
return _BootstrapErrorPage(
message: '后端配置未能正确加载,请重试',
onRetry: bootstrapController.initialize,
);
}
return ListenableBuilder(
listenable: dependencies.sessionController,
builder: (BuildContext context, _) {
if (dependencies.sessionController.isRestoring) {
return const _LoadingSplashPage();
}
if (dependencies.sessionController.isLoggedIn) {
return MobileShellPage(
dependencies: dependencies,
);
}
return LoginPage(
sessionController: dependencies.sessionController,
);
},
);
}
}
class _LoadingSplashPage extends StatelessWidget {
const _LoadingSplashPage();
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
}
class _BootstrapErrorPage extends StatelessWidget {
const _BootstrapErrorPage({
required this.message,
required this.onRetry,
});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: AppErrorCard(
message: message,
onRetry: onRetry,
),
),
),
),
);
}
}
@@ -0,0 +1,167 @@
import 'package:flutter/foundation.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/core/config/api_config.dart';
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
typedef AppDependenciesFactory<T extends AppDependencyBundle> = T Function(String baseUrl);
abstract interface class BackendConfigHandle extends Listenable {
String get seedBaseUrl;
String? get backendBaseUrl;
bool get hasConfiguredBackend;
bool get isInitializing;
String? get initializationErrorMessage;
Future<void> initialize();
Future<void> saveInitialBackendBaseUrl(String rawValue);
Future<bool> updateBackendBaseUrl(String rawValue);
}
class AppBootstrapController<T extends AppDependencyBundle> extends ChangeNotifier
implements BackendConfigHandle {
AppBootstrapController({
required ApiConfig config,
required BackendConfigStore configStorage,
required AppDependenciesFactory<T> dependenciesFactory,
}) : _config = config,
_configStorage = configStorage,
_dependenciesFactory = dependenciesFactory;
final ApiConfig _config;
final BackendConfigStore _configStorage;
final AppDependenciesFactory<T> _dependenciesFactory;
T? _dependencies;
String? _backendBaseUrl;
bool _isInitializing = true;
String? _initializationErrorMessage;
T? get dependencies => _dependencies;
@override
String get seedBaseUrl => _config.seedBaseUrl;
@override
String? get backendBaseUrl => _backendBaseUrl;
@override
bool get hasConfiguredBackend => _backendBaseUrl != null && _backendBaseUrl!.isNotEmpty;
@override
bool get isInitializing => _isInitializing;
@override
String? get initializationErrorMessage => _initializationErrorMessage;
@override
Future<void> initialize() async {
_setInitializing(true);
try {
final storedBaseUrl = await _configStorage.readBackendBaseUrl();
if (storedBaseUrl == null || storedBaseUrl.trim().isEmpty) {
_disposeDependencies();
_backendBaseUrl = null;
return;
}
final normalizedBaseUrl = normalizeBackendBaseUrl(storedBaseUrl);
_backendBaseUrl = normalizedBaseUrl;
await _rebuildDependencies(normalizedBaseUrl);
} on FormatException {
await _configStorage.clear();
_disposeDependencies();
_backendBaseUrl = null;
} catch (_) {
_disposeDependencies();
_backendBaseUrl = null;
_initializationErrorMessage = '读取后端地址失败,请重试';
} finally {
_setInitializing(false);
}
}
@override
Future<void> saveInitialBackendBaseUrl(String rawValue) async {
final normalizedBaseUrl = normalizeBackendBaseUrl(rawValue);
await _persistAndApplyBackendBaseUrl(
normalizedBaseUrl,
clearExistingSession: false,
);
}
@override
Future<bool> updateBackendBaseUrl(String rawValue) async {
final normalizedBaseUrl = normalizeBackendBaseUrl(rawValue);
if (normalizedBaseUrl == _backendBaseUrl) {
return false;
}
await _persistAndApplyBackendBaseUrl(
normalizedBaseUrl,
clearExistingSession: true,
);
return true;
}
Future<void> _persistAndApplyBackendBaseUrl(
String normalizedBaseUrl, {
required bool clearExistingSession,
}) async {
final previousDependencies = _dependencies;
_setInitializing(true);
try {
await _configStorage.writeBackendBaseUrl(normalizedBaseUrl);
if (clearExistingSession) {
await previousDependencies?.sessionController.clearLocalSession();
}
_backendBaseUrl = normalizedBaseUrl;
await _rebuildDependencies(normalizedBaseUrl);
} catch (error) {
if (!identical(previousDependencies, _dependencies)) {
_dependencies?.dispose();
_dependencies = previousDependencies;
}
rethrow;
} finally {
_setInitializing(false);
}
}
Future<void> _rebuildDependencies(String baseUrl) async {
final nextDependencies = _dependenciesFactory(baseUrl);
final previousDependencies = _dependencies;
_dependencies = nextDependencies;
try {
await nextDependencies.sessionController.restore();
previousDependencies?.dispose();
} catch (_) {
nextDependencies.dispose();
_dependencies = previousDependencies;
rethrow;
}
}
void _setInitializing(bool value) {
_isInitializing = value;
if (value) {
_initializationErrorMessage = null;
}
notifyListeners();
}
void _disposeDependencies() {
final dependencies = _dependencies;
_dependencies = null;
dependencies?.dispose();
}
@override
void dispose() {
_disposeDependencies();
super.dispose();
}
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/core/persistence/session_storage.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/auth_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/live_rooms_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/logs_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/media_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recovery_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recordings_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
abstract interface class AppDependencyBundle {
SessionControllerHandle get sessionController;
void dispose();
}
class AppDependencies implements AppDependencyBundle {
AppDependencies._({
required this.backendBaseUrl,
required this.apiClient,
required this.sessionStorage,
required this.authRepository,
required this.liveRoomsRepository,
required this.recordingsRepository,
required this.recoveryRepository,
required this.settingsRepository,
required this.logsRepository,
required this.mediaRepository,
required this.sessionController,
});
factory AppDependencies.create({
required String baseUrl,
}) {
late AppSessionController sessionController;
final sessionStorage = SessionStorage();
final apiClient = ApiClient(
baseUrl: baseUrl,
tokenProvider: () => sessionController.token,
onUnauthorized: () async => sessionController.handleUnauthorized(),
);
final authRepository = AuthRepository(apiClient);
sessionController = AppSessionController(
authRepository: authRepository,
sessionStorage: sessionStorage,
);
return AppDependencies._(
backendBaseUrl: baseUrl,
apiClient: apiClient,
sessionStorage: sessionStorage,
authRepository: authRepository,
liveRoomsRepository: LiveRoomsRepository(apiClient),
recordingsRepository: RecordingsRepository(apiClient),
recoveryRepository: RecoveryRepository(apiClient),
settingsRepository: SettingsRepository(apiClient),
logsRepository: LogsRepository(apiClient),
mediaRepository: MediaRepository(apiClient),
sessionController: sessionController,
);
}
final String backendBaseUrl;
final ApiClient apiClient;
final SessionStorage sessionStorage;
final AuthRepository authRepository;
final LiveRoomsRepository liveRoomsRepository;
final RecordingsRepository recordingsRepository;
final RecoveryRepository recoveryRepository;
final SettingsRepository settingsRepository;
final LogsRepository logsRepository;
final MediaRepository mediaRepository;
@override
final AppSessionController sessionController;
@override
void dispose() {
apiClient.dispose();
sessionController.dispose();
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/widgets.dart';
import 'app_bootstrap_controller.dart';
import 'app_dependencies.dart';
class AppScope extends InheritedWidget {
const AppScope({
super.key,
required this.backendConfig,
required this.dependencies,
required super.child,
});
final BackendConfigHandle backendConfig;
final AppDependencies? dependencies;
static AppDependencies of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'AppScope is not available in this context.');
final dependencies = scope!.dependencies;
assert(dependencies != null, 'AppDependencies are not available in this context.');
return dependencies!;
}
static BackendConfigHandle backendConfigOf(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'AppScope is not available in this context.');
return scope!.backendConfig;
}
@override
bool updateShouldNotify(AppScope oldWidget) {
return dependencies != oldWidget.dependencies || backendConfig != oldWidget.backendConfig;
}
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
ThemeData buildLiveRecorderTheme() {
const seed = Color(0xFF2563EB);
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: seed,
primary: seed,
surface: Colors.white,
),
scaffoldBackgroundColor: const Color(0xFFF6F8FB),
cardTheme: CardThemeData(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
side: const BorderSide(color: Color(0xFFE2E8F0)),
),
margin: EdgeInsets.zero,
),
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
surfaceTintColor: Colors.transparent,
foregroundColor: Color(0xFF0F172A),
),
navigationBarTheme: NavigationBarThemeData(
height: 72,
labelTextStyle: WidgetStateProperty.resolveWith<TextStyle?>(
(Set<WidgetState> states) {
final color = states.contains(WidgetState.selected)
? const Color(0xFF2563EB)
: const Color(0xFF64748B);
return TextStyle(
color: color,
fontWeight: states.contains(WidgetState.selected) ? FontWeight.w700 : FontWeight.w500,
);
},
),
indicatorColor: const Color(0xFFE0ECFF),
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
hintStyle: const TextStyle(color: Color(0xFF64748B)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFF2563EB), width: 1.4),
),
),
chipTheme: ChipThemeData(
backgroundColor: Colors.white,
selectedColor: const Color(0xFFE0ECFF),
side: const BorderSide(color: Color(0xFFE2E8F0)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
labelStyle: const TextStyle(
color: Color(0xFF64748B),
fontWeight: FontWeight.w600,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
),
dividerTheme: const DividerThemeData(
color: Color(0xFFE2E8F0),
thickness: 1,
),
);
}
+14
View File
@@ -0,0 +1,14 @@
class ApiConfig {
const ApiConfig({
this.seedBaseUrl = '',
});
final String seedBaseUrl;
static ApiConfig fromEnvironment() {
const rawValue = String.fromEnvironment('LIVE_RECORDER_API_BASE_URL');
return ApiConfig(seedBaseUrl: rawValue.trim());
}
bool get hasSeedBaseUrl => seedBaseUrl.isNotEmpty;
}
+210
View File
@@ -0,0 +1,210 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'api_exception.dart';
typedef TokenProvider = String? Function();
typedef UnauthorizedCallback = Future<void> Function();
class ApiClient {
ApiClient({
required String baseUrl,
required TokenProvider tokenProvider,
required UnauthorizedCallback onUnauthorized,
http.Client? client,
}) : _baseUri = Uri.parse(baseUrl),
_tokenProvider = tokenProvider,
_onUnauthorized = onUnauthorized,
_client = client ?? http.Client();
final Uri _baseUri;
final TokenProvider _tokenProvider;
final UnauthorizedCallback _onUnauthorized;
final http.Client _client;
Uri buildUri(
String path, {
Map<String, String>? queryParameters,
}) {
if (path.startsWith('http://') || path.startsWith('https://')) {
return Uri.parse(path);
}
final normalizedPath = path.startsWith('/') ? path.substring(1) : path;
final basePath = _baseUri.path == '/' ? '' : _baseUri.path.replaceAll(RegExp(r'/+$'), '');
final resolvedPath = basePath.isEmpty ? '/$normalizedPath' : '$basePath/$normalizedPath';
final resolved = _baseUri.replace(path: resolvedPath);
if (queryParameters == null || queryParameters.isEmpty) {
return resolved;
}
return resolved.replace(
queryParameters: <String, String>{
...resolved.queryParameters,
...queryParameters,
},
);
}
Future<dynamic> getJson(
String path, {
Map<String, String>? queryParameters,
}) {
return _sendJsonRequest(
'GET',
path,
queryParameters: queryParameters,
);
}
Future<dynamic> postJson(
String path, {
Object? body,
Map<String, String>? queryParameters,
}) {
return _sendJsonRequest(
'POST',
path,
body: body,
queryParameters: queryParameters,
);
}
Future<dynamic> putJson(
String path, {
Object? body,
Map<String, String>? queryParameters,
}) {
return _sendJsonRequest(
'PUT',
path,
body: body,
queryParameters: queryParameters,
);
}
Future<dynamic> deleteJson(
String path, {
Object? body,
Map<String, String>? queryParameters,
}) {
return _sendJsonRequest(
'DELETE',
path,
body: body,
queryParameters: queryParameters,
);
}
Future<dynamic> _sendJsonRequest(
String method,
String path, {
Object? body,
Map<String, String>? queryParameters,
}) async {
final uri = buildUri(path, queryParameters: queryParameters);
final request = http.Request(method, uri);
request.headers.addAll(_buildHeaders());
if (body != null) {
request.body = jsonEncode(body);
}
http.StreamedResponse streamedResponse;
try {
streamedResponse = await _client.send(request);
} on Exception catch (error) {
throw ApiException(message: '无法连接后端服务', detail: error.toString());
}
final response = await http.Response.fromStream(streamedResponse);
return _decodeJsonResponse(response);
}
Future<void> postEmpty(
String path, {
Object? body,
}) async {
await postJson(path, body: body);
}
Map<String, String> _buildHeaders() {
final headers = <String, String>{
'Content-Type': 'application/json',
'Accept': 'application/json',
};
final token = _tokenProvider()?.trim();
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
return headers;
}
dynamic _decodeJsonResponse(http.Response response) {
if (response.statusCode == 401) {
_onUnauthorized();
}
final bodyText = utf8.decode(response.bodyBytes);
final jsonBody = bodyText.trim().isEmpty ? null : jsonDecode(bodyText);
if (response.statusCode >= 200 && response.statusCode < 300) {
return jsonBody;
}
throw ApiException(
message: _resolveErrorMessage(response.statusCode, jsonBody),
statusCode: response.statusCode,
detail: jsonBody is Map<String, dynamic>
? (jsonBody['detail'] ?? jsonBody['error'])?.toString()
: null,
);
}
String _resolveErrorMessage(int statusCode, dynamic body) {
if (body is Map<String, dynamic>) {
final candidate = <dynamic>[
body['message'],
body['title'],
body['detail'],
body['error'],
].firstWhere(
(value) => value is String && value.trim().isNotEmpty,
orElse: () => null,
);
if (candidate is String) {
return candidate;
}
} else if (body is String && body.trim().isNotEmpty) {
return body;
}
switch (statusCode) {
case 400:
return '请求参数有误,请检查后重试';
case 401:
return '登录状态已失效,请重新登录';
case 403:
return '当前没有权限执行该操作';
case 404:
return '请求的接口不存在';
case 409:
return '请求发生冲突,请刷新后重试';
case 422:
return '提交的数据格式不正确,请检查后重试';
case 500:
return '后端服务发生内部错误';
case 502:
case 503:
case 504:
return '后端服务暂时不可用,请稍后重试';
default:
return '请求失败,请稍后重试';
}
}
void dispose() {
_client.close();
}
}
@@ -0,0 +1,25 @@
class ApiException implements Exception {
const ApiException({
required this.message,
this.statusCode,
this.detail,
});
final String message;
final int? statusCode;
final String? detail;
@override
String toString() {
final buffer = StringBuffer('ApiException(message: $message');
if (statusCode != null) {
buffer.write(', statusCode: $statusCode');
}
if (detail != null && detail!.isNotEmpty) {
buffer.write(', detail: $detail');
}
buffer.write(')');
return buffer.toString();
}
}
@@ -0,0 +1,60 @@
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
abstract interface class BackendConfigStore {
Future<String?> readBackendBaseUrl();
Future<void> writeBackendBaseUrl(String baseUrl);
Future<void> clear();
}
class AppConfigStorage implements BackendConfigStore {
@override
Future<String?> readBackendBaseUrl() async {
final file = await _configFile();
if (!await file.exists()) {
return null;
}
final content = await file.readAsString();
if (content.trim().isEmpty) {
return null;
}
final payload = jsonDecode(content);
if (payload is! Map<String, dynamic>) {
return null;
}
final value = payload['backendBaseUrl']?.toString().trim();
if (value == null || value.isEmpty) {
return null;
}
return value;
}
@override
Future<void> writeBackendBaseUrl(String baseUrl) async {
final file = await _configFile();
await file.create(recursive: true);
await file.writeAsString(
jsonEncode(<String, dynamic>{
'backendBaseUrl': baseUrl,
}),
);
}
@override
Future<void> clear() async {
final file = await _configFile();
if (await file.exists()) {
await file.delete();
}
}
Future<File> _configFile() async {
final directory = await getApplicationSupportDirectory();
return File('${directory.path}${Platform.pathSeparator}live_recorder_app_config.json');
}
}
@@ -0,0 +1,39 @@
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
class SessionStorage {
Future<Map<String, dynamic>?> read() async {
final file = await _sessionFile();
if (!await file.exists()) {
return null;
}
final content = await file.readAsString();
if (content.trim().isEmpty) {
return null;
}
return jsonDecode(content) as Map<String, dynamic>;
}
Future<void> write(Map<String, dynamic> payload) async {
final file = await _sessionFile();
await file.create(recursive: true);
await file.writeAsString(jsonEncode(payload));
}
Future<void> clear() async {
final file = await _sessionFile();
if (await file.exists()) {
await file.delete();
}
}
Future<File> _sessionFile() async {
final directory = await getApplicationSupportDirectory();
return File('${directory.path}${Platform.pathSeparator}live_recorder_session.json');
}
}
@@ -0,0 +1,67 @@
import 'dart:async';
class PollingController {
PollingController({
required Duration interval,
required Future<void> Function() onTick,
}) : _interval = interval,
_onTick = onTick;
final Duration _interval;
final Future<void> Function() _onTick;
Timer? _timer;
bool _active = false;
bool _busy = false;
void setActive(bool active) {
if (_active == active) {
return;
}
_active = active;
if (_active) {
_schedule();
triggerNow();
} else {
_timer?.cancel();
_timer = null;
}
}
void triggerNow() {
if (!_active || _busy) {
return;
}
_tick();
}
Future<void> _tick() async {
_busy = true;
try {
await _onTick();
} finally {
_busy = false;
_schedule();
}
}
void _schedule() {
_timer?.cancel();
if (!_active) {
return;
}
_timer = Timer(_interval, () {
if (_active && !_busy) {
_tick();
}
});
}
void dispose() {
_timer?.cancel();
}
}
@@ -0,0 +1,34 @@
String normalizeBackendBaseUrl(String rawValue) {
final trimmed = rawValue.trim();
if (trimmed.isEmpty) {
throw const FormatException('请输入后端地址');
}
final uri = Uri.tryParse(trimmed);
if (uri == null ||
!uri.hasScheme ||
(uri.scheme != 'http' && uri.scheme != 'https') ||
uri.host.isEmpty) {
throw const FormatException('请输入以 http:// 或 https:// 开头的完整地址');
}
if (uri.query.isNotEmpty || uri.fragment.isNotEmpty) {
throw const FormatException('后端地址不能包含查询参数或片段');
}
var normalizedPath = uri.path.replaceAll(RegExp(r'/+$'), '');
if (normalizedPath == '/') {
normalizedPath = '';
}
return uri.replace(path: normalizedPath).toString();
}
String? validateBackendBaseUrl(String rawValue) {
try {
normalizeBackendBaseUrl(rawValue);
return null;
} on FormatException catch (error) {
return error.message;
}
}
+89
View File
@@ -0,0 +1,89 @@
import 'package:intl/intl.dart';
final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm');
final DateFormat _timeFormat = DateFormat('HH:mm');
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
String formatDateTime(String? value) {
if (value == null || value.trim().isEmpty) {
return '--';
}
final dateTime = DateTime.tryParse(value)?.toLocal();
if (dateTime == null) {
return '--';
}
return _dateTimeFormat.format(dateTime);
}
String formatTime(String? value) {
if (value == null || value.trim().isEmpty) {
return '--';
}
final dateTime = DateTime.tryParse(value)?.toLocal();
if (dateTime == null) {
return '--';
}
return _timeFormat.format(dateTime);
}
String formatDateOnly(String? value) {
if (value == null || value.trim().isEmpty) {
return '--';
}
final dateTime = DateTime.tryParse(value)?.toLocal();
if (dateTime == null) {
return '--';
}
return _dateFormat.format(dateTime);
}
String formatDurationSeconds(num? seconds) {
if (seconds == null) {
return '--';
}
final totalSeconds = seconds.round();
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final remainingSeconds = totalSeconds % 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
if (minutes > 0) {
return '${minutes}m ${remainingSeconds}s';
}
return '${remainingSeconds}s';
}
String formatBytes(num? bytes) {
if (bytes == null) {
return '--';
}
const units = <String>['B', 'KB', 'MB', 'GB', 'TB'];
var value = bytes.toDouble();
var index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
final fractionDigits = index == 0 ? 0 : index == 1 ? 1 : 2;
return '${value.toStringAsFixed(fractionDigits)} ${units[index]}';
}
String valueOrDash(Object? value) {
if (value == null) {
return '--';
}
final text = value.toString().trim();
return text.isEmpty ? '--' : text;
}

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