Author SHA1 Message Date
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
154 changed files with 15541 additions and 473 deletions
+3
View File
@@ -5,11 +5,14 @@
**/*.suo
frontend/node_modules/
frontend/dist/
.codex-temp/
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/
Vendored
+86 -65
View File
@@ -1,5 +1,5 @@
pipeline {
agent { label '构建机1' }
agent { label '\u6784\u5efa\u673a1' }
options {
timestamps()
@@ -8,15 +8,17 @@ pipeline {
}
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'
REGISTRY_URL = 'reg.nxsir.cn'
API_IMAGE_NAME = 'liverecorder/app-api'
WEB_IMAGE_NAME = 'liverecorder/app-web'
IMAGE_TAG = "${env.BUILD_ID}"
DOCKER_CREDS = 'harbor_key'
TARGET_PLATFORMS = 'linux/amd64,linux/arm64'
BUILDER_NAME = 'liverecorder-buildx'
WEB_NODE_IMAGE = 'docker.m.daocloud.io/library/node:22-alpine'
WEB_NGINX_IMAGE = 'docker.m.daocloud.io/library/nginx:1.27-alpine'
HTTP_PROXY_URL = 'http://192.168.5.200:7890'
NO_PROXY_HOSTS = '127.0.0.1,localhost,reg.nxsir.cn,gitea.nxsir.cn'
GIT_REPO_URL = 'https://gitea.nxsir.cn/nanxun/live_recorder.git'
GIT_BRANCH = 'main'
@@ -26,6 +28,8 @@ pipeline {
API_IMAGE_LATEST = "${REGISTRY_URL}/${API_IMAGE_NAME}:latest"
WEB_IMAGE_TAGGED = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:${IMAGE_TAG}"
WEB_IMAGE_LATEST = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:latest"
API_CACHE_IMAGE = "${REGISTRY_URL}/${API_IMAGE_NAME}:buildcache"
WEB_CACHE_IMAGE = "${REGISTRY_URL}/${WEB_IMAGE_NAME}:buildcache"
}
stages {
@@ -52,9 +56,20 @@ pipeline {
set -e
sudo docker buildx version
sudo docker run --privileged --rm tonistiigi/binfmt --install arm64
# 强制清理旧的 builder 实例,防止僵尸状态
sudo docker buildx rm ${BUILDER_NAME} || true
if ! sudo docker buildx inspect --builder ${BUILDER_NAME} >/dev/null 2>&1; then
sudo docker buildx create \
--name ${BUILDER_NAME} \
--driver docker-container \
--driver-opt network=host \
--driver-opt 'env.HTTP_PROXY=${HTTP_PROXY_URL}' \
--driver-opt 'env.HTTPS_PROXY=${HTTP_PROXY_URL}' \
--driver-opt 'env.NO_PROXY=reg.nxsir.cn' \
--driver-opt 'env.http_proxy=${HTTP_PROXY_URL}' \
--driver-opt 'env.https_proxy=${HTTP_PROXY_URL}' \
--driver-opt 'env.no_proxy=reg.nxsir.cn' \
--use
fi
sudo docker buildx inspect --builder ${BUILDER_NAME} --bootstrap >/dev/null
"""
}
}
@@ -82,53 +97,56 @@ pipeline {
set -e
run_with_heartbeat() {
log_file="\$1"
shift
build_label="\$2"
shift 2
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=\$!
last_size=0
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)"
echo "[heartbeat] \${build_label} still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [ -f "\$log_file" ]; then
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
if [ "\$current_size" -gt "\$last_size" ]; then
start_byte=\$((last_size + 1))
tail -c +"\$start_byte" "\$log_file" || true
last_size=\$current_size
fi
fi
sleep 20
done
wait "\$cmd_pid" || cmd_status=\$?
wait "\$tail_pid" >/dev/null 2>&1 || true
if [ -f "\$log_file" ]; then
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
if [ "\$current_size" -gt "\$last_size" ]; then
start_byte=\$((last_size + 1))
tail -c +"\$start_byte" "\$log_file" || true
fi
fi
if [ "\$cmd_status" -ne 0 ]; then
echo "[heartbeat] API multi-arch build failed with exit code \$cmd_status"
echo "[heartbeat] \${build_label} 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 \
run_with_heartbeat /tmp/live-recorder-api-buildx-${IMAGE_TAG}.log "API multi-arch build" \
sudo docker buildx build \
--builder ${BUILDER_NAME} \
--platform ${TARGET_PLATFORMS} \
--network host \
--progress=plain \
--provenance=false \
--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 \
--cache-from type=registry,ref=${API_CACHE_IMAGE} \
--cache-to type=registry,ref=${API_CACHE_IMAGE},mode=max \
--build-arg HTTP_PROXY=${HTTP_PROXY_URL} \
--build-arg HTTPS_PROXY=${HTTP_PROXY_URL} \
--build-arg NO_PROXY=${NO_PROXY_HOSTS} \
--build-arg http_proxy=${HTTP_PROXY_URL} \
--build-arg https_proxy=${HTTP_PROXY_URL} \
--build-arg no_proxy=${NO_PROXY_HOSTS} \
-f src/LiveRecorder.WebApi/Dockerfile \
-t ${API_IMAGE_TAGGED} \
-t ${API_IMAGE_LATEST} \
@@ -144,55 +162,58 @@ pipeline {
set -e
run_with_heartbeat() {
log_file="\$1"
shift
build_label="\$2"
shift 2
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=\$!
last_size=0
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)"
echo "[heartbeat] \${build_label} still running at \$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [ -f "\$log_file" ]; then
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
if [ "\$current_size" -gt "\$last_size" ]; then
start_byte=\$((last_size + 1))
tail -c +"\$start_byte" "\$log_file" || true
last_size=\$current_size
fi
fi
sleep 20
done
wait "\$cmd_pid" || cmd_status=\$?
wait "\$tail_pid" >/dev/null 2>&1 || true
if [ -f "\$log_file" ]; then
current_size=\$(stat -c%s "\$log_file" 2>/dev/null || echo 0)
if [ "\$current_size" -gt "\$last_size" ]; then
start_byte=\$((last_size + 1))
tail -c +"\$start_byte" "\$log_file" || true
fi
fi
if [ "\$cmd_status" -ne 0 ]; then
echo "[heartbeat] Web multi-arch build failed with exit code \$cmd_status"
echo "[heartbeat] \${build_label} 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 Web image: ${WEB_IMAGE_TAGGED}'
run_with_heartbeat /tmp/live-recorder-web-buildx-${IMAGE_TAG}.log \
run_with_heartbeat /tmp/live-recorder-web-buildx-${IMAGE_TAG}.log "Web multi-arch build" \
sudo docker buildx build \
--builder ${BUILDER_NAME} \
--platform ${TARGET_PLATFORMS} \
--network host \
--progress=plain \
--provenance=false \
--cache-from type=registry,ref=${WEB_CACHE_IMAGE} \
--cache-to type=registry,ref=${WEB_CACHE_IMAGE},mode=max \
--build-arg NODE_IMAGE=${WEB_NODE_IMAGE} \
--build-arg NGINX_IMAGE=${WEB_NGINX_IMAGE} \
--build-arg HTTP_PROXY=http://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 \
--build-arg HTTP_PROXY=${HTTP_PROXY_URL} \
--build-arg HTTPS_PROXY=${HTTP_PROXY_URL} \
--build-arg NO_PROXY=${NO_PROXY_HOSTS} \
--build-arg http_proxy=${HTTP_PROXY_URL} \
--build-arg https_proxy=${HTTP_PROXY_URL} \
--build-arg no_proxy=${NO_PROXY_HOSTS} \
-f frontend/Dockerfile \
--build-arg VITE_API_BASE_URL=/api \
-t ${WEB_IMAGE_TAGGED} \
+2 -1
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,6 +154,7 @@ function notifyBackendUnavailable(message: string) {
}
apiClient.interceptors.request.use((config) => {
console.log("[API DEBUG]", config.method?.toUpperCase(), config.baseURL || "", config.url || "", "→", (config.baseURL || "") + (config.url || ""));
const token = localStorage.getItem("live-recorder-token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
@@ -8,6 +8,7 @@ import { useViewport } from "@/composables/useViewport";
import {
ArrowDown,
Bell,
DataAnalysis,
Document,
Fold,
House,
@@ -36,6 +37,7 @@ const navigationGroups = [
key: "monitor",
title: "直播监控",
items: [
{ index: "/", label: "仪表盘", icon: DataAnalysis },
{ index: "/live-rooms", label: "直播间", icon: House },
{ index: "/record-tasks", label: "录制任务", icon: VideoCamera },
{ index: "/recovery", label: "恢复中心", icon: RefreshRight }
@@ -72,6 +74,8 @@ const backendStatusDescription = computed(() =>
const backendStatusTone = computed(() => (backendUnavailable.value ? "is-danger" : "is-healthy"));
const pageEyebrow = computed(() => {
switch (route.name) {
case "dashboard":
return "系统概览";
case "live-rooms":
return "直播间监控";
case "record-tasks":
@@ -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;
}
@@ -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 -1
View File
@@ -3,6 +3,7 @@ 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");
@@ -29,7 +30,8 @@ const router = createRouter({
children: [
{
path: "",
redirect: "/live-rooms"
name: "dashboard",
component: DashboardView
},
{
path: "live-rooms",
+52
View File
@@ -61,9 +61,17 @@
--el-border-color-light: var(--border-subtle);
--el-border-radius-base: var(--radius-sm);
--el-bg-color: transparent;
--el-bg-color-page: var(--bg-base);
--el-bg-color-overlay: var(--surface);
--el-fill-color: var(--surface-muted);
--el-fill-color-blank: var(--surface);
--el-fill-color-light: var(--surface-muted);
--el-fill-color-lighter: var(--surface-strong);
--el-fill-color-dark: var(--surface-raised);
--el-fill-color-darker: var(--surface-strong);
--el-disabled-bg-color: var(--surface-muted);
--el-disabled-text-color: var(--text-soft);
--el-text-color-placeholder: var(--text-soft);
--el-mask-color: rgba(15, 23, 42, 0.54);
}
@@ -123,9 +131,17 @@ html[data-theme="dark"] {
--el-text-color-secondary: var(--text-muted);
--el-border-color: var(--border-base);
--el-border-color-light: var(--border-subtle);
--el-bg-color-page: var(--bg-base);
--el-bg-color-overlay: var(--surface);
--el-fill-color: var(--surface-muted);
--el-fill-color-blank: var(--surface);
--el-fill-color-light: var(--surface-muted);
--el-fill-color-lighter: var(--surface-strong);
--el-fill-color-dark: var(--surface-raised);
--el-fill-color-darker: var(--surface-strong);
--el-disabled-bg-color: var(--surface-muted);
--el-disabled-text-color: var(--text-soft);
--el-text-color-placeholder: var(--text-soft);
--el-mask-color: rgba(3, 7, 14, 0.72);
}
@@ -255,6 +271,7 @@ select:focus-visible {
.surface-card {
position: relative;
overflow: hidden;
--el-card-bg-color: transparent;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
background:
@@ -573,9 +590,33 @@ html[data-theme="dark"] .stat-card {
.el-card {
--el-card-border-color: transparent;
--el-card-bg-color: transparent;
background: transparent;
}
.el-tabs--border-card {
border-color: var(--border-subtle);
background: var(--surface);
}
.el-tabs--border-card > .el-tabs__content {
background: transparent;
color: var(--text-primary);
}
.el-tabs--border-card > .el-tabs__header {
background: transparent;
}
.el-tabs--border-card > .el-tabs__header .el-tabs__item {
color: var(--text-secondary);
}
.el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active {
color: var(--text-primary);
background: var(--surface-raised);
}
.el-button {
min-height: var(--control-height);
padding: 0 15px;
@@ -802,6 +843,17 @@ html[data-theme="dark"] .stat-card {
box-shadow: var(--shadow-float);
}
.el-message-box,
.el-popover.el-popper,
.el-select__popper.el-popper,
.el-picker__popper.el-popper,
.el-dropdown__popper.el-popper .el-dropdown-menu {
border-color: var(--border-subtle);
background: linear-gradient(180deg, var(--surface-raised), var(--surface));
color: var(--text-primary);
box-shadow: var(--shadow-float);
}
.el-dialog__header {
margin: 0;
padding: 22px 24px 10px;
+125
View File
@@ -421,6 +421,8 @@ export interface SystemSettings {
enableStorageGuard: boolean;
pauseRecordingWhenFreeSpaceBelowMegabytes: number;
resumeRecordingWhenFreeSpaceAboveMegabytes: number;
storageGreenThresholdPercent: number;
storageRedThresholdPercent: number;
enableRetentionCleanup: boolean;
retentionDays: number;
retentionDeleteFiles: boolean;
@@ -458,6 +460,8 @@ export interface SystemSettings {
segmentCompletedScriptPath: string;
segmentCompletedScriptContent: string;
eventScriptTimeoutSeconds: number;
eventScriptRetryAttempts: number;
eventScriptRetryDelaySeconds: number;
enableEmailNotification: boolean;
emailSmtpHost: string;
emailSmtpPort: number;
@@ -775,3 +779,124 @@ export const uploadStatusLabelMap: Record<number, string> = {
1: "已上传",
2: "上传失败"
};
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;
storageStatus: DashboardStorageStatus;
recentSessions: DashboardRecentSession[];
topRooms: DashboardTopRoom[];
pendingTranscodeCount: number;
pendingUploadCount: number;
queuedDataBytes: number;
}
export interface DashboardStorageStatus {
hasEnoughSpace: boolean;
message: string;
availableBytes: number;
tier: string;
usagePercent: 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;
}
+281
View File
@@ -0,0 +1,281 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { Bell, Clock, DataAnalysis, House, VideoCamera, Warning } from "@element-plus/icons-vue";
import apiClient, { getApiErrorMessage } from "@/api/client";
import MetricCard from "@/components/ui/MetricCard.vue";
import type { DashboardData } from "@/types";
import { sessionStatusLabelMap } from "@/types";
const router = useRouter();
const loading = ref(false);
const loadError = ref("");
const data = ref<DashboardData | null>(null);
function formatDuration(seconds?: number) {
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return "-";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function formatDataSize(bytes?: number) {
if (typeof bytes !== "number" || bytes <= 0) 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 formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function sessionStatusTagType(status: number) {
if (status === 2) return "success";
if (status === 5) return "danger";
if (status === 4 || status === 6) return "info";
return "warning";
}
function storageTierTagType(): "success" | "warning" | "danger" {
const tier = data.value?.storageStatus.tier;
if (tier === "Green") return "success";
if (tier === "Yellow") return "warning";
return "danger";
}
function storageTierLabel(): string {
const tier = data.value?.storageStatus.tier;
if (tier === "Green") return "正常";
if (tier === "Yellow") return "警告";
return "紧急";
}
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">
<div class="page-header">
<div>
<h1 class="page-title">仪表盘</h1>
<p class="page-subtitle">系统运行状态一览包含直播间录制会话弹幕和存储概况</p>
</div>
<el-space class="header-actions">
<el-button @click="loadData" :loading="loading">刷新</el-button>
</el-space>
</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="6" />
<template v-else-if="data">
<!-- KPI Cards -->
<div class="stats-grid">
<MetricCard label="正在录制" :value="data.activeRecordingCount">
<template #icon><el-icon :size="18"><VideoCamera /></el-icon></template>
</MetricCard>
<MetricCard label="直播间" :value="`${data.liveRoomCount} / ${data.offlineRoomCount}`" :description="`共 ${data.totalRoomCount} 个直播间`">
<template #icon><el-icon :size="18"><House /></el-icon></template>
</MetricCard>
<MetricCard label="今日录制时长" :value="formatDuration(data.todayRecordingSeconds)">
<template #icon><el-icon :size="18"><Clock /></el-icon></template>
</MetricCard>
<MetricCard label="今日数据量" :value="formatDataSize(data.todayDataBytes)">
<template #icon><el-icon :size="18"><DataAnalysis /></el-icon></template>
</MetricCard>
<MetricCard label="今日弹幕" :value="data.todayDanmakuCount.toLocaleString()">
<template #icon><el-icon :size="18"><Bell /></el-icon></template>
</MetricCard>
<MetricCard
label="24h 异常"
:value="data.recentErrorCount"
:description="data.recentErrorCount > 0 ? '请前往系统日志页面排查' : '系统运行正常'"
>
<template #icon><el-icon :size="18"><Warning /></el-icon></template>
</MetricCard>
</div>
<!-- Storage Status -->
<el-card class="surface-card" shadow="never">
<h3 class="section-title">存储状态</h3>
<p class="section-subtitle">录制输出路径的磁盘剩余空间和当前录制保护阈值</p>
<div class="storage-bar-row">
<div class="storage-bar-card">
<span class="storage-bar-card__label">水位线</span>
<el-tag :type="storageTierTagType()" size="large">
{{ storageTierLabel() }}
</el-tag>
</div>
<div class="storage-bar-card">
<span class="storage-bar-card__label">使用率</span>
<span class="storage-bar-card__value">{{ data.storageStatus.usagePercent.toFixed(1) }}%</span>
</div>
<div class="storage-bar-card">
<span class="storage-bar-card__label">可用空间</span>
<span class="storage-bar-card__value">{{ formatDataSize(data.storageStatus.availableBytes) }}</span>
</div>
<div class="storage-bar-card">
<span class="storage-bar-card__label">说明</span>
<span class="storage-bar-card__value storage-bar-card__value--message">{{ data.storageStatus.message || "-" }}</span>
</div>
</div>
</el-card>
<!-- Queue Status -->
<el-row :gutter="18">
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">处理队列</h3>
<p class="section-subtitle">待转码和待上传的文件积压情况</p>
<div class="storage-bar-row">
<div class="storage-bar-card">
<span class="storage-bar-card__label">待转码</span>
<span class="storage-bar-card__value" :style="{ color: data.pendingTranscodeCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">
{{ data.pendingTranscodeCount }}
</span>
</div>
<div class="storage-bar-card">
<span class="storage-bar-card__label">待上传</span>
<span class="storage-bar-card__value" :style="{ color: data.pendingUploadCount > 0 ? 'var(--warning)' : 'var(--text-primary)' }">
{{ data.pendingUploadCount }}
</span>
</div>
<div class="storage-bar-card">
<span class="storage-bar-card__label">积压数据量</span>
<span class="storage-bar-card__value" :style="{ color: data.queuedDataBytes > 0 ? 'var(--warning)' : 'var(--text-primary)' }">
{{ formatDataSize(data.queuedDataBytes) }}
</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- Recent Sessions + Top Rooms -->
<el-row :gutter="18">
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">最近会话</h3>
<p class="section-subtitle">最近创建的录制会话点击可跳转至详情</p>
<div class="table-scroll-shell">
<el-table :data="data.recentSessions" class="premium-table" size="small">
<el-table-column label="直播间" min-width="140" prop="liveRoomTitle" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="sessionStatusTagType(row.status)" size="small">
{{ sessionStatusLabelMap[row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="分片" width="60" prop="segmentCount" />
<el-table-column label="开始时间" width="160">
<template #default="{ row }">{{ formatDate(row.startedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button size="small" @click="router.push({ name: 'record-session-detail', params: { id: row.id } })">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
<el-col :lg="12" :sm="24">
<el-card class="surface-card" shadow="never">
<h3 class="section-title">今日热门直播间</h3>
<p class="section-subtitle">今日录制时长最长的直播间Top 5</p>
<div class="table-scroll-shell">
<el-table :data="data.topRooms" class="premium-table" size="small">
<el-table-column label="直播间" min-width="130">
<template #default="{ row }">{{ row.title || row.anchorName || "-" }}</template>
</el-table-column>
<el-table-column label="平台" width="90" prop="platformName" />
<el-table-column label="会话数" width="70" prop="sessionCount" />
<el-table-column label="录制时长" width="100">
<template #default="{ row }">{{ formatDuration(row.totalDurationSeconds) }}</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button size="small" @click="router.push({ name: 'live-rooms' })">查看</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</el-col>
</el-row>
</template>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.header-actions {
align-self: center;
}
.page-error-alert {
border-radius: 14px;
}
.storage-bar-row {
display: flex;
flex-wrap: wrap;
gap: 20px 40px;
}
.storage-bar-card {
display: flex;
flex-direction: column;
gap: 6px;
}
.storage-bar-card__label {
font-size: 12px;
text-transform: uppercase;
color: var(--text-muted);
font-weight: 600;
}
.storage-bar-card__value {
font-size: 18px;
font-weight: 700;
color: var(--text-primary);
}
.storage-bar-card__value--message {
font-size: 14px;
font-weight: 400;
color: var(--text-secondary);
}
@media (max-width: 768px) {
.header-actions {
width: 100%;
justify-content: stretch;
}
.header-actions :deep(.el-button) {
flex: 1 1 0;
margin: 0;
}
}
</style>
+16 -157
View File
@@ -12,7 +12,7 @@ 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",
@@ -52,39 +52,11 @@ 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>
</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 class="login-panel__eyebrow">Live Recorder</div>
<h1 class="login-panel__title">登录</h1>
</div>
<el-select v-model="themeMode" size="small" class="login-panel__theme-select">
@@ -123,14 +95,9 @@ async function handleLogin() {
</el-form-item>
<el-button class="login-form__submit" type="primary" :loading="loading" @click="handleLogin">
登录控制台
登录
</el-button>
</el-form>
<div class="login-panel__footer">
<span>当前主题{{ resolvedTheme === "dark" ? "深色" : "浅色" }}</span>
<span>双端适配Web / Mobile</span>
</div>
</section>
</div>
</template>
@@ -139,78 +106,12 @@ 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;
}
.login-hero {
display: grid;
align-content: center;
gap: 24px;
}
.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;
place-items: center;
padding: 24px;
}
.login-panel {
align-self: center;
width: min(100%, 420px);
padding: 24px;
}
@@ -222,6 +123,14 @@ async function handleLogin() {
margin-bottom: 18px;
}
.login-panel__eyebrow {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.login-panel__title {
margin: 6px 0 0;
color: var(--text-primary);
@@ -230,13 +139,6 @@ async function handleLogin() {
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;
}
@@ -250,59 +152,16 @@ async function handleLogin() {
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 {
.login-panel__header {
flex-direction: column;
}
+89 -2
View File
@@ -3,9 +3,12 @@ import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { useViewport } from "@/composables/useViewport";
import apiClient, { getApiErrorMessage } from "@/api/client";
import apiClient, { getApiErrorMessage, buildApiUrl } from "@/api/client";
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
import type {
RecordArtifactUploadBatchResult,
RecordPreviewTicket,
RecordSessionDetail,
RecordSessionTimelineEvent,
RecordSessionHeatBucket,
@@ -28,6 +31,47 @@ 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("");
@@ -502,9 +546,18 @@ onMounted(loadDetail);
{{ formatDuration(row.durationSeconds) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<el-table-column label="操作" min-width="200">
<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>
@@ -544,6 +597,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 +791,17 @@ onMounted(loadDetail);
color: var(--text-secondary);
}
.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 +812,10 @@ onMounted(loadDetail);
flex: 1 1 0;
margin: 0;
}
.preview-empty {
min-height: 180px;
padding: 18px;
}
}
</style>
+50 -3
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,6 +37,29 @@ 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]);
@@ -234,7 +259,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>
@@ -388,6 +422,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 +442,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"
+46 -8
View File
@@ -42,7 +42,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[]>([]);
@@ -174,6 +174,10 @@ const deleteDialogEyebrow = computed(() => {
return "空闲会话清理";
}
if (deleteDialogMode.value === "missing-file-tasks") {
return "无文件分片清理";
}
return "删除确认";
});
const deleteDialogTitle = computed(() => {
@@ -193,6 +197,10 @@ const deleteDialogTitle = computed(() => {
return "清理无分片会话";
}
if (deleteDialogMode.value === "missing-file-tasks") {
return "清理无文件分片";
}
return "删除分片任务";
});
const deleteDialogLead = computed(() => {
@@ -212,6 +220,10 @@ const deleteDialogLead = computed(() => {
return `将自动找出所有没有任何分片任务的录制会话并批量删除。你也可以选择同时删除可能残留的本地文件。`;
}
if (deleteDialogMode.value === "missing-file-tasks") {
return `将自动找出所有视频文件已丢失的分片任务并批量删除(不限会话,只删命中的分片本身)。删除后若某个会话下不再有任何分片,会话也会一并清理;你也可以选择同时清理残留的弹幕 XML 文件。`;
}
return `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`;
});
const deleteDialogNote = computed(() => {
@@ -227,6 +239,10 @@ const deleteDialogNote = computed(() => {
return "仅清理没有任何关联分片的空会话,不影响有录制产物的会话。";
}
if (deleteDialogMode.value === "missing-file-tasks") {
return "以分片为单位判定:仅当分片的视频文件在磁盘上不存在时才会删除。正在录制或处理中的分片会自动跳过,有视频文件的分片不受影响。";
}
return "记录加文件会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。";
});
function isActiveStatus(status: number) {
@@ -572,6 +588,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 +665,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 +673,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 +709,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 +752,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 +776,7 @@ async function confirmDelete(deleteFiles: boolean) {
return;
}
ElMessage.success(
currentMode === "tasks"
? "已删除分片任务。"
: "后台清理任务已创建,页面会自动轮询进度。"
);
ElMessage.success("后台清理任务已创建,页面会自动轮询进度。");
} finally {
deleting.value = false;
}
@@ -931,6 +966,9 @@ onBeforeUnmount(() => {
<el-button plain :loading="deleting" @click="openDeleteEmptySessionsDialog">
清理无分片会话
</el-button>
<el-button plain :loading="deleting" @click="openDeleteMissingFileTasksDialog">
清理无文件分片
</el-button>
</div>
</div>
+180 -131
View File
@@ -106,6 +106,8 @@ const form = reactive<SettingsFormModel>({
enableStorageGuard: true,
pauseRecordingWhenFreeSpaceBelowMegabytes: 1024,
resumeRecordingWhenFreeSpaceAboveMegabytes: 4096,
storageGreenThresholdPercent: 30,
storageRedThresholdPercent: 10,
enableRetentionCleanup: false,
retentionDays: 30,
retentionDeleteFiles: false,
@@ -168,6 +170,8 @@ const form = reactive<SettingsFormModel>({
segmentCompletedScriptPath: "",
segmentCompletedScriptContent: "",
eventScriptTimeoutSeconds: 60,
eventScriptRetryAttempts: 3,
eventScriptRetryDelaySeconds: 10,
enableEmailNotification: false,
emailSmtpHost: "",
emailSmtpPort: 587,
@@ -375,8 +379,8 @@ const eventScriptEnvironmentExamples = [
];
const eventScriptModeOptions = [
{ label: "璺緞", value: "path" },
{ label: "鑴氭湰鏂囨湰", value: "inline" }
{ label: "路径", value: "path" },
{ label: "脚本文本", value: "inline" }
];
const uploadTargetOptions = [
@@ -459,12 +463,12 @@ const canSendTestWebhook = computed(() => Boolean(form.webhookUrl.trim()));
const segmentedExamplePath = computed(() => {
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
return `Douyin/2026/04/15/涓绘挱鍚?221530_origin_涓绘挱鍚峗鐩存挱鏍囬_123456789_00001.${extension}`;
return `Douyin/2026/04/15/主播名_221530_origin_主播名_直播标题_123456789_00001.${extension}`;
});
const nestedSegmentedExamplePath = computed(() => {
const extension = form.defaultOutputFormat === 1 ? "ts" : "mp4";
return `Douyin/origin/2026/04/15/涓绘挱鍚?221530_origin_涓绘挱鍚峗鐩存挱鏍囬_123456789/221530_origin_涓绘挱鍚峗鐩存挱鏍囬_123456789_00001.${extension}`;
return `Douyin/origin/2026/04/15/主播名_221530_origin_主播名_直播标题_123456789/221530_origin_主播名_直播标题_123456789_00001.${extension}`;
});
async function loadSettings() {
@@ -492,11 +496,11 @@ const pwdForm = reactive({
const pwdRules = {
currentPassword: [{ required: true, message: "Please enter the current password", trigger: "blur" }],
newPassword: [
{ required: true, message: "璇疯緭鍏ユ柊瀵嗙爜", trigger: "blur" },
{ required: true, message: "请输入新密码", trigger: "blur" },
{ min: 6, message: "Password must be at least 6 characters", trigger: "blur" }
],
confirmPassword: [
{ required: true, message: "璇峰啀娆¤緭鍏ユ柊瀵嗙爜", trigger: "blur" },
{ required: true, message: "请再次输入新密码", trigger: "blur" },
{
validator: (_rule: unknown, value: string, callback: (error?: Error) => void) => {
if (value !== pwdForm.newPassword) {
@@ -569,7 +573,7 @@ async function sendTestEmail() {
emailExceptionBodyTemplateHtml: form.emailExceptionBodyTemplateHtml
});
ElMessage.success("娴嬭瘯閭欢宸插彂閫侊紝璇锋鏌ユ敹浠剁");
ElMessage.success("测试邮件已发送,请检查收件箱");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "Failed to send test email."));
} finally {
@@ -587,7 +591,7 @@ async function testEventScript(eventType: ScriptEventType) {
} catch (error) {
const result: EventScriptTestResult = {
success: false,
message: getApiErrorMessage(error, "浜嬩欢鑴氭湰娴嬭瘯澶辫触")
message: getApiErrorMessage(error, "事件脚本测试失败")
};
scriptTestResults[eventType] = result;
ElMessage.error(result.message);
@@ -611,7 +615,7 @@ async function testWebhook() {
} catch (error) {
const result: WebhookTestResult = {
success: false,
message: getApiErrorMessage(error, "Webhook 娴嬭瘯澶辫触")
message: getApiErrorMessage(error, "Webhook 测试失败")
};
webhookTestResult.value = result;
ElMessage.error(result.message);
@@ -628,7 +632,7 @@ async function runRetentionCleanup() {
await startRetentionCleanupTracking(data);
ElMessage.success("Retention cleanup background task created.");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "淇濈暀娓呯悊鎵ц澶辫触"));
ElMessage.error(getApiErrorMessage(error, "保留清理执行失败"));
} finally {
runningRetentionCleanup.value = false;
}
@@ -737,7 +741,7 @@ async function exportSettingsBackup() {
URL.revokeObjectURL(downloadUrl);
ElMessage.success("Settings backup exported.");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "绯荤粺璁剧疆瀵煎嚭澶辫触"));
ElMessage.error(getApiErrorMessage(error, "系统设置导出失败"));
} finally {
exportingSettings.value = false;
}
@@ -764,7 +768,7 @@ async function importSettingsBackup(event: Event) {
Object.assign(form, data);
ElMessage.success("Settings imported from backup.");
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "绯荤粺璁剧疆瀵煎叆澶辫触"));
ElMessage.error(getApiErrorMessage(error, "系统设置导入失败"));
} finally {
importingSettings.value = false;
}
@@ -960,25 +964,25 @@ watch(
<div class="settings-grid" v-loading="loading">
<el-tabs v-model="activeSettingTab" type="border-card" class="settings-tabs">
<el-tab-pane label="褰曞埗" name="recording">
<el-tab-pane label="录制" name="recording">
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">褰曞埗鍩虹</h3>
<p class="section-subtitle">Default quality, output format, segmentation, ffmpeg templates, and network tolerance settings are managed here.</p>
<h3 class="section-title">录制基础</h3>
<p class="section-subtitle">默认画质输出格式分段策略ffmpeg 模板和网络容错等录制基础参数在此集中管理</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="ffmpeg 璺緞">
<el-form-item label="ffmpeg 路径">
<el-input v-model="form.ffmpegPath" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Output root">
<el-form-item label="输出根目录">
<el-input v-model="form.outputRoot" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Default quality">
<el-form-item label="默认画质">
<el-select v-model="form.defaultQuality">
<el-option
v-for="option in qualityOptions"
@@ -990,7 +994,7 @@ watch(
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="杈撳嚭鏍煎紡">
<el-form-item label="输出格式">
<el-select v-model="form.defaultOutputFormat">
<el-option
v-for="(label, value) in outputFormatLabelMap"
@@ -1002,7 +1006,7 @@ watch(
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="淇濆瓨妯″紡">
<el-form-item label="保存模式">
<el-select v-model="form.saveMode">
<el-option
v-for="(label, value) in saveModeLabelMap"
@@ -1014,7 +1018,7 @@ watch(
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="褰曞埗妯℃澘">
<el-form-item label="录制模板">
<el-select v-model="form.recordingTemplate">
<el-option
v-for="(label, value) in recordingTemplateLabelMap"
@@ -1026,32 +1030,32 @@ watch(
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="鍒嗘鏃堕暱锛堝垎閽燂級">
<el-form-item label="分段时长(分钟)">
<el-input-number v-model="form.segmentDurationMinutes" :min="1" :max="720" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Max concurrent transcode tasks">
<el-form-item label="最大并发转码任务数">
<el-input-number v-model="form.maxConcurrentFfmpegTranscodeTasks" :min="1" :max="16" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="MP4 杞爜瓒呮椂锛堝垎閽燂級">
<el-form-item label="MP4 转码超时(分钟)">
<el-input-number v-model="form.mp4FinalizeTimeoutMinutes" :min="1" :max="1440" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="鏈€澶ч噸杩炲欢杩燂紙绉掞級">
<el-form-item label="最大重连延迟(秒)">
<el-input-number v-model="form.reconnectDelayMaxSeconds" :min="1" :max="300" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="璇诲啓瓒呮椂锛堟绉掞級">
<el-form-item label="读写超时(毫秒)">
<el-input-number v-model="form.readWriteTimeoutMilliseconds" :min="1000" :max="60000000" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="鍚敤 ffmpeg 鑷姩閲嶈繛">
<el-form-item label="启用 ffmpeg 自动重连">
<el-switch v-model="form.enableAutoReconnect" />
</el-form-item>
</el-col>
@@ -1064,18 +1068,18 @@ watch(
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">瀛樺偍淇濇姢</h3>
<p class="section-subtitle">Pause recording and MP4 finalization below the threshold, then resume once free space recovers.</p>
<h3 class="section-title">存储保护</h3>
<p class="section-subtitle">当磁盘空闲空间低于阈值时暂停录制和 MP4 转码空间恢复后自动继续</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="鍚敤瀛樺偍淇濇姢">
<el-form-item label="启用存储保护">
<el-switch v-model="form.enableStorageGuard" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Pause below free space (MB)">
<el-form-item label="暂停阈值:空闲空间低于 (MB)">
<el-input-number
v-model="form.pauseRecordingWhenFreeSpaceBelowMegabytes"
:min="0"
@@ -1085,7 +1089,7 @@ watch(
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Resume above free space (MB)">
<el-form-item label="恢复阈值:空闲空间高于 (MB)">
<el-input-number
v-model="form.resumeRecordingWhenFreeSpaceAboveMegabytes"
:min="0"
@@ -1095,35 +1099,63 @@ watch(
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-form-item label="绿色水位线:剩余空间高于 (%)">
<el-input-number
v-model="form.storageGreenThresholdPercent"
:min="5"
:max="90"
:step="5"
:disabled="!form.enableStorageGuard"
/>
<div class="field-hint">高于此比例时正常录制低于时拒绝新录制</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="红色水位线:剩余空间低于 (%)">
<el-input-number
v-model="form.storageRedThresholdPercent"
:min="1"
:max="85"
:step="5"
:disabled="!form.enableStorageGuard"
/>
<div class="field-hint">低于此比例时暂停所有录制和转码仅保留上传</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="helper-panel">
鎭㈠闃堝煎缓璁珮浜庢殏鍋滈槇鍊硷紝閬垮厤纾佺洏绌洪棿鍦ㄤ复鐣屽奸檮杩戝弽澶嶆姈鍔ㄣ侻P4 爜浼氶澶栧崰鐢ㄤ腑闂?TS 鏂囦欢绌洪棿銆? </div>
恢复阈值建议高于暂停阈值避免磁盘空间在临界值附近反复抖动MP4 转码会额外占用中间 TS 文件空间<br/>
绿色/红色水位线控制三级存储保护<b>绿色</b>(正常录制) <b>黄色</b>(拒绝新录制现有继续转码上传) <b>红色</b>(暂停所有录制转码仅上传清盘)
</div>
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">淇濈暀娓呯悊</h3>
<p class="section-subtitle">Clean up inactive sessions, tasks, results, and logs by retention age, with optional file deletion.</p>
<h3 class="section-title">保留清理</h3>
<p class="section-subtitle">按保留天数清理不活跃的会话任务结果和日志可选删除磁盘文件</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="鍚敤鑷姩娓呯悊">
<el-form-item label="启用自动清理">
<el-switch v-model="form.enableRetentionCleanup" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="淇濈暀澶╂暟">
<el-form-item label="保留天数">
<el-input-number v-model="form.retentionDays" :min="1" :max="3650" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="鍒犻櫎纾佺洏鏂囦欢">
<el-form-item label="删除磁盘文件">
<el-switch v-model="form.retentionDeleteFiles" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Video file condition">
<el-form-item label="视频文件条件">
<el-select v-model="form.retentionVideoFileCondition" style="width: 100%">
<el-option
v-for="option in retentionVideoFileOptions"
@@ -1135,8 +1167,8 @@ watch(
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Task status condition (all segments)">
<el-select v-model="form.retentionTaskStatuses" multiple placeholder="Any status" style="width: 100%">
<el-form-item label="任务状态条件(所有分段)">
<el-select v-model="form.retentionTaskStatuses" multiple placeholder="任意状态" style="width: 100%">
<el-option
v-for="option in retentionTaskStatusOptions"
:key="option.value"
@@ -1151,7 +1183,7 @@ watch(
<div class="action-strip">
<div class="helper-text">Run now and the daily retention cleanup use the same saved rules. Save this section first if you just changed the filters.</div>
<el-button :loading="runningRetentionCleanup" @click="runRetentionCleanup">Run cleanup now</el-button>
<el-button :loading="runningRetentionCleanup" @click="runRetentionCleanup">立即执行清理</el-button>
</div>
<div
@@ -1180,13 +1212,13 @@ watch(
</el-card>
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">寮瑰箷褰曞埗</h3>
<h3 class="section-title">弹幕录制</h3>
<p class="section-subtitle">Control parallel danmaku XML recording, non-chat event capture, and retry / polling pacing.</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="鍚敤寮瑰箷褰曞埗">
<el-form-item label="启用弹幕录制">
<el-switch v-model="form.enableDanmakuRecording" />
</el-form-item>
</el-col>
@@ -1220,11 +1252,11 @@ watch(
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">緞妯</h3>
<h3 class="section-title">路径模板</h3>
<p class="section-subtitle">Both directory and filename templates support variables. Segmented layouts are fully controlled by the templates themselves.</p>
<el-form label-position="top">
<el-form-item label="鐩綍妯℃澘">
<el-form-item label="目录模板">
<el-input
v-model="form.outputDirectoryTemplate"
type="textarea"
@@ -1244,9 +1276,9 @@ watch(
</el-form>
<div class="example-box">
<div class="example-box__label">绀轰緥杈撳嚭</div>
<div class="monospace example-box__value">榛樿鍒嗘锛歿{ segmentedExamplePath }}</div>
<div class="monospace example-box__value">綍妯澘鍚?{fileStem}锛歿{ nestedSegmentedExamplePath }}</div>
<div class="example-box__label">示例输出</div>
<div class="monospace example-box__value">默认分段{{ segmentedExamplePath }}</div>
<div class="monospace example-box__value">目录模板含 {fileStem}{{ nestedSegmentedExamplePath }}</div>
</div>
<div class="helper-panel">
@@ -1267,26 +1299,26 @@ watch(
</el-card>
</el-tab-pane>
<el-tab-pane label="Polling and upload" name="polling">
<el-tab-pane label="轮询与上传" name="polling">
<el-card class="surface-card settings-card" shadow="never">
<h3 class="section-title">鍚庡彴宸</h3>
<p class="section-subtitle">Control scheduled live-status checks and automatic recording starts when a room goes live.</p>
<h3 class="section-title">后台巡检</h3>
<p class="section-subtitle">控制定时直播状态检查和直播间开播时自动开始录制</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="鍚敤鍚庡彴宸℃">
<el-form-item label="启用后台巡检">
<el-switch v-model="form.enableBackgroundPolling" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="妫€娴嬪埌寮€鎾悗鑷姩褰曞埗">
<el-form-item label="检测到开播后自动录制">
<el-switch v-model="form.autoStartRecordingOnLive" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Polling interval (s)">
<el-form-item label="轮询间隔(秒)">
<el-input-number v-model="form.pollingIntervalSeconds" :min="10" :max="3600" />
</el-form-item>
</el-col>
@@ -1301,7 +1333,7 @@ watch(
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="鍚敤涓婁紶">
<el-form-item label="启用上传">
<el-switch v-model="form.enableFileUpload" />
</el-form-item>
</el-col>
@@ -1316,12 +1348,12 @@ watch(
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="鍒悕鐢ㄤ簬鐩綍">
<el-form-item label="别名用于目录">
<el-switch v-model="form.useAliasForStorage" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="涓婁紶鐩爣">
<el-form-item label="上传目标">
<el-select v-model="form.uploadTarget" :disabled="!form.enableFileUpload">
<el-option
v-for="option in uploadTargetOptions"
@@ -1338,7 +1370,7 @@ watch(
<div v-if="form.enableFileUpload && form.uploadTarget === 1" class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">WebDAV </h4>
<h4 class="template-section__title">WebDAV 目标</h4>
<p class="template-section__subtitle">Create remote directories from recording-relative paths and upload the video plus danmaku files.</p>
</div>
</div>
@@ -1346,22 +1378,22 @@ watch(
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="Endpoint">
<el-form-item label="端点">
<el-input v-model="form.webDavUpload.endpoint" placeholder="https://dav.example.com" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="鍩虹璺緞">
<el-form-item label="基础路径">
<el-input v-model="form.webDavUpload.basePath" placeholder="/live-recorder" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Username">
<el-form-item label="用户名">
<el-input v-model="form.webDavUpload.username" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="瀵嗙爜">
<el-form-item label="密码">
<el-input v-model="form.webDavUpload.password" type="password" show-password />
</el-form-item>
</el-col>
@@ -1372,7 +1404,7 @@ watch(
<div v-if="form.enableFileUpload && form.uploadTarget === 2" class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">S3 </h4>
<h4 class="template-section__title">S3 目标</h4>
<p class="template-section__subtitle">Supports custom endpoint, bucket, region, and prefix settings for object-storage compatible services.</p>
</div>
</div>
@@ -1380,17 +1412,17 @@ watch(
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="Endpoint">
<el-form-item label="端点">
<el-input v-model="form.s3Upload.endpoint" placeholder="https://s3.example.com" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Bucket">
<el-form-item label="存储桶">
<el-input v-model="form.s3Upload.bucket" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Region">
<el-form-item label="区域">
<el-input v-model="form.s3Upload.region" placeholder="auto / us-east-1" />
</el-form-item>
</el-col>
@@ -1400,17 +1432,17 @@ watch(
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Access Key">
<el-form-item label="访问密钥">
<el-input v-model="form.s3Upload.accessKey" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Secret Key">
<el-form-item label="秘密密钥">
<el-input v-model="form.s3Upload.secretKey" type="password" show-password />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Force Path Style">
<el-form-item label="强制路径样式">
<el-switch v-model="form.s3Upload.forcePathStyle" />
</el-form-item>
</el-col>
@@ -1419,11 +1451,12 @@ watch(
</div>
<div class="helper-panel">
姩涓婁紶鍥哄畾澶勭悊鈥滆棰戞枃浠?+ 瀵瑰簲寮瑰箷 XML鈥濄傚彧鏈変袱鑰呴兘涓婁紶鎴愬姛骞朵笖浣犳墦寮鈥滀笂浼犲悗鍒犳湰鍦扳濇椂锛岀郴缁熸墠浼氭竻鐞嗘湰鍦版枃浠躲? </div>
自动上传固定处理视频文件 + 对应弹幕 XML只有两者都上传成功并且你打开上传后删本地系统才会清理本地文件
</div>
</el-card>
<el-card v-if="false" class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">骞冲彴浠g悊</h3>
<h3 class="section-title">平台代理</h3>
<p class="section-subtitle">These proxies only affect platform-side status checks and stream requests, not email, webhook, or file uploads.</p>
<div v-if="false" class="event-script-grid">
@@ -1435,7 +1468,7 @@ watch(
</div>
<el-switch v-model="form.douyinProxy.enabled" />
</div>
<el-form-item label="浠g悊鍦板潃">
<el-form-item label="代理地址">
<el-input v-model="form.douyinProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
</el-form-item>
</div>
@@ -1448,7 +1481,7 @@ watch(
</div>
<el-switch v-model="form.bilibiliProxy.enabled" />
</div>
<el-form-item label="浠g悊鍦板潃">
<el-form-item label="代理地址">
<el-input v-model="form.bilibiliProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
</el-form-item>
</div>
@@ -1461,7 +1494,7 @@ watch(
</div>
<el-switch v-model="form.huyaProxy.enabled" />
</div>
<el-form-item label="浠g悊鍦板潃">
<el-form-item label="代理地址">
<el-input v-model="form.huyaProxy.proxyUrl" placeholder="http://127.0.0.1:7890" />
</el-form-item>
</div>
@@ -1469,25 +1502,36 @@ watch(
</el-card>
</el-tab-pane>
<el-tab-pane label="浜嬩欢鑴氭湰" name="scripts">
<el-tab-pane label="事件脚本" name="scripts">
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">浜嬩欢鑴氭湰</h3>
<h3 class="section-title">事件脚本</h3>
<p class="section-subtitle">
佷笅鎾佸垎鐗囧畬鎴愭椂閮藉彲浠ユ墽琛岃剼鏈傝剼鏈敮鎸佽矾寰勬ā寮忓拰鐩存帴濉啓鏂囨湰妯紡锛涙祴璇曟寜閽細鐩存帴鐢ㄥ綋鍓嶈鍗曞兼墽琛岋紝涓嶈姹傚厛淇濆瓨銆? </p>
开播下播分片完成时都可以执行脚本脚本支持路径模式和直接填写文本模式测试按钮会直接用当前表单值执行不要求先保存
</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="鍚敤浜嬩欢鑴氭湰">
<el-col :span="6">
<el-form-item label="启用事件脚本">
<el-switch v-model="form.enableEventScripts" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="Script timeout (s)">
<el-col :span="6">
<el-form-item label="脚本超时(秒)">
<el-input-number v-model="form.eventScriptTimeoutSeconds" :min="1" :max="3600" />
</el-form-item>
</el-col>
<el-col :span="5">
<el-form-item label="重试次数">
<el-input-number v-model="form.eventScriptRetryAttempts" :min="0" :max="20" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="重试间隔(秒)">
<el-input-number v-model="form.eventScriptRetryDelaySeconds" :min="0" :max="3600" />
</el-form-item>
</el-col>
</el-row>
</el-form>
@@ -1495,8 +1539,8 @@ watch(
<div class="event-script-section">
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">Live started</h4>
<p class="event-script-section__subtitle">Triggered when the room first enters the online state.</p>
<h4 class="event-script-section__title">开播</h4>
<p class="event-script-section__subtitle">当直播间首次进入开播状态时触发</p>
</div>
<div class="event-script-section__actions">
<el-switch v-model="form.enableLiveStartedScript" inline-prompt active-text="On" inactive-text="Off" />
@@ -1516,14 +1560,14 @@ watch(
:disabled="!canTestScript('live_started')"
@click="testEventScript('live_started')"
>
娴嬭瘯鑴氭湰
测试脚本
</el-button>
</div>
</div>
<el-form-item v-if="form.liveStartedScriptMode === 'path'" label="鑴氭湰璺緞">
<el-form-item v-if="form.liveStartedScriptMode === 'path'" label="脚本路径">
<el-input v-model="form.liveStartedScriptPath" placeholder="/app/scripts/live-started.sh" />
</el-form-item>
<el-form-item v-else label="鑴氭湰鏂囨湰">
<el-form-item v-else label="脚本文本">
<el-input
v-model="form.liveStartedScriptContent"
type="textarea"
@@ -1541,7 +1585,7 @@ watch(
{{ scriptTestResults.live_started?.detail }}
</div>
<div v-if="scriptTestResults.live_started?.customLogOutput" class="test-result__detail">
畾涔夋棩蹇楄緭鍑猴細{{ scriptTestResults.live_started?.customLogOutput }}
自定义日志输出{{ scriptTestResults.live_started?.customLogOutput }}
</div>
</div>
</div>
@@ -1549,8 +1593,8 @@ watch(
<div class="event-script-section">
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">涓嬫挱</h4>
<p class="event-script-section__subtitle">Triggered when the room changes from online back to offline.</p>
<h4 class="event-script-section__title">下播</h4>
<p class="event-script-section__subtitle">当直播间从开播状态回到离线状态时触发</p>
</div>
<div class="event-script-section__actions">
<el-switch v-model="form.enableLiveEndedScript" inline-prompt active-text="On" inactive-text="Off" />
@@ -1570,14 +1614,14 @@ watch(
:disabled="!canTestScript('live_ended')"
@click="testEventScript('live_ended')"
>
娴嬭瘯鑴氭湰
测试脚本
</el-button>
</div>
</div>
<el-form-item v-if="form.liveEndedScriptMode === 'path'" label="鑴氭湰璺緞">
<el-form-item v-if="form.liveEndedScriptMode === 'path'" label="脚本路径">
<el-input v-model="form.liveEndedScriptPath" placeholder="/app/scripts/live-ended.sh" />
</el-form-item>
<el-form-item v-else label="鑴氭湰鏂囨湰">
<el-form-item v-else label="脚本文本">
<el-input
v-model="form.liveEndedScriptContent"
type="textarea"
@@ -1595,7 +1639,7 @@ watch(
{{ scriptTestResults.live_ended?.detail }}
</div>
<div v-if="scriptTestResults.live_ended?.customLogOutput" class="test-result__detail">
畾涔夋棩蹇楄緭鍑猴細{{ scriptTestResults.live_ended?.customLogOutput }}
自定义日志输出{{ scriptTestResults.live_ended?.customLogOutput }}
</div>
</div>
</div>
@@ -1603,8 +1647,8 @@ watch(
<div class="event-script-section event-script-section--full">
<div class="event-script-section__header">
<div>
<h4 class="event-script-section__title">鍒嗙墖瀹屾垚</h4>
<p class="event-script-section__subtitle">Triggered after MP4 finalization completes and the segment status becomes completed.</p>
<h4 class="event-script-section__title">分片完成</h4>
<p class="event-script-section__subtitle"> MP4 转码最终化完成且分段状态变为已完成时触发</p>
</div>
<div class="event-script-section__actions">
<el-switch v-model="form.enableSegmentCompletedScript" inline-prompt active-text="On" inactive-text="Off" />
@@ -1624,14 +1668,14 @@ watch(
:disabled="!canTestScript('segment_completed')"
@click="testEventScript('segment_completed')"
>
娴嬭瘯鑴氭湰
测试脚本
</el-button>
</div>
</div>
<el-form-item v-if="form.segmentCompletedScriptMode === 'path'" label="鑴氭湰璺緞">
<el-form-item v-if="form.segmentCompletedScriptMode === 'path'" label="脚本路径">
<el-input v-model="form.segmentCompletedScriptPath" placeholder="/app/scripts/segment-completed.sh" />
</el-form-item>
<el-form-item v-else label="鑴氭湰鏂囨湰">
<el-form-item v-else label="脚本文本">
<el-input
v-model="form.segmentCompletedScriptContent"
type="textarea"
@@ -1649,7 +1693,7 @@ watch(
{{ scriptTestResults.segment_completed?.detail }}
</div>
<div v-if="scriptTestResults.segment_completed?.customLogOutput" class="test-result__detail">
畾涔夋棩蹇楄緭鍑猴細{{ scriptTestResults.segment_completed?.customLogOutput }}
自定义日志输出{{ scriptTestResults.segment_completed?.customLogOutput }}
</div>
</div>
</div>
@@ -1674,6 +1718,10 @@ watch(
The official Docker image includes <code>curl</code> and <code>jq</code> by default. If you run on a host machine or a custom image, rely on the commands available in that environment.
</div>
<div class="event-script-help__intro">
Set <code>Retry attempts</code> to <code>0</code> to disable automatic retries. Retry exhaustion failures are sent through the existing exception notification channel.
</div>
<div class="event-script-example">
<div class="event-script-example__label">Environment variable examples</div>
<div class="event-script-example__grid">
@@ -1693,31 +1741,31 @@ watch(
</el-card>
</el-tab-pane>
<el-tab-pane label="閫氱煡" name="notifications">
<el-tab-pane label="通知" name="notifications">
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">Webhook 閫氱煡</h3>
<p class="section-subtitle">Send fixed JSON POST payloads with custom headers. Currently only live-started and exception events are sent.</p>
<h3 class="section-title">Webhook 通知</h3>
<p class="section-subtitle">Send fixed JSON POST payloads with custom headers. Exception notifications also cover low-storage stop events and event-script failures after retries are exhausted.</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="鍚敤 Webhook">
<el-form-item label="启用 Webhook">
<el-switch v-model="form.enableWebhookNotification" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="寮€鎾€氱煡">
<el-form-item label="开播通知">
<el-switch v-model="form.notifyWebhookOnLiveStarted" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="寮傚父閫氱煡">
<el-form-item label="异常通知">
<el-switch v-model="form.notifyWebhookOnException" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="Timeout (s)">
<el-form-item label="超时(秒)">
<el-input-number v-model="form.webhookTimeoutSeconds" :min="1" :max="300" />
</el-form-item>
</el-col>
@@ -1729,7 +1777,7 @@ watch(
</el-col>
<el-col :span="24">
<el-form-item label="鑷畾涔夎姹傚ご">
<el-form-item label="自定义请求头">
<el-input
v-model="form.webhookHeaders"
type="textarea"
@@ -1739,7 +1787,7 @@ watch(
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="鑷畾涔?JSON Body 妯℃澘">
<el-form-item label="自定义 JSON Body 模板">
<el-input
v-model="form.webhookBodyTemplate"
type="textarea"
@@ -1751,7 +1799,7 @@ watch(
</el-row>
</el-form>
<div class="template-help">
<div class="template-help__label">Webhook 鍙橀噺</div>
<div class="template-help__label">Webhook 变量</div>
<div class="token-list">
<span v-for="token in webhookTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
@@ -1759,7 +1807,7 @@ watch(
<div class="action-strip">
<div class="helper-text">The test sends a sample live_started payload, including sample event script output, using the current URL, headers, and timeout values from this form.</div>
<el-button :loading="testingWebhook" :disabled="!canSendTestWebhook" @click="testWebhook">娴嬭瘯 Webhook</el-button>
<el-button :loading="testingWebhook" :disabled="!canSendTestWebhook" @click="testWebhook">测试 Webhook</el-button>
</div>
<div v-if="webhookTestResult" class="test-result" :class="webhookTestResult.success ? 'test-result--success' : 'test-result--warning'">
@@ -1769,18 +1817,18 @@ watch(
</el-card>
<el-card class="surface-card settings-card settings-grid__full" shadow="never">
<h3 class="section-title">欢閫氱煡</h3>
<p class="section-subtitle">Configure SMTP plus HTML templates for live-started and exception alerts. Tests use the current unsaved form values directly.</p>
<h3 class="section-title">邮件通知</h3>
<p class="section-subtitle">Configure SMTP plus HTML templates for live-started and exception alerts. Exception notifications also cover low-storage stop events and event-script failures after retries are exhausted.</p>
<el-form label-position="top">
<el-row :gutter="16">
<el-col :span="6">
<el-form-item label="鍚敤閭欢閫氱煡">
<el-form-item label="启用邮件通知">
<el-switch v-model="form.enableEmailNotification" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="鍚敤 SSL">
<el-form-item label="启用 SSL">
<el-switch v-model="form.emailUseSsl" />
</el-form-item>
</el-col>
@@ -1790,7 +1838,7 @@ watch(
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="寮傚父鎻愰啋">
<el-form-item label="异常提醒">
<el-switch v-model="form.notifyOnException" />
</el-form-item>
</el-col>
@@ -1817,12 +1865,12 @@ watch(
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="SMTP 瀵嗙爜">
<el-form-item label="SMTP 密码">
<el-input v-model="form.emailPassword" type="password" show-password />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="鍙戜欢鍦板潃">
<el-form-item label="发件地址">
<el-input v-model="form.emailFromAddress" placeholder="noreply@example.com" />
</el-form-item>
</el-col>
@@ -1833,7 +1881,7 @@ watch(
v-model="form.emailToAddresses"
type="textarea"
:rows="3"
placeholder="澶氫釜鍦板潃鍙敤閫楀彿銆佸垎鍙锋垨鎹㈣鍒嗛殧"
placeholder="多个地址可用逗号、分号或换行分隔"
/>
</el-form-item>
</el-col>
@@ -1847,11 +1895,11 @@ watch(
</div>
</div>
<el-form-item label="涓婚妯℃澘">
<el-form-item label="主题模板">
<el-input v-model="form.emailLiveStartedSubjectTemplate" />
</el-form-item>
<el-form-item label="HTML 姝f枃妯℃澘">
<el-form-item label="HTML 正文模板">
<el-input v-model="form.emailLiveStartedBodyTemplateHtml" type="textarea" :rows="10" />
</el-form-item>
</div>
@@ -1861,16 +1909,16 @@ watch(
<div class="template-section">
<div class="template-section__header">
<div>
<h4 class="template-section__title">寮傚父鎻愰啋妯</h4>
<h4 class="template-section__title">异常提醒模板</h4>
<p class="template-section__subtitle">Exception emails inject source, summary, detail, task context values, and optional event script output into the HTML body.</p>
</div>
</div>
<el-form-item label="涓婚妯℃澘">
<el-form-item label="主题模板">
<el-input v-model="form.emailExceptionSubjectTemplate" />
</el-form-item>
<el-form-item label="HTML 姝f枃妯℃澘">
<el-form-item label="HTML 正文模板">
<el-input v-model="form.emailExceptionBodyTemplateHtml" type="textarea" :rows="12" />
</el-form-item>
</div>
@@ -1886,11 +1934,12 @@ watch(
</div>
<div class="helper-panel">
欢妯澘閲岀殑 <code v-pre>{{detectedAtUtc}}</code> ?<code v-pre>{{occurredAtUtc}}</code> 瀛楁鍚嶄繚鎸佷笉鍙橈紝浣嗗疄闄呮覆鏌撳煎凡缁忕粺涓鏀规垚鍖椾含鏃堕棿锛圲TC+8锛夈? <code v-pre>{{eventScriptOutput}}</code> 鍒欏搴旇剼鏈氳繃鑷畾涔夋棩蹇楁枃浠惰緭鍑虹殑鏂囨湰鍐呭? </div>
邮件模板里的 <code v-pre>{{detectedAtUtc}}</code> <code v-pre>{{occurredAtUtc}}</code> 字段名保持不变但实际渲染值已经统一改成北京时间UTC+8<code v-pre>{{eventScriptOutput}}</code> 则对应脚本通过自定义日志文件输出的文本内容
</div>
<div class="action-strip">
<div class="helper-text">Sending a test email does not save settings. The email renders both the live-started and exception template examples.</div>
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">娴嬭瘯閭</el-button>
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">测试邮件</el-button>
</div>
</el-card>
@@ -1942,7 +1991,7 @@ watch(
</div>
<el-form label-position="top">
<el-form-item label="Proxy URL">
<el-form-item label="代理 URL">
<el-input
v-model="form.platformRequestSettings[platform.key].proxy.proxyUrl"
placeholder="http://127.0.0.1:7890"
@@ -1992,7 +2041,7 @@ watch(
v-model="form.douyinCookie"
type="textarea"
:rows="4"
placeholder="鍙矘璐?ttwid銆乵sToken 绛?Cookie"
placeholder="可粘贴 ttwid、msToken Cookie"
/>
</el-form-item>
</el-form>
@@ -2004,10 +2053,10 @@ watch(
<div class="settings-savebar" :style="savebarStyle">
<div class="settings-savebar__content">
<div class="settings-savebar__copy">
<div class="settings-savebar__title">褰撳墠淇敼涓嶄細鑷姩淇濆瓨</div>
<div class="settings-savebar__subtitle">You can save the current settings from anywhere on this page.</div>
<div class="settings-savebar__title">当前修改不会自动保存</div>
<div class="settings-savebar__subtitle">您可以在此页面任意位置保存当前设置</div>
</div>
<el-button type="primary" :loading="saving" @click="saveSettings">淇濆瓨璁剧疆</el-button>
<el-button type="primary" :loading="saving" @click="saveSettings">保存设置</el-button>
</div>
</div>
</div>
+1
View File
@@ -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;
}
@@ -0,0 +1,61 @@
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
Uri? resolveLiveRoomWatchUri(LiveRoom room) {
for (final String candidate in <String>[
room.normalizedUrl,
room.sourceUrl,
room.originalLiveRoomUrl,
]) {
final uri = _parseHttpUri(candidate);
if (uri != null) {
return uri;
}
}
return null;
}
bool hasLiveRoomWatchSource(LiveRoom room) {
return room.normalizedUrl.trim().isNotEmpty ||
room.sourceUrl.trim().isNotEmpty ||
room.originalLiveRoomUrl.trim().isNotEmpty;
}
int compareMonitorRooms(LiveRoom a, LiveRoom b) {
final liveA = a.availabilityStatus == 2 ? 1 : 0;
final liveB = b.availabilityStatus == 2 ? 1 : 0;
if (liveA != liveB) {
return liveB.compareTo(liveA);
}
final recordingA = a.currentRecordingState == 2 ? 1 : 0;
final recordingB = b.currentRecordingState == 2 ? 1 : 0;
if (recordingA != recordingB) {
return recordingB.compareTo(recordingA);
}
final priorityA = (a.isPinned || a.isPriority) ? 1 : 0;
final priorityB = (b.isPinned || b.isPriority) ? 1 : 0;
if (priorityA != priorityB) {
return priorityB.compareTo(priorityA);
}
return b.updatedAt.compareTo(a.updatedAt);
}
Uri? _parseHttpUri(String? rawValue) {
final trimmed = rawValue?.trim() ?? '';
if (trimmed.isEmpty) {
return null;
}
final uri = Uri.tryParse(trimmed);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return null;
}
if (uri.scheme != 'http' && uri.scheme != 'https') {
return null;
}
return uri;
}
+43
View File
@@ -0,0 +1,43 @@
String? deriveRelativeMediaPath({
required String? outputRoot,
required String? outputFilePath,
}) {
final rawPath = outputFilePath?.trim();
if (rawPath == null || rawPath.isEmpty) {
return null;
}
final normalizedPath = rawPath.replaceAll('\\', '/');
if (_containsUnsafeTraversal(normalizedPath)) {
return null;
}
final root = outputRoot?.trim();
if (root == null || root.isEmpty) {
return normalizedPath;
}
final normalizedRoot = root.replaceAll('\\', '/').replaceAll(RegExp(r'/+$'), '');
final normalizedPathLower = normalizedPath.toLowerCase();
final normalizedRootLower = normalizedRoot.toLowerCase();
if (normalizedPathLower == normalizedRootLower) {
return '';
}
if (normalizedPathLower.startsWith('$normalizedRootLower/')) {
final relative = normalizedPath.substring(normalizedRoot.length + 1);
return _containsUnsafeTraversal(relative) ? null : relative;
}
if (!normalizedPath.contains(':') && !normalizedPath.startsWith('/')) {
return normalizedPath;
}
return null;
}
bool _containsUnsafeTraversal(String value) {
return value.split('/').any((segment) => segment == '..');
}
@@ -0,0 +1,59 @@
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
String formatStorageHealthLabel(StorageGuardStatus? storage) {
if (storage == null) {
return '暂无集群数据';
}
if (!storage.isEnabled) {
return '存储守护未启用';
}
final mappedMessage = _mapStorageMessage(storage.message);
if (mappedMessage != null) {
return mappedMessage;
}
return storage.hasEnoughSpace ? '空间充足' : '空间不足';
}
String formatStorageUsageLabel(StorageGuardStatus? storage) {
if (storage == null) {
return '--';
}
final hasAvailableBytes = storage.availableBytes > 0;
final hasRequiredBytes = storage.requiredBytes > 0;
if (hasAvailableBytes || hasRequiredBytes) {
final availableLabel = hasAvailableBytes ? formatBytes(storage.availableBytes) : '--';
final requiredLabel = hasRequiredBytes ? formatBytes(storage.requiredBytes) : '--';
return '可用 $availableLabel / 需保留 $requiredLabel';
}
return _mapStorageMessage(storage.message) ?? '--';
}
String? _mapStorageMessage(String rawMessage) {
final normalized = rawMessage.trim().toLowerCase();
if (normalized.isEmpty) {
return null;
}
if (normalized == 'storage is available' || normalized.contains('enough space')) {
return '空间充足';
}
if (normalized.contains('insufficient') ||
normalized.contains('not enough') ||
normalized.contains('low disk') ||
normalized.contains('space is low')) {
return '空间不足';
}
if (normalized.contains('disabled')) {
return '存储守护未启用';
}
return null;
}
+188
View File
@@ -0,0 +1,188 @@
enum StatusTone {
gray,
green,
blue,
yellow,
red,
orange,
indigo,
}
const Map<int, String> availabilityLabelMap = <int, String>{
0: '未知',
1: '已下播',
2: '直播中',
};
const Map<int, String> recordingStateLabelMap = <int, String>{
0: '已下播',
1: '直播中',
2: '录制中',
};
const Map<int, String> taskStatusLabelMap = <int, String>{
0: '待处理',
1: '启动中',
2: '录制中',
3: '停止中',
4: '已完成',
5: '失败',
6: '已停止',
7: '处理中',
};
const Map<int, String> logLevelLabelMap = <int, String>{
0: '跟踪',
1: '信息',
2: '警告',
3: '错误',
};
const Map<int, String> outputFormatLabelMap = <int, String>{
0: 'MP4',
1: 'TS',
};
const Map<int, String> saveModeLabelMap = <int, String>{
0: '单文件',
1: '分段',
};
const Map<int, String> recordingTemplateLabelMap = <int, String>{
0: '直接封装',
1: '均衡 MP4',
2: '归档 TS',
};
const Map<String, String> qualityLabelMap = <String, String>{
'origin': '原画',
'FULL_HD': '超清',
'HD': '高清',
'SD': '标清',
};
const Map<int, String> platformLabelMap = <int, String>{
0: '未知',
1: 'Douyin',
2: 'Bilibili',
3: 'Huya',
4: 'Douyu',
5: 'Kuaishou',
6: 'TikTok',
7: 'Xiaohongshu',
8: 'YouTube',
9: 'Twitch',
10: 'PandaTV',
11: 'Migu',
};
const Map<int, String> uploadStatusLabelMap = <int, String>{
0: '未上传',
1: '已上传',
2: '上传失败',
};
const Map<String, String> autoStartDecisionLabelMap = <String, String>{
'started': '已启动',
'skipped_disabled': '已禁用',
'skipped_storage': '存储不足',
'skipped_active_session': '已有活动会话',
'skipped_offline': '房间未开播',
'skipped_debounce': '触发防抖中',
'failed_startup': '启动失败',
'poll_failed_transient': '轮询临时失败',
'poll_failed': '轮询失败',
};
String availabilityLabel(int? value) => availabilityLabelMap[value] ?? '未知';
String recordingStateLabel(int? value) => recordingStateLabelMap[value] ?? '未知';
String taskStatusLabel(int? value) => taskStatusLabelMap[value] ?? '未知';
String logLevelLabel(int? value) => logLevelLabelMap[value] ?? '未知';
String outputFormatLabel(int? value) => outputFormatLabelMap[value] ?? '--';
String saveModeLabel(int? value) => saveModeLabelMap[value] ?? '--';
String recordingTemplateLabel(int? value) => recordingTemplateLabelMap[value] ?? '--';
String qualityLabel(String? value) => qualityLabelMap[value] ?? (value == null || value.isEmpty ? '--' : value);
String platformLabel(int? value) => platformLabelMap[value] ?? '未知';
String uploadStatusLabel(int? value) => uploadStatusLabelMap[value] ?? '未知';
String autoStartDecisionLabel(String? value) =>
autoStartDecisionLabelMap[value] ?? (value == null || value.isEmpty ? '暂无事件' : value);
bool isTaskActive(int? value) => value == 1 || value == 2 || value == 3 || value == 7;
bool isTaskFailed(int? value) => value == 5;
StatusTone toneForStatus({String? keyword, int? value, String? context}) {
if (context == 'availability') {
return value == 2 ? StatusTone.green : StatusTone.gray;
}
if (context == 'recording') {
if (value == 2) {
return StatusTone.blue;
}
if (value == 1) {
return StatusTone.green;
}
return StatusTone.gray;
}
if (context == 'task' || context == 'session') {
switch (value) {
case 0:
return StatusTone.yellow;
case 1:
case 7:
return StatusTone.indigo;
case 2:
return StatusTone.blue;
case 3:
return StatusTone.orange;
case 4:
return StatusTone.green;
case 5:
return StatusTone.red;
default:
return StatusTone.gray;
}
}
if (context == 'upload') {
if (value == 1) {
return StatusTone.green;
}
if (value == 2) {
return StatusTone.red;
}
}
final normalized = keyword?.toLowerCase() ?? '';
if (<String>['live', 'online', 'living', '直播中', 'completed', 'archived'].any(normalized.contains)) {
return StatusTone.green;
}
if (<String>['recording', '录制中'].any(normalized.contains)) {
return StatusTone.blue;
}
if (<String>['pending', 'queued', ''].any(normalized.contains)) {
return StatusTone.yellow;
}
if (<String>['retry', 'stopping', '停止中'].any(normalized.contains)) {
return StatusTone.orange;
}
if (<String>['process', 'transcod'].any(normalized.contains)) {
return StatusTone.indigo;
}
if (<String>['error', 'fail', '异常', '错误'].any(normalized.contains)) {
return StatusTone.red;
}
return StatusTone.gray;
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class AppCard extends StatelessWidget {
const AppCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(18),
this.onTap,
});
final Widget child;
final EdgeInsetsGeometry padding;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final card = Card(
child: Padding(
padding: padding,
child: child,
),
);
if (onTap == null) {
return card;
}
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
child: card,
);
}
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
class AppEmptyState extends StatelessWidget {
const AppEmptyState({
super.key,
this.title = '暂无数据',
this.description = '当前没有可展示内容',
this.actionLabel,
this.onAction,
});
final String title;
final String description;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return AppCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.inbox_rounded, color: Color(0xFF2563EB), size: 28),
),
const SizedBox(height: 16),
Text(
title,
style: const TextStyle(
color: Color(0xFF0F172A),
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
description,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.5,
),
),
if (actionLabel != null && onAction != null) ...<Widget>[
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: onAction,
child: Text(actionLabel!),
),
],
],
),
);
}
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
class AppErrorCard extends StatelessWidget {
const AppErrorCard({
super.key,
required this.message,
required this.onRetry,
});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Row(
children: <Widget>[
Icon(Icons.error_outline_rounded, color: Color(0xFFDC2626)),
SizedBox(width: 8),
Text(
'加载失败',
style: TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 12),
Text(
message,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.5,
),
),
const SizedBox(height: 16),
FilledButton.tonalIcon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded),
label: const Text('重试'),
),
],
),
);
}
}
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
class AppSearchBar extends StatelessWidget {
const AppSearchBar({
super.key,
required this.controller,
required this.hintText,
this.onSubmitted,
this.onChanged,
});
final TextEditingController controller;
final String hintText;
final ValueChanged<String>? onSubmitted;
final ValueChanged<String>? onChanged;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44,
child: TextField(
controller: controller,
onSubmitted: onSubmitted,
onChanged: onChanged,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: hintText,
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: controller.text.isEmpty
? null
: IconButton(
onPressed: () {
controller.clear();
onChanged?.call('');
},
icon: const Icon(Icons.close_rounded),
),
),
),
);
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
class MetricCard extends StatelessWidget {
const MetricCard({
super.key,
required this.label,
required this.value,
required this.description,
this.color = const Color(0xFF2563EB),
this.trendValue,
});
final String label;
final String value;
final String description;
final Color color;
final double? trendValue;
@override
Widget build(BuildContext context) {
final progress = (trendValue ?? 0).clamp(0.05, 1.0);
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
Text(
value,
style: TextStyle(
color: color,
fontSize: 28,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
SizedBox(
height: 40,
child: Text(
description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.45,
),
),
),
const SizedBox(height: 14),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 6,
value: progress,
color: color,
backgroundColor: color.withValues(alpha: 0.12),
),
),
],
),
);
}
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
class MobileHeader extends StatelessWidget {
const MobileHeader({
super.key,
required this.eyebrow,
required this.title,
this.trailing,
this.userInitials = 'L',
this.onNotificationsPressed,
this.onProfilePressed,
});
final String eyebrow;
final String title;
final Widget? trailing;
final String userInitials;
final VoidCallback? onNotificationsPressed;
final VoidCallback? onProfilePressed;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
eyebrow,
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
title,
style: const TextStyle(
color: Color(0xFF0F172A),
fontSize: 28,
fontWeight: FontWeight.w800,
),
),
],
),
),
IconButton.filledTonal(
onPressed: onNotificationsPressed,
icon: const Icon(Icons.notifications_none_rounded),
),
const SizedBox(width: 8),
GestureDetector(
onTap: onProfilePressed,
child: CircleAvatar(
radius: 20,
backgroundColor: const Color(0xFFE0ECFF),
foregroundColor: const Color(0xFF2563EB),
child: Text(
userInitials,
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
),
],
),
if (trailing != null) ...<Widget>[
const SizedBox(height: 16),
trailing!,
],
],
),
);
}
}
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
class SkeletonCard extends StatefulWidget {
const SkeletonCard({
super.key,
this.height = 120,
});
final double height;
@override
State<SkeletonCard> createState() => _SkeletonCardState();
}
class _SkeletonCardState extends State<SkeletonCard> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (BuildContext context, Widget? child) {
final opacity = 0.35 + (_controller.value * 0.4);
return AppCard(
child: Opacity(
opacity: opacity,
child: Container(
height: widget.height,
decoration: BoxDecoration(
color: const Color(0xFFE2E8F0),
borderRadius: BorderRadius.circular(16),
),
),
),
);
},
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
class StatusBadge extends StatelessWidget {
const StatusBadge({
super.key,
required this.status,
this.label,
this.context,
});
final Object? status;
final String? label;
final String? context;
@override
Widget build(BuildContext context) {
final tone = toneForStatus(
value: status is int ? status as int : null,
keyword: status?.toString(),
context: this.context,
);
final style = _styleFor(tone);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: style.background,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: style.border),
),
child: Text(
label ?? status?.toString() ?? '--',
style: TextStyle(
color: style.foreground,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
);
}
_BadgeStyle _styleFor(StatusTone tone) {
switch (tone) {
case StatusTone.green:
return const _BadgeStyle(
background: Color(0xFFECFDF5),
foreground: Color(0xFF047857),
border: Color(0xFFA7F3D0),
);
case StatusTone.blue:
return const _BadgeStyle(
background: Color(0xFFEFF6FF),
foreground: Color(0xFF1D4ED8),
border: Color(0xFFBFDBFE),
);
case StatusTone.yellow:
return const _BadgeStyle(
background: Color(0xFFFFFBEB),
foreground: Color(0xFFB45309),
border: Color(0xFFFDE68A),
);
case StatusTone.red:
return const _BadgeStyle(
background: Color(0xFFFEF2F2),
foreground: Color(0xFFB91C1C),
border: Color(0xFFFECACA),
);
case StatusTone.orange:
return const _BadgeStyle(
background: Color(0xFFFFF7ED),
foreground: Color(0xFFC2410C),
border: Color(0xFFFED7AA),
);
case StatusTone.indigo:
return const _BadgeStyle(
background: Color(0xFFEEF2FF),
foreground: Color(0xFF4338CA),
border: Color(0xFFC7D2FE),
);
case StatusTone.gray:
return const _BadgeStyle(
background: Color(0xFFF1F5F9),
foreground: Color(0xFF475569),
border: Color(0xFFE2E8F0),
);
}
}
}
class _BadgeStyle {
const _BadgeStyle({
required this.background,
required this.foreground,
required this.border,
});
final Color background;
final Color foreground;
final Color border;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class AuthRepository {
const AuthRepository(this._apiClient);
final ApiClient _apiClient;
Future<LoginResponse> login({
required String username,
required String password,
}) async {
final response = await _apiClient.postJson(
'/api/auth/login',
body: <String, dynamic>{
'username': username,
'password': password,
},
);
return LoginResponse.fromJson(response as Map<String, dynamic>);
}
Future<void> logout() {
return _apiClient.postEmpty('/api/auth/logout');
}
Future<void> changePassword({
required String currentPassword,
required String newPassword,
}) {
return _apiClient.postEmpty(
'/api/auth/change-password',
body: <String, dynamic>{
'currentPassword': currentPassword,
'newPassword': newPassword,
},
);
}
}
@@ -0,0 +1,72 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class LiveRoomsRepository {
const LiveRoomsRepository(this._apiClient);
final ApiClient _apiClient;
Future<List<LiveRoom>> listRooms() async {
final response = await _apiClient.getJson('/api/live-rooms') as List<dynamic>;
return response
.map((dynamic item) => LiveRoom.fromJson(item as Map<String, dynamic>))
.toList(growable: false);
}
Future<LiveRoom> getRoom(String roomId) async {
final response = await _apiClient.getJson('/api/live-rooms/$roomId') as Map<String, dynamic>;
return LiveRoom.fromJson(response);
}
Future<LiveRoom> refreshRoom(String roomId) async {
final response = await _apiClient.postJson('/api/live-rooms/$roomId/refresh') as Map<String, dynamic>;
return LiveRoom.fromJson(response);
}
Future<LiveRoom> setRoomEnabled({
required String roomId,
required bool isEnabled,
}) async {
final response = await _apiClient.putJson(
'/api/live-rooms/$roomId/enabled',
body: <String, dynamic>{'isEnabled': isEnabled},
) as Map<String, dynamic>;
return LiveRoom.fromJson(response);
}
Future<LiveRoom> createRoom({
required String url,
int? platformOverride,
}) async {
final response = await _apiClient.postJson(
'/api/live-rooms',
body: <String, dynamic>{
'url': url,
if (platformOverride case final int value) 'platformOverride': value,
},
) as Map<String, dynamic>;
return LiveRoom.fromJson(response);
}
Future<LiveRoom> updateMetadata({
required String roomId,
required Map<String, dynamic> payload,
}) async {
final response = await _apiClient.putJson(
'/api/live-rooms/$roomId/metadata',
body: payload,
) as Map<String, dynamic>;
return LiveRoom.fromJson(response);
}
Future<LiveRoom> updateSettings({
required String roomId,
required Map<String, dynamic> payload,
}) async {
final response = await _apiClient.putJson(
'/api/live-rooms/$roomId/settings',
body: payload,
) as Map<String, dynamic>;
return LiveRoom.fromJson(response);
}
}
@@ -0,0 +1,34 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class LogsRepository {
const LogsRepository(this._apiClient);
final ApiClient _apiClient;
Future<List<SystemLog>> listLogs({
String? liveRoomId,
String? recordSessionId,
String? recordTaskId,
int? level,
String? content,
int take = 200,
}) async {
final response = await _apiClient.getJson(
'/api/logs',
queryParameters: <String, String>{
if (liveRoomId != null && liveRoomId.isNotEmpty) 'liveRoomId': liveRoomId,
if (recordSessionId != null && recordSessionId.isNotEmpty) 'recordSessionId': recordSessionId,
if (recordTaskId != null && recordTaskId.isNotEmpty) 'recordTaskId': recordTaskId,
if (level != null) 'level': '$level',
if (content != null && content.trim().isNotEmpty) 'content': content.trim(),
'take': '$take',
},
) as List<dynamic>;
return response
.map((dynamic item) => SystemLog.fromJson(item as Map<String, dynamic>))
.toList(growable: false);
}
}
@@ -0,0 +1,39 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class MediaRepository {
const MediaRepository(this._apiClient);
final ApiClient _apiClient;
Future<MediaBrowserResponse> browse({String? path}) async {
final response = await _apiClient.getJson(
'/api/media/browser',
queryParameters: <String, String>{
if (path != null && path.isNotEmpty) 'path': path,
},
) as Map<String, dynamic>;
return MediaBrowserResponse.fromJson(response);
}
Uri buildFileUri({
required String relativePath,
bool download = false,
}) {
return _apiClient.buildUri(
'/api/media/file',
queryParameters: <String, String>{
'path': relativePath,
if (download) 'download': 'true',
},
);
}
Future<String> transcodeFile(String relativePath) async {
final response = await _apiClient.postJson(
'/api/media/transcode-file',
body: <String, dynamic>{'relativePath': relativePath},
) as Map<String, dynamic>;
return response['message']?.toString() ?? '转码任务已提交';
}
}
@@ -0,0 +1,82 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class RecordingsRepository {
const RecordingsRepository(this._apiClient);
final ApiClient _apiClient;
Future<List<RecordTask>> listTasks({String? liveRoomId}) async {
final response = await _apiClient.getJson(
'/api/record-tasks',
queryParameters: <String, String>{
if (liveRoomId != null && liveRoomId.isNotEmpty) 'liveRoomId': liveRoomId,
},
) as List<dynamic>;
return response
.map((dynamic item) => RecordTask.fromJson(item as Map<String, dynamic>))
.toList(growable: false);
}
Future<RecordTaskDetail> getTaskDetail(String taskId) async {
final response = await _apiClient.getJson('/api/record-tasks/$taskId') as Map<String, dynamic>;
return RecordTaskDetail.fromJson(response);
}
Future<RecordTask> startRecording({
required String liveRoomId,
required String preferredQuality,
required int outputFormat,
}) async {
final response = await _apiClient.postJson(
'/api/record-tasks/start',
body: <String, dynamic>{
'liveRoomId': liveRoomId,
'preferredQuality': preferredQuality,
'outputFormat': outputFormat,
},
) as Map<String, dynamic>;
return RecordTask.fromJson(response);
}
Future<RecordTask> stopTask(String taskId) async {
final response = await _apiClient.postJson('/api/record-tasks/$taskId/stop') as Map<String, dynamic>;
return RecordTask.fromJson(response);
}
Future<RecordPreviewTicket> createPreviewTicket(String taskId) async {
final response = await _apiClient.postJson('/api/record-tasks/$taskId/preview-ticket') as Map<String, dynamic>;
return RecordPreviewTicket.fromJson(response);
}
Future<void> uploadTask(String taskId) {
return _apiClient.postEmpty('/api/record-tasks/$taskId/upload');
}
Future<List<RecordSession>> listSessions({String? liveRoomId}) async {
final response = await _apiClient.getJson(
'/api/record-sessions',
queryParameters: <String, String>{
if (liveRoomId != null && liveRoomId.isNotEmpty) 'liveRoomId': liveRoomId,
},
) as List<dynamic>;
return response
.map((dynamic item) => RecordSession.fromJson(item as Map<String, dynamic>))
.toList(growable: false);
}
Future<RecordSessionDetail> getSessionDetail(String sessionId) async {
final response = await _apiClient.getJson('/api/record-sessions/$sessionId') as Map<String, dynamic>;
return RecordSessionDetail.fromJson(response);
}
Future<RecordSession> stopSession(String sessionId) async {
final response = await _apiClient.postJson('/api/record-sessions/$sessionId/stop') as Map<String, dynamic>;
return RecordSession.fromJson(response);
}
Future<void> uploadSession(String sessionId) {
return _apiClient.postEmpty('/api/record-sessions/$sessionId/upload');
}
}
@@ -0,0 +1,29 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class RecoveryRepository {
const RecoveryRepository(this._apiClient);
final ApiClient _apiClient;
Future<RecoveryOverview> getOverview() async {
final response = await _apiClient.getJson('/api/recovery') as Map<String, dynamic>;
return RecoveryOverview.fromJson(response);
}
Future<RecoveryActionResult> retryLiveRoom(String roomId) async {
final response = await _apiClient.postJson('/api/recovery/live-rooms/$roomId/retry') as Map<String, dynamic>;
return RecoveryActionResult.fromJson(response);
}
Future<RecoveryActionResult> retryAllLiveRooms() async {
final response = await _apiClient.postJson('/api/recovery/live-rooms/retry-all') as Map<String, dynamic>;
return RecoveryActionResult.fromJson(response);
}
Future<RecoveryActionResult> resumeFinalization(String taskId) async {
final response = await _apiClient.postJson('/api/recovery/finalizations/$taskId/resume') as Map<String, dynamic>;
return RecoveryActionResult.fromJson(response);
}
}
@@ -0,0 +1,27 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class SettingsRepository {
const SettingsRepository(this._apiClient);
final ApiClient _apiClient;
Future<SystemSettings> getSettings() async {
final response = await _apiClient.getJson('/api/settings') as Map<String, dynamic>;
return SystemSettings.fromJson(response);
}
Future<SystemSettings> updateSettings(SystemSettings settings) async {
final response = await _apiClient.putJson(
'/api/settings',
body: settings.toJson(),
) as Map<String, dynamic>;
return SystemSettings.fromJson(response);
}
Future<CleanupOperation> runRetentionCleanup() async {
final response = await _apiClient.postJson('/api/settings/retention/run-now') as Map<String, dynamic>;
return CleanupOperation.fromJson(response);
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/foundation.dart';
import 'package:live_recorder_mobile/core/persistence/session_storage.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/auth_repository.dart';
abstract interface class SessionControllerHandle extends Listenable {
bool get isRestoring;
bool get isLoggedIn;
Future<void> restore();
Future<void> clearLocalSession();
void dispose();
}
class AppSessionController extends ChangeNotifier implements SessionControllerHandle {
AppSessionController({
required AuthRepository authRepository,
required SessionStorage sessionStorage,
}) : _authRepository = authRepository,
_sessionStorage = sessionStorage;
final AuthRepository _authRepository;
final SessionStorage _sessionStorage;
LoginResponse? _session;
bool _isRestoring = true;
@override
bool get isRestoring => _isRestoring;
@override
bool get isLoggedIn => token != null && token!.isNotEmpty;
String? get token => _session?.token;
AuthenticatedUser? get user => _session?.user;
LoginResponse? get session => _session;
@override
Future<void> restore() async {
_isRestoring = true;
notifyListeners();
final persisted = await _sessionStorage.read();
if (persisted != null) {
_session = LoginResponse.fromJson(persisted);
}
_isRestoring = false;
notifyListeners();
}
Future<void> login({
required String username,
required String password,
}) async {
final session = await _authRepository.login(
username: username,
password: password,
);
_session = session;
await _sessionStorage.write(session.toJson());
notifyListeners();
}
Future<void> logout() async {
try {
await _authRepository.logout();
} finally {
await clearLocalSession();
}
}
Future<void> changePassword({
required String currentPassword,
required String newPassword,
}) {
return _authRepository.changePassword(
currentPassword: currentPassword,
newPassword: newPassword,
);
}
Future<void> handleUnauthorized() async {
await clearLocalSession();
}
@override
Future<void> clearLocalSession() async {
_session = null;
await _sessionStorage.clear();
notifyListeners();
}
}
@@ -0,0 +1,201 @@
import 'package:live_recorder_mobile/core/utils/path_utils.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.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 'main_controllers.dart';
class RoomDetailController extends BaseController {
RoomDetailController({
required LiveRoomsRepository liveRoomsRepository,
required RecordingsRepository recordingsRepository,
required RecoveryRepository recoveryRepository,
required this.roomId,
}) : _liveRoomsRepository = liveRoomsRepository,
_recordingsRepository = recordingsRepository,
_recoveryRepository = recoveryRepository;
final LiveRoomsRepository _liveRoomsRepository;
final RecordingsRepository _recordingsRepository;
final RecoveryRepository _recoveryRepository;
final String roomId;
LiveRoom? room;
List<RecordSession> sessions = const <RecordSession>[];
RecoveryOverview? recoveryOverview;
@override
bool get hasData => room != null || sessions.isNotEmpty;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_liveRoomsRepository.getRoom(roomId),
_recordingsRepository.listSessions(liveRoomId: roomId),
_recoveryRepository.getOverview(),
]);
room = results[0] as LiveRoom;
sessions = results[1] as List<RecordSession>;
recoveryOverview = results[2] as RecoveryOverview;
}, silent: silent);
}
RecoverableLiveRoom? get recoveryInfo {
try {
return recoveryOverview?.liveRooms.firstWhere((RecoverableLiveRoom item) => item.liveRoomId == roomId);
} catch (_) {
return null;
}
}
}
class RecordingDetailController extends BaseController {
RecordingDetailController({
required RecordingsRepository recordingsRepository,
required SettingsRepository settingsRepository,
required MediaRepository mediaRepository,
required this.taskId,
}) : _recordingsRepository = recordingsRepository,
_settingsRepository = settingsRepository,
_mediaRepository = mediaRepository;
final RecordingsRepository _recordingsRepository;
final SettingsRepository _settingsRepository;
final MediaRepository _mediaRepository;
final String taskId;
RecordTaskDetail? detail;
SystemSettings? settings;
@override
bool get hasData => detail != null;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_recordingsRepository.getTaskDetail(taskId),
_settingsRepository.getSettings(),
]);
detail = results[0] as RecordTaskDetail;
settings = results[1] as SystemSettings;
}, silent: silent);
}
Future<RecordPreviewTicket> createPreviewTicket() {
return _recordingsRepository.createPreviewTicket(taskId);
}
Uri? get downloadUri {
final task = detail?.task;
if (task == null) {
return null;
}
final relativePath = deriveRelativeMediaPath(
outputRoot: settings?.outputRoot,
outputFilePath: task.outputFilePath,
);
if (relativePath == null || relativePath.isEmpty) {
return null;
}
return _mediaRepository.buildFileUri(relativePath: relativePath, download: true);
}
}
class LogsController extends BaseController {
LogsController({
required LogsRepository logsRepository,
}) : _logsRepository = logsRepository;
final LogsRepository _logsRepository;
List<SystemLog> logs = const <SystemLog>[];
int? level;
String query = '';
@override
bool get hasData => logs.isNotEmpty;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
logs = await _logsRepository.listLogs(
level: level,
content: query,
);
}, silent: silent);
}
void setLevel(int? value) {
level = value;
safeNotify();
}
void setQuery(String value) {
query = value;
safeNotify();
}
}
class StorageController extends BaseController {
StorageController({
required SettingsRepository settingsRepository,
required RecoveryRepository recoveryRepository,
}) : _settingsRepository = settingsRepository,
_recoveryRepository = recoveryRepository;
final SettingsRepository _settingsRepository;
final RecoveryRepository _recoveryRepository;
SystemSettings? settings;
RecoveryOverview? recoveryOverview;
@override
bool get hasData => settings != null || recoveryOverview != null;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_settingsRepository.getSettings(),
_recoveryRepository.getOverview(),
]);
settings = results[0] as SystemSettings;
recoveryOverview = results[1] as RecoveryOverview;
}, silent: silent);
}
Future<CleanupOperation> runRetentionCleanup() {
return _settingsRepository.runRetentionCleanup();
}
}
class MediaBrowserController extends BaseController {
MediaBrowserController({
required MediaRepository mediaRepository,
}) : _mediaRepository = mediaRepository;
final MediaRepository _mediaRepository;
MediaBrowserResponse? response;
String currentPath = '';
@override
bool get hasData => response != null;
Future<void> refresh({bool silent = false, String? path}) {
return runLoad(() async {
currentPath = path ?? currentPath;
response = await _mediaRepository.browse(path: currentPath);
}, silent: silent);
}
Uri fileUri(String relativePath, {bool download = false}) {
return _mediaRepository.buildFileUri(relativePath: relativePath, download: download);
}
Future<String> transcodeFile(String relativePath) {
return _mediaRepository.transcodeFile(relativePath);
}
}
@@ -0,0 +1,624 @@
import 'package:flutter/foundation.dart';
import 'package:live_recorder_mobile/core/network/api_exception.dart';
import 'package:live_recorder_mobile/core/polling/polling_controller.dart';
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
import 'package:live_recorder_mobile/core/utils/path_utils.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.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/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';
enum RoomFilter {
all,
live,
recording,
error,
retrying,
}
abstract class BaseController extends ChangeNotifier {
bool isLoading = false;
String? errorMessage;
bool _disposed = false;
bool get hasData => false;
@protected
void safeNotify() {
if (!_disposed) {
notifyListeners();
}
}
@protected
Future<void> runLoad(
Future<void> Function() action, {
bool silent = false,
}) async {
if (!silent) {
isLoading = true;
errorMessage = null;
safeNotify();
}
try {
await action();
errorMessage = null;
} on ApiException catch (error) {
if (!silent || !hasData) {
errorMessage = error.message;
}
} catch (error) {
if (!silent || !hasData) {
errorMessage = error.toString();
}
} finally {
if (!silent) {
isLoading = false;
}
safeNotify();
}
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
class DashboardController extends BaseController {
DashboardController({
required LiveRoomsRepository liveRoomsRepository,
required RecordingsRepository recordingsRepository,
required RecoveryRepository recoveryRepository,
}) : _liveRoomsRepository = liveRoomsRepository,
_recordingsRepository = recordingsRepository,
_recoveryRepository = recoveryRepository {
_polling = PollingController(
interval: const Duration(seconds: 15),
onTick: () => refresh(silent: true),
);
}
final LiveRoomsRepository _liveRoomsRepository;
final RecordingsRepository _recordingsRepository;
final RecoveryRepository _recoveryRepository;
late final PollingController _polling;
List<LiveRoom> rooms = const <LiveRoom>[];
List<RecordSession> sessions = const <RecordSession>[];
List<RecordTask> tasks = const <RecordTask>[];
RecoveryOverview? recoveryOverview;
@override
bool get hasData => rooms.isNotEmpty || sessions.isNotEmpty || tasks.isNotEmpty || recoveryOverview != null;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_liveRoomsRepository.listRooms(),
_recordingsRepository.listSessions(),
_recordingsRepository.listTasks(),
_recoveryRepository.getOverview(),
]);
rooms = (results[0] as List<LiveRoom>)
..sort((LiveRoom a, LiveRoom b) => b.updatedAt.compareTo(a.updatedAt));
sessions = results[1] as List<RecordSession>;
tasks = (results[2] as List<RecordTask>)
..sort((RecordTask a, RecordTask b) => b.createdAt.compareTo(a.createdAt));
recoveryOverview = results[3] as RecoveryOverview;
}, silent: silent);
}
void setActive(bool active) {
_polling.setActive(active);
}
int get onlineRoomCount => rooms.where((LiveRoom room) => room.availabilityStatus == 2).length;
int get activeRecordingTaskCount => tasks.where((RecordTask task) => isTaskActive(task.status)).length;
int get todayRecordingCount {
final now = DateTime.now();
return tasks.where((RecordTask task) {
final createdAt = DateTime.tryParse(task.createdAt)?.toLocal();
return createdAt != null &&
createdAt.year == now.year &&
createdAt.month == now.month &&
createdAt.day == now.day;
}).length;
}
int get alertCount {
final recoveryCount = (recoveryOverview?.liveRooms.length ?? 0) + (recoveryOverview?.finalizations.length ?? 0);
final failedTasks = tasks.where((RecordTask task) => isTaskFailed(task.status)).length;
return recoveryCount + failedTasks;
}
String get clusterHealthLabel {
final storage = recoveryOverview?.storage;
if (storage == null) {
return '暂无集群数据';
}
if (!storage.isEnabled) {
return '存储守护未启用';
}
if (storage.message.trim().isNotEmpty) {
return storage.message;
}
return storage.hasEnoughSpace ? '存储空间正常' : '存储空间告警';
}
String get clusterNodeCountLabel => '--';
String get concurrentRecordingLabel =>
'${sessions.where((RecordSession session) => isTaskActive(session.status)).length}';
String get storageUsageLabel {
final storage = recoveryOverview?.storage;
if (storage == null) {
return '--';
}
return storage.message.trim().isEmpty ? '--' : storage.message;
}
List<int> get throughputBuckets {
final now = DateTime.now();
final buckets = List<int>.filled(8, 0);
for (final RecordTask task in tasks) {
final parsed = DateTime.tryParse(task.startedAt ?? task.createdAt)?.toLocal();
if (parsed == null) {
continue;
}
final diff = now.difference(parsed);
if (diff.inHours < 0 || diff.inHours >= 8) {
continue;
}
final index = 7 - diff.inHours;
buckets[index] += 1;
}
return buckets;
}
List<LiveRoom> get focusRooms {
final prioritized = rooms.where((LiveRoom room) => room.isPinned || room.isPriority).toList(growable: false);
if (prioritized.isNotEmpty) {
return prioritized.take(4).toList(growable: false);
}
return rooms.take(4).toList(growable: false);
}
RecordSession? activeSessionForRoom(String roomId) {
try {
return sessions.firstWhere(
(RecordSession session) => session.liveRoomId == roomId && isTaskActive(session.status),
);
} catch (_) {
return null;
}
}
@override
void dispose() {
_polling.dispose();
super.dispose();
}
}
class RoomsController extends BaseController {
RoomsController({
required LiveRoomsRepository liveRoomsRepository,
required RecordingsRepository recordingsRepository,
required RecoveryRepository recoveryRepository,
}) : _liveRoomsRepository = liveRoomsRepository,
_recordingsRepository = recordingsRepository,
_recoveryRepository = recoveryRepository {
_polling = PollingController(
interval: const Duration(seconds: 15),
onTick: () => refresh(silent: true),
);
}
final LiveRoomsRepository _liveRoomsRepository;
final RecordingsRepository _recordingsRepository;
final RecoveryRepository _recoveryRepository;
late final PollingController _polling;
List<LiveRoom> rooms = const <LiveRoom>[];
List<RecordSession> sessions = const <RecordSession>[];
RecoveryOverview? recoveryOverview;
String query = '';
RoomFilter filter = RoomFilter.all;
String? busyRoomId;
@override
bool get hasData => rooms.isNotEmpty || sessions.isNotEmpty || recoveryOverview != null;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_liveRoomsRepository.listRooms(),
_recordingsRepository.listSessions(),
_recoveryRepository.getOverview(),
]);
sessions = results[1] as List<RecordSession>;
recoveryOverview = results[2] as RecoveryOverview;
rooms = (results[0] as List<LiveRoom>)..sort(compareRooms);
}, silent: silent);
}
@protected
int compareRooms(LiveRoom a, LiveRoom b) {
final priorityA = (a.isPinned || a.isPriority) ? 1 : 0;
final priorityB = (b.isPinned || b.isPriority) ? 1 : 0;
if (priorityA != priorityB) {
return priorityB.compareTo(priorityA);
}
return b.updatedAt.compareTo(a.updatedAt);
}
void setActive(bool active) {
_polling.setActive(active);
}
void setQuery(String value) {
query = value;
safeNotify();
}
void setFilter(RoomFilter value) {
filter = value;
safeNotify();
}
List<LiveRoom> get filteredRooms {
final normalizedQuery = query.trim().toLowerCase();
return rooms.where((LiveRoom room) {
if (normalizedQuery.isNotEmpty) {
final searchPool = <String>[
room.title ?? '',
room.anchorName ?? '',
room.roomId,
room.platformName,
room.alias ?? '',
recentEventForRoom(room),
].join(' ').toLowerCase();
if (!searchPool.contains(normalizedQuery)) {
return false;
}
}
switch (filter) {
case RoomFilter.all:
return true;
case RoomFilter.live:
return room.availabilityStatus == 2;
case RoomFilter.recording:
return room.currentRecordingState == 2;
case RoomFilter.error:
return roomHasError(room);
case RoomFilter.retrying:
return roomIsRetrying(room);
}
}).toList(growable: false);
}
RecordSession? sessionForRoom(String roomId) {
final matchingSessions = sessions.where((RecordSession session) => session.liveRoomId == roomId).toList(growable: false);
if (matchingSessions.isEmpty) {
return null;
}
matchingSessions.sort((RecordSession a, RecordSession b) => (b.startedAt ?? b.createdAt).compareTo(a.startedAt ?? a.createdAt));
return matchingSessions.first;
}
RecoverableLiveRoom? recoveryInfoForRoom(String roomId) {
try {
return recoveryOverview?.liveRooms.firstWhere((RecoverableLiveRoom item) => item.liveRoomId == roomId);
} catch (_) {
return null;
}
}
String recentEventForRoom(LiveRoom room) {
final recoveryInfo = recoveryInfoForRoom(room.id);
return recoveryInfo?.lastAutoStartDecisionSummary ??
room.lastAutoStartDecisionSummary ??
autoStartDecisionLabel(recoveryInfo?.lastAutoStartDecisionCode ?? room.lastAutoStartDecisionCode);
}
bool roomHasError(LiveRoom room) {
final code = (room.lastAutoStartDecisionCode ?? '').toLowerCase();
return recoveryInfoForRoom(room.id) != null || code.contains('fail') || code.contains('error');
}
bool roomIsRetrying(LiveRoom room) {
final code = (room.lastAutoStartDecisionCode ?? '').toLowerCase();
return code.contains('retry');
}
Future<String> createRoom({
required String url,
int? platformOverride,
}) async {
await _liveRoomsRepository.createRoom(url: url, platformOverride: platformOverride);
await refresh(silent: true);
return '直播间已添加';
}
Future<String> toggleRoomEnabled(LiveRoom room) async {
busyRoomId = room.id;
safeNotify();
try {
await _liveRoomsRepository.setRoomEnabled(
roomId: room.id,
isEnabled: !room.isEnabled,
);
await refresh(silent: true);
return room.isEnabled ? '直播间已停用' : '直播间已启用';
} finally {
busyRoomId = null;
safeNotify();
}
}
Future<String> refreshRoom(LiveRoom room) async {
busyRoomId = room.id;
safeNotify();
try {
await _liveRoomsRepository.refreshRoom(room.id);
await refresh(silent: true);
return '直播状态已刷新';
} finally {
busyRoomId = null;
safeNotify();
}
}
Future<String> startRecording({
required LiveRoom room,
String? preferredQuality,
int? outputFormat,
}) async {
busyRoomId = room.id;
safeNotify();
try {
await _recordingsRepository.startRecording(
liveRoomId: room.id,
preferredQuality: preferredQuality ?? room.effectiveSettings.preferredQuality,
outputFormat: outputFormat ?? room.effectiveSettings.outputFormat,
);
await refresh(silent: true);
return '录制任务已启动';
} finally {
busyRoomId = null;
safeNotify();
}
}
Future<String> retryRoom(LiveRoom room) async {
busyRoomId = room.id;
safeNotify();
try {
await _recoveryRepository.retryLiveRoom(room.id);
await refresh(silent: true);
return '已提交重试请求';
} finally {
busyRoomId = null;
safeNotify();
}
}
Future<String> saveMetadata({
required LiveRoom room,
required String? remark,
required bool isPinned,
required String? alias,
required bool isPriority,
required int? pollingIntervalSecondsOverride,
}) async {
busyRoomId = room.id;
safeNotify();
try {
await _liveRoomsRepository.updateMetadata(
roomId: room.id,
payload: <String, dynamic>{
'remark': remark?.trim().isEmpty ?? true ? null : remark?.trim(),
'isPinned': isPinned,
'alias': alias?.trim().isEmpty ?? true ? null : alias?.trim(),
'isPriority': isPriority,
'pollingIntervalSecondsOverride': pollingIntervalSecondsOverride,
},
);
await refresh(silent: true);
return '房间信息已保存';
} finally {
busyRoomId = null;
safeNotify();
}
}
Future<String> saveRoomSettings({
required LiveRoom room,
required Map<String, dynamic> payload,
}) async {
busyRoomId = room.id;
safeNotify();
try {
await _liveRoomsRepository.updateSettings(roomId: room.id, payload: payload);
await refresh(silent: true);
return '录制设置已保存';
} finally {
busyRoomId = null;
safeNotify();
}
}
@override
void dispose() {
_polling.dispose();
super.dispose();
}
}
class MonitorController extends RoomsController {
MonitorController({
required super.liveRoomsRepository,
required super.recordingsRepository,
required super.recoveryRepository,
});
@override
int compareRooms(LiveRoom a, LiveRoom b) => compareMonitorRooms(a, b);
}
class RecordingsController extends BaseController {
RecordingsController({
required RecordingsRepository recordingsRepository,
required SettingsRepository settingsRepository,
required MediaRepository mediaRepository,
}) : _recordingsRepository = recordingsRepository,
_settingsRepository = settingsRepository,
_mediaRepository = mediaRepository {
_polling = PollingController(
interval: const Duration(seconds: 15),
onTick: () => refresh(silent: true),
);
}
final RecordingsRepository _recordingsRepository;
final SettingsRepository _settingsRepository;
final MediaRepository _mediaRepository;
late final PollingController _polling;
List<RecordTask> tasks = const <RecordTask>[];
SystemSettings? settings;
final Map<String, RecordTaskDetail> detailCache = <String, RecordTaskDetail>{};
String query = '';
@override
bool get hasData => tasks.isNotEmpty || settings != null;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_recordingsRepository.listTasks(),
_settingsRepository.getSettings(),
]);
tasks = (results[0] as List<RecordTask>)
..sort((RecordTask a, RecordTask b) => b.createdAt.compareTo(a.createdAt));
settings = results[1] as SystemSettings;
}, silent: silent);
}
void setActive(bool active) {
_polling.setActive(active);
}
void setQuery(String value) {
query = value;
safeNotify();
}
List<RecordTask> get filteredTasks {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return tasks;
}
return tasks.where((RecordTask task) {
final searchPool = <String>[
task.liveRoomTitle,
task.roomId,
task.outputFilePath ?? '',
].join(' ').toLowerCase();
return searchPool.contains(normalizedQuery);
}).toList(growable: false);
}
RecordTaskDetail? cachedDetail(String taskId) => detailCache[taskId];
Future<void> ensureDetailLoaded(String taskId) async {
if (detailCache.containsKey(taskId)) {
return;
}
try {
final detail = await _recordingsRepository.getTaskDetail(taskId);
detailCache[taskId] = detail;
safeNotify();
} catch (_) {
// Keep lightweight list rendering resilient.
}
}
Uri? downloadUriForTask(RecordTask task) {
final relativePath = deriveRelativeMediaPath(
outputRoot: settings?.outputRoot,
outputFilePath: task.outputFilePath,
);
if (relativePath == null || relativePath.isEmpty) {
return null;
}
return _mediaRepository.buildFileUri(
relativePath: relativePath,
download: true,
);
}
@override
void dispose() {
_polling.dispose();
super.dispose();
}
}
class ProfileController extends BaseController {
ProfileController({
required SettingsRepository settingsRepository,
required RecoveryRepository recoveryRepository,
}) : _settingsRepository = settingsRepository,
_recoveryRepository = recoveryRepository;
final SettingsRepository _settingsRepository;
final RecoveryRepository _recoveryRepository;
SystemSettings? settings;
RecoveryOverview? recoveryOverview;
@override
bool get hasData => settings != null || recoveryOverview != null;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_settingsRepository.getSettings(),
_recoveryRepository.getOverview(),
]);
settings = results[0] as SystemSettings;
recoveryOverview = results[1] as RecoveryOverview;
}, silent: silent);
}
Future<SystemSettings> saveNotificationSettings(SystemSettings updated) async {
final saved = await _settingsRepository.updateSettings(updated);
settings = saved;
safeNotify();
return saved;
}
Future<CleanupOperation> runRetentionCleanup() {
return _settingsRepository.runRetentionCleanup();
}
}
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/backend_address_form_card.dart';
class BackendSettingsPage extends StatefulWidget {
const BackendSettingsPage({
super.key,
required this.bootstrapController,
});
final BackendConfigHandle bootstrapController;
@override
State<BackendSettingsPage> createState() => _BackendSettingsPageState();
}
class _BackendSettingsPageState extends State<BackendSettingsPage> {
late final TextEditingController _controller = TextEditingController(
text: widget.bootstrapController.backendBaseUrl ?? widget.bootstrapController.seedBaseUrl,
);
bool _submitting = false;
String? _errorText;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _submit() async {
FocusScope.of(context).unfocus();
final validationMessage = validateBackendBaseUrl(_controller.text);
if (validationMessage != null) {
setState(() {
_errorText = validationMessage;
});
return;
}
final normalizedValue = normalizeBackendBaseUrl(_controller.text);
if (normalizedValue == widget.bootstrapController.backendBaseUrl) {
Navigator.of(context).pop();
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('切换后端地址'),
content: const Text(
'修改后端地址后,当前登录状态会被清空,并返回登录页重新连接。是否继续?',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('确认切换'),
),
],
);
},
) ??
false;
if (!confirmed) {
return;
}
setState(() {
_submitting = true;
_errorText = null;
});
try {
final changed = await widget.bootstrapController.updateBackendBaseUrl(normalizedValue);
if (!mounted) {
return;
}
if (!changed) {
Navigator.of(context).pop();
return;
}
Navigator.of(context).popUntil((Route<dynamic> route) => route.isFirst);
} on FormatException catch (error) {
setState(() {
_errorText = error.message;
});
} catch (error) {
setState(() {
_errorText = '保存后端地址失败:$error';
});
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final currentBaseUrl = widget.bootstrapController.backendBaseUrl ?? '--';
return Scaffold(
appBar: AppBar(
title: const Text('连接设置'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'当前后端地址',
style: TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
SelectableText(
currentBaseUrl,
style: const TextStyle(
color: Color(0xFF2563EB),
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 12),
BackendAddressFormCard(
title: '修改后端地址',
description: '你可以在这里切换到新的 LiveRecorder 后端环境。地址保存成功后,应用会自动清空当前登录态并返回登录页。',
note: '此操作不会修改后端接口,只会切换移动端请求的基础地址。',
controller: _controller,
actionLabel: '保存并切换',
onSubmit: _submit,
isSubmitting: _submitting,
errorText: _errorText,
onFieldSubmitted: (_) => _submit(),
),
],
),
);
}
}
@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/backend_address_form_card.dart';
class BackendSetupPage extends StatefulWidget {
const BackendSetupPage({
super.key,
required this.bootstrapController,
});
final BackendConfigHandle bootstrapController;
@override
State<BackendSetupPage> createState() => _BackendSetupPageState();
}
class _BackendSetupPageState extends State<BackendSetupPage> {
late final TextEditingController _controller = TextEditingController(
text: widget.bootstrapController.backendBaseUrl ?? widget.bootstrapController.seedBaseUrl,
);
bool _submitting = false;
String? _errorText;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _submit() async {
FocusScope.of(context).unfocus();
final validationMessage = validateBackendBaseUrl(_controller.text);
if (validationMessage != null) {
setState(() {
_errorText = validationMessage;
});
return;
}
setState(() {
_submitting = true;
_errorText = null;
});
try {
await widget.bootstrapController.saveInitialBackendBaseUrl(_controller.text);
} on FormatException catch (error) {
setState(() {
_errorText = error.message;
});
} catch (error) {
setState(() {
_errorText = '保存后端地址失败:$error';
});
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final isWide = constraints.maxWidth >= 900;
return Padding(
padding: const EdgeInsets.all(24),
child: isWide
? Row(
children: <Widget>[
Expanded(child: _buildHero()),
const SizedBox(width: 32),
SizedBox(
width: 460,
child: _buildForm(),
),
],
)
: ListView(
children: <Widget>[
_buildHero(),
const SizedBox(height: 24),
_buildForm(),
],
),
);
},
),
),
);
}
Widget _buildHero() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'LiveRecorder',
style: TextStyle(
color: Color(0xFF2563EB),
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
const Text(
'首次进入先连接你的后端服务',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 38,
fontWeight: FontWeight.w800,
height: 1.08,
),
),
const SizedBox(height: 16),
const Text(
'配置完成后,应用会继续使用现有登录、Token 和真实接口。以后也可以在“我的 > 连接设置”里随时修改后端地址。',
style: TextStyle(
color: Color(0xFF64748B),
height: 1.6,
),
),
const SizedBox(height: 24),
Wrap(
spacing: 12,
runSpacing: 12,
children: const <Widget>[
_HeroChip(label: '真实接口接入'),
_HeroChip(label: '保留现有认证'),
_HeroChip(label: '支持子路径部署'),
],
),
],
);
}
Widget _buildForm() {
return BackendAddressFormCard(
title: '配置后端地址',
description: '请输入 LiveRecorder 后端的完整访问地址。保存后会进入登录流程,不会写入任何 mock 数据。',
note: widget.bootstrapController.seedBaseUrl.isEmpty
? null
: '已检测到启动参数中的默认地址,当前已为你预填,可直接修改后保存。',
controller: _controller,
actionLabel: '保存并继续',
onSubmit: _submit,
isSubmitting: _submitting,
errorText: _errorText,
onFieldSubmitted: (_) => _submit(),
);
}
}
class _HeroChip extends StatelessWidget {
const _HeroChip({
required this.label,
});
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Text(
label,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_scope.dart';
import 'package:live_recorder_mobile/core/utils/recovery_formatters.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/metric_card.dart';
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/room_detail_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/cluster_status_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/room_card.dart';
class DashboardPage extends StatefulWidget {
const DashboardPage({
super.key,
required this.controller,
required this.userInitials,
required this.onOpenLogs,
required this.onOpenProfile,
});
final DashboardController controller;
final String userInitials;
final VoidCallback onOpenLogs;
final VoidCallback onOpenProfile;
@override
State<DashboardPage> createState() => _DashboardPageState();
}
class _DashboardPageState extends State<DashboardPage> {
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
final buckets = widget.controller.throughputBuckets;
final hasThroughput = buckets.any((int value) => value > 0);
final maxBucket = hasThroughput
? buckets.reduce((int a, int b) => a > b ? a : b)
: 0;
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
children: <Widget>[
MobileHeader(
eyebrow: 'LiveRecorder · 安卓端',
title: '监控大盘',
userInitials: widget.userInitials,
onNotificationsPressed: widget.onOpenLogs,
onProfilePressed: widget.onOpenProfile,
),
if (widget.controller.isLoading && !widget.controller.hasData)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: <Widget>[
SkeletonCard(height: 140),
SizedBox(height: 16),
SkeletonCard(height: 180),
],
),
)
else if (widget.controller.errorMessage != null &&
!widget.controller.hasData)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
),
)
else ...<Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ClusterStatusCard(
healthLabel: formatStorageHealthLabel(
widget.controller.recoveryOverview?.storage,
),
nodeCountLabel: widget.controller.clusterNodeCountLabel,
concurrentRecordingLabel:
widget.controller.concurrentRecordingLabel,
storageLabel: formatStorageUsageLabel(
widget.controller.recoveryOverview?.storage,
),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: 4,
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
mainAxisExtent: 180,
),
itemBuilder: (BuildContext context, int index) {
final cards = <Widget>[
MetricCard(
label: '在线直播间',
value: '${widget.controller.onlineRoomCount}',
description: '来自 /api/live-rooms 的实时状态统计',
trendValue: widget.controller.rooms.isEmpty
? 0.08
: widget.controller.onlineRoomCount /
widget.controller.rooms.length,
),
MetricCard(
label: '录制中任务',
value:
'${widget.controller.activeRecordingTaskCount}',
description: '启动中、录制中、处理中任务总数',
trendValue: widget.controller.tasks.isEmpty
? 0.08
: widget.controller.activeRecordingTaskCount /
widget.controller.tasks.length,
),
MetricCard(
label: '今日新增录像',
value: '${widget.controller.todayRecordingCount}',
description: '基于真实 task.createdAt 统计',
trendValue:
widget.controller.todayRecordingCount == 0
? 0.08
: 0.45,
),
MetricCard(
label: '异常告警',
value: '${widget.controller.alertCount}',
description: '恢复中心与失败任务数量',
color: const Color(0xFFDC2626),
trendValue: widget.controller.alertCount == 0
? 0.08
: 0.75,
),
];
return cards[index];
},
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'录制吞吐',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
const Text(
'近 8 小时',
style: TextStyle(color: Color(0xFF64748B)),
),
const SizedBox(height: 18),
if (!hasThroughput)
const AppEmptyState(
title: '暂无吞吐数据',
description: '当前 8 小时窗口内没有可统计的真实录制任务。',
)
else
SizedBox(
height: 140,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: buckets.map((int value) {
final ratio = maxBucket == 0
? 0.08
: value / maxBucket;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
),
child: Column(
mainAxisAlignment:
MainAxisAlignment.end,
children: <Widget>[
Text(
'$value',
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
),
),
const SizedBox(height: 8),
Container(
height: 18 + (ratio * 90),
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
borderRadius:
BorderRadius.circular(12),
),
),
],
),
),
);
}).toList(growable: false),
),
),
],
),
),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'重点直播间',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F172A),
),
),
),
const SizedBox(height: 12),
if (widget.controller.focusRooms.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: AppEmptyState(),
)
else
...widget.controller.focusRooms.map((room) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: RoomCard(
room: room,
session: widget.controller.activeSessionForRoom(room.id),
recentEvent:
room.lastAutoStartDecisionSummary ?? '暂无事件',
onTap: () {
final dependencies = AppScope.of(context);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => RoomDetailPage(
dependencies: dependencies,
roomId: room.id,
),
),
);
},
),
);
}),
],
],
),
);
},
);
}
}
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/network/api_exception.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
class LoginPage extends StatefulWidget {
const LoginPage({
super.key,
required this.sessionController,
});
final AppSessionController sessionController;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
bool _submitting = false;
String? _errorMessage;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
FocusScope.of(context).unfocus();
setState(() {
_submitting = true;
_errorMessage = null;
});
try {
await widget.sessionController.login(
username: _usernameController.text.trim(),
password: _passwordController.text,
);
} on ApiException catch (error) {
setState(() {
_errorMessage = error.message;
});
} catch (error) {
setState(() {
_errorMessage = error.toString();
});
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth >= 700 ? 420 : 480,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const _BrandHeader(),
const SizedBox(height: 16),
_buildForm(),
],
),
),
),
);
},
),
),
);
}
Widget _buildForm() {
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'登录',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 20),
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: '用户名',
prefixIcon: Icon(Icons.person_outline_rounded),
),
),
const SizedBox(height: 14),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: '密码',
prefixIcon: Icon(Icons.lock_outline_rounded),
),
onSubmitted: (_) => _submit(),
),
if (_errorMessage != null) ...<Widget>[
const SizedBox(height: 14),
Text(
_errorMessage!,
style: const TextStyle(
color: Color(0xFFDC2626),
height: 1.5,
),
),
],
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('登录'),
),
),
],
),
),
);
}
}
class _BrandHeader extends StatelessWidget {
const _BrandHeader();
@override
Widget build(BuildContext context) {
return const Text(
'LiveRecorder',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xFF2563EB),
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
),
);
}
}
@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_search_bar.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
class LogsPage extends StatefulWidget {
const LogsPage({
super.key,
required this.controller,
});
final LogsController controller;
@override
State<LogsPage> createState() => _LogsPageState();
}
class _LogsPageState extends State<LogsPage> {
late final TextEditingController _searchController = TextEditingController(text: widget.controller.query);
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
@override
void dispose() {
_searchController.dispose();
widget.controller.dispose();
super.dispose();
}
Future<void> _setLevelAndRefresh(int? level) async {
widget.controller.setLevel(level);
await widget.controller.refresh();
}
Future<void> _setQueryAndRefresh(String query) async {
widget.controller.setQuery(query);
await widget.controller.refresh();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('操作日志'),
),
body: ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: <Widget>[
AppSearchBar(
controller: _searchController,
hintText: '搜索日志内容 / 分类',
onSubmitted: (String value) => _setQueryAndRefresh(value),
onChanged: widget.controller.setQuery,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
FilterChip(
selected: widget.controller.level == null,
onSelected: (_) => _setLevelAndRefresh(null),
label: const Text('全部'),
),
FilterChip(
selected: widget.controller.level == 1,
onSelected: (_) => _setLevelAndRefresh(1),
label: const Text('信息'),
),
FilterChip(
selected: widget.controller.level == 2,
onSelected: (_) => _setLevelAndRefresh(2),
label: const Text('警告'),
),
FilterChip(
selected: widget.controller.level == 3,
onSelected: (_) => _setLevelAndRefresh(3),
label: const Text('错误'),
),
],
),
const SizedBox(height: 12),
if (widget.controller.isLoading && !widget.controller.hasData)
const SkeletonCard(height: 180)
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
)
else if (widget.controller.logs.isEmpty)
const AppEmptyState(
title: '暂无日志',
description: '当前筛选条件下没有可展示的真实日志。',
)
else
...widget.controller.logs.map((SystemLog log) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
log.message,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
_LogLevelBadge(level: log.level),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_InfoChip(label: '分类', value: log.category),
_InfoChip(label: '时间', value: formatDateTime(log.createdAt)),
if ((log.liveRoomId ?? '').isNotEmpty) _InfoChip(label: '房间', value: log.liveRoomId!),
if ((log.recordTaskId ?? '').isNotEmpty) _InfoChip(label: '任务', value: log.recordTaskId!),
],
),
if ((log.detail ?? '').trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 10),
Text(
log.detail!,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.5,
),
),
],
],
),
),
);
}),
],
),
);
},
),
);
}
}
class _LogLevelBadge extends StatelessWidget {
const _LogLevelBadge({required this.level});
final int level;
@override
Widget build(BuildContext context) {
final color = switch (level) {
3 => const Color(0xFFDC2626),
2 => const Color(0xFFF59E0B),
_ => const Color(0xFF2563EB),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
),
child: Text(
logLevelLabel(level),
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
);
}
}
class _InfoChip extends StatelessWidget {
const _InfoChip({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$label · $value',
style: const TextStyle(
color: Color(0xFF475569),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
import 'package:url_launcher/url_launcher.dart';
class MediaBrowserPage extends StatefulWidget {
const MediaBrowserPage({
super.key,
required this.controller,
});
final MediaBrowserController controller;
@override
State<MediaBrowserPage> createState() => _MediaBrowserPageState();
}
class _MediaBrowserPageState extends State<MediaBrowserPage> {
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh(path: '');
}
}
@override
void dispose() {
widget.controller.dispose();
super.dispose();
}
Future<void> _openFile(String relativePath, {bool download = false}) async {
final uri = widget.controller.fileUri(relativePath, download: download);
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<void> _transcodeFile(String relativePath) async {
try {
final message = await widget.controller.transcodeFile(relativePath);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('文件浏览')),
body: ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
final response = widget.controller.response;
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(path: widget.controller.currentPath),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: <Widget>[
if (response != null && response.breadcrumbs.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: response.breadcrumbs.map((MediaBrowserBreadcrumb crumb) {
return ActionChip(
label: Text(crumb.label),
onPressed: () => widget.controller.refresh(path: crumb.relativePath),
);
}).toList(growable: false),
),
),
if (widget.controller.isLoading && !widget.controller.hasData)
const SkeletonCard(height: 220)
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh(path: widget.controller.currentPath);
},
)
else if (response == null || response.items.isEmpty)
const AppEmptyState(
title: '目录为空',
description: '当前路径下没有可展示的真实文件或目录。',
)
else
...response.items.map((MediaBrowserItem item) {
final type = item.type.toLowerCase();
final isDirectory = type == 'directory' || type == 'dir' || type == 'folder';
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: AppCard(
onTap: isDirectory ? () => widget.controller.refresh(path: item.relativePath) : null,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: isDirectory ? const Color(0xFFEFF6FF) : const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
isDirectory ? Icons.folder_rounded : Icons.insert_drive_file_rounded,
color: isDirectory ? const Color(0xFF2563EB) : const Color(0xFF64748B),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.name,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_FileChip(label: '类型', value: item.type),
_FileChip(label: '大小', value: formatBytes(item.sizeBytes)),
_FileChip(label: '修改时间', value: formatDateTime(item.modifiedAt)),
],
),
],
),
),
if (!isDirectory)
PopupMenuButton<String>(
onSelected: (String value) {
switch (value) {
case 'preview':
_openFile(item.relativePath);
return;
case 'download':
_openFile(item.relativePath, download: true);
return;
case 'transcode':
_transcodeFile(item.relativePath);
return;
}
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
if (item.canPreview) const PopupMenuItem(value: 'preview', child: Text('预览')),
const PopupMenuItem(value: 'download', child: Text('下载')),
if (item.canTranscode) const PopupMenuItem(value: 'transcode', child: Text('提交转码')),
],
),
],
),
),
);
}),
],
),
);
},
),
);
}
}
class _FileChip extends StatelessWidget {
const _FileChip({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$label · $value',
style: const TextStyle(
color: Color(0xFF475569),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/dashboard_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/logs_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/monitor_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/profile_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/recordings_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/rooms_page.dart';
class MobileShellPage extends StatefulWidget {
const MobileShellPage({
super.key,
required this.dependencies,
});
final AppDependencies dependencies;
@override
State<MobileShellPage> createState() => _MobileShellPageState();
}
class _MobileShellPageState extends State<MobileShellPage> with WidgetsBindingObserver {
late final DashboardController _dashboardController = DashboardController(
liveRoomsRepository: widget.dependencies.liveRoomsRepository,
recordingsRepository: widget.dependencies.recordingsRepository,
recoveryRepository: widget.dependencies.recoveryRepository,
);
late final MonitorController _monitorController = MonitorController(
liveRoomsRepository: widget.dependencies.liveRoomsRepository,
recordingsRepository: widget.dependencies.recordingsRepository,
recoveryRepository: widget.dependencies.recoveryRepository,
);
late final RoomsController _roomsController = RoomsController(
liveRoomsRepository: widget.dependencies.liveRoomsRepository,
recordingsRepository: widget.dependencies.recordingsRepository,
recoveryRepository: widget.dependencies.recoveryRepository,
);
late final RecordingsController _recordingsController = RecordingsController(
recordingsRepository: widget.dependencies.recordingsRepository,
settingsRepository: widget.dependencies.settingsRepository,
mediaRepository: widget.dependencies.mediaRepository,
);
late final ProfileController _profileController = ProfileController(
settingsRepository: widget.dependencies.settingsRepository,
recoveryRepository: widget.dependencies.recoveryRepository,
);
int _selectedIndex = 0;
bool _isForeground = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_syncPolling();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_dashboardController.dispose();
_monitorController.dispose();
_roomsController.dispose();
_recordingsController.dispose();
_profileController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_isForeground = state == AppLifecycleState.resumed;
_syncPolling();
}
void _syncPolling() {
final active = _isForeground;
_dashboardController.setActive(active && _selectedIndex == 0);
_monitorController.setActive(active && _selectedIndex == 1);
_roomsController.setActive(active && _selectedIndex == 2);
_recordingsController.setActive(active && _selectedIndex == 3);
}
void _openLogs() {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => LogsPage(
controller: LogsController(
logsRepository: widget.dependencies.logsRepository,
),
),
),
);
}
@override
Widget build(BuildContext context) {
final sessionController = widget.dependencies.sessionController;
final userName = sessionController.user?.displayName.isNotEmpty == true
? sessionController.user!.displayName
: sessionController.user?.username ?? 'L';
final userInitials = userName.isEmpty ? 'L' : userName.characters.first.toUpperCase();
return Scaffold(
body: SafeArea(
top: true,
bottom: false,
child: IndexedStack(
index: _selectedIndex,
children: <Widget>[
DashboardPage(
controller: _dashboardController,
userInitials: userInitials,
onOpenLogs: _openLogs,
onOpenProfile: () => setState(() => _selectedIndex = 4),
),
MonitorPage(
controller: _monitorController,
userInitials: userInitials,
onOpenLogs: _openLogs,
onOpenProfile: () => setState(() => _selectedIndex = 4),
),
RoomsPage(
controller: _roomsController,
userInitials: userInitials,
onOpenLogs: _openLogs,
onOpenProfile: () => setState(() => _selectedIndex = 4),
),
RecordingsPage(
controller: _recordingsController,
userInitials: userInitials,
onOpenLogs: _openLogs,
onOpenProfile: () => setState(() => _selectedIndex = 4),
),
ProfilePage(
controller: _profileController,
dependencies: widget.dependencies,
userInitials: userInitials,
onOpenLogs: _openLogs,
),
],
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (int index) {
setState(() {
_selectedIndex = index;
_syncPolling();
});
},
destinations: const <NavigationDestination>[
NavigationDestination(
icon: Icon(Icons.dashboard_rounded),
label: '大盘',
),
NavigationDestination(
icon: Icon(Icons.radar_rounded),
label: '监控',
),
NavigationDestination(
icon: Icon(Icons.video_camera_back_rounded),
label: '直播间',
),
NavigationDestination(
icon: Icon(Icons.folder_copy_rounded),
label: '录像',
),
NavigationDestination(
icon: Icon(Icons.person_rounded),
label: '我的',
),
],
),
);
}
}
@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_scope.dart';
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/room_detail_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/room_preview_card.dart';
import 'package:url_launcher/url_launcher.dart';
class MonitorPage extends StatefulWidget {
const MonitorPage({
super.key,
required this.controller,
required this.userInitials,
required this.onOpenLogs,
required this.onOpenProfile,
});
final MonitorController controller;
final String userInitials;
final VoidCallback onOpenLogs;
final VoidCallback onOpenProfile;
@override
State<MonitorPage> createState() => _MonitorPageState();
}
class _MonitorPageState extends State<MonitorPage> {
bool _paused = false;
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
Future<void> _showAddRoomDialog() async {
final TextEditingController controller = TextEditingController();
await showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('添加监控'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: '直播间链接 / Room ID',
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () async {
Navigator.of(context).pop();
if (controller.text.trim().isEmpty) {
return;
}
final messenger = ScaffoldMessenger.of(this.context);
try {
final message = await widget.controller.createRoom(
url: controller.text.trim(),
);
messenger.showSnackBar(SnackBar(content: Text(message)));
} catch (error) {
messenger.showSnackBar(
SnackBar(content: Text(error.toString())),
);
}
},
child: const Text('添加'),
),
],
);
},
);
controller.dispose();
}
Future<void> _openLiveRoom(LiveRoom room) async {
final messenger = ScaffoldMessenger.of(context);
final uri = resolveLiveRoomWatchUri(room);
if (uri == null) {
messenger.showSnackBar(
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
);
return;
}
try {
final launched = await launchUrl(
uri,
mode: LaunchMode.inAppBrowserView,
);
if (!launched && mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
);
}
} catch (_) {
if (!mounted) {
return;
}
messenger.showSnackBar(
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
);
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
children: <Widget>[
MobileHeader(
eyebrow: '直播预览 · 自动刷新',
title: '实时监控墙',
userInitials: widget.userInitials,
onNotificationsPressed: widget.onOpenLogs,
onProfilePressed: widget.onOpenProfile,
trailing: Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
FilledButton.tonalIcon(
onPressed: _showAddRoomDialog,
icon: const Icon(Icons.add_rounded),
label: const Text('添加监控'),
),
FilledButton.tonalIcon(
onPressed: () {
setState(() {
_paused = !_paused;
widget.controller.setActive(!_paused);
});
},
icon: Icon(
_paused
? Icons.play_arrow_rounded
: Icons.pause_rounded,
),
label: Text(_paused ? '恢复刷新' : '暂停刷新'),
),
],
),
),
if (widget.controller.isLoading && !widget.controller.hasData)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: SkeletonCard(height: 280),
)
else if (widget.controller.errorMessage != null &&
!widget.controller.hasData)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
),
)
else if (widget.controller.filteredRooms.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: AppEmptyState(),
)
else
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final isTablet = constraints.maxWidth >= 900;
final rooms = widget.controller.filteredRooms;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: rooms.length,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isTablet ? 2 : 1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: isTablet ? 0.96 : 0.82,
),
itemBuilder: (BuildContext context, int index) {
final room = rooms[index];
return RoomPreviewCard(
room: room,
session: widget.controller.sessionForRoom(room.id),
recentEvent: widget.controller.recentEventForRoom(room),
onWatchLive: () => _openLiveRoom(room),
onTap: () {
final dependencies = AppScope.of(context);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => RoomDetailPage(
dependencies: dependencies,
roomId: room.id,
),
),
);
},
);
},
);
},
),
],
),
);
},
);
}
}
@@ -0,0 +1,275 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/network/api_exception.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
class NotificationSettingsPage extends StatefulWidget {
const NotificationSettingsPage({
super.key,
required this.settingsRepository,
required this.initialSettings,
});
final SettingsRepository settingsRepository;
final SystemSettings? initialSettings;
@override
State<NotificationSettingsPage> createState() => _NotificationSettingsPageState();
}
class _NotificationSettingsPageState extends State<NotificationSettingsPage> {
final TextEditingController _emailToController = TextEditingController();
final TextEditingController _webhookUrlController = TextEditingController();
final TextEditingController _webhookTimeoutController = TextEditingController();
SystemSettings? _settings;
bool _loading = true;
bool _saving = false;
String? _errorMessage;
bool _enableEmailNotification = false;
bool _notifyOnLiveStarted = false;
bool _notifyOnException = false;
bool _enableWebhookNotification = false;
bool _notifyWebhookOnLiveStarted = false;
bool _notifyWebhookOnException = false;
@override
void initState() {
super.initState();
if (widget.initialSettings != null) {
_applySettings(widget.initialSettings!);
_loading = false;
} else {
_load();
}
}
@override
void dispose() {
_emailToController.dispose();
_webhookUrlController.dispose();
_webhookTimeoutController.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_loading = true;
_errorMessage = null;
});
try {
final settings = await widget.settingsRepository.getSettings();
_applySettings(settings);
} on ApiException catch (error) {
setState(() {
_errorMessage = error.message;
});
} catch (error) {
setState(() {
_errorMessage = error.toString();
});
} finally {
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
void _applySettings(SystemSettings settings) {
_settings = settings.copy();
_enableEmailNotification = settings.enableEmailNotification;
_emailToController.text = settings.emailToAddresses;
_notifyOnLiveStarted = settings.notifyOnLiveStarted;
_notifyOnException = settings.notifyOnException;
_enableWebhookNotification = settings.enableWebhookNotification;
_webhookUrlController.text = settings.webhookUrl;
_webhookTimeoutController.text = '${settings.webhookTimeoutSeconds}';
_notifyWebhookOnLiveStarted = settings.notifyWebhookOnLiveStarted;
_notifyWebhookOnException = settings.notifyWebhookOnException;
}
Future<void> _save() async {
final settings = _settings?.copy();
if (settings == null) {
return;
}
settings.updateNotificationSettings(
enableEmailNotification: _enableEmailNotification,
emailToAddresses: _emailToController.text.trim(),
notifyOnLiveStarted: _notifyOnLiveStarted,
notifyOnException: _notifyOnException,
enableWebhookNotification: _enableWebhookNotification,
webhookUrl: _webhookUrlController.text.trim(),
webhookTimeoutSeconds: int.tryParse(_webhookTimeoutController.text.trim()) ?? 0,
notifyWebhookOnLiveStarted: _notifyWebhookOnLiveStarted,
notifyWebhookOnException: _notifyWebhookOnException,
);
setState(() {
_saving = true;
_errorMessage = null;
});
try {
final saved = await widget.settingsRepository.updateSettings(settings);
_applySettings(saved);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('通知设置已保存。')),
);
} on ApiException catch (error) {
setState(() {
_errorMessage = error.message;
});
} catch (error) {
setState(() {
_errorMessage = error.toString();
});
} finally {
if (mounted) {
setState(() {
_saving = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('通知设置')),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
if (_loading)
const SkeletonCard(height: 220)
else if (_errorMessage != null && _settings == null)
AppErrorCard(
message: _errorMessage!,
onRetry: () {
_load();
},
)
else ...<Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'邮件通知',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _enableEmailNotification,
onChanged: (bool value) => setState(() => _enableEmailNotification = value),
title: const Text('启用邮件通知'),
),
TextField(
controller: _emailToController,
decoration: const InputDecoration(labelText: '收件人地址(逗号分隔)'),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _notifyOnLiveStarted,
onChanged: (bool value) => setState(() => _notifyOnLiveStarted = value),
title: const Text('开播时通知'),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _notifyOnException,
onChanged: (bool value) => setState(() => _notifyOnException = value),
title: const Text('异常时通知'),
),
],
),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'Webhook 通知',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _enableWebhookNotification,
onChanged: (bool value) => setState(() => _enableWebhookNotification = value),
title: const Text('启用 Webhook 通知'),
),
TextField(
controller: _webhookUrlController,
decoration: const InputDecoration(labelText: 'Webhook URL'),
),
const SizedBox(height: 12),
TextField(
controller: _webhookTimeoutController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '超时时间(秒)'),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _notifyWebhookOnLiveStarted,
onChanged: (bool value) => setState(() => _notifyWebhookOnLiveStarted = value),
title: const Text('开播时回调'),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _notifyWebhookOnException,
onChanged: (bool value) => setState(() => _notifyWebhookOnException = value),
title: const Text('异常时回调'),
),
],
),
),
),
if (_errorMessage != null) ...<Widget>[
const SizedBox(height: 12),
Text(
_errorMessage!,
style: const TextStyle(color: Color(0xFFDC2626), height: 1.5),
),
],
const SizedBox(height: 16),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('保存设置'),
),
],
],
),
);
}
}
@@ -0,0 +1,350 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/app/app_scope.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/backend_settings_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/logs_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/media_browser_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/notification_settings_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/security_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/storage_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/system_summary_page.dart';
class ProfilePage extends StatefulWidget {
const ProfilePage({
super.key,
required this.controller,
required this.dependencies,
required this.userInitials,
required this.onOpenLogs,
});
final ProfileController controller;
final AppDependencies dependencies;
final String userInitials;
final VoidCallback onOpenLogs;
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
void _push(Widget page) {
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => page),
);
}
@override
Widget build(BuildContext context) {
final user = widget.dependencies.sessionController.user;
final displayName = user?.displayName.isNotEmpty == true ? user!.displayName : user?.username ?? '--';
final backendConfig = AppScope.backendConfigOf(context);
return ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
children: <Widget>[
MobileHeader(
eyebrow: '个人中心',
title: '我的',
userInitials: widget.userInitials,
onNotificationsPressed: widget.onOpenLogs,
onProfilePressed: () {},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppCard(
child: Row(
children: <Widget>[
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFFE0ECFF),
foregroundColor: const Color(0xFF2563EB),
child: Text(
widget.userInitials,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
displayName,
style: const TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
user?.username ?? '--',
style: const TextStyle(color: Color(0xFF64748B)),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
const _MetaChip(label: '角色 --'),
const _MetaChip(label: '环境 --'),
_MetaChip(label: '到期 ${formatDateTime(user?.expiresAt)}'),
],
),
],
),
),
],
),
),
),
const SizedBox(height: 16),
if (widget.controller.isLoading && !widget.controller.hasData)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: <Widget>[
SkeletonCard(height: 120),
SizedBox(height: 12),
SkeletonCard(height: 120),
],
),
)
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
),
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppCard(
child: Wrap(
spacing: 10,
runSpacing: 10,
children: <Widget>[
_MetaChip(label: '输出目录 ${valueOrDash(widget.controller.settings?.outputRoot)}'),
_MetaChip(label: '轮询 ${widget.controller.settings?.pollingIntervalSeconds ?? '--'}'),
_MetaChip(
label:
'自动开录 ${widget.controller.settings?.autoStartRecordingOnLive == true ? '开启' : '关闭'}',
),
_MetaChip(
label: '存储守护 ${widget.controller.settings?.enableStorageGuard == true ? '开启' : '关闭'}',
),
],
),
),
),
const SizedBox(height: 16),
_EntryTile(
icon: Icons.lock_outline_rounded,
title: '账号安全',
subtitle: '修改当前账号密码',
onTap: () => _push(
SecurityPage(
sessionController: widget.dependencies.sessionController,
),
),
),
_EntryTile(
icon: Icons.cloud_outlined,
title: '连接设置',
subtitle: '修改后端地址并切换当前环境',
onTap: () => _push(
BackendSettingsPage(
bootstrapController: backendConfig,
),
),
),
_EntryTile(
icon: Icons.notifications_outlined,
title: '通知设置',
subtitle: '邮件和 Webhook 通知开关',
onTap: () => _push(
NotificationSettingsPage(
settingsRepository: widget.dependencies.settingsRepository,
initialSettings: widget.controller.settings,
),
),
),
_EntryTile(
icon: Icons.storage_rounded,
title: '存储管理',
subtitle: '查看存储守护和保留清理状态',
onTap: () => _push(
StoragePage(
controller: StorageController(
settingsRepository: widget.dependencies.settingsRepository,
recoveryRepository: widget.dependencies.recoveryRepository,
),
dependencies: widget.dependencies,
),
),
),
_EntryTile(
icon: Icons.receipt_long_rounded,
title: '操作日志',
subtitle: '真实系统日志筛选与查看',
onTap: () => _push(
LogsPage(
controller: LogsController(
logsRepository: widget.dependencies.logsRepository,
),
),
),
),
_EntryTile(
icon: Icons.settings_outlined,
title: '系统设置',
subtitle: '当前系统配置摘要',
onTap: () => _push(
SystemSummaryPage(
settingsRepository: widget.dependencies.settingsRepository,
initialSettings: widget.controller.settings,
),
),
),
_EntryTile(
icon: Icons.folder_outlined,
title: '文件浏览',
subtitle: '通过真实 /api/media 接口浏览录制目录',
onTap: () => _push(
MediaBrowserPage(
controller: MediaBrowserController(
mediaRepository: widget.dependencies.mediaRepository,
),
),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FilledButton.tonalIcon(
onPressed: () async => widget.dependencies.sessionController.logout(),
icon: const Icon(Icons.logout_rounded),
label: const Text('退出登录'),
),
),
],
),
);
},
);
}
}
class _EntryTile extends StatelessWidget {
const _EntryTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: AppCard(
onTap: onTap,
child: Row(
children: <Widget>[
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(16),
),
child: Icon(icon, color: const Color(0xFF2563EB)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(color: Color(0xFF64748B)),
),
],
),
),
const Icon(Icons.chevron_right_rounded, color: Color(0xFF94A3B8)),
],
),
),
);
}
}
class _MetaChip extends StatelessWidget {
const _MetaChip({
required this.label,
});
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: const TextStyle(
color: Color(0xFF475569),
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
);
}
}
@@ -0,0 +1,363 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
import 'package:url_launcher/url_launcher.dart';
class RecordingDetailPage extends StatefulWidget {
const RecordingDetailPage({
super.key,
required this.dependencies,
required this.taskId,
});
final AppDependencies dependencies;
final String taskId;
@override
State<RecordingDetailPage> createState() => _RecordingDetailPageState();
}
class _RecordingDetailPageState extends State<RecordingDetailPage> {
late final RecordingDetailController _controller = RecordingDetailController(
recordingsRepository: widget.dependencies.recordingsRepository,
settingsRepository: widget.dependencies.settingsRepository,
mediaRepository: widget.dependencies.mediaRepository,
taskId: widget.taskId,
);
bool _previewLoading = false;
@override
void initState() {
super.initState();
_controller.refresh();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _openPreview() async {
setState(() {
_previewLoading = true;
});
try {
final ticket = await _controller.createPreviewTicket();
if (ticket.url.isEmpty) {
throw Exception('预览地址为空。');
}
await launchUrl(Uri.parse(ticket.url), mode: LaunchMode.externalApplication);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() {
_previewLoading = false;
});
}
}
}
Future<void> _openDownload() async {
final uri = _controller.downloadUri;
if (uri == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('当前任务暂无可下载文件。')),
);
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('录像详情')),
body: ListenableBuilder(
listenable: _controller,
builder: (BuildContext context, _) {
final detail = _controller.detail;
final task = detail?.task;
final result = detail?.result;
return RefreshIndicator(
onRefresh: () => _controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: <Widget>[
if (_controller.isLoading && !_controller.hasData)
const SkeletonCard(height: 240)
else if (_controller.errorMessage != null && !_controller.hasData)
AppErrorCard(
message: _controller.errorMessage!,
onRetry: () {
_controller.refresh();
},
)
else if (detail == null || task == null)
const AppEmptyState(
title: '暂无录像详情',
description: '当前任务没有返回可展示的真实详情。',
)
else ...<Widget>[
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
(task.outputFilePath ?? '').split(RegExp(r'[\\/]')).last.isEmpty
? '--'
: (task.outputFilePath ?? '').split(RegExp(r'[\\/]')).last,
style: const TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
),
StatusBadge(
status: task.status,
context: 'task',
label: taskStatusLabel(task.status),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_DetailChip(label: '直播间', value: task.liveRoomTitle.isEmpty ? '--' : task.liveRoomTitle),
_DetailChip(label: 'Room ID', value: task.roomId.isEmpty ? '--' : task.roomId),
_DetailChip(label: '清晰度', value: qualityLabel(task.preferredQuality)),
_DetailChip(label: '输出格式', value: outputFormatLabel(task.outputFormat)),
_DetailChip(label: '创建时间', value: formatDateTime(task.createdAt)),
_DetailChip(label: '录制时长', value: formatDurationSeconds(result?.durationSeconds ?? task.durationSeconds)),
],
),
if ((task.errorMessage ?? '').trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 12),
Text(
task.errorMessage!,
style: const TextStyle(
color: Color(0xFFDC2626),
height: 1.5,
),
),
],
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
FilledButton.tonalIcon(
onPressed: _previewLoading ? null : _openPreview,
icon: const Icon(Icons.play_circle_outline_rounded),
label: Text(_previewLoading ? '打开中...' : '预览'),
),
FilledButton.icon(
onPressed: _openDownload,
icon: const Icon(Icons.download_rounded),
label: const Text('下载'),
),
],
),
],
),
),
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'结果信息',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
if (result == null)
const AppEmptyState(
title: '暂无结果',
description: '任务仍在处理中,或后端尚未返回结果对象。',
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_DetailRow(label: '文件路径', value: result.filePath.isEmpty ? '--' : result.filePath),
_DetailRow(label: '文件大小', value: formatBytes(result.fileSizeBytes)),
_DetailRow(label: '时长', value: formatDurationSeconds(result.durationSeconds)),
_DetailRow(label: '最终状态', value: taskStatusLabel(result.finalStatus)),
_DetailRow(label: '上传状态', value: uploadStatusLabel(result.uploadStatus)),
_DetailRow(label: '最近上传时间', value: formatDateTime(result.lastUploadedAt)),
_DetailRow(label: '远端视频路径', value: valueOrDash(result.remoteVideoPath)),
if ((result.errorMessage ?? '').trim().isNotEmpty)
_DetailRow(label: '错误信息', value: result.errorMessage!),
if ((result.uploadErrorMessage ?? '').trim().isNotEmpty)
_DetailRow(label: '上传错误', value: result.uploadErrorMessage!),
],
),
],
),
),
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'任务日志',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
if (detail.logs.isEmpty)
const AppEmptyState(
title: '暂无日志',
description: '当前任务没有返回附带日志。',
)
else
...detail.logs.map((SystemLog log) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
log.message,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
Text(
formatDateTime(log.createdAt),
style: const TextStyle(
color: Color(0xFF94A3B8),
fontSize: 12,
),
),
],
),
const SizedBox(height: 6),
Text(
log.detail?.trim().isEmpty ?? true ? log.category : '${log.category} · ${log.detail}',
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.5,
),
),
const Divider(height: 20),
],
),
);
}),
],
),
),
],
],
),
);
},
),
);
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 96,
child: Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}
class _DetailChip extends StatelessWidget {
const _DetailChip({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$label · $value',
style: const TextStyle(
color: Color(0xFF475569),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_scope.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_search_bar.dart';
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/recording_detail_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/recording_file_card.dart';
import 'package:url_launcher/url_launcher.dart';
class RecordingsPage extends StatefulWidget {
const RecordingsPage({
super.key,
required this.controller,
required this.userInitials,
required this.onOpenLogs,
required this.onOpenProfile,
});
final RecordingsController controller;
final String userInitials;
final VoidCallback onOpenLogs;
final VoidCallback onOpenProfile;
@override
State<RecordingsPage> createState() => _RecordingsPageState();
}
class _RecordingsPageState extends State<RecordingsPage> {
late final TextEditingController _searchController = TextEditingController(text: widget.controller.query);
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _openDownload(RecordTask task) async {
final uri = widget.controller.downloadUriForTask(task);
if (uri == null) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('当前录像文件无法映射到下载接口。')),
);
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
final tasks = widget.controller.filteredTasks;
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
children: <Widget>[
MobileHeader(
eyebrow: '转码 · 归档 · 下载',
title: '录像文件',
userInitials: widget.userInitials,
onNotificationsPressed: widget.onOpenLogs,
onProfilePressed: widget.onOpenProfile,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppSearchBar(
controller: _searchController,
hintText: '搜索文件名 / 直播间',
onChanged: widget.controller.setQuery,
),
),
const SizedBox(height: 12),
if (widget.controller.isLoading && !widget.controller.hasData)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: SkeletonCard(height: 160),
)
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
),
)
else if (tasks.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: AppEmptyState(),
)
else
...tasks.map((RecordTask task) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: RecordingFileCard(
task: task,
detail: widget.controller.cachedDetail(task.id),
onVisible: () => widget.controller.ensureDetailLoaded(task.id),
onTap: () {
final dependencies = AppScope.of(context);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => RecordingDetailPage(
dependencies: dependencies,
taskId: task.id,
),
),
);
},
onDownload: () => _openDownload(task),
),
);
}),
],
),
);
},
);
}
}
@@ -0,0 +1,727 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
import 'package:url_launcher/url_launcher.dart';
class RoomDetailPage extends StatefulWidget {
const RoomDetailPage({
super.key,
required this.dependencies,
required this.roomId,
});
final AppDependencies dependencies;
final String roomId;
@override
State<RoomDetailPage> createState() => _RoomDetailPageState();
}
class _RoomDetailPageState extends State<RoomDetailPage> {
late final RoomDetailController _controller = RoomDetailController(
liveRoomsRepository: widget.dependencies.liveRoomsRepository,
recordingsRepository: widget.dependencies.recordingsRepository,
recoveryRepository: widget.dependencies.recoveryRepository,
roomId: widget.roomId,
);
bool _actionBusy = false;
@override
void initState() {
super.initState();
_controller.refresh();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
RecordSession? get _activeSession {
final sessions = _controller.sessions.where((RecordSession session) => isTaskActive(session.status)).toList(growable: false);
if (sessions.isEmpty) {
return null;
}
sessions.sort((RecordSession a, RecordSession b) => (b.startedAt ?? b.createdAt).compareTo(a.startedAt ?? a.createdAt));
return sessions.first;
}
Future<void> _runAction(Future<String> Function() action) async {
setState(() {
_actionBusy = true;
});
try {
final message = await action();
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
await _controller.refresh(silent: true);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() {
_actionBusy = false;
});
}
}
}
Future<void> _openLiveRoom(LiveRoom room) async {
final uri = resolveLiveRoomWatchUri(room);
if (uri == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
);
return;
}
try {
final launched = await launchUrl(
uri,
mode: LaunchMode.inAppBrowserView,
);
if (!launched && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
);
}
} catch (_) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
);
}
}
Future<void> _showStartRecordingSheet(LiveRoom room) async {
final qualityController = TextEditingController(text: room.effectiveSettings.preferredQuality);
final outputFormat = ValueNotifier<int>(room.effectiveSettings.outputFormat);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'开始录制',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
const SizedBox(height: 16),
TextField(
controller: qualityController,
decoration: const InputDecoration(labelText: '清晰度'),
),
const SizedBox(height: 14),
ValueListenableBuilder<int>(
valueListenable: outputFormat,
builder: (BuildContext context, int value, _) {
return DropdownButtonFormField<int>(
initialValue: value,
decoration: const InputDecoration(labelText: '输出格式'),
items: const <DropdownMenuItem<int>>[
DropdownMenuItem(value: 0, child: Text('MP4')),
DropdownMenuItem(value: 1, child: Text('TS')),
],
onChanged: (int? next) {
if (next != null) {
outputFormat.value = next;
}
},
);
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () async {
Navigator.of(context).pop();
await _runAction(() async {
await widget.dependencies.recordingsRepository.startRecording(
liveRoomId: room.id,
preferredQuality: qualityController.text.trim().isEmpty
? room.effectiveSettings.preferredQuality
: qualityController.text.trim(),
outputFormat: outputFormat.value,
);
return '录制任务已启动';
});
},
child: const Text('启动录制'),
),
),
],
),
);
},
);
qualityController.dispose();
outputFormat.dispose();
}
Future<void> _showEditSheet(LiveRoom room) async {
final remarkController = TextEditingController(text: room.remark ?? '');
final aliasController = TextEditingController(text: room.alias ?? '');
final pollingController = TextEditingController(text: room.pollingIntervalSecondsOverride?.toString() ?? '');
bool isPinned = room.isPinned;
bool isPriority = room.isPriority;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (BuildContext context, void Function(void Function()) setSheetState) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'编辑直播间',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
const SizedBox(height: 16),
TextField(
controller: remarkController,
decoration: const InputDecoration(labelText: '备注'),
),
const SizedBox(height: 14),
TextField(
controller: aliasController,
decoration: const InputDecoration(labelText: '主播别名'),
),
const SizedBox(height: 14),
TextField(
controller: pollingController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '单房间轮询间隔(秒)'),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: isPinned,
onChanged: (bool value) => setSheetState(() => isPinned = value),
title: const Text('置顶'),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: isPriority,
onChanged: (bool value) => setSheetState(() => isPriority = value),
title: const Text('重点主播'),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () async {
Navigator.of(context).pop();
await _runAction(() async {
await widget.dependencies.liveRoomsRepository.updateMetadata(
roomId: room.id,
payload: <String, dynamic>{
'remark': remarkController.text.trim().isEmpty ? null : remarkController.text.trim(),
'isPinned': isPinned,
'alias': aliasController.text.trim().isEmpty ? null : aliasController.text.trim(),
'isPriority': isPriority,
'pollingIntervalSecondsOverride': int.tryParse(pollingController.text.trim()),
},
);
return '房间信息已保存';
});
},
child: const Text('保存'),
),
),
],
),
),
);
},
);
},
);
remarkController.dispose();
aliasController.dispose();
pollingController.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('直播间详情')),
body: ListenableBuilder(
listenable: _controller,
builder: (BuildContext context, _) {
final activeSession = _activeSession;
final room = _controller.room;
final recoveryInfo = _controller.recoveryInfo;
final allTasks = _controller.sessions.expand((RecordSession session) => session.tasks).toList(growable: false);
return RefreshIndicator(
onRefresh: () => _controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: <Widget>[
if (_controller.isLoading && !_controller.hasData)
const SkeletonCard(height: 260)
else if (_controller.errorMessage != null && !_controller.hasData)
AppErrorCard(
message: _controller.errorMessage!,
onRetry: () {
_controller.refresh();
},
)
else if (room == null)
const AppEmptyState(
title: '暂无直播间详情',
description: '当前房间没有返回可展示的真实详情。',
)
else ...<Widget>[
_RoomHero(room: room),
const SizedBox(height: 12),
AppCard(
child: Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
if (hasLiveRoomWatchSource(room))
FilledButton.icon(
onPressed: _actionBusy ? null : () => _openLiveRoom(room),
icon: const Icon(Icons.play_circle_fill_rounded),
label: const Text('观看直播'),
),
FilledButton.tonalIcon(
onPressed: _actionBusy
? null
: () => _runAction(() async {
await widget.dependencies.liveRoomsRepository.refreshRoom(room.id);
return '直播状态已刷新';
}),
icon: const Icon(Icons.refresh_rounded),
label: const Text('刷新'),
),
FilledButton.tonalIcon(
onPressed: _actionBusy ? null : () => _showStartRecordingSheet(room),
icon: const Icon(Icons.fiber_manual_record_rounded),
label: const Text('开始录制'),
),
if (activeSession != null)
FilledButton.tonalIcon(
onPressed: _actionBusy
? null
: () => _runAction(() async {
await widget.dependencies.recordingsRepository.stopSession(activeSession.id);
return '停止录制请求已提交';
}),
icon: const Icon(Icons.stop_circle_outlined),
label: const Text('停止录制'),
),
if (recoveryInfo != null || (room.lastAutoStartDecisionCode ?? '').isNotEmpty)
FilledButton.tonalIcon(
onPressed: _actionBusy
? null
: () => _runAction(() async {
await widget.dependencies.recoveryRepository.retryLiveRoom(room.id);
return '重试请求已提交';
}),
icon: const Icon(Icons.restart_alt_rounded),
label: const Text('重试'),
),
FilledButton.tonalIcon(
onPressed: _actionBusy ? null : () => _showEditSheet(room),
icon: const Icon(Icons.edit_outlined),
label: const Text('编辑'),
),
],
),
),
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'状态信息',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
StatusBadge(
status: room.availabilityStatus,
context: 'availability',
label: availabilityLabel(room.availabilityStatus),
),
StatusBadge(
status: room.currentRecordingState,
context: 'recording',
label: recordingStateLabel(room.currentRecordingState),
),
if (room.isPinned) const StatusBadge(status: 'completed', label: '置顶'),
if (room.isPriority) const StatusBadge(status: 'retrying', label: '重点'),
],
),
const SizedBox(height: 16),
_DetailRow(label: '平台', value: room.platformName.isEmpty ? '--' : room.platformName),
_DetailRow(label: 'Room ID', value: room.roomId.isEmpty ? '--' : room.roomId),
_DetailRow(label: '在线人数', value: '--'),
_DetailRow(label: '码率', value: '--'),
_DetailRow(
label: '录制时长',
value: () {
if (activeSession == null) {
return '--';
}
final startedAt = DateTime.tryParse(activeSession.startedAt ?? activeSession.createdAt)?.toLocal();
if (startedAt == null) {
return '--';
}
return formatDurationSeconds(DateTime.now().difference(startedAt).inSeconds);
}(),
),
_DetailRow(label: '采集账号', value: '--'),
_DetailRow(
label: '最近事件',
value: recoveryInfo?.lastAutoStartDecisionSummary ??
room.lastAutoStartDecisionSummary ??
autoStartDecisionLabel(recoveryInfo?.lastAutoStartDecisionCode ?? room.lastAutoStartDecisionCode),
),
_DetailRow(label: '最近检查', value: formatDateTime(room.lastCheckedAt)),
],
),
),
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'录制策略',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
_DetailRow(label: '清晰度', value: qualityLabel(room.effectiveSettings.preferredQuality)),
_DetailRow(label: '输出格式', value: outputFormatLabel(room.effectiveSettings.outputFormat)),
_DetailRow(label: '保存模式', value: saveModeLabel(room.effectiveSettings.saveMode)),
_DetailRow(label: '录制模板', value: recordingTemplateLabel(room.effectiveSettings.recordingTemplate)),
_DetailRow(label: '分段时长', value: '${room.effectiveSettings.segmentDurationMinutes} 分钟'),
_DetailRow(label: '自动重连', value: room.effectiveSettings.enableAutoReconnect ? '开启' : '关闭'),
],
),
),
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'最近会话与文件',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
if (_controller.sessions.isEmpty)
const AppEmptyState(
title: '暂无会话',
description: '当前直播间还没有可展示的录制会话。',
)
else ...<Widget>[
..._controller.sessions.take(3).map((RecordSession session) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
'会话 ${session.id}',
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
StatusBadge(
status: session.status,
context: 'session',
label: taskStatusLabel(session.status),
),
],
),
const SizedBox(height: 6),
Text(
'${formatDateTime(session.startedAt ?? session.createdAt)} · 片段 ${session.segmentCount}',
style: const TextStyle(color: Color(0xFF64748B)),
),
const Divider(height: 18),
],
),
);
}),
if (allTasks.isNotEmpty)
...allTasks.take(5).map((RecordTask task) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _TaskRow(task: task),
);
}),
],
],
),
),
],
],
),
);
},
),
);
}
}
class _RoomHero extends StatelessWidget {
const _RoomHero({
required this.room,
});
final LiveRoom room;
@override
Widget build(BuildContext context) {
final preview = room.coverUrl;
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
if (preview != null && preview.isNotEmpty)
Image.network(
preview,
fit: BoxFit.cover,
errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) => const _FallbackHero(),
)
else
const _FallbackHero(),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
Colors.black.withValues(alpha: 0.15),
Colors.black.withValues(alpha: 0.60),
],
),
),
),
Positioned(
left: 16,
top: 16,
child: StatusBadge(
status: room.availabilityStatus,
context: 'availability',
label: availabilityLabel(room.availabilityStatus),
),
),
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
room.title ?? room.anchorName ?? room.roomId,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'${room.platformName.isEmpty ? '--' : room.platformName} · Room ${room.roomId.isEmpty ? '--' : room.roomId}',
style: const TextStyle(color: Colors.white70),
),
],
),
),
],
),
),
);
}
}
class _FallbackHero extends StatelessWidget {
const _FallbackHero();
@override
Widget build(BuildContext context) {
return const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: <Color>[Color(0xFF0F172A), Color(0xFF1E293B)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Center(
child: Text(
'LiveRecorder',
style: TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
),
);
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 96,
child: Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}
class _TaskRow extends StatelessWidget {
const _TaskRow({
required this.task,
});
final RecordTask task;
@override
Widget build(BuildContext context) {
final fileName = (task.outputFilePath ?? '').split(RegExp(r'[\\/]')).last;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
fileName.isEmpty ? '--' : fileName,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'${formatDateTime(task.createdAt)} · ${formatDurationSeconds(task.durationSeconds)}',
style: const TextStyle(color: Color(0xFF64748B)),
),
],
),
),
StatusBadge(
status: task.status,
context: 'task',
label: taskStatusLabel(task.status),
),
],
),
);
}
}
@@ -0,0 +1,469 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_scope.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_search_bar.dart';
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/room_detail_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/room_card.dart';
class RoomsPage extends StatefulWidget {
const RoomsPage({
super.key,
required this.controller,
required this.userInitials,
required this.onOpenLogs,
required this.onOpenProfile,
});
final RoomsController controller;
final String userInitials;
final VoidCallback onOpenLogs;
final VoidCallback onOpenProfile;
@override
State<RoomsPage> createState() => _RoomsPageState();
}
class _RoomsPageState extends State<RoomsPage> {
late final TextEditingController _searchController = TextEditingController(text: widget.controller.query);
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _showAddRoomDialog() async {
final TextEditingController controller = TextEditingController();
await showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('新增直播间'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: '直播间链接 / Room ID',
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () async {
Navigator.of(context).pop();
if (controller.text.trim().isEmpty) {
return;
}
await _runAction(() => widget.controller.createRoom(url: controller.text.trim()));
},
child: const Text('添加'),
),
],
);
},
);
controller.dispose();
}
Future<void> _runAction(Future<String> Function() action) async {
final messenger = ScaffoldMessenger.of(context);
try {
final message = await action();
messenger.showSnackBar(SnackBar(content: Text(message)));
} catch (error) {
messenger.showSnackBar(SnackBar(content: Text(error.toString())));
}
}
Future<void> _showStartRecordingSheet(LiveRoom room) async {
final qualityController = TextEditingController(text: room.effectiveSettings.preferredQuality);
final outputFormat = ValueNotifier<int>(room.effectiveSettings.outputFormat);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'开始录制',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
const SizedBox(height: 16),
TextField(
controller: qualityController,
decoration: const InputDecoration(labelText: '清晰度'),
),
const SizedBox(height: 14),
ValueListenableBuilder<int>(
valueListenable: outputFormat,
builder: (BuildContext context, int value, _) {
return DropdownButtonFormField<int>(
initialValue: value,
decoration: const InputDecoration(labelText: '输出格式'),
items: const <DropdownMenuItem<int>>[
DropdownMenuItem(value: 0, child: Text('MP4')),
DropdownMenuItem(value: 1, child: Text('TS')),
],
onChanged: (int? next) {
if (next != null) {
outputFormat.value = next;
}
},
);
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () async {
Navigator.of(context).pop();
await _runAction(
() => widget.controller.startRecording(
room: room,
preferredQuality: qualityController.text.trim(),
outputFormat: outputFormat.value,
),
);
},
child: const Text('启动录制'),
),
),
],
),
);
},
);
qualityController.dispose();
outputFormat.dispose();
}
Future<void> _showEditSheet(LiveRoom room) async {
final remarkController = TextEditingController(text: room.remark ?? '');
final aliasController = TextEditingController(text: room.alias ?? '');
final pollingController = TextEditingController(text: room.pollingIntervalSecondsOverride?.toString() ?? '');
final qualityController = TextEditingController(text: room.overrides.preferredQuality ?? room.effectiveSettings.preferredQuality);
final segmentController = TextEditingController(
text: (room.overrides.segmentDurationMinutes ?? room.effectiveSettings.segmentDurationMinutes).toString(),
);
bool isPinned = room.isPinned;
bool isPriority = room.isPriority;
int outputFormat = room.overrides.outputFormat ?? room.effectiveSettings.outputFormat;
int saveMode = room.overrides.saveMode ?? room.effectiveSettings.saveMode;
int recordingTemplate = room.overrides.recordingTemplate ?? room.effectiveSettings.recordingTemplate;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (BuildContext context, void Function(void Function()) setSheetState) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'编辑直播间',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
const SizedBox(height: 16),
TextField(
controller: remarkController,
decoration: const InputDecoration(labelText: '备注'),
),
const SizedBox(height: 14),
TextField(
controller: aliasController,
decoration: const InputDecoration(labelText: '主播别名'),
),
const SizedBox(height: 14),
TextField(
controller: pollingController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '单房间轮询间隔(秒)'),
),
const SizedBox(height: 14),
SwitchListTile(
value: isPinned,
onChanged: (bool value) => setSheetState(() => isPinned = value),
title: const Text('置顶'),
),
SwitchListTile(
value: isPriority,
onChanged: (bool value) => setSheetState(() => isPriority = value),
title: const Text('重点主播'),
),
const Divider(height: 28),
const Text(
'录制设置',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
TextField(
controller: qualityController,
decoration: const InputDecoration(labelText: '默认清晰度'),
),
const SizedBox(height: 14),
DropdownButtonFormField<int>(
initialValue: outputFormat,
decoration: const InputDecoration(labelText: '默认输出格式'),
items: const <DropdownMenuItem<int>>[
DropdownMenuItem(value: 0, child: Text('MP4')),
DropdownMenuItem(value: 1, child: Text('TS')),
],
onChanged: (int? value) => setSheetState(() => outputFormat = value ?? outputFormat),
),
const SizedBox(height: 14),
DropdownButtonFormField<int>(
initialValue: saveMode,
decoration: const InputDecoration(labelText: '保存模式'),
items: const <DropdownMenuItem<int>>[
DropdownMenuItem(value: 0, child: Text('单文件')),
DropdownMenuItem(value: 1, child: Text('分段')),
],
onChanged: (int? value) => setSheetState(() => saveMode = value ?? saveMode),
),
const SizedBox(height: 14),
DropdownButtonFormField<int>(
initialValue: recordingTemplate,
decoration: const InputDecoration(labelText: '录制模板'),
items: const <DropdownMenuItem<int>>[
DropdownMenuItem(value: 0, child: Text('直接封装')),
DropdownMenuItem(value: 1, child: Text('均衡 MP4')),
DropdownMenuItem(value: 2, child: Text('归档 TS')),
],
onChanged: (int? value) => setSheetState(() => recordingTemplate = value ?? recordingTemplate),
),
const SizedBox(height: 14),
TextField(
controller: segmentController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '分段时长(分钟)'),
),
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () async {
Navigator.of(context).pop();
final pollingValue = int.tryParse(pollingController.text.trim());
final segmentValue = int.tryParse(segmentController.text.trim());
await _runAction(
() => widget.controller.saveMetadata(
room: room,
remark: remarkController.text,
isPinned: isPinned,
alias: aliasController.text,
isPriority: isPriority,
pollingIntervalSecondsOverride: pollingValue,
),
);
await _runAction(
() => widget.controller.saveRoomSettings(
room: room,
payload: <String, dynamic>{
'preferredQualityOverride': qualityController.text.trim(),
'outputFormatOverride': outputFormat,
'saveModeOverride': saveMode,
'recordingTemplateOverride': recordingTemplate,
'segmentDurationMinutesOverride': segmentValue,
},
),
);
},
child: const Text('保存'),
),
),
],
),
),
);
},
);
},
);
remarkController.dispose();
aliasController.dispose();
pollingController.dispose();
qualityController.dispose();
segmentController.dispose();
}
PopupMenuButton<String> _roomMenu(LiveRoom room) {
return PopupMenuButton<String>(
onSelected: (String value) {
switch (value) {
case 'refresh':
unawaited(_runAction(() => widget.controller.refreshRoom(room)));
return;
case 'toggle':
unawaited(_runAction(() => widget.controller.toggleRoomEnabled(room)));
return;
case 'start':
unawaited(_showStartRecordingSheet(room));
return;
case 'retry':
unawaited(_runAction(() => widget.controller.retryRoom(room)));
return;
case 'edit':
unawaited(_showEditSheet(room));
return;
}
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
const PopupMenuItem(value: 'refresh', child: Text('刷新状态')),
PopupMenuItem(value: 'toggle', child: Text(room.isEnabled ? '停用' : '启用')),
const PopupMenuItem(value: 'start', child: Text('开始录制')),
const PopupMenuItem(value: 'retry', child: Text('重试恢复')),
const PopupMenuItem(value: 'edit', child: Text('编辑')),
],
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
final filteredRooms = widget.controller.filteredRooms;
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
children: <Widget>[
MobileHeader(
eyebrow: '直播状态 · 录制状态',
title: '直播间',
userInitials: widget.userInitials,
onNotificationsPressed: widget.onOpenLogs,
onProfilePressed: widget.onOpenProfile,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppSearchBar(
controller: _searchController,
hintText: '搜索主播 / Room ID / 状态',
onChanged: widget.controller.setQuery,
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: RoomFilter.values.map((RoomFilter filter) {
final label = switch (filter) {
RoomFilter.all => '全部',
RoomFilter.live => '直播中',
RoomFilter.recording => '录制中',
RoomFilter.error => '异常',
RoomFilter.retrying => '重试中',
};
return FilterChip(
selected: widget.controller.filter == filter,
onSelected: (_) => widget.controller.setFilter(filter),
label: Text(label),
);
}).toList(growable: false),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FilledButton.tonalIcon(
onPressed: _showAddRoomDialog,
icon: const Icon(Icons.add_rounded),
label: const Text('新增直播间'),
),
),
const SizedBox(height: 12),
if (widget.controller.isLoading && !widget.controller.hasData)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: SkeletonCard(height: 180),
)
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
),
)
else if (filteredRooms.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: AppEmptyState(),
)
else
...filteredRooms.map((LiveRoom room) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: RoomCard(
room: room,
session: widget.controller.sessionForRoom(room.id),
recentEvent: widget.controller.recentEventForRoom(room),
trailing: _roomMenu(room),
onTap: () {
final dependencies = AppScope.of(context);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => RoomDetailPage(
dependencies: dependencies,
roomId: room.id,
),
),
);
},
),
);
}),
],
),
);
},
);
}
}
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/network/api_exception.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
class SecurityPage extends StatefulWidget {
const SecurityPage({
super.key,
required this.sessionController,
});
final AppSessionController sessionController;
@override
State<SecurityPage> createState() => _SecurityPageState();
}
class _SecurityPageState extends State<SecurityPage> {
final TextEditingController _currentPasswordController = TextEditingController();
final TextEditingController _newPasswordController = TextEditingController();
final TextEditingController _confirmPasswordController = TextEditingController();
bool _submitting = false;
String? _errorMessage;
@override
void dispose() {
_currentPasswordController.dispose();
_newPasswordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _submit() async {
FocusScope.of(context).unfocus();
if (_newPasswordController.text != _confirmPasswordController.text) {
setState(() {
_errorMessage = '两次输入的新密码不一致。';
});
return;
}
setState(() {
_submitting = true;
_errorMessage = null;
});
try {
await widget.sessionController.changePassword(
currentPassword: _currentPasswordController.text,
newPassword: _newPasswordController.text,
);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('密码修改成功。')),
);
_currentPasswordController.clear();
_newPasswordController.clear();
_confirmPasswordController.clear();
} on ApiException catch (error) {
setState(() {
_errorMessage = error.message;
});
} catch (error) {
setState(() {
_errorMessage = error.toString();
});
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('账号安全')),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'修改密码',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
const Text(
'调用现有 /api/auth/change-password 接口,不改认证逻辑。',
style: TextStyle(color: Color(0xFF64748B), height: 1.5),
),
const SizedBox(height: 18),
TextField(
controller: _currentPasswordController,
obscureText: true,
decoration: const InputDecoration(labelText: '当前密码'),
),
const SizedBox(height: 14),
TextField(
controller: _newPasswordController,
obscureText: true,
decoration: const InputDecoration(labelText: '新密码'),
),
const SizedBox(height: 14),
TextField(
controller: _confirmPasswordController,
obscureText: true,
decoration: const InputDecoration(labelText: '确认新密码'),
onSubmitted: (_) => _submit(),
),
if (_errorMessage != null) ...<Widget>[
const SizedBox(height: 12),
Text(
_errorMessage!,
style: const TextStyle(
color: Color(0xFFDC2626),
height: 1.5,
),
),
],
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('提交修改'),
),
),
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/media_browser_page.dart';
class StoragePage extends StatefulWidget {
const StoragePage({
super.key,
required this.controller,
required this.dependencies,
});
final StorageController controller;
final AppDependencies dependencies;
@override
State<StoragePage> createState() => _StoragePageState();
}
class _StoragePageState extends State<StoragePage> {
bool _runningCleanup = false;
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
@override
void dispose() {
widget.controller.dispose();
super.dispose();
}
Future<void> _runCleanup() async {
setState(() {
_runningCleanup = true;
});
try {
final result = await widget.controller.runRetentionCleanup();
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('清理任务已提交:${result.status}')),
);
await widget.controller.refresh(silent: true);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
} finally {
if (mounted) {
setState(() {
_runningCleanup = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('存储管理')),
body: ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
final settings = widget.controller.settings;
final recovery = widget.controller.recoveryOverview;
final storage = recovery?.storage;
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: <Widget>[
if (widget.controller.isLoading && !widget.controller.hasData)
const SkeletonCard(height: 220)
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
)
else ...<Widget>[
if (storage != null)
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'存储守护',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
_StorageRow(label: '状态', value: storage.isEnabled ? '已启用' : '未启用'),
_StorageRow(label: '检查结果', value: storage.hasEnoughSpace ? '空间充足' : '空间不足'),
_StorageRow(label: '检查路径', value: storage.checkedPath.isEmpty ? '--' : storage.checkedPath),
_StorageRow(label: '可用空间', value: formatBytes(storage.availableBytes)),
_StorageRow(label: '最低要求', value: formatBytes(storage.requiredBytes)),
_StorageRow(label: '后端信息', value: storage.message.isEmpty ? '--' : storage.message),
],
),
),
if (settings != null) ...<Widget>[
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'保留清理',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
_StorageRow(label: '开关', value: settings.enableRetentionCleanup ? '开启' : '关闭'),
_StorageRow(label: '保留天数', value: '${settings.retentionDays}'),
_StorageRow(label: '删除文件', value: settings.retentionDeleteFiles ? '' : ''),
_StorageRow(label: '文件条件', value: settings.retentionVideoFileCondition),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: _runningCleanup ? null : _runCleanup,
icon: const Icon(Icons.cleaning_services_rounded),
label: Text(_runningCleanup ? '提交中...' : '立即执行清理'),
),
],
),
),
],
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'恢复队列',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
_StorageRow(label: '待恢复直播间', value: '${recovery?.liveRooms.length ?? 0}'),
_StorageRow(label: '待补完录像', value: '${recovery?.finalizations.length ?? 0}'),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => MediaBrowserPage(
controller: MediaBrowserController(
mediaRepository: widget.dependencies.mediaRepository,
),
),
),
);
},
icon: const Icon(Icons.folder_open_rounded),
label: const Text('打开文件浏览器'),
),
],
),
),
if ((recovery?.liveRooms.isEmpty ?? true) && (recovery?.finalizations.isEmpty ?? true))
const Padding(
padding: EdgeInsets.only(top: 12),
child: AppEmptyState(
title: '暂无恢复项',
description: '当前没有需要人工关注的恢复队列。',
),
),
],
],
),
);
},
),
);
}
}
class _StorageRow extends StatelessWidget {
const _StorageRow({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 96,
child: Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/network/api_exception.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
class SystemSummaryPage extends StatefulWidget {
const SystemSummaryPage({
super.key,
required this.settingsRepository,
required this.initialSettings,
});
final SettingsRepository settingsRepository;
final SystemSettings? initialSettings;
@override
State<SystemSummaryPage> createState() => _SystemSummaryPageState();
}
class _SystemSummaryPageState extends State<SystemSummaryPage> {
SystemSettings? _settings;
bool _loading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
if (widget.initialSettings != null) {
_settings = widget.initialSettings;
_loading = false;
} else {
_load();
}
}
Future<void> _load() async {
setState(() {
_loading = true;
_errorMessage = null;
});
try {
_settings = await widget.settingsRepository.getSettings();
} on ApiException catch (error) {
_errorMessage = error.message;
} catch (error) {
_errorMessage = error.toString();
} finally {
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final settings = _settings;
return Scaffold(
appBar: AppBar(title: const Text('系统设置')),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
if (_loading)
const SkeletonCard(height: 220)
else if (_errorMessage != null && settings == null)
AppErrorCard(
message: _errorMessage!,
onRetry: () {
_load();
},
)
else if (settings != null)
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'当前配置摘要',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
_SettingRow(label: '输出目录', value: settings.outputRoot.isEmpty ? '--' : settings.outputRoot),
_SettingRow(label: '全局轮询间隔', value: '${settings.pollingIntervalSeconds}'),
_SettingRow(label: '自动开播录制', value: settings.autoStartRecordingOnLive ? '开启' : '关闭'),
_SettingRow(label: '存储守护', value: settings.enableStorageGuard ? '开启' : '关闭'),
_SettingRow(
label: '低于阈值暂停',
value: '${settings.pauseRecordingWhenFreeSpaceBelowMegabytes} MB',
),
_SettingRow(
label: '高于阈值恢复',
value: '${settings.resumeRecordingWhenFreeSpaceAboveMegabytes} MB',
),
_SettingRow(label: '保留清理', value: settings.enableRetentionCleanup ? '开启' : '关闭'),
_SettingRow(label: '保留天数', value: '${settings.retentionDays}'),
_SettingRow(label: '删除本地文件', value: settings.retentionDeleteFiles ? '' : ''),
_SettingRow(label: '视频文件条件', value: settings.retentionVideoFileCondition),
_SettingRow(
label: '保留任务状态',
value: settings.retentionTaskStatuses.isEmpty
? '--'
: settings.retentionTaskStatuses.map(taskStatusLabel).join(' / '),
),
],
),
),
],
),
);
}
}
class _SettingRow extends StatelessWidget {
const _SettingRow({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 112,
child: Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
class BackendAddressFormCard extends StatelessWidget {
const BackendAddressFormCard({
super.key,
required this.title,
required this.description,
required this.controller,
required this.actionLabel,
required this.onSubmit,
required this.isSubmitting,
this.errorText,
this.note,
this.onFieldSubmitted,
});
final String title;
final String description;
final TextEditingController controller;
final String actionLabel;
final VoidCallback onSubmit;
final bool isSubmitting;
final String? errorText;
final String? note;
final ValueChanged<String>? onFieldSubmitted;
@override
Widget build(BuildContext context) {
return AppCard(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: const TextStyle(
color: Color(0xFF0F172A),
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
Text(
description,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.6,
),
),
if (note != null) ...<Widget>[
const SizedBox(height: 18),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(16),
),
child: Text(
note!,
style: const TextStyle(
color: Color(0xFF1D4ED8),
height: 1.5,
fontWeight: FontWeight.w600,
),
),
),
],
const SizedBox(height: 20),
TextField(
controller: controller,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
onSubmitted: onFieldSubmitted,
decoration: const InputDecoration(
labelText: '后端地址',
hintText: 'https://api.example.com',
helperText: '支持 http/https,可保留子路径,例如 https://example.com/live-recorder',
prefixIcon: Icon(Icons.link_rounded),
),
),
if (errorText != null) ...<Widget>[
const SizedBox(height: 14),
Text(
errorText!,
style: const TextStyle(
color: Color(0xFFDC2626),
height: 1.5,
),
),
],
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: isSubmitting ? null : onSubmit,
child: isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(actionLabel),
),
),
],
),
);
}
}
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
class ClusterStatusCard extends StatelessWidget {
const ClusterStatusCard({
super.key,
required this.healthLabel,
required this.nodeCountLabel,
required this.concurrentRecordingLabel,
required this.storageLabel,
});
final String healthLabel;
final String nodeCountLabel;
final String concurrentRecordingLabel;
final String storageLabel;
@override
Widget build(BuildContext context) {
final items = <({String label, String value})>[
(label: '健康状态', value: healthLabel),
(label: '节点数量', value: nodeCountLabel),
(label: '并发录制', value: concurrentRecordingLabel),
(label: '存储状态', value: storageLabel),
];
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'集群概览',
style: TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final isTwoColumn = constraints.maxWidth >= 320;
final itemWidth =
isTwoColumn ? (constraints.maxWidth - 12) / 2 : constraints.maxWidth;
return Wrap(
spacing: 12,
runSpacing: 12,
children: items.map((({String label, String value}) item) {
return SizedBox(
width: itemWidth,
child: _FactCard(
label: item.label,
value: item.value,
),
);
}).toList(growable: false),
);
},
),
],
),
);
}
}
class _FactCard extends StatelessWidget {
const _FactCard({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
value,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
height: 1.35,
),
),
],
),
);
}
}
@@ -0,0 +1,127 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class RecordingFileCard extends StatefulWidget {
const RecordingFileCard({
super.key,
required this.task,
required this.detail,
required this.onTap,
required this.onDownload,
required this.onVisible,
});
final RecordTask task;
final RecordTaskDetail? detail;
final VoidCallback onTap;
final VoidCallback? onDownload;
final VoidCallback onVisible;
@override
State<RecordingFileCard> createState() => _RecordingFileCardState();
}
class _RecordingFileCardState extends State<RecordingFileCard> {
@override
void initState() {
super.initState();
unawaited(Future<void>.microtask(widget.onVisible));
}
@override
Widget build(BuildContext context) {
final fileName = (widget.task.outputFilePath ?? '').split(RegExp(r'[\\/]')).last;
return AppCard(
onTap: widget.onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
fileName.isEmpty ? '--' : fileName,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
),
StatusBadge(
status: widget.task.status,
context: 'task',
label: taskStatusLabel(widget.task.status),
),
],
),
const SizedBox(height: 8),
Text(
widget.task.liveRoomTitle.isEmpty ? '--' : widget.task.liveRoomTitle,
style: const TextStyle(color: Color(0xFF64748B)),
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: <Widget>[
_InfoPill(label: '大小', value: formatBytes(widget.detail?.result?.fileSizeBytes)),
_InfoPill(label: '时长', value: formatDurationSeconds(widget.detail?.result?.durationSeconds ?? widget.task.durationSeconds)),
_InfoPill(label: '创建时间', value: formatDateTime(widget.task.createdAt)),
_InfoPill(label: '所属房间', value: widget.task.roomId.isEmpty ? '--' : widget.task.roomId),
],
),
const SizedBox(height: 12),
Row(
children: <Widget>[
FilledButton.tonal(
onPressed: widget.onTap,
child: const Text('详情'),
),
const SizedBox(width: 12),
FilledButton(
onPressed: widget.onDownload,
child: const Text('下载'),
),
],
),
],
),
);
}
}
class _InfoPill extends StatelessWidget {
const _InfoPill({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(14),
),
child: Text(
'$label · $value',
style: const TextStyle(
color: Color(0xFF475569),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class RoomCard extends StatelessWidget {
const RoomCard({
super.key,
required this.room,
required this.session,
required this.recentEvent,
required this.onTap,
this.trailing,
});
final LiveRoom room;
final RecordSession? session;
final String recentEvent;
final VoidCallback onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return AppCard(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
room.title ?? room.anchorName ?? room.roomId,
style: const TextStyle(
color: Color(0xFF0F172A),
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'${room.platformName.isEmpty ? '--' : room.platformName} · ${room.roomId.isEmpty ? '--' : room.roomId}',
style: const TextStyle(
color: Color(0xFF64748B),
),
),
],
),
),
?trailing,
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
StatusBadge(
status: room.availabilityStatus,
context: 'availability',
label: availabilityLabel(room.availabilityStatus),
),
StatusBadge(
status: room.currentRecordingState,
context: 'recording',
label: recordingStateLabel(room.currentRecordingState),
),
if (room.isPinned) ...const <Widget>[StatusBadge(status: 'completed', label: '置顶')],
if (room.isPriority) ...const <Widget>[StatusBadge(status: 'retrying', label: '重点')],
],
),
const SizedBox(height: 14),
Wrap(
spacing: 10,
runSpacing: 10,
children: <Widget>[
_Fact(label: '在线人数', value: '--'),
_Fact(label: '码率', value: '--'),
_Fact(label: '录制时长', value: formatDurationSeconds(_recordingDurationSeconds)),
_Fact(label: '采集账号', value: '--'),
],
),
const SizedBox(height: 14),
Text(
'最近事件 · $recentEvent',
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.45,
),
),
],
),
);
}
num? get _recordingDurationSeconds {
final startedAt = DateTime.tryParse(session?.startedAt ?? '');
if (startedAt == null) {
return session?.tasks.isNotEmpty == true ? session?.tasks.last.durationSeconds : null;
}
return DateTime.now().difference(startedAt.toLocal()).inSeconds;
}
}
class _Fact extends StatelessWidget {
const _Fact({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(14),
),
child: Text(
'$label · $value',
style: const TextStyle(
color: Color(0xFF475569),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class RoomPreviewCard extends StatelessWidget {
const RoomPreviewCard({
super.key,
required this.room,
required this.session,
required this.recentEvent,
required this.onTap,
this.onWatchLive,
});
final LiveRoom room;
final RecordSession? session;
final String recentEvent;
final VoidCallback onTap;
final VoidCallback? onWatchLive;
@override
Widget build(BuildContext context) {
final preview = room.coverUrl;
final canWatchLive = hasLiveRoomWatchSource(room);
return AppCard(
onTap: onTap,
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: Stack(
fit: StackFit.expand,
children: <Widget>[
if (preview != null && preview.isNotEmpty)
Image.network(
preview,
fit: BoxFit.cover,
errorBuilder: (
BuildContext context,
Object error,
StackTrace? stackTrace,
) =>
_FallbackPreview(room: room),
)
else
_FallbackPreview(room: room),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
Colors.black.withValues(alpha: 0.08),
Colors.black.withValues(alpha: 0.42),
],
),
),
),
Positioned(
left: 12,
top: 12,
child: StatusBadge(
status: room.availabilityStatus,
context: 'availability',
label: availabilityLabel(room.availabilityStatus),
),
),
if (canWatchLive && onWatchLive != null)
Positioned(
right: 12,
bottom: 12,
child: IconButton.filledTonal(
onPressed: onWatchLive,
tooltip: '观看直播',
icon: const Icon(Icons.play_circle_fill_rounded),
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
room.title ?? room.anchorName ?? room.roomId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
const SizedBox(height: 6),
Text(
'${room.platformName.isEmpty ? '--' : room.platformName} · Room ${room.roomId.isEmpty ? '--' : room.roomId}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF64748B),
),
),
const SizedBox(height: 12),
StatusBadge(
status: room.currentRecordingState,
context: 'recording',
label: recordingStateLabel(room.currentRecordingState),
),
const SizedBox(height: 12),
const Row(
children: <Widget>[
Expanded(
child: _PreviewFact(
label: '在线人数',
value: '--',
),
),
SizedBox(width: 10),
Expanded(
child: _PreviewFact(
label: '码率',
value: '--',
),
),
],
),
const SizedBox(height: 10),
Row(
children: <Widget>[
Expanded(
child: _PreviewFact(
label: '录制时长',
value: formatDurationSeconds(_duration),
),
),
const SizedBox(width: 10),
Expanded(
child: _PreviewFact(
label: '最近事件',
value: recentEvent,
maxLines: 2,
),
),
],
),
],
),
),
],
),
);
}
num? get _duration {
final startedAt = DateTime.tryParse(session?.startedAt ?? '');
if (startedAt == null) {
return session?.tasks.isNotEmpty == true
? session?.tasks.last.durationSeconds
: null;
}
return DateTime.now().difference(startedAt.toLocal()).inSeconds;
}
}
class _PreviewFact extends StatelessWidget {
const _PreviewFact({
required this.label,
required this.value,
this.maxLines = 1,
});
final String label;
final String value;
final int maxLines;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
value.isEmpty ? '--' : value,
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
height: 1.35,
),
),
],
),
);
}
}
class _FallbackPreview extends StatelessWidget {
const _FallbackPreview({
this.room,
});
final LiveRoom? room;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: <Color>[Color(0xFF0F172A), Color(0xFF1E293B)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Center(
child: Text(
room?.platformName ?? 'LiveRecorder',
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
),
);
}
}

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