Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a062bf84bf | ||
|
|
df70b64956 | ||
|
|
5196ffa0f0 | ||
|
|
6da0690fd3 | ||
|
|
e640d5b5cc | ||
|
|
0831b33ea0 | ||
|
|
f5ad1dec00 | ||
|
|
a819322559 | ||
|
|
2b0345722e | ||
|
|
9e5bbbb948 | ||
|
|
f9016931e8 | ||
|
|
46da2d78f0 | ||
|
|
524a053263 | ||
|
|
24e5cf2a06 | ||
|
|
cbee29bef9 | ||
|
|
a5c2cc3202 | ||
|
|
b2aaef093d | ||
|
|
f7fa02d13c | ||
|
|
b82110461a | ||
|
|
fd5be2cc8e | ||
|
|
78f02978fd | ||
|
|
850c55f5da | ||
|
|
14773248d9 | ||
|
|
5a6c374320 | ||
|
|
9c2767f78c | ||
|
|
db458a9a14 | ||
|
|
50b207415a | ||
|
|
4b5077e3da |
@@ -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
@@ -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} \
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -14,6 +14,7 @@ export default defineConfig(({ command }) => ({
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: "es2015",
|
||||
chunkSizeWarningLimit: 900,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.dart_tool/
|
||||
.tmp/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub/
|
||||
build/
|
||||
coverage/
|
||||
android/.gradle/
|
||||
android/local.properties
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
avoid_print: false
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Vendored
+160
@@ -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 "$@"
|
||||
Vendored
+90
@@ -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
|
||||
@@ -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")
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+91
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+114
@@ -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
Reference in New Issue
Block a user