143 changed files with 16312 additions and 1078 deletions
+1
View File
@@ -5,6 +5,7 @@
**/*.suo
frontend/node_modules/
frontend/dist/
.codex-temp/
build.log
webapi-build.log
webapi-build-no-restore.log
+17
View File
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Application",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Infrastructure", "src\LiveRecorder.Infrastructure\LiveRecorder.Infrastructure.csproj", "{A502FCC8-83F9-402B-A027-D020D34624E1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveRecorder.Tests", "tests\LiveRecorder.Tests\LiveRecorder.Tests.csproj", "{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -71,6 +75,18 @@ Global
{A502FCC8-83F9-402B-A027-D020D34624E1}.Release|x64.Build.0 = Release|Any CPU
{A502FCC8-83F9-402B-A027-D020D34624E1}.Release|x86.ActiveCfg = Release|Any CPU
{A502FCC8-83F9-402B-A027-D020D34624E1}.Release|x86.Build.0 = Release|Any CPU
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Debug|Any CPU.Build.0 = Debug|Any CPU
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Debug|x64.ActiveCfg = Debug|Any CPU
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Debug|x64.Build.0 = Debug|Any CPU
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Debug|x86.ActiveCfg = Debug|Any CPU
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Debug|x86.Build.0 = Debug|Any CPU
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Release|Any CPU.ActiveCfg = Release|Any CPU
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Release|Any CPU.Build.0 = Release|Any CPU
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Release|x64.ActiveCfg = Release|Any CPU
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Release|x64.Build.0 = Release|Any CPU
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Release|x86.ActiveCfg = Release|Any CPU
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -80,5 +96,6 @@ Global
{CEE984AA-CA08-48B3-B341-BD2C1C68CC1F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{3B807934-A995-4F7F-8A4E-878D161F95A1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{A502FCC8-83F9-402B-A027-D020D34624E1} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{51C97AC8-CDF9-4C3C-AAEC-7C9D2F3A1273} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
+1151
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@
"@vitejs/plugin-vue": "^5.2.3",
"typescript": "^5.7.3",
"vite": "^6.2.0",
"vite-plugin-vue-devtools": "^8.1.2",
"vue-tsc": "^2.2.0"
}
}
+5
View File
@@ -1,6 +1,7 @@
import axios from "axios";
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";
@@ -134,6 +135,10 @@ export function getApiErrorMessage(error: unknown, fallback = "请求失败,
}
function notifyBackendUnavailable(message: string) {
if (isNoBackendPreviewMode) {
return;
}
const now = Date.now();
if (now - lastBackendUnavailableNotificationAt < backendUnavailableNotificationIntervalMs) {
return;
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { Box } from "@element-plus/icons-vue";
withDefaults(
defineProps<{
title?: string;
description?: string;
actionText?: string;
}>(),
{
title: "暂无数据",
description: "当前筛选条件下没有可展示内容",
actionText: ""
}
);
defineEmits<{
(event: "action"): void;
}>();
</script>
<template>
<div class="empty-state">
<div class="empty-state__icon">
<el-icon><Box /></el-icon>
</div>
<div class="empty-state__title">{{ title }}</div>
<div class="empty-state__description">{{ description }}</div>
<el-button v-if="actionText" type="primary" plain @click="$emit('action')">
{{ actionText }}
</el-button>
</div>
</template>
<style scoped>
.empty-state {
display: grid;
justify-items: center;
gap: 14px;
padding: 42px 20px;
text-align: center;
}
.empty-state__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 18px;
background: rgba(37, 99, 235, 0.08);
color: var(--accent);
font-size: 28px;
}
.empty-state__title {
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
}
.empty-state__description {
max-width: 36ch;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import type { Component } from "vue";
withDefaults(
defineProps<{
label: string;
value: string | number;
description?: string;
icon?: Component | null;
}>(),
{
description: "",
icon: null
}
);
</script>
<template>
<article class="metric-card">
<div class="metric-card__header">
<span class="metric-card__label">{{ label }}</span>
<span v-if="icon" class="metric-card__icon">
<el-icon><component :is="icon" /></el-icon>
</span>
</div>
<div class="metric-card__value">{{ value }}</div>
<div v-if="description" class="metric-card__description">{{ description }}</div>
</article>
</template>
<style scoped>
.metric-card {
display: grid;
gap: 12px;
min-height: 164px;
padding: 20px;
border-radius: 16px;
border: 1px solid var(--border-subtle);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
var(--surface);
box-shadow: var(--shadow-soft);
}
.metric-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.metric-card__label {
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.metric-card__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 12px;
background: rgba(37, 99, 235, 0.1);
color: var(--accent);
font-size: 18px;
}
.metric-card__value {
color: var(--text-primary);
font-size: clamp(28px, 2vw, 40px);
font-weight: 760;
letter-spacing: -0.06em;
line-height: 1;
}
.metric-card__description {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
</style>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from "vue";
import { Close } from "@element-plus/icons-vue";
const props = withDefaults(
defineProps<{
modelValue: boolean;
title: string;
subtitle?: string;
size?: string | number;
}>(),
{
subtitle: "",
size: "520px"
}
);
const emit = defineEmits<{
(event: "update:modelValue", value: boolean): void;
}>();
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit("update:modelValue", value)
});
</script>
<template>
<el-drawer v-model="visible" class="right-drawer" direction="rtl" :size="size" :with-header="false">
<div class="right-drawer__shell">
<header class="right-drawer__header">
<div class="right-drawer__copy">
<div class="right-drawer__eyebrow">详情面板</div>
<h3 class="right-drawer__title">{{ title }}</h3>
<p v-if="subtitle" class="right-drawer__subtitle">{{ subtitle }}</p>
</div>
<el-button class="right-drawer__close" text circle @click="visible = false">
<el-icon><Close /></el-icon>
</el-button>
</header>
<div class="right-drawer__body">
<slot />
</div>
<footer v-if="$slots.footer" class="right-drawer__footer">
<slot name="footer" />
</footer>
</div>
</el-drawer>
</template>
<style scoped>
:deep(.right-drawer .el-drawer__body) {
padding: 0;
}
.right-drawer__shell {
display: flex;
min-height: 100%;
flex-direction: column;
background: linear-gradient(180deg, var(--surface-raised), var(--surface));
}
.right-drawer__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 24px 24px 18px;
border-bottom: 1px solid var(--border-subtle);
}
.right-drawer__eyebrow {
margin-bottom: 8px;
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.right-drawer__title {
margin: 0;
color: var(--text-primary);
font-size: 22px;
font-weight: 760;
letter-spacing: -0.04em;
}
.right-drawer__subtitle {
margin: 8px 0 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
.right-drawer__close {
color: var(--text-muted);
}
.right-drawer__body {
flex: 1;
padding: 20px 24px 24px;
overflow: auto;
}
.right-drawer__footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px 24px;
border-top: 1px solid var(--border-subtle);
}
</style>
+299
View File
@@ -0,0 +1,299 @@
<script setup lang="ts">
import { computed } from "vue";
type BadgeContext =
| "generic"
| "availability"
| "recording"
| "task"
| "session"
| "cleanup"
| "upload"
| "boolean";
const props = withDefaults(
defineProps<{
label?: string;
status?: string | number | boolean | null;
context?: BadgeContext;
size?: "sm" | "md";
}>(),
{
label: "",
status: null,
context: "generic",
size: "md"
}
);
type Tone = "gray" | "green" | "blue" | "yellow" | "red" | "orange" | "indigo";
function normalizeValue(value: string | number | boolean | null) {
if (typeof value === "string") {
return value.trim().toLowerCase();
}
return value;
}
function resolveToneByNumber(value: number, context: BadgeContext): Tone {
if (context === "availability") {
if (value === 2) {
return "green";
}
return "gray";
}
if (context === "recording") {
if (value === 2) {
return "blue";
}
if (value === 1) {
return "green";
}
return "gray";
}
if (context === "task" || context === "session") {
if (value === 2) {
return "blue";
}
if (value === 1 || value === 7) {
return "indigo";
}
if (value === 0) {
return "yellow";
}
if (value === 3) {
return "orange";
}
if (value === 4) {
return "green";
}
if (value === 5) {
return "red";
}
return "gray";
}
if (context === "upload") {
if (value === 1) {
return "green";
}
if (value === 2) {
return "red";
}
return "gray";
}
if (context === "boolean") {
return value ? "green" : "gray";
}
return "gray";
}
function resolveToneByKeyword(value: string): Tone {
if (
value.includes("live") ||
value.includes("online") ||
value.includes("living") ||
value.includes("开播") ||
value.includes("直播中") ||
value === "started" ||
value === "completed" ||
value.includes("归档") ||
value.includes("healthy")
) {
return "green";
}
if (value.includes("recording") || value.includes("录制中") || value.includes("uploading")) {
return "blue";
}
if (
value.includes("pending") ||
value.includes("queued") ||
value.includes("待") ||
value.includes("waiting") ||
value.includes("storage")
) {
return "yellow";
}
if (value.includes("retry") || value.includes("stopping") || value.includes("停止中")) {
return "orange";
}
if (value.includes("process") || value.includes("transcod")) {
return "indigo";
}
if (
value.includes("error") ||
value.includes("fail") ||
value.includes("异常") ||
value.includes("错误") ||
value.includes("离线") ||
value === "poll_failed"
) {
return "red";
}
return "gray";
}
const tone = computed<Tone>(() => {
const normalized = normalizeValue(props.status);
if (typeof normalized === "number") {
return resolveToneByNumber(normalized, props.context);
}
if (typeof normalized === "boolean") {
return normalized ? "green" : "gray";
}
if (typeof normalized === "string" && normalized.length > 0) {
return resolveToneByKeyword(normalized);
}
return "gray";
});
const displayLabel = computed(() => {
if (props.label) {
return props.label;
}
if (props.status === null || props.status === undefined || props.status === "") {
return "--";
}
return String(props.status);
});
</script>
<template>
<span class="status-badge" :class="[`status-badge--${tone}`, `status-badge--${size}`]">
<span class="status-badge__dot"></span>
<span>{{ displayLabel }}</span>
</span>
</template>
<style scoped>
.status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 100%;
border-radius: 999px;
border: 1px solid transparent;
font-weight: 600;
white-space: nowrap;
}
.status-badge--sm {
min-height: 28px;
padding: 0 10px;
font-size: 12px;
}
.status-badge--md {
min-height: 30px;
padding: 0 12px;
font-size: 12px;
}
.status-badge__dot {
width: 8px;
height: 8px;
border-radius: 999px;
flex-shrink: 0;
background: currentColor;
}
.status-badge--gray {
background: #f8fafc;
border-color: #e2e8f0;
color: #64748b;
}
.status-badge--green {
background: rgba(34, 197, 94, 0.12);
border-color: rgba(34, 197, 94, 0.18);
color: #15803d;
}
.status-badge--blue {
background: rgba(37, 99, 235, 0.1);
border-color: rgba(37, 99, 235, 0.16);
color: #2563eb;
}
.status-badge--yellow {
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.18);
color: #b45309;
}
.status-badge--red {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.16);
color: #dc2626;
}
.status-badge--orange {
background: rgba(249, 115, 22, 0.12);
border-color: rgba(249, 115, 22, 0.18);
color: #c2410c;
}
.status-badge--indigo {
background: rgba(99, 102, 241, 0.12);
border-color: rgba(99, 102, 241, 0.18);
color: #4f46e5;
}
:global(html[data-theme="dark"]) .status-badge--gray {
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.2);
color: #cbd5e1;
}
:global(html[data-theme="dark"]) .status-badge--green {
color: #86efac;
}
:global(html[data-theme="dark"]) .status-badge--blue {
color: #93c5fd;
}
:global(html[data-theme="dark"]) .status-badge--yellow {
color: #fcd34d;
}
:global(html[data-theme="dark"]) .status-badge--red {
color: #fca5a5;
}
:global(html[data-theme="dark"]) .status-badge--orange {
color: #fdba74;
}
:global(html[data-theme="dark"]) .status-badge--indigo {
color: #a5b4fc;
}
</style>
+3
View File
@@ -5,5 +5,8 @@ import "element-plus/dist/index.css";
import App from "./App.vue";
import router from "./router";
import "./styles/main.css";
import { ensurePreviewSession } from "./utils/devPreview";
ensurePreviewSession();
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app");
+126 -87
View File
@@ -1,54 +1,55 @@
:root {
color-scheme: light;
font-family: "Inter", "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif;
font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", sans-serif;
--bg-base: #f3f7fb;
--bg-subtle: #eef3f8;
--bg-emphasis: #e6eef7;
--bg-base: #f6f8fb;
--bg-subtle: #f1f5f9;
--bg-emphasis: #e8eef7;
--surface: #ffffff;
--surface-raised: #fbfdff;
--surface-muted: #f4f8fc;
--surface-strong: #edf4fb;
--border-subtle: #d7e1eb;
--border-base: #c7d3e1;
--border-strong: #a7b7cb;
--text-primary: #0d1726;
--text-secondary: #425167;
--text-muted: #6b7c91;
--text-soft: #8f9cb0;
--text-inverse: #eff5fb;
--accent: #2f6fb4;
--accent-strong: #245a92;
--accent-soft: rgba(47, 111, 180, 0.12);
--info: #2f6fb4;
--success: #1f8a63;
--warning: #ba7b1f;
--danger: #c75151;
--focus-ring: rgba(47, 111, 180, 0.2);
--shadow-soft: 0 10px 30px rgba(15, 23, 38, 0.06);
--shadow-card: 0 18px 42px rgba(15, 23, 38, 0.08);
--shadow-float: 0 24px 60px rgba(15, 23, 38, 0.12);
--surface-raised: #ffffff;
--surface-muted: #f8fafc;
--surface-strong: #eff6ff;
--border-subtle: #e2e8f0;
--border-base: #cbd5e1;
--border-strong: #94a3b8;
--text-primary: #0f172a;
--text-secondary: #334155;
--text-muted: #64748b;
--text-soft: #94a3b8;
--text-inverse: #eff6ff;
--accent: #2563eb;
--accent-strong: #1d4ed8;
--accent-soft: rgba(37, 99, 235, 0.1);
--info: #2563eb;
--success: #16a34a;
--warning: #d97706;
--danger: #dc2626;
--focus-ring: rgba(37, 99, 235, 0.18);
--shadow-soft: 0 12px 30px rgba(15, 23, 42, 0.06);
--shadow-card: 0 18px 44px rgba(15, 23, 42, 0.08);
--shadow-float: 0 24px 60px rgba(15, 23, 42, 0.14);
--sidebar-shell-bg:
linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0.18)),
var(--bg-emphasis);
--topbar-shell-bg: rgba(255, 255, 255, 0.56);
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 10px;
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 252, 0.92)),
var(--surface);
--topbar-shell-bg: rgba(255, 255, 255, 0.9);
--radius-sm: 10px;
--radius-md: 16px;
--radius-lg: 18px;
--page-gap: 24px;
--content-padding: 28px;
--page-max-width: 1280px;
--content-padding: 24px;
--content-padding-mobile: 16px;
--control-height: 40px;
--control-height-sm: 34px;
--table-row-padding: 16px;
--header-row-height: 68px;
--header-row-height: 64px;
--el-color-primary: var(--accent);
--el-color-primary-light-3: #5d90ca;
--el-color-primary-light-5: #84adde;
--el-color-primary-light-7: #bad2ec;
--el-color-primary-light-8: #d4e4f3;
--el-color-primary-light-9: #ebf3fa;
--el-color-primary-light-3: #5b8cf0;
--el-color-primary-light-5: #7ba4f5;
--el-color-primary-light-7: #a8c3fa;
--el-color-primary-light-8: #c7dbfd;
--el-color-primary-light-9: #e8f0ff;
--el-color-primary-dark-2: var(--accent-strong);
--el-color-success: var(--success);
--el-color-warning: var(--warning);
@@ -58,17 +59,17 @@
--el-text-color-secondary: var(--text-muted);
--el-border-color: var(--border-base);
--el-border-color-light: var(--border-subtle);
--el-border-radius-base: var(--radius-md);
--el-border-radius-base: var(--radius-sm);
--el-bg-color: transparent;
--el-fill-color-blank: var(--surface);
--el-fill-color-light: var(--surface-muted);
--el-fill-color-lighter: var(--surface-strong);
--el-mask-color: rgba(7, 13, 22, 0.58);
--el-mask-color: rgba(15, 23, 42, 0.54);
}
html[data-density="compact"] {
--page-gap: 20px;
--content-padding: 22px;
--content-padding: 20px;
--content-padding-mobile: 14px;
--control-height: 36px;
--control-height-sm: 30px;
@@ -79,43 +80,43 @@ html[data-density="compact"] {
html[data-theme="dark"] {
color-scheme: dark;
--bg-base: #07111b;
--bg-subtle: #0d1825;
--bg-emphasis: #102131;
--surface: #0f1b29;
--surface-raised: #132233;
--surface-muted: #17283a;
--surface-strong: #1b2f45;
--border-subtle: #22364d;
--border-base: #314862;
--border-strong: #4a6686;
--bg-base: #08111d;
--bg-subtle: #0f172a;
--bg-emphasis: #132033;
--surface: #0f1b2d;
--surface-raised: #132236;
--surface-muted: #17293f;
--surface-strong: #1d3653;
--border-subtle: #22334d;
--border-base: #334a68;
--border-strong: #4b6486;
--text-primary: #eaf2fb;
--text-secondary: #bdd0e3;
--text-muted: #8ca0b6;
--text-soft: #6f8399;
--text-secondary: #bfd0e6;
--text-muted: #8ea3bc;
--text-soft: #6f849d;
--text-inverse: #08111a;
--accent: #5ba8ff;
--accent-strong: #3f8de0;
--accent-soft: rgba(91, 168, 255, 0.16);
--info: #5ba8ff;
--accent: #60a5fa;
--accent-strong: #3b82f6;
--accent-soft: rgba(96, 165, 250, 0.18);
--info: #60a5fa;
--success: #31c48d;
--warning: #f0a23c;
--danger: #ef6666;
--focus-ring: rgba(91, 168, 255, 0.24);
--warning: #f59e0b;
--danger: #f87171;
--focus-ring: rgba(96, 165, 250, 0.24);
--shadow-soft: 0 12px 30px rgba(2, 6, 12, 0.32);
--shadow-card: 0 18px 40px rgba(2, 6, 12, 0.4);
--shadow-float: 0 24px 60px rgba(2, 6, 12, 0.52);
--sidebar-shell-bg:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)),
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
var(--bg-subtle);
--topbar-shell-bg: rgba(15, 27, 41, 0.78);
--topbar-shell-bg: rgba(15, 23, 42, 0.84);
--el-color-primary: var(--accent);
--el-color-primary-light-3: #7db9ff;
--el-color-primary-light-5: #9ac8ff;
--el-color-primary-light-7: #bfdcff;
--el-color-primary-light-8: #d8e9ff;
--el-color-primary-light-9: #eef6ff;
--el-color-primary-light-3: #93c5fd;
--el-color-primary-light-5: #bfdbfe;
--el-color-primary-light-7: #dbeafe;
--el-color-primary-light-8: #e5f0ff;
--el-color-primary-light-9: #eff6ff;
--el-color-primary-dark-2: var(--accent-strong);
--el-text-color-primary: var(--text-primary);
--el-text-color-regular: var(--text-secondary);
@@ -147,14 +148,36 @@ body,
body {
color: var(--text-primary);
background:
radial-gradient(circle at top left, rgba(91, 168, 255, 0.06), transparent 24%),
radial-gradient(circle at top right, rgba(79, 201, 168, 0.05), transparent 22%),
radial-gradient(circle at top left, rgba(37, 99, 235, 0.08), transparent 24%),
radial-gradient(circle at top right, rgba(14, 165, 233, 0.06), transparent 22%),
linear-gradient(180deg, rgba(255, 255, 255, 0.6), transparent 18%),
linear-gradient(180deg, var(--bg-base) 0%, var(--bg-subtle) 100%);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.6) transparent;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: rgba(148, 163, 184, 0.52);
background-clip: padding-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
body,
button,
input,
@@ -178,13 +201,16 @@ select:focus-visible {
.page-stack {
display: grid;
gap: var(--page-gap);
width: min(100%, var(--page-max-width));
margin: 0 auto;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
gap: 24px;
padding: 4px 0;
}
.page-header > div:first-child {
@@ -194,16 +220,16 @@ select:focus-visible {
.page-title {
margin: 0;
font-size: clamp(28px, 2vw, 40px);
font-weight: 750;
font-size: clamp(30px, 2vw, 42px);
font-weight: 780;
letter-spacing: -0.045em;
line-height: 1.02;
line-height: 1;
color: var(--text-primary);
}
.page-subtitle {
max-width: 76ch;
margin: 12px 0 0;
margin: 14px 0 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.75;
@@ -231,7 +257,9 @@ select:focus-visible {
overflow: hidden;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
background: linear-gradient(180deg, var(--surface-raised) 0%, var(--surface) 100%);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
var(--surface);
box-shadow: var(--shadow-soft);
transition:
transform 0.2s ease,
@@ -252,20 +280,20 @@ select:focus-visible {
.stats-grid {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.stat-card {
grid-column: span 3;
display: grid;
gap: 10px;
padding: 18px;
gap: 12px;
min-height: 154px;
padding: 20px;
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.42)),
var(--surface-muted);
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
var(--surface);
box-shadow: var(--shadow-soft);
}
@@ -285,7 +313,7 @@ html[data-theme="dark"] .stat-card {
.stat-card__value {
color: var(--text-primary);
font-size: clamp(26px, 2vw, 36px);
font-size: clamp(30px, 2vw, 40px);
font-weight: 760;
letter-spacing: -0.05em;
line-height: 1;
@@ -768,7 +796,7 @@ html[data-theme="dark"] .stat-card {
}
.el-dialog {
border-radius: 12px;
border-radius: 18px;
border: 1px solid var(--border-base);
background: linear-gradient(180deg, var(--surface-raised), var(--surface));
box-shadow: var(--shadow-float);
@@ -800,9 +828,20 @@ html[data-theme="dark"] .stat-card {
color: var(--text-secondary);
}
.el-badge__content.is-fixed.is-dot {
top: 10px;
right: 10px;
}
.el-dropdown-menu {
border-radius: 16px;
border: 1px solid var(--border-subtle);
box-shadow: var(--shadow-card);
}
@media (max-width: 1280px) {
.stat-card {
grid-column: span 6;
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.highlight-grid {
+67 -7
View File
@@ -441,9 +441,7 @@ export interface SystemSettings {
enableAutoUpload: boolean;
deleteLocalFilesAfterUpload: boolean;
uploadTarget: number;
douyinProxy: PlatformProxySettings;
bilibiliProxy: PlatformProxySettings;
huyaProxy: PlatformProxySettings;
platformRequestSettings: Record<string, PlatformRequestSettings>;
webDavUpload: WebDavUploadSettings;
s3Upload: S3UploadSettings;
enableEventScripts: boolean;
@@ -482,9 +480,6 @@ export interface SystemSettings {
notifyWebhookOnLiveStarted: boolean;
notifyWebhookOnException: boolean;
webhookBodyTemplate: string;
douyinUserAgent: string;
douyinReferer: string;
douyinCookie: string;
}
export interface PlatformProxySettings {
@@ -492,6 +487,63 @@ export interface PlatformProxySettings {
proxyUrl: string;
}
export interface PlatformRequestSettings {
proxy: PlatformProxySettings;
userAgent: string;
referer: string;
cookie: string;
}
export interface PlatformOption {
key: string;
value: number;
label: string;
}
export const platformOptionList: PlatformOption[] = [
{ key: "douyin", value: 1, label: "Douyin" },
{ key: "bilibili", value: 2, label: "Bilibili" },
{ key: "huya", value: 3, label: "Huya" },
{ key: "douyu", value: 4, label: "Douyu" },
{ key: "kuaishou", value: 5, label: "Kuaishou" },
{ key: "tiktok", value: 6, label: "TikTok" },
{ key: "xiaohongshu", value: 7, label: "Xiaohongshu" },
{ key: "youtube", value: 8, label: "YouTube" },
{ key: "twitch", value: 9, label: "Twitch" },
{ key: "pandatv", value: 10, label: "PandaTV" },
{ key: "migu", value: 11, label: "Migu" }
];
export function createDefaultPlatformRequestSettingsMap(): Record<string, PlatformRequestSettings> {
const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36 Edg/132.0.0.0";
const referers: Record<string, string> = {
douyin: "https://live.douyin.com/",
bilibili: "https://live.bilibili.com/",
huya: "https://www.huya.com/",
douyu: "https://www.douyu.com/",
kuaishou: "https://live.kuaishou.com/",
tiktok: "https://www.tiktok.com/",
xiaohongshu: "https://www.xiaohongshu.com/",
youtube: "https://www.youtube.com/",
twitch: "https://www.twitch.tv/",
pandatv: "https://www.pandalive.co.kr/",
migu: "https://www.miguvideo.com/"
};
return platformOptionList.reduce<Record<string, PlatformRequestSettings>>((accumulator, platform) => {
accumulator[platform.key] = {
proxy: {
enabled: false,
proxyUrl: ""
},
userAgent,
referer: referers[platform.key] ?? "",
cookie: ""
};
return accumulator;
}, {});
}
export interface WebDavUploadSettings {
endpoint: string;
basePath: string;
@@ -701,7 +753,15 @@ export const platformLabelMap: Record<number, string> = {
0: "未知",
1: "Douyin",
2: "Bilibili",
3: "Huya"
3: "Huya",
4: "Douyu",
5: "Kuaishou",
6: "TikTok",
7: "Xiaohongshu",
8: "YouTube",
9: "Twitch",
10: "PandaTV",
11: "Migu"
};
export const uploadTargetLabelMap: Record<number, string> = {
+42
View File
@@ -0,0 +1,42 @@
import type { AuthenticatedUser } from "@/types";
const TOKEN_STORAGE_KEY = "live-recorder-token";
const USER_STORAGE_KEY = "live-recorder-user";
const PREVIEW_TOKEN = "dev-preview-token";
const previewUser: AuthenticatedUser = {
userId: "dev-preview",
username: "preview",
displayName: "UI Preview",
token: PREVIEW_TOKEN,
expiresAt: "2099-12-31T23:59:59.000Z"
};
export const isNoBackendPreviewMode = import.meta.env.DEV && import.meta.env.VITE_PREVIEW_NO_BACKEND === "1";
function hasUsablePreviewUser(value: string | null) {
if (!value) {
return false;
}
try {
const parsed = JSON.parse(value) as Partial<AuthenticatedUser>;
return Boolean(parsed.userId && parsed.username && parsed.displayName);
} catch {
return false;
}
}
export function ensurePreviewSession() {
if (!isNoBackendPreviewMode || typeof window === "undefined") {
return;
}
if (!localStorage.getItem(TOKEN_STORAGE_KEY)) {
localStorage.setItem(TOKEN_STORAGE_KEY, PREVIEW_TOKEN);
}
if (!hasUsablePreviewUser(localStorage.getItem(USER_STORAGE_KEY))) {
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(previewUser));
}
}
+18 -34
View File
@@ -4,12 +4,15 @@ import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import axios from "axios";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import { useViewport } from "@/composables/useViewport";
import type {
DailyReviewPushResult,
DailyReviewReport,
PushDailyReviewRequest
} from "@/types";
import { Bell, Calendar, Clock, DataAnalysis, VideoCamera } from "@element-plus/icons-vue";
const router = useRouter();
const { isMobile } = useViewport();
@@ -137,6 +140,7 @@ onMounted(loadReport);
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">回顾分析</div>
<h1 class="page-title">回顾日报</h1>
<p class="page-subtitle">
按天聚合录制时长异常和弹幕热度支持手动推送到已配置的 Webhook 或邮件
@@ -163,37 +167,13 @@ onMounted(loadReport);
<el-skeleton v-if="loading && !report" animated :rows="10" />
<template v-else-if="report">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card__label">活跃直播间</div>
<div class="stat-card__value">{{ report.summary.activeLiveRoomCount }}</div>
<div class="stat-card__hint">{{ report.date }} 当天有录制重叠的房间数</div>
</div>
<div class="stat-card">
<div class="stat-card__label">录制会话</div>
<div class="stat-card__value">{{ report.summary.sessionCount }}</div>
<div class="stat-card__hint">按整场直播会话聚合统计</div>
</div>
<div class="stat-card">
<div class="stat-card__label">分片总数</div>
<div class="stat-card__value">{{ report.summary.segmentCount }}</div>
<div class="stat-card__hint">只统计与当天有时间重叠的分片</div>
</div>
<div class="stat-card">
<div class="stat-card__label">录制时长</div>
<div class="stat-card__value">{{ formatDuration(report.summary.totalDurationSeconds) }}</div>
<div class="stat-card__hint">跨天会话按日报窗口裁剪</div>
</div>
<div class="stat-card">
<div class="stat-card__label">警告 / 错误</div>
<div class="stat-card__value">{{ report.summary.warningCount }} / {{ report.summary.errorCount }}</div>
<div class="stat-card__hint">来自当天警告 / 错误系统日志</div>
</div>
<div class="stat-card">
<div class="stat-card__label">弹幕事件</div>
<div class="stat-card__value">{{ report.summary.totalDanmakuCount }}</div>
<div class="stat-card__hint">优先按弹幕 XML 分钟桶统计</div>
</div>
<div class="stats-grid daily-review-metrics">
<MetricCard label="活跃直播间" :value="report.summary.activeLiveRoomCount" :description="`${report.date} 当天有录制重叠的房间数`" :icon="Calendar" />
<MetricCard label="录制会话" :value="report.summary.sessionCount" description="按整场直播会话聚合统计" :icon="VideoCamera" />
<MetricCard label="分片总数" :value="report.summary.segmentCount" description="只统计与当天有时间重叠的分片" :icon="DataAnalysis" />
<MetricCard label="录制时长" :value="formatDuration(report.summary.totalDurationSeconds)" description="跨天会话按日报窗口裁剪" :icon="Clock" />
<MetricCard label="警告 / 错误" :value="`${report.summary.warningCount} / ${report.summary.errorCount}`" description="来自当天警告和错误系统日志" :icon="Bell" />
<MetricCard label="弹幕事件" :value="report.summary.totalDanmakuCount" description="优先按弹幕 XML 分钟桶统计" :icon="Bell" />
</div>
<el-card class="surface-card" shadow="never">
@@ -204,7 +184,7 @@ onMounted(loadReport);
</div>
</div>
<el-empty v-if="report.rooms.length === 0" description="当天没有录制数据" />
<EmptyState v-if="report.rooms.length === 0" description="当前筛选条件下没有可展示内容" />
<div v-else class="table-scroll-shell">
<el-table :data="report.rooms" :height="roomTableHeight" class="premium-table" table-layout="auto">
@@ -240,7 +220,7 @@ onMounted(loadReport);
</div>
</div>
<el-empty v-if="report.highlights.length === 0" description="当天没有可展示的会话亮点" />
<EmptyState v-if="report.highlights.length === 0" description="当前筛选条件下没有可展示内容" />
<div v-else class="highlight-grid">
<section
@@ -278,7 +258,7 @@ onMounted(loadReport);
</div>
</div>
<el-empty v-if="report.moments.length === 0" description="当天没有可用的弹幕热度数据" />
<EmptyState v-if="report.moments.length === 0" description="当前筛选条件下没有可展示内容" />
<div v-else class="table-scroll-shell">
<el-table :data="report.moments" :height="momentTableHeight" class="premium-table" table-layout="auto">
@@ -342,6 +322,10 @@ onMounted(loadReport);
border-radius: 14px;
}
.daily-review-metrics :deep(.metric-card__value) {
font-size: clamp(24px, 1.9vw, 34px);
}
.highlight-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
+312 -74
View File
@@ -2,6 +2,10 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import RightDrawer from "@/components/ui/RightDrawer.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import { useViewport } from "@/composables/useViewport";
import type { BatchLiveRoomsResult, ImportLiveRoomsRequest, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types";
import {
@@ -10,10 +14,12 @@ import {
availabilityLabelMap,
currentRecordingStateLabelMap,
outputFormatLabelMap,
platformOptionList,
qualityOptionList,
recordingTemplateLabelMap,
saveModeLabelMap
} from "@/types";
import { House, RefreshRight, SwitchButton, VideoCamera } from "@element-plus/icons-vue";
const inheritValue = "__inherit__";
const AUTO_REFRESH_INTERVAL_MS = 15000;
@@ -38,6 +44,8 @@ const settingsRoom = ref<LiveRoom | null>(null);
const rooms = ref<LiveRoom[]>([]);
const selectedRooms = ref<LiveRoom[]>([]);
const loadError = ref("");
const roomDetailVisible = ref(false);
const activeRoom = ref<LiveRoom | null>(null);
const { isMobile } = useViewport();
const roomsTableShellRef = ref<HTMLElement | null>(null);
const roomsTableProxyRef = ref<HTMLElement | null>(null);
@@ -85,14 +93,15 @@ const settingsForm = reactive({
});
const platformOptions = [
{ label: "自动识别", value: null },
{ label: "抖音", value: 1 },
{ label: "Bilibili", value: 2 },
{ label: "虎牙", value: 3 }
{ label: "自动识别", value: null as number | null },
...platformOptionList.map((option) => ({
label: option.label,
value: option.value
}))
];
const qualityOptions = qualityOptionList;
const qualitySupportHint = "抖音和 Bilibili 支持按画质选流;虎牙暂未实现。若目标档位不可用,平台会自动回退到最接近的可用画质。";
const qualitySupportHint = "不同平台的可选画质不同;如果目标档位不可用,系统会自动回退到当前平台最接近的可用。";
const booleanOverrideOptions = [
{ label: "跟随全局", value: inheritValue },
{ label: "开启", value: "true" },
@@ -108,6 +117,15 @@ const totalRooms = computed(() => rooms.value.length);
const enabledRooms = computed(() => rooms.value.filter((item) => item.isEnabled).length);
const disabledRooms = computed(() => rooms.value.filter((item) => !item.isEnabled).length);
const liveRooms = computed(() => rooms.value.filter((item) => item.availabilityStatus === 2).length);
const recordingRooms = computed(() => rooms.value.filter((item) => item.currentRecordingState === 2).length);
const priorityRooms = computed(() => rooms.value.filter((item) => item.isPriority).length);
const activeRoomSubtitle = computed(() => {
if (!activeRoom.value) {
return "";
}
return `${activeRoom.value.platformName || "--"} · ${activeRoom.value.roomId || "--"}`;
});
const tableHeight = computed(() => (isMobile.value ? undefined : 700));
const createDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "560px"));
const importDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 720px)" : "720px"));
@@ -501,6 +519,11 @@ function openRecordDialog(room: LiveRoom) {
recordDialogVisible.value = true;
}
function openRoomDetails(room: LiveRoom) {
activeRoom.value = room;
roomDetailVisible.value = true;
}
async function copyRoomLink(room: LiveRoom) {
const url = room.originalLiveRoomUrl || room.sourceUrl;
@@ -688,6 +711,14 @@ function getRoomAvatarText(room: LiveRoom) {
return source.slice(0, 1).toUpperCase();
}
function roomAvailabilityLabel(room: LiveRoom) {
return availabilityLabelMap[room.availabilityStatus] ?? "--";
}
function latestEventLabel(room: LiveRoom) {
return room.lastAutoStartDecisionSummary || "暂无事件";
}
function getQualityLabel(quality?: string | null) {
return formatQualityLabel(quality);
}
@@ -802,14 +833,14 @@ onBeforeUnmount(() => {
<div class="page-header">
<div>
<div class="page-kicker">直播监控</div>
<h1 class="page-title">直播间管理</h1>
<h1 class="page-title">直播间监控录制控制台</h1>
<p class="page-subtitle">
直播间会长期保留在系统里单房间配置优先于全局配置未设置的项会自动回退到系统设置
聚合直播状态自动开录决策和单房间录制配置面向开播检测异常巡检与手动介入场景
</p>
</div>
<div class="page-toolbar">
<el-button @click="loadRooms">刷新列表</el-button>
<el-button @click="loadRooms">刷新状态</el-button>
<el-button @click="openImportDialog">批量导入</el-button>
<el-button :loading="exportLoading" @click="exportRooms">导出列表</el-button>
<el-button type="primary" @click="openCreateDialog">新增直播间</el-button>
@@ -818,34 +849,44 @@ onBeforeUnmount(() => {
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card__label">直播间总数</div>
<div class="stat-card__value">{{ totalRooms }}</div>
<div class="stat-card__hint">当前已接入系统的直播间数量</div>
</div>
<div class="stat-card">
<div class="stat-card__label">启用中</div>
<div class="stat-card__value">{{ enabledRooms }}</div>
<div class="stat-card__hint">参与后台巡检与自动录制</div>
</div>
<div class="stat-card">
<div class="stat-card__label">已停用</div>
<div class="stat-card__value">{{ disabledRooms }}</div>
<div class="stat-card__hint">保留解析结果但不再自动录制</div>
</div>
<div class="stat-card">
<div class="stat-card__label">当前开播</div>
<div class="stat-card__value">{{ liveRooms }}</div>
<div class="stat-card__hint">基于最近一次状态检测</div>
<div class="live-console-grid">
<div class="stats-grid">
<MetricCard label="在线直播间" :value="liveRooms" description="基于最近一次巡检识别为开播中的房间" :icon="House" />
<MetricCard label="录制中任务" :value="recordingRooms" description="当前处于录制状态的直播间数量" :icon="VideoCamera" />
<MetricCard label="启用房间" :value="enabledRooms" description="参与自动检测与自动录制的直播间" :icon="SwitchButton" />
<MetricCard label="重点监控" :value="priorityRooms" description="标记为重点巡检与优先关注的房间" :icon="RefreshRight" />
</div>
<el-card class="surface-card focus-card" shadow="never">
<div class="focus-card__header">
<div>
<div class="focus-card__eyebrow">监控概览</div>
<h3 class="section-title">直播采集态势</h3>
</div>
<StatusBadge
:label="recordingRooms > 0 ? '录制通道活跃' : '等待开播'"
:status="recordingRooms > 0"
context="boolean"
/>
</div>
<p class="focus-card__description">
当前共接入 {{ totalRooms }} 个直播间其中 {{ enabledRooms }} 个处于自动巡检范围{{ disabledRooms }} 个处于保留但停用状态
</p>
<div class="focus-card__chips">
<span class="info-pill">自动开录复用现有后端策略</span>
<span class="info-pill">单房间配置优先于全局设置</span>
<span class="info-pill">无实时吞吐字段时统一显示占位</span>
</div>
</el-card>
</div>
<el-card class="surface-card table-card" shadow="never">
<el-card class="surface-card table-card" shadow="never" v-loading="loading">
<div class="toolbar-row">
<div>
<h3 class="section-title">直播间列表</h3>
<p class="section-subtitle">支持刷新状态手动启动录制单房间配置覆盖以及按需删除直播间</p>
<p class="section-subtitle">继续使用真实接口数据统一强化为监控控制台视图支持查看详情手动录制和单房间配置覆盖</p>
</div>
<el-space v-if="!isMobile" wrap class="batch-actions">
<span class="batch-actions__count">已选 {{ selectedRoomCount }} </span>
@@ -854,10 +895,18 @@ onBeforeUnmount(() => {
<el-button type="danger" plain :disabled="!hasSelectedRooms || batchActionLoading" @click="openBatchDeleteDialog">
批量删除
</el-button>
</el-space>
</el-space>
</div>
<div v-if="isMobile" class="data-card-list room-card-list">
<EmptyState
v-if="!loading && rooms.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新列表"
@action="loadRooms"
/>
<div v-else-if="isMobile" class="data-card-list room-card-list">
<article v-for="row in rooms" :key="row.id" class="data-card room-card">
<div class="data-card__header">
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
@@ -874,30 +923,42 @@ onBeforeUnmount(() => {
</div>
<div class="badge-row">
<el-tag :type="currentRecordingStateTagType(row.currentRecordingState)">
{{ currentRecordingStateLabelMap[row.currentRecordingState] }}
</el-tag>
<el-tag effect="plain">{{ row.platformName }}</el-tag>
<el-tag v-if="row.isPinned" size="small" effect="plain">置顶</el-tag>
<el-tag v-if="row.isPriority" size="small" effect="plain" type="danger">重点</el-tag>
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
</el-tag>
<StatusBadge :label="roomAvailabilityLabel(row)" :status="row.availabilityStatus" context="availability" />
<StatusBadge
:label="currentRecordingStateLabelMap[row.currentRecordingState]"
:status="row.currentRecordingState"
context="recording"
/>
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
<StatusBadge v-if="row.isPinned" label="置顶" status="completed" size="sm" />
<StatusBadge v-if="row.isPriority" label="重点" status="retrying" size="sm" />
</div>
<div class="data-card__grid">
<div>
<dt>自动开录</dt>
<dd>{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</dd>
<dt>最近事件</dt>
<dd>{{ latestEventLabel(row) }}</dd>
</div>
<div>
<dt>最近巡检</dt>
<dd>{{ formatDate(row.lastCheckedAt) }}</dd>
</div>
<div>
<dt>在线人数</dt>
<dd>--</dd>
</div>
<div>
<dt>码率</dt>
<dd>--</dd>
</div>
<div>
<dt>检测间隔</dt>
<dd>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</dd>
</div>
<div>
<dt>采集账号</dt>
<dd>--</dd>
</div>
<div style="grid-column: 1 / -1;">
<dt>房间配置</dt>
<dd>
@@ -926,6 +987,7 @@ onBeforeUnmount(() => {
</div>
<div class="data-card__actions">
<el-button size="small" @click="openRoomDetails(row)">查看详情</el-button>
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
@@ -950,7 +1012,6 @@ onBeforeUnmount(() => {
<el-table
:data="rooms"
v-loading="loading"
:height="tableHeight"
class="premium-table rooms-table"
table-layout="fixed"
@@ -959,53 +1020,59 @@ onBeforeUnmount(() => {
>
<el-table-column type="selection" width="48" />
<el-table-column label="头像" width="88">
<el-table-column label="直播间" min-width="340">
<template #default="{ row }">
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="56" class="room-avatar">
{{ getRoomAvatarText(row) }}
</el-avatar>
</template>
</el-table-column>
<el-table-column label="直播间" min-width="320">
<template #default="{ row }">
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
<div class="room-summary-cell">
<el-avatar :src="row.avatarUrl || row.coverUrl" :size="52" class="room-avatar">
{{ getRoomAvatarText(row) }}
</el-avatar>
<div class="room-summary-cell__copy">
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="cell-subtitle">{{ row.anchorName || "未知主播" }}</div>
<div class="config-summary">
<span>{{ row.platformName || "--" }}</span>
<span>{{ row.roomId || "--" }}</span>
<span>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</span>
</div>
</div>
</div>
<div v-if="row.alias && row.alias !== row.anchorName" class="cell-subtitle">别名{{ row.alias }}</div>
<div v-if="row.remark" class="cell-subtitle">{{ row.remark }}</div>
<div class="config-summary">
<span v-if="row.isPinned">置顶</span>
<span v-if="row.isPriority">重点</span>
<span>{{ formatPollingInterval(row.pollingIntervalSecondsOverride) }}</span>
<div class="badge-row">
<StatusBadge v-if="row.isPinned" label="置顶" status="completed" size="sm" />
<StatusBadge v-if="row.isPriority" label="重点" status="retrying" size="sm" />
</div>
<div class="monospace cell-mono">{{ row.roomId }}</div>
</template>
</el-table-column>
<el-table-column label="平台" width="110">
<el-table-column label="直播状态" width="128">
<template #default="{ row }">
<el-tag effect="plain">{{ row.platformName }}</el-tag>
<StatusBadge :label="roomAvailabilityLabel(row)" :status="row.availabilityStatus" context="availability" />
</template>
</el-table-column>
<el-table-column label="直播状态" width="120">
<el-table-column label="录制状态" width="128">
<template #default="{ row }">
<el-tag :type="currentRecordingStateTagType(row.currentRecordingState)">
{{ currentRecordingStateLabelMap[row.currentRecordingState] }}
</el-tag>
<StatusBadge
:label="currentRecordingStateLabelMap[row.currentRecordingState]"
:status="row.currentRecordingState"
context="recording"
/>
</template>
</el-table-column>
<el-table-column label="自动开录" min-width="280">
<el-table-column label="最近事件" min-width="300">
<template #default="{ row }">
<div class="auto-start-cell">
<div class="auto-start-cell__head">
<el-tag size="small" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)" effect="plain">
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
</el-tag>
<StatusBadge
size="sm"
:label="autoStartDecisionLabel(row.lastAutoStartDecisionCode)"
:status="row.lastAutoStartDecisionCode || 'unknown'"
/>
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
</div>
<div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div>
<div class="cell-subtitle">{{ latestEventLabel(row) }}</div>
<el-popover
v-if="row.lastAutoStartDecisionDetail"
trigger="hover"
@@ -1032,7 +1099,7 @@ onBeforeUnmount(() => {
</template>
</el-table-column>
<el-table-column label="录制开关" width="128">
<el-table-column label="录制开关" width="120">
<template #default="{ row }">
<el-switch
:model-value="row.isEnabled"
@@ -1045,6 +1112,16 @@ onBeforeUnmount(() => {
</template>
</el-table-column>
<el-table-column label="监控字段" width="168">
<template #default="{ row }">
<div class="table-placeholder-grid">
<span>在线人数 --</span>
<span>码率 --</span>
<span>采集账号 --</span>
</div>
</template>
</el-table-column>
<el-table-column label="最近检测" width="180">
<template #default="{ row }">
<div class="table-date-text">{{ formatDate(row.lastCheckedAt) }}</div>
@@ -1054,6 +1131,7 @@ onBeforeUnmount(() => {
<el-table-column label="操作" width="380">
<template #default="{ row }">
<div class="room-actions-cell">
<el-button size="small" @click="openRoomDetails(row)">查看</el-button>
<el-button size="small" @click="refreshRoom(row)">刷新</el-button>
<el-button size="small" @click="openSettingsDialog(row)">配置</el-button>
<el-button size="small" @click="copyRoomLink(row)">复制链接</el-button>
@@ -1069,6 +1147,76 @@ onBeforeUnmount(() => {
</div>
</el-card>
<RightDrawer
v-model="roomDetailVisible"
:title="activeRoom?.title || activeRoom?.anchorName || activeRoom?.roomId || '直播间详情'"
:subtitle="activeRoomSubtitle"
>
<div v-if="activeRoom" class="detail-panel">
<div class="detail-panel__hero">
<el-avatar :src="activeRoom.avatarUrl || activeRoom.coverUrl" :size="64" class="room-avatar">
{{ getRoomAvatarText(activeRoom) }}
</el-avatar>
<div>
<div class="detail-panel__title">{{ activeRoom.anchorName || "未知主播" }}</div>
<div class="detail-panel__meta">{{ activeRoom.originalLiveRoomUrl || activeRoom.sourceUrl || "--" }}</div>
</div>
</div>
<div class="badge-row">
<StatusBadge :label="roomAvailabilityLabel(activeRoom)" :status="activeRoom.availabilityStatus" context="availability" />
<StatusBadge
:label="currentRecordingStateLabelMap[activeRoom.currentRecordingState]"
:status="activeRoom.currentRecordingState"
context="recording"
/>
<StatusBadge :label="activeRoom.platformName || '--'" :status="activeRoom.platformName || 'unknown'" />
</div>
<el-descriptions :column="1" border class="detail-panel__descriptions">
<el-descriptions-item label="直播间名称">
{{ activeRoom.title || activeRoom.anchorName || activeRoom.roomId || "--" }}
</el-descriptions-item>
<el-descriptions-item label="平台 + Room ID">
{{ activeRoom.platformName || "--" }} · {{ activeRoom.roomId || "--" }}
</el-descriptions-item>
<el-descriptions-item label="直播状态">
{{ roomAvailabilityLabel(activeRoom) }}
</el-descriptions-item>
<el-descriptions-item label="录制状态">
{{ currentRecordingStateLabelMap[activeRoom.currentRecordingState] || "--" }}
</el-descriptions-item>
<el-descriptions-item label="在线人数">--</el-descriptions-item>
<el-descriptions-item label="码率">--</el-descriptions-item>
<el-descriptions-item label="录制时长">--</el-descriptions-item>
<el-descriptions-item label="采集账号">--</el-descriptions-item>
<el-descriptions-item label="最近事件">
{{ latestEventLabel(activeRoom) }}
</el-descriptions-item>
<el-descriptions-item label="最近巡检">
{{ formatDate(activeRoom.lastCheckedAt) }}
</el-descriptions-item>
<el-descriptions-item label="自动开录详情">
{{ activeRoom.lastAutoStartDecisionDetail || "--" }}
</el-descriptions-item>
<el-descriptions-item label="房间配置">
{{ getQualityLabel(activeRoom.effectiveSettings.preferredQuality) }} ·
{{ outputFormatLabelMap[activeRoom.effectiveSettings.outputFormat] }} ·
{{ saveModeLabelMap[activeRoom.effectiveSettings.saveMode] }} ·
{{ activeRoom.effectiveSettings.enableDanmakuRecording ? "弹幕开" : "弹幕关" }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="roomDetailVisible = false">关闭</el-button>
<el-button @click="activeRoom && openSettingsDialog(activeRoom)">配置</el-button>
<el-button type="primary" :disabled="!activeRoom?.isEnabled" @click="activeRoom && openRecordDialog(activeRoom)">
开始录制
</el-button>
</template>
</RightDrawer>
<el-dialog
v-model="createDialogVisible"
class="form-dialog"
@@ -1083,7 +1231,7 @@ onBeforeUnmount(() => {
v-model="createForm.url"
type="textarea"
:rows="5"
placeholder="粘贴抖音 / Bilibili / 虎牙直播间链接,或直接输入 roomId"
placeholder="粘贴 Douyin / Bilibili / Huya / Douyu / Kuaishou / TikTok / YouTube / Twitch 等直播间链接"
/>
</el-form-item>
@@ -1129,7 +1277,7 @@ onBeforeUnmount(() => {
v-model="importForm.content"
type="textarea"
:rows="12"
placeholder="https://live.douyin.com/845878323112,主播: 熊宇一&#10;https://live.douyin.com/262011082654"
placeholder="https://live.douyin.com/845878323112,主播: 熊宇一&#10;https://www.twitch.tv/example_channel&#10;https://www.youtube.com/watch?v=example12345"
/>
</el-form-item>
@@ -1455,6 +1603,46 @@ onBeforeUnmount(() => {
gap: 24px;
}
.live-console-grid {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.9fr);
gap: 18px;
}
.focus-card :deep(.el-card__body) {
display: grid;
gap: 18px;
}
.focus-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.focus-card__eyebrow {
margin-bottom: 8px;
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.focus-card__description {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.75;
}
.focus-card__chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.header-actions {
align-self: center;
}
@@ -1518,6 +1706,16 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.room-summary-cell {
display: flex;
align-items: flex-start;
gap: 14px;
}
.room-summary-cell__copy {
min-width: 0;
}
.room-avatar {
box-shadow: 0 14px 28px rgba(52, 84, 112, 0.14);
}
@@ -1617,10 +1815,46 @@ onBeforeUnmount(() => {
width: 100%;
}
.table-placeholder-grid {
display: grid;
gap: 6px;
color: var(--text-secondary);
font-size: 12px;
}
.room-actions-cell :deep(.el-button) {
margin: 0;
}
.detail-panel {
display: grid;
gap: 18px;
}
.detail-panel__hero {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--border-subtle);
background: var(--surface-muted);
}
.detail-panel__title {
color: var(--text-primary);
font-size: 18px;
font-weight: 700;
}
.detail-panel__meta {
margin-top: 6px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
word-break: break-all;
}
.field-help {
margin-top: -8px;
margin-bottom: 14px;
@@ -1798,6 +2032,10 @@ onBeforeUnmount(() => {
}
@media (max-width: 960px) {
.live-console-grid {
grid-template-columns: 1fr;
}
.toolbar-row {
flex-direction: column;
}
+16 -157
View File
@@ -12,7 +12,7 @@ const router = useRouter();
const authStore = useAuthStore();
const loading = ref(false);
const { backendUnavailable, backendMessage } = useBackendStatus();
const { themeMode, resolvedTheme } = useUiPreferences();
const { themeMode } = useUiPreferences();
const form = reactive({
username: "admin",
@@ -52,39 +52,11 @@ async function handleLogin() {
<template>
<div class="login-screen">
<section class="login-hero">
<div class="login-hero__eyebrow">Live Recorder</div>
<h1 class="login-hero__title">把直播录制做成可长期维护的专业控制台</h1>
<p class="login-hero__subtitle">
统一管理直播间自动开录恢复流程系统日志事件脚本和日报回顾让录制系统像真正的运维平台一样稳定工作
</p>
<div class="login-hero__grid">
<article class="login-hero__tile">
<strong>实时监控</strong>
<span>直播间自动开录决策会话与分片状态集中可见</span>
</article>
<article class="login-hero__tile">
<strong>恢复能力</strong>
<span>中断转码暂停录制重启恢复都有统一入口</span>
</article>
<article class="login-hero__tile">
<strong>自动化</strong>
<span>邮件Webhook事件脚本与自定义日志全部贯通</span>
</article>
<article class="login-hero__tile">
<strong>回顾分析</strong>
<span>时间轴和日报帮助我们快速复盘每一场直播</span>
</article>
</div>
</section>
<section class="login-panel surface-card">
<div class="login-panel__header">
<div>
<div class="login-panel__kicker">Sign In</div>
<h2 class="login-panel__title">进入控制台</h2>
<p class="login-panel__subtitle">默认账户为 <span class="monospace">admin / Admin@123</span></p>
<div class="login-panel__eyebrow">Live Recorder</div>
<h1 class="login-panel__title">登录</h1>
</div>
<el-select v-model="themeMode" size="small" class="login-panel__theme-select">
@@ -123,14 +95,9 @@ async function handleLogin() {
</el-form-item>
<el-button class="login-form__submit" type="primary" :loading="loading" @click="handleLogin">
登录控制台
登录
</el-button>
</el-form>
<div class="login-panel__footer">
<span>当前主题{{ resolvedTheme === "dark" ? "深色" : "浅色" }}</span>
<span>双端适配Web / Mobile</span>
</div>
</section>
</div>
</template>
@@ -139,78 +106,12 @@ async function handleLogin() {
.login-screen {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(360px, 440px);
gap: 48px;
padding: 48px 56px;
}
.login-hero {
display: grid;
align-content: center;
gap: 24px;
}
.login-hero__eyebrow,
.login-panel__kicker {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.login-hero__title {
margin: 0;
max-width: 11ch;
color: var(--text-primary);
font-size: clamp(44px, 5vw, 72px);
font-weight: 780;
letter-spacing: -0.065em;
line-height: 0.94;
}
.login-hero__subtitle {
max-width: 60ch;
margin: 0;
color: var(--text-secondary);
font-size: 16px;
line-height: 1.85;
}
.login-hero__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
max-width: 720px;
}
.login-hero__tile {
display: grid;
gap: 8px;
padding: 18px;
border-radius: 10px;
border: 1px solid var(--border-subtle);
background: rgba(255, 255, 255, 0.42);
box-shadow: var(--shadow-soft);
}
:global(html[data-theme="dark"]) .login-hero__tile {
background: rgba(255, 255, 255, 0.02);
}
.login-hero__tile strong {
color: var(--text-primary);
font-size: 14px;
}
.login-hero__tile span {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.65;
place-items: center;
padding: 24px;
}
.login-panel {
align-self: center;
width: min(100%, 420px);
padding: 24px;
}
@@ -222,6 +123,14 @@ async function handleLogin() {
margin-bottom: 18px;
}
.login-panel__eyebrow {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.login-panel__title {
margin: 6px 0 0;
color: var(--text-primary);
@@ -230,13 +139,6 @@ async function handleLogin() {
letter-spacing: -0.045em;
}
.login-panel__subtitle {
margin: 10px 0 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.6;
}
.login-panel__theme-select {
width: 122px;
}
@@ -250,59 +152,16 @@ async function handleLogin() {
margin-top: 8px;
}
.login-panel__footer {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
color: var(--text-muted);
font-size: 12px;
}
@media (max-width: 1100px) {
.login-screen {
grid-template-columns: 1fr;
gap: 28px;
padding: 28px 20px;
}
.login-hero__grid {
grid-template-columns: 1fr;
max-width: none;
}
.login-panel {
width: 100%;
max-width: 480px;
}
}
@media (max-width: 767px) {
.login-screen {
padding: 18px 14px 24px;
}
.login-hero {
gap: 18px;
}
.login-hero__title {
max-width: none;
font-size: clamp(34px, 12vw, 48px);
}
.login-hero__subtitle {
font-size: 14px;
}
.login-panel {
padding: 18px;
}
.login-panel__header,
.login-panel__footer {
.login-panel__header {
flex-direction: column;
}
+26 -1
View File
@@ -3,6 +3,8 @@ import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { FolderOpened, VideoPlay, Document, RefreshRight } from "@element-plus/icons-vue";
import apiClient, { buildApiUrl, getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import type { MediaBrowserItem, MediaBrowserResponse, TranscodeMediaFileResult } from "@/types";
const loading = ref(false);
@@ -14,6 +16,9 @@ const previewTitle = ref("");
const previewUrl = ref("");
const currentPathLabel = computed(() => browser.value?.currentPath || "平台目录");
const directoryCount = computed(() => browser.value?.items.filter((item) => item.type === "directory").length ?? 0);
const mediaFileCount = computed(() => browser.value?.items.filter((item) => item.type !== "directory").length ?? 0);
const transcodeReadyCount = computed(() => browser.value?.items.filter((item) => item.canTranscode).length ?? 0);
async function loadDirectory(path = "") {
loading.value = true;
@@ -140,6 +145,13 @@ onMounted(() => {
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid media-browser-metrics">
<MetricCard label="当前目录" :value="currentPathLabel" description="录制归档目录仅在后端允许的路径范围内浏览" />
<MetricCard label="子目录数" :value="directoryCount" description="当前目录下的可进入目录数量" :icon="FolderOpened" />
<MetricCard label="媒体文件" :value="mediaFileCount" description="当前目录下可直接预览或下载的文件数" :icon="Document" />
<MetricCard label="待转码" :value="transcodeReadyCount" description="当前目录下支持补转码为 MP4 的文件数量" :icon="VideoPlay" />
</div>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
@@ -171,7 +183,13 @@ onMounted(() => {
<el-skeleton v-if="loading && !browser" animated :rows="8" />
<el-empty v-else-if="browser && browser.items.length === 0" description="当前目录为空" />
<EmptyState
v-else-if="browser && browser.items.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新目录"
@action="refreshCurrentDirectory"
/>
<div v-else-if="browser" class="table-scroll-shell">
<el-table :data="browser.items" class="premium-table" table-layout="auto">
@@ -252,6 +270,13 @@ onMounted(() => {
border-radius: 14px;
}
.media-browser-metrics :deep(.metric-card__value) {
overflow: hidden;
font-size: clamp(22px, 1.8vw, 30px);
text-overflow: ellipsis;
white-space: nowrap;
}
.breadcrumb-row {
display: flex;
align-items: center;
+88 -32
View File
@@ -2,11 +2,15 @@
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElNotification } from "element-plus";
import { Bell, Connection, VideoCamera } from "@element-plus/icons-vue";
import apiClient, {
buildApiUrl,
getApiErrorMessage,
getBackendUnavailableMessage
} from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import { markBackendAvailable, markBackendUnavailable } from "@/composables/useBackendStatus";
import { useViewport } from "@/composables/useViewport";
import type {
@@ -818,7 +822,7 @@ onBeforeUnmount(() => {
<div class="page-kicker">录制工作台</div>
<h1 class="page-title">录制任务</h1>
<p class="page-subtitle">
按直播会话聚合展示分片任务列表会自动接收状态转码进度和分片变保留手动刷新入口用于兜底
按直播会话聚合展示真实录制任务自动接收状态转码进度和分片变适合观察开播检测到归档上传的完整链路
</p>
</div>
@@ -856,7 +860,7 @@ onBeforeUnmount(() => {
<div class="cleanup-status-card__title">当前任务状态{{ cleanupOperationStatusLabel }}</div>
</div>
<div class="cleanup-status-card__actions">
<el-tag :type="cleanupOperationTagType">{{ cleanupOperationStatusLabel }}</el-tag>
<StatusBadge :label="cleanupOperationStatusLabel" :status="cleanupOperation?.status" context="cleanup" />
<el-button v-if="cleanupOperationFinished" text @click="clearTrackedCleanupOperation">收起</el-button>
</div>
</div>
@@ -875,26 +879,30 @@ onBeforeUnmount(() => {
</ul>
</el-card>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-card__label">录制会话</div>
<div class="stat-card__value">{{ sessions.length }}</div>
<div class="stat-card__hint">按整场直播聚合分片任务</div>
<div class="record-ops-grid">
<div class="stats-grid">
<MetricCard label="录制会话" :value="sessions.length" description="按整场直播聚合的录制会话数" :icon="VideoCamera" />
<MetricCard label="活跃会话" :value="activeSessionCount" description="启动中、录制中、停止中、处理中" :icon="Connection" />
<MetricCard label="分片任务" :value="totalTaskCount" description="包含单文件模式下的唯一任务" :icon="Bell" />
<MetricCard label="弹幕事件" :value="totalDanmakuCount" description="累计写入 XML 的事件总数" :icon="Bell" />
</div>
<div class="stat-card">
<div class="stat-card__label">活跃会话</div>
<div class="stat-card__value">{{ activeSessionCount }}</div>
<div class="stat-card__hint">Starting / Running / Stopping / Processing</div>
</div>
<div class="stat-card">
<div class="stat-card__label">分片任务</div>
<div class="stat-card__value">{{ totalTaskCount }}</div>
<div class="stat-card__hint">包含单文件模式下的唯一任务</div>
</div>
<div class="stat-card">
<div class="stat-card__label">弹幕事件</div>
<div class="stat-card__value">{{ totalDanmakuCount }}</div>
<div class="stat-card__hint">累计写入 XML 的事件总数</div>
<div class="record-feature-strip">
<article class="record-feature-card">
<div class="record-feature-card__eyebrow">能力说明</div>
<div class="record-feature-card__title">开播自动录制</div>
<p class="record-feature-card__description">继续复用后端轮询自动开录和活动会话保护逻辑不新增任何前端假状态</p>
</article>
<article class="record-feature-card">
<div class="record-feature-card__eyebrow">能力说明</div>
<div class="record-feature-card__title">分片后处理</div>
<p class="record-feature-card__description">保留现有转码分片完成事件和实时进度展示聚焦运维可读性</p>
</article>
<article class="record-feature-card">
<div class="record-feature-card__eyebrow">能力说明</div>
<div class="record-feature-card__title">上传归档</div>
<p class="record-feature-card__description">继续调用真实上传接口空数据时显示空状态而不是伪造归档数量</p>
</article>
</div>
</div>
@@ -926,7 +934,13 @@ onBeforeUnmount(() => {
</div>
</div>
<el-empty v-if="!loading && sessions.length === 0" description="暂无录制会话" />
<EmptyState
v-if="!loading && sessions.length === 0"
title="暂无录制任务"
description="当前筛选条件下没有可展示内容"
action-text="刷新列表"
@action="loadSessions()"
/>
<div v-else-if="isMobile" class="data-card-list session-card-list">
<article v-for="session in sessions" :key="session.id" class="data-card session-card">
@@ -935,9 +949,7 @@ onBeforeUnmount(() => {
<div class="data-card__title">{{ session.liveRoomTitle }}</div>
<div class="data-card__subtitle monospace">{{ session.roomId }}</div>
</div>
<el-tag :type="sessionTagType(session.status)">
{{ sessionStatusLabelMap[session.status] }}
</el-tag>
<StatusBadge :label="sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
</div>
<div class="badge-row">
@@ -993,7 +1005,7 @@ onBeforeUnmount(() => {
<strong class="monospace">#{{ task.segmentIndex }}</strong>
<div class="cell-subtitle">{{ formatDate(task.startedAt || task.createdAt) }}</div>
</div>
<el-tag :type="taskTagType(task.status)">{{ taskStatusLabelMap[task.status] }}</el-tag>
<StatusBadge :label="taskStatusLabelMap[task.status]" :status="task.status" context="task" />
</div>
<div v-if="hasPostProcess(task)" class="session-task-card__progress">
@@ -1081,9 +1093,7 @@ onBeforeUnmount(() => {
</div>
<div class="session-title__stats">
<el-tag :type="sessionTagType(session.status)">
{{ sessionStatusLabelMap[session.status] }}
</el-tag>
<StatusBadge :label="sessionStatusLabelMap[session.status]" :status="session.status" context="session" />
<span>{{ saveModeLabelMap[session.saveMode] }}</span>
<span>{{ outputFormatLabelMap[session.outputFormat] }}</span>
<span>分片 {{ session.segmentCount }}</span>
@@ -1157,9 +1167,7 @@ onBeforeUnmount(() => {
<el-table-column label="状态" width="220">
<template #default="{ row }">
<div class="task-status-cell">
<el-tag :type="taskTagType(row.status)">
{{ taskStatusLabelMap[row.status] }}
</el-tag>
<StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
<template v-if="hasPostProcess(row)">
<div class="task-status-cell__stage">{{ row.postProcessStage }}</div>
<el-progress
@@ -1360,6 +1368,50 @@ onBeforeUnmount(() => {
gap: 24px;
}
.record-ops-grid {
display: grid;
gap: 18px;
}
.record-feature-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.record-feature-card {
display: grid;
gap: 10px;
padding: 18px;
border-radius: 16px;
border: 1px solid var(--border-subtle);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.98)),
var(--surface);
box-shadow: var(--shadow-soft);
}
.record-feature-card__eyebrow {
color: var(--accent);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.record-feature-card__title {
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
}
.record-feature-card__description {
margin: 0;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.75;
}
.header-actions {
align-self: center;
}
@@ -1722,6 +1774,10 @@ onBeforeUnmount(() => {
}
@media (max-width: 960px) {
.record-feature-strip {
grid-template-columns: 1fr;
}
.header-actions {
align-self: stretch;
justify-content: stretch;
+54 -33
View File
@@ -3,6 +3,9 @@ import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import axios from "axios";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import StatusBadge from "@/components/ui/StatusBadge.vue";
import { useViewport } from "@/composables/useViewport";
import type {
RecoverableFinalization,
@@ -11,6 +14,7 @@ import type {
RecoveryOverview
} from "@/types";
import { autoStartDecisionLabelMap, taskStatusLabelMap } from "@/types";
import { RefreshRight, VideoCamera, WarningFilled } from "@element-plus/icons-vue";
const loading = ref(false);
const retryAllLoading = ref(false);
@@ -183,27 +187,20 @@ onMounted(loadOverview);
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid" v-loading="loading">
<div class="stat-card">
<div class="stat-card__label">存储保护</div>
<div class="stat-card__value">{{ storage?.hasEnoughSpace ? "可恢复" : "受限" }}</div>
<div class="stat-card__hint">{{ storage?.message || "暂无数据" }}</div>
</div>
<div class="stat-card">
<div class="stat-card__label">可重试开录</div>
<div class="stat-card__value">{{ liveRooms.length }}</div>
<div class="stat-card__hint">在线启用中且当前没有活动会话的直播间</div>
</div>
<div class="stat-card">
<div class="stat-card__label">可恢复转码</div>
<div class="stat-card__value">{{ finalizations.length }}</div>
<div class="stat-card__hint">等待继续或可手动补转码的 MP4 任务</div>
</div>
<div class="stat-card">
<div class="stat-card__label">剩余空间</div>
<div class="stat-card__value">{{ storage ? formatBytes(storage.availableBytes) : "-" }}</div>
<div class="stat-card__hint">恢复阈值 {{ storage ? formatBytes(storage.requiredBytes) : "-" }}</div>
</div>
<div class="stats-grid recovery-metrics" v-loading="loading">
<MetricCard
label="存储保护"
:value="storage?.hasEnoughSpace ? '可恢复' : '受限'"
:description="storage?.message || '暂无存储数据'"
:icon="WarningFilled"
/>
<MetricCard label="可重试开录" :value="liveRooms.length" description="在线、启用中且没有活动会话的直播间" :icon="RefreshRight" />
<MetricCard label="可恢复转码" :value="finalizations.length" description="等待继续或可手动补转码的 MP4 任务" :icon="VideoCamera" />
<MetricCard
label="剩余空间"
:value="storage ? formatBytes(storage.availableBytes) : '--'"
:description="`恢复阈值 ${storage ? formatBytes(storage.requiredBytes) : '--'}`"
/>
</div>
<el-card class="surface-card table-card" shadow="never">
@@ -223,20 +220,30 @@ onMounted(loadOverview);
</el-button>
</div>
<div v-if="isMobile" class="data-card-list">
<EmptyState
v-if="!loading && liveRooms.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新总览"
@action="loadOverview"
/>
<div v-else-if="isMobile" class="data-card-list">
<article v-for="row in liveRooms" :key="row.liveRoomId" class="data-card">
<div class="data-card__header">
<div>
<div class="data-card__title">{{ liveRoomTitle(row) }}</div>
<div class="data-card__subtitle">{{ row.anchorName || "未知主播" }}</div>
</div>
<el-tag effect="plain">{{ row.platformName }}</el-tag>
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
</div>
<div class="badge-row">
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
</el-tag>
<StatusBadge
size="sm"
:label="autoStartDecisionLabel(row.lastAutoStartDecisionCode)"
:status="row.lastAutoStartDecisionCode || 'unknown'"
/>
<span class="info-pill">{{ formatDate(row.lastCheckedAt) }}</span>
</div>
@@ -286,7 +293,7 @@ onMounted(loadOverview);
<el-table-column label="平台" width="120">
<template #default="{ row }">
<el-tag effect="plain">{{ row.platformName }}</el-tag>
<StatusBadge :label="row.platformName || '--'" :status="row.platformName || 'unknown'" />
</template>
</el-table-column>
@@ -294,9 +301,11 @@ onMounted(loadOverview);
<template #default="{ row }">
<div class="decision-cell">
<div class="decision-cell__head">
<el-tag size="small" effect="plain" :type="autoStartDecisionTagType(row.lastAutoStartDecisionCode)">
{{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }}
</el-tag>
<StatusBadge
size="sm"
:label="autoStartDecisionLabel(row.lastAutoStartDecisionCode)"
:status="row.lastAutoStartDecisionCode || 'unknown'"
/>
<span class="table-date-text">{{ formatDate(row.lastAutoStartDecisionAt) }}</span>
</div>
<div class="cell-subtitle">{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}</div>
@@ -344,14 +353,22 @@ onMounted(loadOverview);
</el-button>
</div>
<div v-if="isMobile" class="data-card-list">
<EmptyState
v-if="!loading && finalizations.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新总览"
@action="loadOverview"
/>
<div v-else-if="isMobile" class="data-card-list">
<article v-for="row in finalizations" :key="row.recordTaskId" class="data-card">
<div class="data-card__header">
<div>
<div class="data-card__title">{{ finalizationTitle(row) }}</div>
<div class="data-card__subtitle">{{ row.platformName }} · Segment #{{ row.segmentIndex }}</div>
</div>
<el-tag effect="plain">{{ taskStatusLabelMap[row.status] }}</el-tag>
<StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
</div>
<div class="data-card__grid">
@@ -404,7 +421,7 @@ onMounted(loadOverview);
<el-table-column label="状态" width="120">
<template #default="{ row }">
<el-tag effect="plain">{{ taskStatusLabelMap[row.status] }}</el-tag>
<StatusBadge :label="taskStatusLabelMap[row.status]" :status="row.status" context="task" />
</template>
</el-table-column>
@@ -439,6 +456,10 @@ onMounted(loadOverview);
</template>
<style scoped>
.recovery-metrics :deep(.metric-card__value) {
font-size: clamp(24px, 1.9vw, 34px);
}
.decision-cell {
display: grid;
gap: 6px;
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1,9 +1,13 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import VueDevTools from "vite-plugin-vue-devtools";
import path from "node:path";
export default defineConfig({
plugins: [vue()],
export default defineConfig(({ command }) => ({
plugins: [
...(command === "serve" ? [VueDevTools()] : []),
vue()
],
resolve: {
alias: {
"@": path.resolve(__dirname, "src")
@@ -42,4 +46,4 @@ export default defineConfig({
}
}
}
});
}));
+10
View File
@@ -0,0 +1,10 @@
.dart_tool/
.tmp/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub/
build/
coverage/
android/.gradle/
android/local.properties
+24
View File
@@ -0,0 +1,24 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "44a626f4f0027bc38a46dc68aed5964b05a83c18"
channel: "stable"
project_type: app
migration:
platforms:
- platform: root
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
- platform: android
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
unmanaged_files:
- "lib/main.dart"
- "android/app/src/main/kotlin/com/liverecorder/mobile/MainActivity.kt"
+6
View File
@@ -0,0 +1,6 @@
include: package:flutter_lints/flutter.yaml
linter:
rules:
avoid_print: false
+39
View File
@@ -0,0 +1,39 @@
plugins {
id("com.android.application")
id("kotlin-android")
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.liverecorder.mobile"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
applicationId = "com.liverecorder.mobile"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:usesCleartextTraffic="true"
tools:targetApi="28" />
</manifest>
@@ -0,0 +1,34 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="LiveRecorder"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,34 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new com.github.dart_lang.jni.JniPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin jni, com.github.dart_lang.jni.JniPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.github.dart_lang.jni_flutter.JniFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin jni_flutter, com.github.dart_lang.jni_flutter.JniFlutterPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
}
}
}
@@ -0,0 +1,6 @@
package com.liverecorder.mobile
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
+38
View File
@@ -0,0 +1,38 @@
allprojects {
buildscript {
repositories {
maven("https://maven.aliyun.com/repository/public")
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
google()
mavenCentral()
}
}
repositories {
maven("https://maven.aliyun.com/repository/public")
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
Binary file not shown.
@@ -0,0 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
+90
View File
@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+29
View File
@@ -0,0 +1,29 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
maven("https://maven.aliyun.com/repository/gradle-plugin")
maven("https://maven.aliyun.com/repository/google")
maven("https://maven.aliyun.com/repository/central")
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")
+161
View File
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/app/app_scope.dart';
import 'package:live_recorder_mobile/app/app_theme.dart';
import 'package:live_recorder_mobile/core/config/api_config.dart';
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/backend_setup_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/login_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/mobile_shell_page.dart';
class LiveRecorderBootstrap extends StatefulWidget {
const LiveRecorderBootstrap({
super.key,
required this.config,
});
final ApiConfig config;
@override
State<LiveRecorderBootstrap> createState() => _LiveRecorderBootstrapState();
}
class _LiveRecorderBootstrapState extends State<LiveRecorderBootstrap> {
late final AppBootstrapController<AppDependencies> _bootstrapController =
AppBootstrapController<AppDependencies>(
config: widget.config,
configStorage: AppConfigStorage(),
dependenciesFactory: (String baseUrl) => AppDependencies.create(baseUrl: baseUrl),
);
@override
void initState() {
super.initState();
_bootstrapController.initialize();
}
@override
void dispose() {
_bootstrapController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _bootstrapController,
builder: (BuildContext context, _) {
return AppScope(
backendConfig: _bootstrapController,
dependencies: _bootstrapController.dependencies,
child: MaterialApp(
debugShowCheckedModeBanner: false,
title: 'LiveRecorder',
theme: buildLiveRecorderTheme(),
home: _BootstrapHome(
bootstrapController: _bootstrapController,
),
),
);
},
);
}
}
class _BootstrapHome extends StatelessWidget {
const _BootstrapHome({
required this.bootstrapController,
});
final AppBootstrapController<AppDependencies> bootstrapController;
@override
Widget build(BuildContext context) {
if (bootstrapController.isInitializing) {
return const _LoadingSplashPage();
}
if (bootstrapController.initializationErrorMessage != null) {
return _BootstrapErrorPage(
message: bootstrapController.initializationErrorMessage!,
onRetry: bootstrapController.initialize,
);
}
if (!bootstrapController.hasConfiguredBackend) {
return BackendSetupPage(
bootstrapController: bootstrapController,
);
}
final dependencies = bootstrapController.dependencies;
if (dependencies == null) {
return _BootstrapErrorPage(
message: '后端配置未能正确加载,请重试',
onRetry: bootstrapController.initialize,
);
}
return ListenableBuilder(
listenable: dependencies.sessionController,
builder: (BuildContext context, _) {
if (dependencies.sessionController.isRestoring) {
return const _LoadingSplashPage();
}
if (dependencies.sessionController.isLoggedIn) {
return MobileShellPage(
dependencies: dependencies,
);
}
return LoginPage(
sessionController: dependencies.sessionController,
);
},
);
}
}
class _LoadingSplashPage extends StatelessWidget {
const _LoadingSplashPage();
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: CircularProgressIndicator(),
),
);
}
}
class _BootstrapErrorPage extends StatelessWidget {
const _BootstrapErrorPage({
required this.message,
required this.onRetry,
});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: AppErrorCard(
message: message,
onRetry: onRetry,
),
),
),
),
);
}
}
@@ -0,0 +1,167 @@
import 'package:flutter/foundation.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/core/config/api_config.dart';
import 'package:live_recorder_mobile/core/persistence/app_config_storage.dart';
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
typedef AppDependenciesFactory<T extends AppDependencyBundle> = T Function(String baseUrl);
abstract interface class BackendConfigHandle extends Listenable {
String get seedBaseUrl;
String? get backendBaseUrl;
bool get hasConfiguredBackend;
bool get isInitializing;
String? get initializationErrorMessage;
Future<void> initialize();
Future<void> saveInitialBackendBaseUrl(String rawValue);
Future<bool> updateBackendBaseUrl(String rawValue);
}
class AppBootstrapController<T extends AppDependencyBundle> extends ChangeNotifier
implements BackendConfigHandle {
AppBootstrapController({
required ApiConfig config,
required BackendConfigStore configStorage,
required AppDependenciesFactory<T> dependenciesFactory,
}) : _config = config,
_configStorage = configStorage,
_dependenciesFactory = dependenciesFactory;
final ApiConfig _config;
final BackendConfigStore _configStorage;
final AppDependenciesFactory<T> _dependenciesFactory;
T? _dependencies;
String? _backendBaseUrl;
bool _isInitializing = true;
String? _initializationErrorMessage;
T? get dependencies => _dependencies;
@override
String get seedBaseUrl => _config.seedBaseUrl;
@override
String? get backendBaseUrl => _backendBaseUrl;
@override
bool get hasConfiguredBackend => _backendBaseUrl != null && _backendBaseUrl!.isNotEmpty;
@override
bool get isInitializing => _isInitializing;
@override
String? get initializationErrorMessage => _initializationErrorMessage;
@override
Future<void> initialize() async {
_setInitializing(true);
try {
final storedBaseUrl = await _configStorage.readBackendBaseUrl();
if (storedBaseUrl == null || storedBaseUrl.trim().isEmpty) {
_disposeDependencies();
_backendBaseUrl = null;
return;
}
final normalizedBaseUrl = normalizeBackendBaseUrl(storedBaseUrl);
_backendBaseUrl = normalizedBaseUrl;
await _rebuildDependencies(normalizedBaseUrl);
} on FormatException {
await _configStorage.clear();
_disposeDependencies();
_backendBaseUrl = null;
} catch (_) {
_disposeDependencies();
_backendBaseUrl = null;
_initializationErrorMessage = '读取后端地址失败,请重试';
} finally {
_setInitializing(false);
}
}
@override
Future<void> saveInitialBackendBaseUrl(String rawValue) async {
final normalizedBaseUrl = normalizeBackendBaseUrl(rawValue);
await _persistAndApplyBackendBaseUrl(
normalizedBaseUrl,
clearExistingSession: false,
);
}
@override
Future<bool> updateBackendBaseUrl(String rawValue) async {
final normalizedBaseUrl = normalizeBackendBaseUrl(rawValue);
if (normalizedBaseUrl == _backendBaseUrl) {
return false;
}
await _persistAndApplyBackendBaseUrl(
normalizedBaseUrl,
clearExistingSession: true,
);
return true;
}
Future<void> _persistAndApplyBackendBaseUrl(
String normalizedBaseUrl, {
required bool clearExistingSession,
}) async {
final previousDependencies = _dependencies;
_setInitializing(true);
try {
await _configStorage.writeBackendBaseUrl(normalizedBaseUrl);
if (clearExistingSession) {
await previousDependencies?.sessionController.clearLocalSession();
}
_backendBaseUrl = normalizedBaseUrl;
await _rebuildDependencies(normalizedBaseUrl);
} catch (error) {
if (!identical(previousDependencies, _dependencies)) {
_dependencies?.dispose();
_dependencies = previousDependencies;
}
rethrow;
} finally {
_setInitializing(false);
}
}
Future<void> _rebuildDependencies(String baseUrl) async {
final nextDependencies = _dependenciesFactory(baseUrl);
final previousDependencies = _dependencies;
_dependencies = nextDependencies;
try {
await nextDependencies.sessionController.restore();
previousDependencies?.dispose();
} catch (_) {
nextDependencies.dispose();
_dependencies = previousDependencies;
rethrow;
}
}
void _setInitializing(bool value) {
_isInitializing = value;
if (value) {
_initializationErrorMessage = null;
}
notifyListeners();
}
void _disposeDependencies() {
final dependencies = _dependencies;
_dependencies = null;
dependencies?.dispose();
}
@override
void dispose() {
_disposeDependencies();
super.dispose();
}
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:live_recorder_mobile/core/network/api_client.dart';
import 'package:live_recorder_mobile/core/persistence/session_storage.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/auth_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/live_rooms_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/logs_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/media_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recovery_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recordings_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
abstract interface class AppDependencyBundle {
SessionControllerHandle get sessionController;
void dispose();
}
class AppDependencies implements AppDependencyBundle {
AppDependencies._({
required this.backendBaseUrl,
required this.apiClient,
required this.sessionStorage,
required this.authRepository,
required this.liveRoomsRepository,
required this.recordingsRepository,
required this.recoveryRepository,
required this.settingsRepository,
required this.logsRepository,
required this.mediaRepository,
required this.sessionController,
});
factory AppDependencies.create({
required String baseUrl,
}) {
late AppSessionController sessionController;
final sessionStorage = SessionStorage();
final apiClient = ApiClient(
baseUrl: baseUrl,
tokenProvider: () => sessionController.token,
onUnauthorized: () async => sessionController.handleUnauthorized(),
);
final authRepository = AuthRepository(apiClient);
sessionController = AppSessionController(
authRepository: authRepository,
sessionStorage: sessionStorage,
);
return AppDependencies._(
backendBaseUrl: baseUrl,
apiClient: apiClient,
sessionStorage: sessionStorage,
authRepository: authRepository,
liveRoomsRepository: LiveRoomsRepository(apiClient),
recordingsRepository: RecordingsRepository(apiClient),
recoveryRepository: RecoveryRepository(apiClient),
settingsRepository: SettingsRepository(apiClient),
logsRepository: LogsRepository(apiClient),
mediaRepository: MediaRepository(apiClient),
sessionController: sessionController,
);
}
final String backendBaseUrl;
final ApiClient apiClient;
final SessionStorage sessionStorage;
final AuthRepository authRepository;
final LiveRoomsRepository liveRoomsRepository;
final RecordingsRepository recordingsRepository;
final RecoveryRepository recoveryRepository;
final SettingsRepository settingsRepository;
final LogsRepository logsRepository;
final MediaRepository mediaRepository;
@override
final AppSessionController sessionController;
@override
void dispose() {
apiClient.dispose();
sessionController.dispose();
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/widgets.dart';
import 'app_bootstrap_controller.dart';
import 'app_dependencies.dart';
class AppScope extends InheritedWidget {
const AppScope({
super.key,
required this.backendConfig,
required this.dependencies,
required super.child,
});
final BackendConfigHandle backendConfig;
final AppDependencies? dependencies;
static AppDependencies of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'AppScope is not available in this context.');
final dependencies = scope!.dependencies;
assert(dependencies != null, 'AppDependencies are not available in this context.');
return dependencies!;
}
static BackendConfigHandle backendConfigOf(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<AppScope>();
assert(scope != null, 'AppScope is not available in this context.');
return scope!.backendConfig;
}
@override
bool updateShouldNotify(AppScope oldWidget) {
return dependencies != oldWidget.dependencies || backendConfig != oldWidget.backendConfig;
}
}
+81
View File
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
ThemeData buildLiveRecorderTheme() {
const seed = Color(0xFF2563EB);
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: seed,
primary: seed,
surface: Colors.white,
),
scaffoldBackgroundColor: const Color(0xFFF6F8FB),
cardTheme: CardThemeData(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
side: const BorderSide(color: Color(0xFFE2E8F0)),
),
margin: EdgeInsets.zero,
),
appBarTheme: const AppBarTheme(
backgroundColor: Colors.transparent,
elevation: 0,
surfaceTintColor: Colors.transparent,
foregroundColor: Color(0xFF0F172A),
),
navigationBarTheme: NavigationBarThemeData(
height: 72,
labelTextStyle: WidgetStateProperty.resolveWith<TextStyle?>(
(Set<WidgetState> states) {
final color = states.contains(WidgetState.selected)
? const Color(0xFF2563EB)
: const Color(0xFF64748B);
return TextStyle(
color: color,
fontWeight: states.contains(WidgetState.selected) ? FontWeight.w700 : FontWeight.w500,
);
},
),
indicatorColor: const Color(0xFFE0ECFF),
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
hintStyle: const TextStyle(color: Color(0xFF64748B)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFF2563EB), width: 1.4),
),
),
chipTheme: ChipThemeData(
backgroundColor: Colors.white,
selectedColor: const Color(0xFFE0ECFF),
side: const BorderSide(color: Color(0xFFE2E8F0)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
labelStyle: const TextStyle(
color: Color(0xFF64748B),
fontWeight: FontWeight.w600,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)),
),
dividerTheme: const DividerThemeData(
color: Color(0xFFE2E8F0),
thickness: 1,
),
);
}
+14
View File
@@ -0,0 +1,14 @@
class ApiConfig {
const ApiConfig({
this.seedBaseUrl = '',
});
final String seedBaseUrl;
static ApiConfig fromEnvironment() {
const rawValue = String.fromEnvironment('LIVE_RECORDER_API_BASE_URL');
return ApiConfig(seedBaseUrl: rawValue.trim());
}
bool get hasSeedBaseUrl => seedBaseUrl.isNotEmpty;
}
+210
View File
@@ -0,0 +1,210 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'api_exception.dart';
typedef TokenProvider = String? Function();
typedef UnauthorizedCallback = Future<void> Function();
class ApiClient {
ApiClient({
required String baseUrl,
required TokenProvider tokenProvider,
required UnauthorizedCallback onUnauthorized,
http.Client? client,
}) : _baseUri = Uri.parse(baseUrl),
_tokenProvider = tokenProvider,
_onUnauthorized = onUnauthorized,
_client = client ?? http.Client();
final Uri _baseUri;
final TokenProvider _tokenProvider;
final UnauthorizedCallback _onUnauthorized;
final http.Client _client;
Uri buildUri(
String path, {
Map<String, String>? queryParameters,
}) {
if (path.startsWith('http://') || path.startsWith('https://')) {
return Uri.parse(path);
}
final normalizedPath = path.startsWith('/') ? path.substring(1) : path;
final basePath = _baseUri.path == '/' ? '' : _baseUri.path.replaceAll(RegExp(r'/+$'), '');
final resolvedPath = basePath.isEmpty ? '/$normalizedPath' : '$basePath/$normalizedPath';
final resolved = _baseUri.replace(path: resolvedPath);
if (queryParameters == null || queryParameters.isEmpty) {
return resolved;
}
return resolved.replace(
queryParameters: <String, String>{
...resolved.queryParameters,
...queryParameters,
},
);
}
Future<dynamic> getJson(
String path, {
Map<String, String>? queryParameters,
}) {
return _sendJsonRequest(
'GET',
path,
queryParameters: queryParameters,
);
}
Future<dynamic> postJson(
String path, {
Object? body,
Map<String, String>? queryParameters,
}) {
return _sendJsonRequest(
'POST',
path,
body: body,
queryParameters: queryParameters,
);
}
Future<dynamic> putJson(
String path, {
Object? body,
Map<String, String>? queryParameters,
}) {
return _sendJsonRequest(
'PUT',
path,
body: body,
queryParameters: queryParameters,
);
}
Future<dynamic> deleteJson(
String path, {
Object? body,
Map<String, String>? queryParameters,
}) {
return _sendJsonRequest(
'DELETE',
path,
body: body,
queryParameters: queryParameters,
);
}
Future<dynamic> _sendJsonRequest(
String method,
String path, {
Object? body,
Map<String, String>? queryParameters,
}) async {
final uri = buildUri(path, queryParameters: queryParameters);
final request = http.Request(method, uri);
request.headers.addAll(_buildHeaders());
if (body != null) {
request.body = jsonEncode(body);
}
http.StreamedResponse streamedResponse;
try {
streamedResponse = await _client.send(request);
} on Exception catch (error) {
throw ApiException(message: '无法连接后端服务', detail: error.toString());
}
final response = await http.Response.fromStream(streamedResponse);
return _decodeJsonResponse(response);
}
Future<void> postEmpty(
String path, {
Object? body,
}) async {
await postJson(path, body: body);
}
Map<String, String> _buildHeaders() {
final headers = <String, String>{
'Content-Type': 'application/json',
'Accept': 'application/json',
};
final token = _tokenProvider()?.trim();
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
return headers;
}
dynamic _decodeJsonResponse(http.Response response) {
if (response.statusCode == 401) {
_onUnauthorized();
}
final bodyText = utf8.decode(response.bodyBytes);
final jsonBody = bodyText.trim().isEmpty ? null : jsonDecode(bodyText);
if (response.statusCode >= 200 && response.statusCode < 300) {
return jsonBody;
}
throw ApiException(
message: _resolveErrorMessage(response.statusCode, jsonBody),
statusCode: response.statusCode,
detail: jsonBody is Map<String, dynamic>
? (jsonBody['detail'] ?? jsonBody['error'])?.toString()
: null,
);
}
String _resolveErrorMessage(int statusCode, dynamic body) {
if (body is Map<String, dynamic>) {
final candidate = <dynamic>[
body['message'],
body['title'],
body['detail'],
body['error'],
].firstWhere(
(value) => value is String && value.trim().isNotEmpty,
orElse: () => null,
);
if (candidate is String) {
return candidate;
}
} else if (body is String && body.trim().isNotEmpty) {
return body;
}
switch (statusCode) {
case 400:
return '请求参数有误,请检查后重试';
case 401:
return '登录状态已失效,请重新登录';
case 403:
return '当前没有权限执行该操作';
case 404:
return '请求的接口不存在';
case 409:
return '请求发生冲突,请刷新后重试';
case 422:
return '提交的数据格式不正确,请检查后重试';
case 500:
return '后端服务发生内部错误';
case 502:
case 503:
case 504:
return '后端服务暂时不可用,请稍后重试';
default:
return '请求失败,请稍后重试';
}
}
void dispose() {
_client.close();
}
}
@@ -0,0 +1,25 @@
class ApiException implements Exception {
const ApiException({
required this.message,
this.statusCode,
this.detail,
});
final String message;
final int? statusCode;
final String? detail;
@override
String toString() {
final buffer = StringBuffer('ApiException(message: $message');
if (statusCode != null) {
buffer.write(', statusCode: $statusCode');
}
if (detail != null && detail!.isNotEmpty) {
buffer.write(', detail: $detail');
}
buffer.write(')');
return buffer.toString();
}
}
@@ -0,0 +1,60 @@
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
abstract interface class BackendConfigStore {
Future<String?> readBackendBaseUrl();
Future<void> writeBackendBaseUrl(String baseUrl);
Future<void> clear();
}
class AppConfigStorage implements BackendConfigStore {
@override
Future<String?> readBackendBaseUrl() async {
final file = await _configFile();
if (!await file.exists()) {
return null;
}
final content = await file.readAsString();
if (content.trim().isEmpty) {
return null;
}
final payload = jsonDecode(content);
if (payload is! Map<String, dynamic>) {
return null;
}
final value = payload['backendBaseUrl']?.toString().trim();
if (value == null || value.isEmpty) {
return null;
}
return value;
}
@override
Future<void> writeBackendBaseUrl(String baseUrl) async {
final file = await _configFile();
await file.create(recursive: true);
await file.writeAsString(
jsonEncode(<String, dynamic>{
'backendBaseUrl': baseUrl,
}),
);
}
@override
Future<void> clear() async {
final file = await _configFile();
if (await file.exists()) {
await file.delete();
}
}
Future<File> _configFile() async {
final directory = await getApplicationSupportDirectory();
return File('${directory.path}${Platform.pathSeparator}live_recorder_app_config.json');
}
}
@@ -0,0 +1,39 @@
import 'dart:convert';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
class SessionStorage {
Future<Map<String, dynamic>?> read() async {
final file = await _sessionFile();
if (!await file.exists()) {
return null;
}
final content = await file.readAsString();
if (content.trim().isEmpty) {
return null;
}
return jsonDecode(content) as Map<String, dynamic>;
}
Future<void> write(Map<String, dynamic> payload) async {
final file = await _sessionFile();
await file.create(recursive: true);
await file.writeAsString(jsonEncode(payload));
}
Future<void> clear() async {
final file = await _sessionFile();
if (await file.exists()) {
await file.delete();
}
}
Future<File> _sessionFile() async {
final directory = await getApplicationSupportDirectory();
return File('${directory.path}${Platform.pathSeparator}live_recorder_session.json');
}
}
@@ -0,0 +1,67 @@
import 'dart:async';
class PollingController {
PollingController({
required Duration interval,
required Future<void> Function() onTick,
}) : _interval = interval,
_onTick = onTick;
final Duration _interval;
final Future<void> Function() _onTick;
Timer? _timer;
bool _active = false;
bool _busy = false;
void setActive(bool active) {
if (_active == active) {
return;
}
_active = active;
if (_active) {
_schedule();
triggerNow();
} else {
_timer?.cancel();
_timer = null;
}
}
void triggerNow() {
if (!_active || _busy) {
return;
}
_tick();
}
Future<void> _tick() async {
_busy = true;
try {
await _onTick();
} finally {
_busy = false;
_schedule();
}
}
void _schedule() {
_timer?.cancel();
if (!_active) {
return;
}
_timer = Timer(_interval, () {
if (_active && !_busy) {
_tick();
}
});
}
void dispose() {
_timer?.cancel();
}
}
@@ -0,0 +1,34 @@
String normalizeBackendBaseUrl(String rawValue) {
final trimmed = rawValue.trim();
if (trimmed.isEmpty) {
throw const FormatException('请输入后端地址');
}
final uri = Uri.tryParse(trimmed);
if (uri == null ||
!uri.hasScheme ||
(uri.scheme != 'http' && uri.scheme != 'https') ||
uri.host.isEmpty) {
throw const FormatException('请输入以 http:// 或 https:// 开头的完整地址');
}
if (uri.query.isNotEmpty || uri.fragment.isNotEmpty) {
throw const FormatException('后端地址不能包含查询参数或片段');
}
var normalizedPath = uri.path.replaceAll(RegExp(r'/+$'), '');
if (normalizedPath == '/') {
normalizedPath = '';
}
return uri.replace(path: normalizedPath).toString();
}
String? validateBackendBaseUrl(String rawValue) {
try {
normalizeBackendBaseUrl(rawValue);
return null;
} on FormatException catch (error) {
return error.message;
}
}
+89
View File
@@ -0,0 +1,89 @@
import 'package:intl/intl.dart';
final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm');
final DateFormat _timeFormat = DateFormat('HH:mm');
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
String formatDateTime(String? value) {
if (value == null || value.trim().isEmpty) {
return '--';
}
final dateTime = DateTime.tryParse(value)?.toLocal();
if (dateTime == null) {
return '--';
}
return _dateTimeFormat.format(dateTime);
}
String formatTime(String? value) {
if (value == null || value.trim().isEmpty) {
return '--';
}
final dateTime = DateTime.tryParse(value)?.toLocal();
if (dateTime == null) {
return '--';
}
return _timeFormat.format(dateTime);
}
String formatDateOnly(String? value) {
if (value == null || value.trim().isEmpty) {
return '--';
}
final dateTime = DateTime.tryParse(value)?.toLocal();
if (dateTime == null) {
return '--';
}
return _dateFormat.format(dateTime);
}
String formatDurationSeconds(num? seconds) {
if (seconds == null) {
return '--';
}
final totalSeconds = seconds.round();
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final remainingSeconds = totalSeconds % 60;
if (hours > 0) {
return '${hours}h ${minutes}m';
}
if (minutes > 0) {
return '${minutes}m ${remainingSeconds}s';
}
return '${remainingSeconds}s';
}
String formatBytes(num? bytes) {
if (bytes == null) {
return '--';
}
const units = <String>['B', 'KB', 'MB', 'GB', 'TB'];
var value = bytes.toDouble();
var index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
final fractionDigits = index == 0 ? 0 : index == 1 ? 1 : 2;
return '${value.toStringAsFixed(fractionDigits)} ${units[index]}';
}
String valueOrDash(Object? value) {
if (value == null) {
return '--';
}
final text = value.toString().trim();
return text.isEmpty ? '--' : text;
}
@@ -0,0 +1,61 @@
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
Uri? resolveLiveRoomWatchUri(LiveRoom room) {
for (final String candidate in <String>[
room.normalizedUrl,
room.sourceUrl,
room.originalLiveRoomUrl,
]) {
final uri = _parseHttpUri(candidate);
if (uri != null) {
return uri;
}
}
return null;
}
bool hasLiveRoomWatchSource(LiveRoom room) {
return room.normalizedUrl.trim().isNotEmpty ||
room.sourceUrl.trim().isNotEmpty ||
room.originalLiveRoomUrl.trim().isNotEmpty;
}
int compareMonitorRooms(LiveRoom a, LiveRoom b) {
final liveA = a.availabilityStatus == 2 ? 1 : 0;
final liveB = b.availabilityStatus == 2 ? 1 : 0;
if (liveA != liveB) {
return liveB.compareTo(liveA);
}
final recordingA = a.currentRecordingState == 2 ? 1 : 0;
final recordingB = b.currentRecordingState == 2 ? 1 : 0;
if (recordingA != recordingB) {
return recordingB.compareTo(recordingA);
}
final priorityA = (a.isPinned || a.isPriority) ? 1 : 0;
final priorityB = (b.isPinned || b.isPriority) ? 1 : 0;
if (priorityA != priorityB) {
return priorityB.compareTo(priorityA);
}
return b.updatedAt.compareTo(a.updatedAt);
}
Uri? _parseHttpUri(String? rawValue) {
final trimmed = rawValue?.trim() ?? '';
if (trimmed.isEmpty) {
return null;
}
final uri = Uri.tryParse(trimmed);
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return null;
}
if (uri.scheme != 'http' && uri.scheme != 'https') {
return null;
}
return uri;
}
+43
View File
@@ -0,0 +1,43 @@
String? deriveRelativeMediaPath({
required String? outputRoot,
required String? outputFilePath,
}) {
final rawPath = outputFilePath?.trim();
if (rawPath == null || rawPath.isEmpty) {
return null;
}
final normalizedPath = rawPath.replaceAll('\\', '/');
if (_containsUnsafeTraversal(normalizedPath)) {
return null;
}
final root = outputRoot?.trim();
if (root == null || root.isEmpty) {
return normalizedPath;
}
final normalizedRoot = root.replaceAll('\\', '/').replaceAll(RegExp(r'/+$'), '');
final normalizedPathLower = normalizedPath.toLowerCase();
final normalizedRootLower = normalizedRoot.toLowerCase();
if (normalizedPathLower == normalizedRootLower) {
return '';
}
if (normalizedPathLower.startsWith('$normalizedRootLower/')) {
final relative = normalizedPath.substring(normalizedRoot.length + 1);
return _containsUnsafeTraversal(relative) ? null : relative;
}
if (!normalizedPath.contains(':') && !normalizedPath.startsWith('/')) {
return normalizedPath;
}
return null;
}
bool _containsUnsafeTraversal(String value) {
return value.split('/').any((segment) => segment == '..');
}
@@ -0,0 +1,59 @@
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
String formatStorageHealthLabel(StorageGuardStatus? storage) {
if (storage == null) {
return '暂无集群数据';
}
if (!storage.isEnabled) {
return '存储守护未启用';
}
final mappedMessage = _mapStorageMessage(storage.message);
if (mappedMessage != null) {
return mappedMessage;
}
return storage.hasEnoughSpace ? '空间充足' : '空间不足';
}
String formatStorageUsageLabel(StorageGuardStatus? storage) {
if (storage == null) {
return '--';
}
final hasAvailableBytes = storage.availableBytes > 0;
final hasRequiredBytes = storage.requiredBytes > 0;
if (hasAvailableBytes || hasRequiredBytes) {
final availableLabel = hasAvailableBytes ? formatBytes(storage.availableBytes) : '--';
final requiredLabel = hasRequiredBytes ? formatBytes(storage.requiredBytes) : '--';
return '可用 $availableLabel / 需保留 $requiredLabel';
}
return _mapStorageMessage(storage.message) ?? '--';
}
String? _mapStorageMessage(String rawMessage) {
final normalized = rawMessage.trim().toLowerCase();
if (normalized.isEmpty) {
return null;
}
if (normalized == 'storage is available' || normalized.contains('enough space')) {
return '空间充足';
}
if (normalized.contains('insufficient') ||
normalized.contains('not enough') ||
normalized.contains('low disk') ||
normalized.contains('space is low')) {
return '空间不足';
}
if (normalized.contains('disabled')) {
return '存储守护未启用';
}
return null;
}
+188
View File
@@ -0,0 +1,188 @@
enum StatusTone {
gray,
green,
blue,
yellow,
red,
orange,
indigo,
}
const Map<int, String> availabilityLabelMap = <int, String>{
0: '未知',
1: '已下播',
2: '直播中',
};
const Map<int, String> recordingStateLabelMap = <int, String>{
0: '已下播',
1: '直播中',
2: '录制中',
};
const Map<int, String> taskStatusLabelMap = <int, String>{
0: '待处理',
1: '启动中',
2: '录制中',
3: '停止中',
4: '已完成',
5: '失败',
6: '已停止',
7: '处理中',
};
const Map<int, String> logLevelLabelMap = <int, String>{
0: '跟踪',
1: '信息',
2: '警告',
3: '错误',
};
const Map<int, String> outputFormatLabelMap = <int, String>{
0: 'MP4',
1: 'TS',
};
const Map<int, String> saveModeLabelMap = <int, String>{
0: '单文件',
1: '分段',
};
const Map<int, String> recordingTemplateLabelMap = <int, String>{
0: '直接封装',
1: '均衡 MP4',
2: '归档 TS',
};
const Map<String, String> qualityLabelMap = <String, String>{
'origin': '原画',
'FULL_HD': '超清',
'HD': '高清',
'SD': '标清',
};
const Map<int, String> platformLabelMap = <int, String>{
0: '未知',
1: 'Douyin',
2: 'Bilibili',
3: 'Huya',
4: 'Douyu',
5: 'Kuaishou',
6: 'TikTok',
7: 'Xiaohongshu',
8: 'YouTube',
9: 'Twitch',
10: 'PandaTV',
11: 'Migu',
};
const Map<int, String> uploadStatusLabelMap = <int, String>{
0: '未上传',
1: '已上传',
2: '上传失败',
};
const Map<String, String> autoStartDecisionLabelMap = <String, String>{
'started': '已启动',
'skipped_disabled': '已禁用',
'skipped_storage': '存储不足',
'skipped_active_session': '已有活动会话',
'skipped_offline': '房间未开播',
'skipped_debounce': '触发防抖中',
'failed_startup': '启动失败',
'poll_failed_transient': '轮询临时失败',
'poll_failed': '轮询失败',
};
String availabilityLabel(int? value) => availabilityLabelMap[value] ?? '未知';
String recordingStateLabel(int? value) => recordingStateLabelMap[value] ?? '未知';
String taskStatusLabel(int? value) => taskStatusLabelMap[value] ?? '未知';
String logLevelLabel(int? value) => logLevelLabelMap[value] ?? '未知';
String outputFormatLabel(int? value) => outputFormatLabelMap[value] ?? '--';
String saveModeLabel(int? value) => saveModeLabelMap[value] ?? '--';
String recordingTemplateLabel(int? value) => recordingTemplateLabelMap[value] ?? '--';
String qualityLabel(String? value) => qualityLabelMap[value] ?? (value == null || value.isEmpty ? '--' : value);
String platformLabel(int? value) => platformLabelMap[value] ?? '未知';
String uploadStatusLabel(int? value) => uploadStatusLabelMap[value] ?? '未知';
String autoStartDecisionLabel(String? value) =>
autoStartDecisionLabelMap[value] ?? (value == null || value.isEmpty ? '暂无事件' : value);
bool isTaskActive(int? value) => value == 1 || value == 2 || value == 3 || value == 7;
bool isTaskFailed(int? value) => value == 5;
StatusTone toneForStatus({String? keyword, int? value, String? context}) {
if (context == 'availability') {
return value == 2 ? StatusTone.green : StatusTone.gray;
}
if (context == 'recording') {
if (value == 2) {
return StatusTone.blue;
}
if (value == 1) {
return StatusTone.green;
}
return StatusTone.gray;
}
if (context == 'task' || context == 'session') {
switch (value) {
case 0:
return StatusTone.yellow;
case 1:
case 7:
return StatusTone.indigo;
case 2:
return StatusTone.blue;
case 3:
return StatusTone.orange;
case 4:
return StatusTone.green;
case 5:
return StatusTone.red;
default:
return StatusTone.gray;
}
}
if (context == 'upload') {
if (value == 1) {
return StatusTone.green;
}
if (value == 2) {
return StatusTone.red;
}
}
final normalized = keyword?.toLowerCase() ?? '';
if (<String>['live', 'online', 'living', '直播中', 'completed', 'archived'].any(normalized.contains)) {
return StatusTone.green;
}
if (<String>['recording', '录制中'].any(normalized.contains)) {
return StatusTone.blue;
}
if (<String>['pending', 'queued', ''].any(normalized.contains)) {
return StatusTone.yellow;
}
if (<String>['retry', 'stopping', '停止中'].any(normalized.contains)) {
return StatusTone.orange;
}
if (<String>['process', 'transcod'].any(normalized.contains)) {
return StatusTone.indigo;
}
if (<String>['error', 'fail', '异常', '错误'].any(normalized.contains)) {
return StatusTone.red;
}
return StatusTone.gray;
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
class AppCard extends StatelessWidget {
const AppCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(18),
this.onTap,
});
final Widget child;
final EdgeInsetsGeometry padding;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final card = Card(
child: Padding(
padding: padding,
child: child,
),
);
if (onTap == null) {
return card;
}
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(24),
child: card,
);
}
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
class AppEmptyState extends StatelessWidget {
const AppEmptyState({
super.key,
this.title = '暂无数据',
this.description = '当前没有可展示内容',
this.actionLabel,
this.onAction,
});
final String title;
final String description;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return AppCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.inbox_rounded, color: Color(0xFF2563EB), size: 28),
),
const SizedBox(height: 16),
Text(
title,
style: const TextStyle(
color: Color(0xFF0F172A),
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
description,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.5,
),
),
if (actionLabel != null && onAction != null) ...<Widget>[
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: onAction,
child: Text(actionLabel!),
),
],
],
),
);
}
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
class AppErrorCard extends StatelessWidget {
const AppErrorCard({
super.key,
required this.message,
required this.onRetry,
});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Row(
children: <Widget>[
Icon(Icons.error_outline_rounded, color: Color(0xFFDC2626)),
SizedBox(width: 8),
Text(
'加载失败',
style: TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 12),
Text(
message,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.5,
),
),
const SizedBox(height: 16),
FilledButton.tonalIcon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded),
label: const Text('重试'),
),
],
),
);
}
}
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
class AppSearchBar extends StatelessWidget {
const AppSearchBar({
super.key,
required this.controller,
required this.hintText,
this.onSubmitted,
this.onChanged,
});
final TextEditingController controller;
final String hintText;
final ValueChanged<String>? onSubmitted;
final ValueChanged<String>? onChanged;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 44,
child: TextField(
controller: controller,
onSubmitted: onSubmitted,
onChanged: onChanged,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: hintText,
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: controller.text.isEmpty
? null
: IconButton(
onPressed: () {
controller.clear();
onChanged?.call('');
},
icon: const Icon(Icons.close_rounded),
),
),
),
);
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
class MetricCard extends StatelessWidget {
const MetricCard({
super.key,
required this.label,
required this.value,
required this.description,
this.color = const Color(0xFF2563EB),
this.trendValue,
});
final String label;
final String value;
final String description;
final Color color;
final double? trendValue;
@override
Widget build(BuildContext context) {
final progress = (trendValue ?? 0).clamp(0.05, 1.0);
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
Text(
value,
style: TextStyle(
color: color,
fontSize: 28,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
SizedBox(
height: 40,
child: Text(
description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.45,
),
),
),
const SizedBox(height: 14),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 6,
value: progress,
color: color,
backgroundColor: color.withValues(alpha: 0.12),
),
),
],
),
);
}
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
class MobileHeader extends StatelessWidget {
const MobileHeader({
super.key,
required this.eyebrow,
required this.title,
this.trailing,
this.userInitials = 'L',
this.onNotificationsPressed,
this.onProfilePressed,
});
final String eyebrow;
final String title;
final Widget? trailing;
final String userInitials;
final VoidCallback? onNotificationsPressed;
final VoidCallback? onProfilePressed;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
eyebrow,
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
title,
style: const TextStyle(
color: Color(0xFF0F172A),
fontSize: 28,
fontWeight: FontWeight.w800,
),
),
],
),
),
IconButton.filledTonal(
onPressed: onNotificationsPressed,
icon: const Icon(Icons.notifications_none_rounded),
),
const SizedBox(width: 8),
GestureDetector(
onTap: onProfilePressed,
child: CircleAvatar(
radius: 20,
backgroundColor: const Color(0xFFE0ECFF),
foregroundColor: const Color(0xFF2563EB),
child: Text(
userInitials,
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
),
],
),
if (trailing != null) ...<Widget>[
const SizedBox(height: 16),
trailing!,
],
],
),
);
}
}
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
class SkeletonCard extends StatefulWidget {
const SkeletonCard({
super.key,
this.height = 120,
});
final double height;
@override
State<SkeletonCard> createState() => _SkeletonCardState();
}
class _SkeletonCardState extends State<SkeletonCard> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (BuildContext context, Widget? child) {
final opacity = 0.35 + (_controller.value * 0.4);
return AppCard(
child: Opacity(
opacity: opacity,
child: Container(
height: widget.height,
decoration: BoxDecoration(
color: const Color(0xFFE2E8F0),
borderRadius: BorderRadius.circular(16),
),
),
),
);
},
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
class StatusBadge extends StatelessWidget {
const StatusBadge({
super.key,
required this.status,
this.label,
this.context,
});
final Object? status;
final String? label;
final String? context;
@override
Widget build(BuildContext context) {
final tone = toneForStatus(
value: status is int ? status as int : null,
keyword: status?.toString(),
context: this.context,
);
final style = _styleFor(tone);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: style.background,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: style.border),
),
child: Text(
label ?? status?.toString() ?? '--',
style: TextStyle(
color: style.foreground,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
);
}
_BadgeStyle _styleFor(StatusTone tone) {
switch (tone) {
case StatusTone.green:
return const _BadgeStyle(
background: Color(0xFFECFDF5),
foreground: Color(0xFF047857),
border: Color(0xFFA7F3D0),
);
case StatusTone.blue:
return const _BadgeStyle(
background: Color(0xFFEFF6FF),
foreground: Color(0xFF1D4ED8),
border: Color(0xFFBFDBFE),
);
case StatusTone.yellow:
return const _BadgeStyle(
background: Color(0xFFFFFBEB),
foreground: Color(0xFFB45309),
border: Color(0xFFFDE68A),
);
case StatusTone.red:
return const _BadgeStyle(
background: Color(0xFFFEF2F2),
foreground: Color(0xFFB91C1C),
border: Color(0xFFFECACA),
);
case StatusTone.orange:
return const _BadgeStyle(
background: Color(0xFFFFF7ED),
foreground: Color(0xFFC2410C),
border: Color(0xFFFED7AA),
);
case StatusTone.indigo:
return const _BadgeStyle(
background: Color(0xFFEEF2FF),
foreground: Color(0xFF4338CA),
border: Color(0xFFC7D2FE),
);
case StatusTone.gray:
return const _BadgeStyle(
background: Color(0xFFF1F5F9),
foreground: Color(0xFF475569),
border: Color(0xFFE2E8F0),
);
}
}
}
class _BadgeStyle {
const _BadgeStyle({
required this.background,
required this.foreground,
required this.border,
});
final Color background;
final Color foreground;
final Color border;
}
@@ -0,0 +1,91 @@
import 'package:flutter/foundation.dart';
import 'package:live_recorder_mobile/core/persistence/session_storage.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/auth_repository.dart';
abstract interface class SessionControllerHandle extends Listenable {
bool get isRestoring;
bool get isLoggedIn;
Future<void> restore();
Future<void> clearLocalSession();
void dispose();
}
class AppSessionController extends ChangeNotifier implements SessionControllerHandle {
AppSessionController({
required AuthRepository authRepository,
required SessionStorage sessionStorage,
}) : _authRepository = authRepository,
_sessionStorage = sessionStorage;
final AuthRepository _authRepository;
final SessionStorage _sessionStorage;
LoginResponse? _session;
bool _isRestoring = true;
@override
bool get isRestoring => _isRestoring;
@override
bool get isLoggedIn => token != null && token!.isNotEmpty;
String? get token => _session?.token;
AuthenticatedUser? get user => _session?.user;
LoginResponse? get session => _session;
@override
Future<void> restore() async {
_isRestoring = true;
notifyListeners();
final persisted = await _sessionStorage.read();
if (persisted != null) {
_session = LoginResponse.fromJson(persisted);
}
_isRestoring = false;
notifyListeners();
}
Future<void> login({
required String username,
required String password,
}) async {
final session = await _authRepository.login(
username: username,
password: password,
);
_session = session;
await _sessionStorage.write(session.toJson());
notifyListeners();
}
Future<void> logout() async {
try {
await _authRepository.logout();
} finally {
await clearLocalSession();
}
}
Future<void> changePassword({
required String currentPassword,
required String newPassword,
}) {
return _authRepository.changePassword(
currentPassword: currentPassword,
newPassword: newPassword,
);
}
Future<void> handleUnauthorized() async {
await clearLocalSession();
}
@override
Future<void> clearLocalSession() async {
_session = null;
await _sessionStorage.clear();
notifyListeners();
}
}
@@ -0,0 +1,201 @@
import 'package:live_recorder_mobile/core/utils/path_utils.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/live_rooms_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/logs_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/media_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recovery_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recordings_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
import 'main_controllers.dart';
class RoomDetailController extends BaseController {
RoomDetailController({
required LiveRoomsRepository liveRoomsRepository,
required RecordingsRepository recordingsRepository,
required RecoveryRepository recoveryRepository,
required this.roomId,
}) : _liveRoomsRepository = liveRoomsRepository,
_recordingsRepository = recordingsRepository,
_recoveryRepository = recoveryRepository;
final LiveRoomsRepository _liveRoomsRepository;
final RecordingsRepository _recordingsRepository;
final RecoveryRepository _recoveryRepository;
final String roomId;
LiveRoom? room;
List<RecordSession> sessions = const <RecordSession>[];
RecoveryOverview? recoveryOverview;
@override
bool get hasData => room != null || sessions.isNotEmpty;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_liveRoomsRepository.getRoom(roomId),
_recordingsRepository.listSessions(liveRoomId: roomId),
_recoveryRepository.getOverview(),
]);
room = results[0] as LiveRoom;
sessions = results[1] as List<RecordSession>;
recoveryOverview = results[2] as RecoveryOverview;
}, silent: silent);
}
RecoverableLiveRoom? get recoveryInfo {
try {
return recoveryOverview?.liveRooms.firstWhere((RecoverableLiveRoom item) => item.liveRoomId == roomId);
} catch (_) {
return null;
}
}
}
class RecordingDetailController extends BaseController {
RecordingDetailController({
required RecordingsRepository recordingsRepository,
required SettingsRepository settingsRepository,
required MediaRepository mediaRepository,
required this.taskId,
}) : _recordingsRepository = recordingsRepository,
_settingsRepository = settingsRepository,
_mediaRepository = mediaRepository;
final RecordingsRepository _recordingsRepository;
final SettingsRepository _settingsRepository;
final MediaRepository _mediaRepository;
final String taskId;
RecordTaskDetail? detail;
SystemSettings? settings;
@override
bool get hasData => detail != null;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_recordingsRepository.getTaskDetail(taskId),
_settingsRepository.getSettings(),
]);
detail = results[0] as RecordTaskDetail;
settings = results[1] as SystemSettings;
}, silent: silent);
}
Future<RecordPreviewTicket> createPreviewTicket() {
return _recordingsRepository.createPreviewTicket(taskId);
}
Uri? get downloadUri {
final task = detail?.task;
if (task == null) {
return null;
}
final relativePath = deriveRelativeMediaPath(
outputRoot: settings?.outputRoot,
outputFilePath: task.outputFilePath,
);
if (relativePath == null || relativePath.isEmpty) {
return null;
}
return _mediaRepository.buildFileUri(relativePath: relativePath, download: true);
}
}
class LogsController extends BaseController {
LogsController({
required LogsRepository logsRepository,
}) : _logsRepository = logsRepository;
final LogsRepository _logsRepository;
List<SystemLog> logs = const <SystemLog>[];
int? level;
String query = '';
@override
bool get hasData => logs.isNotEmpty;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
logs = await _logsRepository.listLogs(
level: level,
content: query,
);
}, silent: silent);
}
void setLevel(int? value) {
level = value;
safeNotify();
}
void setQuery(String value) {
query = value;
safeNotify();
}
}
class StorageController extends BaseController {
StorageController({
required SettingsRepository settingsRepository,
required RecoveryRepository recoveryRepository,
}) : _settingsRepository = settingsRepository,
_recoveryRepository = recoveryRepository;
final SettingsRepository _settingsRepository;
final RecoveryRepository _recoveryRepository;
SystemSettings? settings;
RecoveryOverview? recoveryOverview;
@override
bool get hasData => settings != null || recoveryOverview != null;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_settingsRepository.getSettings(),
_recoveryRepository.getOverview(),
]);
settings = results[0] as SystemSettings;
recoveryOverview = results[1] as RecoveryOverview;
}, silent: silent);
}
Future<CleanupOperation> runRetentionCleanup() {
return _settingsRepository.runRetentionCleanup();
}
}
class MediaBrowserController extends BaseController {
MediaBrowserController({
required MediaRepository mediaRepository,
}) : _mediaRepository = mediaRepository;
final MediaRepository _mediaRepository;
MediaBrowserResponse? response;
String currentPath = '';
@override
bool get hasData => response != null;
Future<void> refresh({bool silent = false, String? path}) {
return runLoad(() async {
currentPath = path ?? currentPath;
response = await _mediaRepository.browse(path: currentPath);
}, silent: silent);
}
Uri fileUri(String relativePath, {bool download = false}) {
return _mediaRepository.buildFileUri(relativePath: relativePath, download: download);
}
Future<String> transcodeFile(String relativePath) {
return _mediaRepository.transcodeFile(relativePath);
}
}
@@ -0,0 +1,624 @@
import 'package:flutter/foundation.dart';
import 'package:live_recorder_mobile/core/network/api_exception.dart';
import 'package:live_recorder_mobile/core/polling/polling_controller.dart';
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
import 'package:live_recorder_mobile/core/utils/path_utils.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/live_rooms_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/media_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recovery_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/recordings_repository.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
enum RoomFilter {
all,
live,
recording,
error,
retrying,
}
abstract class BaseController extends ChangeNotifier {
bool isLoading = false;
String? errorMessage;
bool _disposed = false;
bool get hasData => false;
@protected
void safeNotify() {
if (!_disposed) {
notifyListeners();
}
}
@protected
Future<void> runLoad(
Future<void> Function() action, {
bool silent = false,
}) async {
if (!silent) {
isLoading = true;
errorMessage = null;
safeNotify();
}
try {
await action();
errorMessage = null;
} on ApiException catch (error) {
if (!silent || !hasData) {
errorMessage = error.message;
}
} catch (error) {
if (!silent || !hasData) {
errorMessage = error.toString();
}
} finally {
if (!silent) {
isLoading = false;
}
safeNotify();
}
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
class DashboardController extends BaseController {
DashboardController({
required LiveRoomsRepository liveRoomsRepository,
required RecordingsRepository recordingsRepository,
required RecoveryRepository recoveryRepository,
}) : _liveRoomsRepository = liveRoomsRepository,
_recordingsRepository = recordingsRepository,
_recoveryRepository = recoveryRepository {
_polling = PollingController(
interval: const Duration(seconds: 15),
onTick: () => refresh(silent: true),
);
}
final LiveRoomsRepository _liveRoomsRepository;
final RecordingsRepository _recordingsRepository;
final RecoveryRepository _recoveryRepository;
late final PollingController _polling;
List<LiveRoom> rooms = const <LiveRoom>[];
List<RecordSession> sessions = const <RecordSession>[];
List<RecordTask> tasks = const <RecordTask>[];
RecoveryOverview? recoveryOverview;
@override
bool get hasData => rooms.isNotEmpty || sessions.isNotEmpty || tasks.isNotEmpty || recoveryOverview != null;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_liveRoomsRepository.listRooms(),
_recordingsRepository.listSessions(),
_recordingsRepository.listTasks(),
_recoveryRepository.getOverview(),
]);
rooms = (results[0] as List<LiveRoom>)
..sort((LiveRoom a, LiveRoom b) => b.updatedAt.compareTo(a.updatedAt));
sessions = results[1] as List<RecordSession>;
tasks = (results[2] as List<RecordTask>)
..sort((RecordTask a, RecordTask b) => b.createdAt.compareTo(a.createdAt));
recoveryOverview = results[3] as RecoveryOverview;
}, silent: silent);
}
void setActive(bool active) {
_polling.setActive(active);
}
int get onlineRoomCount => rooms.where((LiveRoom room) => room.availabilityStatus == 2).length;
int get activeRecordingTaskCount => tasks.where((RecordTask task) => isTaskActive(task.status)).length;
int get todayRecordingCount {
final now = DateTime.now();
return tasks.where((RecordTask task) {
final createdAt = DateTime.tryParse(task.createdAt)?.toLocal();
return createdAt != null &&
createdAt.year == now.year &&
createdAt.month == now.month &&
createdAt.day == now.day;
}).length;
}
int get alertCount {
final recoveryCount = (recoveryOverview?.liveRooms.length ?? 0) + (recoveryOverview?.finalizations.length ?? 0);
final failedTasks = tasks.where((RecordTask task) => isTaskFailed(task.status)).length;
return recoveryCount + failedTasks;
}
String get clusterHealthLabel {
final storage = recoveryOverview?.storage;
if (storage == null) {
return '暂无集群数据';
}
if (!storage.isEnabled) {
return '存储守护未启用';
}
if (storage.message.trim().isNotEmpty) {
return storage.message;
}
return storage.hasEnoughSpace ? '存储空间正常' : '存储空间告警';
}
String get clusterNodeCountLabel => '--';
String get concurrentRecordingLabel =>
'${sessions.where((RecordSession session) => isTaskActive(session.status)).length}';
String get storageUsageLabel {
final storage = recoveryOverview?.storage;
if (storage == null) {
return '--';
}
return storage.message.trim().isEmpty ? '--' : storage.message;
}
List<int> get throughputBuckets {
final now = DateTime.now();
final buckets = List<int>.filled(8, 0);
for (final RecordTask task in tasks) {
final parsed = DateTime.tryParse(task.startedAt ?? task.createdAt)?.toLocal();
if (parsed == null) {
continue;
}
final diff = now.difference(parsed);
if (diff.inHours < 0 || diff.inHours >= 8) {
continue;
}
final index = 7 - diff.inHours;
buckets[index] += 1;
}
return buckets;
}
List<LiveRoom> get focusRooms {
final prioritized = rooms.where((LiveRoom room) => room.isPinned || room.isPriority).toList(growable: false);
if (prioritized.isNotEmpty) {
return prioritized.take(4).toList(growable: false);
}
return rooms.take(4).toList(growable: false);
}
RecordSession? activeSessionForRoom(String roomId) {
try {
return sessions.firstWhere(
(RecordSession session) => session.liveRoomId == roomId && isTaskActive(session.status),
);
} catch (_) {
return null;
}
}
@override
void dispose() {
_polling.dispose();
super.dispose();
}
}
class RoomsController extends BaseController {
RoomsController({
required LiveRoomsRepository liveRoomsRepository,
required RecordingsRepository recordingsRepository,
required RecoveryRepository recoveryRepository,
}) : _liveRoomsRepository = liveRoomsRepository,
_recordingsRepository = recordingsRepository,
_recoveryRepository = recoveryRepository {
_polling = PollingController(
interval: const Duration(seconds: 15),
onTick: () => refresh(silent: true),
);
}
final LiveRoomsRepository _liveRoomsRepository;
final RecordingsRepository _recordingsRepository;
final RecoveryRepository _recoveryRepository;
late final PollingController _polling;
List<LiveRoom> rooms = const <LiveRoom>[];
List<RecordSession> sessions = const <RecordSession>[];
RecoveryOverview? recoveryOverview;
String query = '';
RoomFilter filter = RoomFilter.all;
String? busyRoomId;
@override
bool get hasData => rooms.isNotEmpty || sessions.isNotEmpty || recoveryOverview != null;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_liveRoomsRepository.listRooms(),
_recordingsRepository.listSessions(),
_recoveryRepository.getOverview(),
]);
sessions = results[1] as List<RecordSession>;
recoveryOverview = results[2] as RecoveryOverview;
rooms = (results[0] as List<LiveRoom>)..sort(compareRooms);
}, silent: silent);
}
@protected
int compareRooms(LiveRoom a, LiveRoom b) {
final priorityA = (a.isPinned || a.isPriority) ? 1 : 0;
final priorityB = (b.isPinned || b.isPriority) ? 1 : 0;
if (priorityA != priorityB) {
return priorityB.compareTo(priorityA);
}
return b.updatedAt.compareTo(a.updatedAt);
}
void setActive(bool active) {
_polling.setActive(active);
}
void setQuery(String value) {
query = value;
safeNotify();
}
void setFilter(RoomFilter value) {
filter = value;
safeNotify();
}
List<LiveRoom> get filteredRooms {
final normalizedQuery = query.trim().toLowerCase();
return rooms.where((LiveRoom room) {
if (normalizedQuery.isNotEmpty) {
final searchPool = <String>[
room.title ?? '',
room.anchorName ?? '',
room.roomId,
room.platformName,
room.alias ?? '',
recentEventForRoom(room),
].join(' ').toLowerCase();
if (!searchPool.contains(normalizedQuery)) {
return false;
}
}
switch (filter) {
case RoomFilter.all:
return true;
case RoomFilter.live:
return room.availabilityStatus == 2;
case RoomFilter.recording:
return room.currentRecordingState == 2;
case RoomFilter.error:
return roomHasError(room);
case RoomFilter.retrying:
return roomIsRetrying(room);
}
}).toList(growable: false);
}
RecordSession? sessionForRoom(String roomId) {
final matchingSessions = sessions.where((RecordSession session) => session.liveRoomId == roomId).toList(growable: false);
if (matchingSessions.isEmpty) {
return null;
}
matchingSessions.sort((RecordSession a, RecordSession b) => (b.startedAt ?? b.createdAt).compareTo(a.startedAt ?? a.createdAt));
return matchingSessions.first;
}
RecoverableLiveRoom? recoveryInfoForRoom(String roomId) {
try {
return recoveryOverview?.liveRooms.firstWhere((RecoverableLiveRoom item) => item.liveRoomId == roomId);
} catch (_) {
return null;
}
}
String recentEventForRoom(LiveRoom room) {
final recoveryInfo = recoveryInfoForRoom(room.id);
return recoveryInfo?.lastAutoStartDecisionSummary ??
room.lastAutoStartDecisionSummary ??
autoStartDecisionLabel(recoveryInfo?.lastAutoStartDecisionCode ?? room.lastAutoStartDecisionCode);
}
bool roomHasError(LiveRoom room) {
final code = (room.lastAutoStartDecisionCode ?? '').toLowerCase();
return recoveryInfoForRoom(room.id) != null || code.contains('fail') || code.contains('error');
}
bool roomIsRetrying(LiveRoom room) {
final code = (room.lastAutoStartDecisionCode ?? '').toLowerCase();
return code.contains('retry');
}
Future<String> createRoom({
required String url,
int? platformOverride,
}) async {
await _liveRoomsRepository.createRoom(url: url, platformOverride: platformOverride);
await refresh(silent: true);
return '直播间已添加';
}
Future<String> toggleRoomEnabled(LiveRoom room) async {
busyRoomId = room.id;
safeNotify();
try {
await _liveRoomsRepository.setRoomEnabled(
roomId: room.id,
isEnabled: !room.isEnabled,
);
await refresh(silent: true);
return room.isEnabled ? '直播间已停用' : '直播间已启用';
} finally {
busyRoomId = null;
safeNotify();
}
}
Future<String> refreshRoom(LiveRoom room) async {
busyRoomId = room.id;
safeNotify();
try {
await _liveRoomsRepository.refreshRoom(room.id);
await refresh(silent: true);
return '直播状态已刷新';
} finally {
busyRoomId = null;
safeNotify();
}
}
Future<String> startRecording({
required LiveRoom room,
String? preferredQuality,
int? outputFormat,
}) async {
busyRoomId = room.id;
safeNotify();
try {
await _recordingsRepository.startRecording(
liveRoomId: room.id,
preferredQuality: preferredQuality ?? room.effectiveSettings.preferredQuality,
outputFormat: outputFormat ?? room.effectiveSettings.outputFormat,
);
await refresh(silent: true);
return '录制任务已启动';
} finally {
busyRoomId = null;
safeNotify();
}
}
Future<String> retryRoom(LiveRoom room) async {
busyRoomId = room.id;
safeNotify();
try {
await _recoveryRepository.retryLiveRoom(room.id);
await refresh(silent: true);
return '已提交重试请求';
} finally {
busyRoomId = null;
safeNotify();
}
}
Future<String> saveMetadata({
required LiveRoom room,
required String? remark,
required bool isPinned,
required String? alias,
required bool isPriority,
required int? pollingIntervalSecondsOverride,
}) async {
busyRoomId = room.id;
safeNotify();
try {
await _liveRoomsRepository.updateMetadata(
roomId: room.id,
payload: <String, dynamic>{
'remark': remark?.trim().isEmpty ?? true ? null : remark?.trim(),
'isPinned': isPinned,
'alias': alias?.trim().isEmpty ?? true ? null : alias?.trim(),
'isPriority': isPriority,
'pollingIntervalSecondsOverride': pollingIntervalSecondsOverride,
},
);
await refresh(silent: true);
return '房间信息已保存';
} finally {
busyRoomId = null;
safeNotify();
}
}
Future<String> saveRoomSettings({
required LiveRoom room,
required Map<String, dynamic> payload,
}) async {
busyRoomId = room.id;
safeNotify();
try {
await _liveRoomsRepository.updateSettings(roomId: room.id, payload: payload);
await refresh(silent: true);
return '录制设置已保存';
} finally {
busyRoomId = null;
safeNotify();
}
}
@override
void dispose() {
_polling.dispose();
super.dispose();
}
}
class MonitorController extends RoomsController {
MonitorController({
required super.liveRoomsRepository,
required super.recordingsRepository,
required super.recoveryRepository,
});
@override
int compareRooms(LiveRoom a, LiveRoom b) => compareMonitorRooms(a, b);
}
class RecordingsController extends BaseController {
RecordingsController({
required RecordingsRepository recordingsRepository,
required SettingsRepository settingsRepository,
required MediaRepository mediaRepository,
}) : _recordingsRepository = recordingsRepository,
_settingsRepository = settingsRepository,
_mediaRepository = mediaRepository {
_polling = PollingController(
interval: const Duration(seconds: 15),
onTick: () => refresh(silent: true),
);
}
final RecordingsRepository _recordingsRepository;
final SettingsRepository _settingsRepository;
final MediaRepository _mediaRepository;
late final PollingController _polling;
List<RecordTask> tasks = const <RecordTask>[];
SystemSettings? settings;
final Map<String, RecordTaskDetail> detailCache = <String, RecordTaskDetail>{};
String query = '';
@override
bool get hasData => tasks.isNotEmpty || settings != null;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_recordingsRepository.listTasks(),
_settingsRepository.getSettings(),
]);
tasks = (results[0] as List<RecordTask>)
..sort((RecordTask a, RecordTask b) => b.createdAt.compareTo(a.createdAt));
settings = results[1] as SystemSettings;
}, silent: silent);
}
void setActive(bool active) {
_polling.setActive(active);
}
void setQuery(String value) {
query = value;
safeNotify();
}
List<RecordTask> get filteredTasks {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.isEmpty) {
return tasks;
}
return tasks.where((RecordTask task) {
final searchPool = <String>[
task.liveRoomTitle,
task.roomId,
task.outputFilePath ?? '',
].join(' ').toLowerCase();
return searchPool.contains(normalizedQuery);
}).toList(growable: false);
}
RecordTaskDetail? cachedDetail(String taskId) => detailCache[taskId];
Future<void> ensureDetailLoaded(String taskId) async {
if (detailCache.containsKey(taskId)) {
return;
}
try {
final detail = await _recordingsRepository.getTaskDetail(taskId);
detailCache[taskId] = detail;
safeNotify();
} catch (_) {
// Keep lightweight list rendering resilient.
}
}
Uri? downloadUriForTask(RecordTask task) {
final relativePath = deriveRelativeMediaPath(
outputRoot: settings?.outputRoot,
outputFilePath: task.outputFilePath,
);
if (relativePath == null || relativePath.isEmpty) {
return null;
}
return _mediaRepository.buildFileUri(
relativePath: relativePath,
download: true,
);
}
@override
void dispose() {
_polling.dispose();
super.dispose();
}
}
class ProfileController extends BaseController {
ProfileController({
required SettingsRepository settingsRepository,
required RecoveryRepository recoveryRepository,
}) : _settingsRepository = settingsRepository,
_recoveryRepository = recoveryRepository;
final SettingsRepository _settingsRepository;
final RecoveryRepository _recoveryRepository;
SystemSettings? settings;
RecoveryOverview? recoveryOverview;
@override
bool get hasData => settings != null || recoveryOverview != null;
Future<void> refresh({bool silent = false}) {
return runLoad(() async {
final results = await Future.wait<dynamic>(<Future<dynamic>>[
_settingsRepository.getSettings(),
_recoveryRepository.getOverview(),
]);
settings = results[0] as SystemSettings;
recoveryOverview = results[1] as RecoveryOverview;
}, silent: silent);
}
Future<SystemSettings> saveNotificationSettings(SystemSettings updated) async {
final saved = await _settingsRepository.updateSettings(updated);
settings = saved;
safeNotify();
return saved;
}
Future<CleanupOperation> runRetentionCleanup() {
return _settingsRepository.runRetentionCleanup();
}
}
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/backend_address_form_card.dart';
class BackendSettingsPage extends StatefulWidget {
const BackendSettingsPage({
super.key,
required this.bootstrapController,
});
final BackendConfigHandle bootstrapController;
@override
State<BackendSettingsPage> createState() => _BackendSettingsPageState();
}
class _BackendSettingsPageState extends State<BackendSettingsPage> {
late final TextEditingController _controller = TextEditingController(
text: widget.bootstrapController.backendBaseUrl ?? widget.bootstrapController.seedBaseUrl,
);
bool _submitting = false;
String? _errorText;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _submit() async {
FocusScope.of(context).unfocus();
final validationMessage = validateBackendBaseUrl(_controller.text);
if (validationMessage != null) {
setState(() {
_errorText = validationMessage;
});
return;
}
final normalizedValue = normalizeBackendBaseUrl(_controller.text);
if (normalizedValue == widget.bootstrapController.backendBaseUrl) {
Navigator.of(context).pop();
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('切换后端地址'),
content: const Text(
'修改后端地址后,当前登录状态会被清空,并返回登录页重新连接。是否继续?',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('确认切换'),
),
],
);
},
) ??
false;
if (!confirmed) {
return;
}
setState(() {
_submitting = true;
_errorText = null;
});
try {
final changed = await widget.bootstrapController.updateBackendBaseUrl(normalizedValue);
if (!mounted) {
return;
}
if (!changed) {
Navigator.of(context).pop();
return;
}
Navigator.of(context).popUntil((Route<dynamic> route) => route.isFirst);
} on FormatException catch (error) {
setState(() {
_errorText = error.message;
});
} catch (error) {
setState(() {
_errorText = '保存后端地址失败:$error';
});
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final currentBaseUrl = widget.bootstrapController.backendBaseUrl ?? '--';
return Scaffold(
appBar: AppBar(
title: const Text('连接设置'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'当前后端地址',
style: TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
SelectableText(
currentBaseUrl,
style: const TextStyle(
color: Color(0xFF2563EB),
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 12),
BackendAddressFormCard(
title: '修改后端地址',
description: '你可以在这里切换到新的 LiveRecorder 后端环境。地址保存成功后,应用会自动清空当前登录态并返回登录页。',
note: '此操作不会修改后端接口,只会切换移动端请求的基础地址。',
controller: _controller,
actionLabel: '保存并切换',
onSubmit: _submit,
isSubmitting: _submitting,
errorText: _errorText,
onFieldSubmitted: (_) => _submit(),
),
],
),
);
}
}
@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_bootstrap_controller.dart';
import 'package:live_recorder_mobile/core/utils/backend_base_url.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/backend_address_form_card.dart';
class BackendSetupPage extends StatefulWidget {
const BackendSetupPage({
super.key,
required this.bootstrapController,
});
final BackendConfigHandle bootstrapController;
@override
State<BackendSetupPage> createState() => _BackendSetupPageState();
}
class _BackendSetupPageState extends State<BackendSetupPage> {
late final TextEditingController _controller = TextEditingController(
text: widget.bootstrapController.backendBaseUrl ?? widget.bootstrapController.seedBaseUrl,
);
bool _submitting = false;
String? _errorText;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _submit() async {
FocusScope.of(context).unfocus();
final validationMessage = validateBackendBaseUrl(_controller.text);
if (validationMessage != null) {
setState(() {
_errorText = validationMessage;
});
return;
}
setState(() {
_submitting = true;
_errorText = null;
});
try {
await widget.bootstrapController.saveInitialBackendBaseUrl(_controller.text);
} on FormatException catch (error) {
setState(() {
_errorText = error.message;
});
} catch (error) {
setState(() {
_errorText = '保存后端地址失败:$error';
});
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final isWide = constraints.maxWidth >= 900;
return Padding(
padding: const EdgeInsets.all(24),
child: isWide
? Row(
children: <Widget>[
Expanded(child: _buildHero()),
const SizedBox(width: 32),
SizedBox(
width: 460,
child: _buildForm(),
),
],
)
: ListView(
children: <Widget>[
_buildHero(),
const SizedBox(height: 24),
_buildForm(),
],
),
);
},
),
),
);
}
Widget _buildHero() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'LiveRecorder',
style: TextStyle(
color: Color(0xFF2563EB),
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
const Text(
'首次进入先连接你的后端服务',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 38,
fontWeight: FontWeight.w800,
height: 1.08,
),
),
const SizedBox(height: 16),
const Text(
'配置完成后,应用会继续使用现有登录、Token 和真实接口。以后也可以在“我的 > 连接设置”里随时修改后端地址。',
style: TextStyle(
color: Color(0xFF64748B),
height: 1.6,
),
),
const SizedBox(height: 24),
Wrap(
spacing: 12,
runSpacing: 12,
children: const <Widget>[
_HeroChip(label: '真实接口接入'),
_HeroChip(label: '保留现有认证'),
_HeroChip(label: '支持子路径部署'),
],
),
],
);
}
Widget _buildForm() {
return BackendAddressFormCard(
title: '配置后端地址',
description: '请输入 LiveRecorder 后端的完整访问地址。保存后会进入登录流程,不会写入任何 mock 数据。',
note: widget.bootstrapController.seedBaseUrl.isEmpty
? null
: '已检测到启动参数中的默认地址,当前已为你预填,可直接修改后保存。',
controller: _controller,
actionLabel: '保存并继续',
onSubmit: _submit,
isSubmitting: _submitting,
errorText: _errorText,
onFieldSubmitted: (_) => _submit(),
);
}
}
class _HeroChip extends StatelessWidget {
const _HeroChip({
required this.label,
});
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Text(
label,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_scope.dart';
import 'package:live_recorder_mobile/core/utils/recovery_formatters.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/metric_card.dart';
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/room_detail_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/cluster_status_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/room_card.dart';
class DashboardPage extends StatefulWidget {
const DashboardPage({
super.key,
required this.controller,
required this.userInitials,
required this.onOpenLogs,
required this.onOpenProfile,
});
final DashboardController controller;
final String userInitials;
final VoidCallback onOpenLogs;
final VoidCallback onOpenProfile;
@override
State<DashboardPage> createState() => _DashboardPageState();
}
class _DashboardPageState extends State<DashboardPage> {
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
final buckets = widget.controller.throughputBuckets;
final hasThroughput = buckets.any((int value) => value > 0);
final maxBucket = hasThroughput
? buckets.reduce((int a, int b) => a > b ? a : b)
: 0;
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
children: <Widget>[
MobileHeader(
eyebrow: 'LiveRecorder · 安卓端',
title: '监控大盘',
userInitials: widget.userInitials,
onNotificationsPressed: widget.onOpenLogs,
onProfilePressed: widget.onOpenProfile,
),
if (widget.controller.isLoading && !widget.controller.hasData)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: <Widget>[
SkeletonCard(height: 140),
SizedBox(height: 16),
SkeletonCard(height: 180),
],
),
)
else if (widget.controller.errorMessage != null &&
!widget.controller.hasData)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
),
)
else ...<Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ClusterStatusCard(
healthLabel: formatStorageHealthLabel(
widget.controller.recoveryOverview?.storage,
),
nodeCountLabel: widget.controller.clusterNodeCountLabel,
concurrentRecordingLabel:
widget.controller.concurrentRecordingLabel,
storageLabel: formatStorageUsageLabel(
widget.controller.recoveryOverview?.storage,
),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: 4,
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
mainAxisExtent: 180,
),
itemBuilder: (BuildContext context, int index) {
final cards = <Widget>[
MetricCard(
label: '在线直播间',
value: '${widget.controller.onlineRoomCount}',
description: '来自 /api/live-rooms 的实时状态统计',
trendValue: widget.controller.rooms.isEmpty
? 0.08
: widget.controller.onlineRoomCount /
widget.controller.rooms.length,
),
MetricCard(
label: '录制中任务',
value:
'${widget.controller.activeRecordingTaskCount}',
description: '启动中、录制中、处理中任务总数',
trendValue: widget.controller.tasks.isEmpty
? 0.08
: widget.controller.activeRecordingTaskCount /
widget.controller.tasks.length,
),
MetricCard(
label: '今日新增录像',
value: '${widget.controller.todayRecordingCount}',
description: '基于真实 task.createdAt 统计',
trendValue:
widget.controller.todayRecordingCount == 0
? 0.08
: 0.45,
),
MetricCard(
label: '异常告警',
value: '${widget.controller.alertCount}',
description: '恢复中心与失败任务数量',
color: const Color(0xFFDC2626),
trendValue: widget.controller.alertCount == 0
? 0.08
: 0.75,
),
];
return cards[index];
},
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'录制吞吐',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
const Text(
'近 8 小时',
style: TextStyle(color: Color(0xFF64748B)),
),
const SizedBox(height: 18),
if (!hasThroughput)
const AppEmptyState(
title: '暂无吞吐数据',
description: '当前 8 小时窗口内没有可统计的真实录制任务。',
)
else
SizedBox(
height: 140,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: buckets.map((int value) {
final ratio = maxBucket == 0
? 0.08
: value / maxBucket;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
),
child: Column(
mainAxisAlignment:
MainAxisAlignment.end,
children: <Widget>[
Text(
'$value',
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
),
),
const SizedBox(height: 8),
Container(
height: 18 + (ratio * 90),
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
borderRadius:
BorderRadius.circular(12),
),
),
],
),
),
);
}).toList(growable: false),
),
),
],
),
),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'重点直播间',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
color: const Color(0xFF0F172A),
),
),
),
const SizedBox(height: 12),
if (widget.controller.focusRooms.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: AppEmptyState(),
)
else
...widget.controller.focusRooms.map((room) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: RoomCard(
room: room,
session: widget.controller.activeSessionForRoom(room.id),
recentEvent:
room.lastAutoStartDecisionSummary ?? '暂无事件',
onTap: () {
final dependencies = AppScope.of(context);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => RoomDetailPage(
dependencies: dependencies,
roomId: room.id,
),
),
);
},
),
);
}),
],
],
),
);
},
);
}
}
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/network/api_exception.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
class LoginPage extends StatefulWidget {
const LoginPage({
super.key,
required this.sessionController,
});
final AppSessionController sessionController;
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
bool _submitting = false;
String? _errorMessage;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
FocusScope.of(context).unfocus();
setState(() {
_submitting = true;
_errorMessage = null;
});
try {
await widget.sessionController.login(
username: _usernameController.text.trim(),
password: _passwordController.text,
);
} on ApiException catch (error) {
setState(() {
_errorMessage = error.message;
});
} catch (error) {
setState(() {
_errorMessage = error.toString();
});
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth >= 700 ? 420 : 480,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
const _BrandHeader(),
const SizedBox(height: 16),
_buildForm(),
],
),
),
),
);
},
),
),
);
}
Widget _buildForm() {
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'登录',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 20),
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: '用户名',
prefixIcon: Icon(Icons.person_outline_rounded),
),
),
const SizedBox(height: 14),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: '密码',
prefixIcon: Icon(Icons.lock_outline_rounded),
),
onSubmitted: (_) => _submit(),
),
if (_errorMessage != null) ...<Widget>[
const SizedBox(height: 14),
Text(
_errorMessage!,
style: const TextStyle(
color: Color(0xFFDC2626),
height: 1.5,
),
),
],
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('登录'),
),
),
],
),
),
);
}
}
class _BrandHeader extends StatelessWidget {
const _BrandHeader();
@override
Widget build(BuildContext context) {
return const Text(
'LiveRecorder',
textAlign: TextAlign.center,
style: TextStyle(
color: Color(0xFF2563EB),
fontSize: 14,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
),
);
}
}
@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_search_bar.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
class LogsPage extends StatefulWidget {
const LogsPage({
super.key,
required this.controller,
});
final LogsController controller;
@override
State<LogsPage> createState() => _LogsPageState();
}
class _LogsPageState extends State<LogsPage> {
late final TextEditingController _searchController = TextEditingController(text: widget.controller.query);
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
@override
void dispose() {
_searchController.dispose();
widget.controller.dispose();
super.dispose();
}
Future<void> _setLevelAndRefresh(int? level) async {
widget.controller.setLevel(level);
await widget.controller.refresh();
}
Future<void> _setQueryAndRefresh(String query) async {
widget.controller.setQuery(query);
await widget.controller.refresh();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('操作日志'),
),
body: ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: <Widget>[
AppSearchBar(
controller: _searchController,
hintText: '搜索日志内容 / 分类',
onSubmitted: (String value) => _setQueryAndRefresh(value),
onChanged: widget.controller.setQuery,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
FilterChip(
selected: widget.controller.level == null,
onSelected: (_) => _setLevelAndRefresh(null),
label: const Text('全部'),
),
FilterChip(
selected: widget.controller.level == 1,
onSelected: (_) => _setLevelAndRefresh(1),
label: const Text('信息'),
),
FilterChip(
selected: widget.controller.level == 2,
onSelected: (_) => _setLevelAndRefresh(2),
label: const Text('警告'),
),
FilterChip(
selected: widget.controller.level == 3,
onSelected: (_) => _setLevelAndRefresh(3),
label: const Text('错误'),
),
],
),
const SizedBox(height: 12),
if (widget.controller.isLoading && !widget.controller.hasData)
const SkeletonCard(height: 180)
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
)
else if (widget.controller.logs.isEmpty)
const AppEmptyState(
title: '暂无日志',
description: '当前筛选条件下没有可展示的真实日志。',
)
else
...widget.controller.logs.map((SystemLog log) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
log.message,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
_LogLevelBadge(level: log.level),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_InfoChip(label: '分类', value: log.category),
_InfoChip(label: '时间', value: formatDateTime(log.createdAt)),
if ((log.liveRoomId ?? '').isNotEmpty) _InfoChip(label: '房间', value: log.liveRoomId!),
if ((log.recordTaskId ?? '').isNotEmpty) _InfoChip(label: '任务', value: log.recordTaskId!),
],
),
if ((log.detail ?? '').trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 10),
Text(
log.detail!,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.5,
),
),
],
],
),
),
);
}),
],
),
);
},
),
);
}
}
class _LogLevelBadge extends StatelessWidget {
const _LogLevelBadge({required this.level});
final int level;
@override
Widget build(BuildContext context) {
final color = switch (level) {
3 => const Color(0xFFDC2626),
2 => const Color(0xFFF59E0B),
_ => const Color(0xFF2563EB),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
),
child: Text(
logLevelLabel(level),
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
fontSize: 12,
),
),
);
}
}
class _InfoChip extends StatelessWidget {
const _InfoChip({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$label · $value',
style: const TextStyle(
color: Color(0xFF475569),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
import 'package:url_launcher/url_launcher.dart';
class MediaBrowserPage extends StatefulWidget {
const MediaBrowserPage({
super.key,
required this.controller,
});
final MediaBrowserController controller;
@override
State<MediaBrowserPage> createState() => _MediaBrowserPageState();
}
class _MediaBrowserPageState extends State<MediaBrowserPage> {
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh(path: '');
}
}
@override
void dispose() {
widget.controller.dispose();
super.dispose();
}
Future<void> _openFile(String relativePath, {bool download = false}) async {
final uri = widget.controller.fileUri(relativePath, download: download);
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<void> _transcodeFile(String relativePath) async {
try {
final message = await widget.controller.transcodeFile(relativePath);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('文件浏览')),
body: ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
final response = widget.controller.response;
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(path: widget.controller.currentPath),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: <Widget>[
if (response != null && response.breadcrumbs.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: response.breadcrumbs.map((MediaBrowserBreadcrumb crumb) {
return ActionChip(
label: Text(crumb.label),
onPressed: () => widget.controller.refresh(path: crumb.relativePath),
);
}).toList(growable: false),
),
),
if (widget.controller.isLoading && !widget.controller.hasData)
const SkeletonCard(height: 220)
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh(path: widget.controller.currentPath);
},
)
else if (response == null || response.items.isEmpty)
const AppEmptyState(
title: '目录为空',
description: '当前路径下没有可展示的真实文件或目录。',
)
else
...response.items.map((MediaBrowserItem item) {
final type = item.type.toLowerCase();
final isDirectory = type == 'directory' || type == 'dir' || type == 'folder';
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: AppCard(
onTap: isDirectory ? () => widget.controller.refresh(path: item.relativePath) : null,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: isDirectory ? const Color(0xFFEFF6FF) : const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
isDirectory ? Icons.folder_rounded : Icons.insert_drive_file_rounded,
color: isDirectory ? const Color(0xFF2563EB) : const Color(0xFF64748B),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.name,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_FileChip(label: '类型', value: item.type),
_FileChip(label: '大小', value: formatBytes(item.sizeBytes)),
_FileChip(label: '修改时间', value: formatDateTime(item.modifiedAt)),
],
),
],
),
),
if (!isDirectory)
PopupMenuButton<String>(
onSelected: (String value) {
switch (value) {
case 'preview':
_openFile(item.relativePath);
return;
case 'download':
_openFile(item.relativePath, download: true);
return;
case 'transcode':
_transcodeFile(item.relativePath);
return;
}
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
if (item.canPreview) const PopupMenuItem(value: 'preview', child: Text('预览')),
const PopupMenuItem(value: 'download', child: Text('下载')),
if (item.canTranscode) const PopupMenuItem(value: 'transcode', child: Text('提交转码')),
],
),
],
),
),
);
}),
],
),
);
},
),
);
}
}
class _FileChip extends StatelessWidget {
const _FileChip({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$label · $value',
style: const TextStyle(
color: Color(0xFF475569),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/dashboard_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/logs_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/monitor_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/profile_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/recordings_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/rooms_page.dart';
class MobileShellPage extends StatefulWidget {
const MobileShellPage({
super.key,
required this.dependencies,
});
final AppDependencies dependencies;
@override
State<MobileShellPage> createState() => _MobileShellPageState();
}
class _MobileShellPageState extends State<MobileShellPage> with WidgetsBindingObserver {
late final DashboardController _dashboardController = DashboardController(
liveRoomsRepository: widget.dependencies.liveRoomsRepository,
recordingsRepository: widget.dependencies.recordingsRepository,
recoveryRepository: widget.dependencies.recoveryRepository,
);
late final MonitorController _monitorController = MonitorController(
liveRoomsRepository: widget.dependencies.liveRoomsRepository,
recordingsRepository: widget.dependencies.recordingsRepository,
recoveryRepository: widget.dependencies.recoveryRepository,
);
late final RoomsController _roomsController = RoomsController(
liveRoomsRepository: widget.dependencies.liveRoomsRepository,
recordingsRepository: widget.dependencies.recordingsRepository,
recoveryRepository: widget.dependencies.recoveryRepository,
);
late final RecordingsController _recordingsController = RecordingsController(
recordingsRepository: widget.dependencies.recordingsRepository,
settingsRepository: widget.dependencies.settingsRepository,
mediaRepository: widget.dependencies.mediaRepository,
);
late final ProfileController _profileController = ProfileController(
settingsRepository: widget.dependencies.settingsRepository,
recoveryRepository: widget.dependencies.recoveryRepository,
);
int _selectedIndex = 0;
bool _isForeground = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_syncPolling();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_dashboardController.dispose();
_monitorController.dispose();
_roomsController.dispose();
_recordingsController.dispose();
_profileController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_isForeground = state == AppLifecycleState.resumed;
_syncPolling();
}
void _syncPolling() {
final active = _isForeground;
_dashboardController.setActive(active && _selectedIndex == 0);
_monitorController.setActive(active && _selectedIndex == 1);
_roomsController.setActive(active && _selectedIndex == 2);
_recordingsController.setActive(active && _selectedIndex == 3);
}
void _openLogs() {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => LogsPage(
controller: LogsController(
logsRepository: widget.dependencies.logsRepository,
),
),
),
);
}
@override
Widget build(BuildContext context) {
final sessionController = widget.dependencies.sessionController;
final userName = sessionController.user?.displayName.isNotEmpty == true
? sessionController.user!.displayName
: sessionController.user?.username ?? 'L';
final userInitials = userName.isEmpty ? 'L' : userName.characters.first.toUpperCase();
return Scaffold(
body: SafeArea(
top: true,
bottom: false,
child: IndexedStack(
index: _selectedIndex,
children: <Widget>[
DashboardPage(
controller: _dashboardController,
userInitials: userInitials,
onOpenLogs: _openLogs,
onOpenProfile: () => setState(() => _selectedIndex = 4),
),
MonitorPage(
controller: _monitorController,
userInitials: userInitials,
onOpenLogs: _openLogs,
onOpenProfile: () => setState(() => _selectedIndex = 4),
),
RoomsPage(
controller: _roomsController,
userInitials: userInitials,
onOpenLogs: _openLogs,
onOpenProfile: () => setState(() => _selectedIndex = 4),
),
RecordingsPage(
controller: _recordingsController,
userInitials: userInitials,
onOpenLogs: _openLogs,
onOpenProfile: () => setState(() => _selectedIndex = 4),
),
ProfilePage(
controller: _profileController,
dependencies: widget.dependencies,
userInitials: userInitials,
onOpenLogs: _openLogs,
),
],
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (int index) {
setState(() {
_selectedIndex = index;
_syncPolling();
});
},
destinations: const <NavigationDestination>[
NavigationDestination(
icon: Icon(Icons.dashboard_rounded),
label: '大盘',
),
NavigationDestination(
icon: Icon(Icons.radar_rounded),
label: '监控',
),
NavigationDestination(
icon: Icon(Icons.video_camera_back_rounded),
label: '直播间',
),
NavigationDestination(
icon: Icon(Icons.folder_copy_rounded),
label: '录像',
),
NavigationDestination(
icon: Icon(Icons.person_rounded),
label: '我的',
),
],
),
);
}
}
@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_scope.dart';
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/room_detail_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/room_preview_card.dart';
import 'package:url_launcher/url_launcher.dart';
class MonitorPage extends StatefulWidget {
const MonitorPage({
super.key,
required this.controller,
required this.userInitials,
required this.onOpenLogs,
required this.onOpenProfile,
});
final MonitorController controller;
final String userInitials;
final VoidCallback onOpenLogs;
final VoidCallback onOpenProfile;
@override
State<MonitorPage> createState() => _MonitorPageState();
}
class _MonitorPageState extends State<MonitorPage> {
bool _paused = false;
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
Future<void> _showAddRoomDialog() async {
final TextEditingController controller = TextEditingController();
await showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('添加监控'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: '直播间链接 / Room ID',
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () async {
Navigator.of(context).pop();
if (controller.text.trim().isEmpty) {
return;
}
final messenger = ScaffoldMessenger.of(this.context);
try {
final message = await widget.controller.createRoom(
url: controller.text.trim(),
);
messenger.showSnackBar(SnackBar(content: Text(message)));
} catch (error) {
messenger.showSnackBar(
SnackBar(content: Text(error.toString())),
);
}
},
child: const Text('添加'),
),
],
);
},
);
controller.dispose();
}
Future<void> _openLiveRoom(LiveRoom room) async {
final messenger = ScaffoldMessenger.of(context);
final uri = resolveLiveRoomWatchUri(room);
if (uri == null) {
messenger.showSnackBar(
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
);
return;
}
try {
final launched = await launchUrl(
uri,
mode: LaunchMode.inAppBrowserView,
);
if (!launched && mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
);
}
} catch (_) {
if (!mounted) {
return;
}
messenger.showSnackBar(
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
);
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
children: <Widget>[
MobileHeader(
eyebrow: '直播预览 · 自动刷新',
title: '实时监控墙',
userInitials: widget.userInitials,
onNotificationsPressed: widget.onOpenLogs,
onProfilePressed: widget.onOpenProfile,
trailing: Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
FilledButton.tonalIcon(
onPressed: _showAddRoomDialog,
icon: const Icon(Icons.add_rounded),
label: const Text('添加监控'),
),
FilledButton.tonalIcon(
onPressed: () {
setState(() {
_paused = !_paused;
widget.controller.setActive(!_paused);
});
},
icon: Icon(
_paused
? Icons.play_arrow_rounded
: Icons.pause_rounded,
),
label: Text(_paused ? '恢复刷新' : '暂停刷新'),
),
],
),
),
if (widget.controller.isLoading && !widget.controller.hasData)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: SkeletonCard(height: 280),
)
else if (widget.controller.errorMessage != null &&
!widget.controller.hasData)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
),
)
else if (widget.controller.filteredRooms.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: AppEmptyState(),
)
else
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final isTablet = constraints.maxWidth >= 900;
final rooms = widget.controller.filteredRooms;
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: rooms.length,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isTablet ? 2 : 1,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: isTablet ? 0.96 : 0.82,
),
itemBuilder: (BuildContext context, int index) {
final room = rooms[index];
return RoomPreviewCard(
room: room,
session: widget.controller.sessionForRoom(room.id),
recentEvent: widget.controller.recentEventForRoom(room),
onWatchLive: () => _openLiveRoom(room),
onTap: () {
final dependencies = AppScope.of(context);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => RoomDetailPage(
dependencies: dependencies,
roomId: room.id,
),
),
);
},
);
},
);
},
),
],
),
);
},
);
}
}
@@ -0,0 +1,275 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/network/api_exception.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
class NotificationSettingsPage extends StatefulWidget {
const NotificationSettingsPage({
super.key,
required this.settingsRepository,
required this.initialSettings,
});
final SettingsRepository settingsRepository;
final SystemSettings? initialSettings;
@override
State<NotificationSettingsPage> createState() => _NotificationSettingsPageState();
}
class _NotificationSettingsPageState extends State<NotificationSettingsPage> {
final TextEditingController _emailToController = TextEditingController();
final TextEditingController _webhookUrlController = TextEditingController();
final TextEditingController _webhookTimeoutController = TextEditingController();
SystemSettings? _settings;
bool _loading = true;
bool _saving = false;
String? _errorMessage;
bool _enableEmailNotification = false;
bool _notifyOnLiveStarted = false;
bool _notifyOnException = false;
bool _enableWebhookNotification = false;
bool _notifyWebhookOnLiveStarted = false;
bool _notifyWebhookOnException = false;
@override
void initState() {
super.initState();
if (widget.initialSettings != null) {
_applySettings(widget.initialSettings!);
_loading = false;
} else {
_load();
}
}
@override
void dispose() {
_emailToController.dispose();
_webhookUrlController.dispose();
_webhookTimeoutController.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_loading = true;
_errorMessage = null;
});
try {
final settings = await widget.settingsRepository.getSettings();
_applySettings(settings);
} on ApiException catch (error) {
setState(() {
_errorMessage = error.message;
});
} catch (error) {
setState(() {
_errorMessage = error.toString();
});
} finally {
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
void _applySettings(SystemSettings settings) {
_settings = settings.copy();
_enableEmailNotification = settings.enableEmailNotification;
_emailToController.text = settings.emailToAddresses;
_notifyOnLiveStarted = settings.notifyOnLiveStarted;
_notifyOnException = settings.notifyOnException;
_enableWebhookNotification = settings.enableWebhookNotification;
_webhookUrlController.text = settings.webhookUrl;
_webhookTimeoutController.text = '${settings.webhookTimeoutSeconds}';
_notifyWebhookOnLiveStarted = settings.notifyWebhookOnLiveStarted;
_notifyWebhookOnException = settings.notifyWebhookOnException;
}
Future<void> _save() async {
final settings = _settings?.copy();
if (settings == null) {
return;
}
settings.updateNotificationSettings(
enableEmailNotification: _enableEmailNotification,
emailToAddresses: _emailToController.text.trim(),
notifyOnLiveStarted: _notifyOnLiveStarted,
notifyOnException: _notifyOnException,
enableWebhookNotification: _enableWebhookNotification,
webhookUrl: _webhookUrlController.text.trim(),
webhookTimeoutSeconds: int.tryParse(_webhookTimeoutController.text.trim()) ?? 0,
notifyWebhookOnLiveStarted: _notifyWebhookOnLiveStarted,
notifyWebhookOnException: _notifyWebhookOnException,
);
setState(() {
_saving = true;
_errorMessage = null;
});
try {
final saved = await widget.settingsRepository.updateSettings(settings);
_applySettings(saved);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('通知设置已保存。')),
);
} on ApiException catch (error) {
setState(() {
_errorMessage = error.message;
});
} catch (error) {
setState(() {
_errorMessage = error.toString();
});
} finally {
if (mounted) {
setState(() {
_saving = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('通知设置')),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
if (_loading)
const SkeletonCard(height: 220)
else if (_errorMessage != null && _settings == null)
AppErrorCard(
message: _errorMessage!,
onRetry: () {
_load();
},
)
else ...<Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'邮件通知',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _enableEmailNotification,
onChanged: (bool value) => setState(() => _enableEmailNotification = value),
title: const Text('启用邮件通知'),
),
TextField(
controller: _emailToController,
decoration: const InputDecoration(labelText: '收件人地址(逗号分隔)'),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _notifyOnLiveStarted,
onChanged: (bool value) => setState(() => _notifyOnLiveStarted = value),
title: const Text('开播时通知'),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _notifyOnException,
onChanged: (bool value) => setState(() => _notifyOnException = value),
title: const Text('异常时通知'),
),
],
),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'Webhook 通知',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _enableWebhookNotification,
onChanged: (bool value) => setState(() => _enableWebhookNotification = value),
title: const Text('启用 Webhook 通知'),
),
TextField(
controller: _webhookUrlController,
decoration: const InputDecoration(labelText: 'Webhook URL'),
),
const SizedBox(height: 12),
TextField(
controller: _webhookTimeoutController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '超时时间(秒)'),
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _notifyWebhookOnLiveStarted,
onChanged: (bool value) => setState(() => _notifyWebhookOnLiveStarted = value),
title: const Text('开播时回调'),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _notifyWebhookOnException,
onChanged: (bool value) => setState(() => _notifyWebhookOnException = value),
title: const Text('异常时回调'),
),
],
),
),
),
if (_errorMessage != null) ...<Widget>[
const SizedBox(height: 12),
Text(
_errorMessage!,
style: const TextStyle(color: Color(0xFFDC2626), height: 1.5),
),
],
const SizedBox(height: 16),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('保存设置'),
),
],
],
),
);
}
}
@@ -0,0 +1,350 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/app/app_scope.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/backend_settings_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/logs_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/media_browser_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/notification_settings_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/security_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/storage_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/system_summary_page.dart';
class ProfilePage extends StatefulWidget {
const ProfilePage({
super.key,
required this.controller,
required this.dependencies,
required this.userInitials,
required this.onOpenLogs,
});
final ProfileController controller;
final AppDependencies dependencies;
final String userInitials;
final VoidCallback onOpenLogs;
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
void _push(Widget page) {
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => page),
);
}
@override
Widget build(BuildContext context) {
final user = widget.dependencies.sessionController.user;
final displayName = user?.displayName.isNotEmpty == true ? user!.displayName : user?.username ?? '--';
final backendConfig = AppScope.backendConfigOf(context);
return ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
children: <Widget>[
MobileHeader(
eyebrow: '个人中心',
title: '我的',
userInitials: widget.userInitials,
onNotificationsPressed: widget.onOpenLogs,
onProfilePressed: () {},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppCard(
child: Row(
children: <Widget>[
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFFE0ECFF),
foregroundColor: const Color(0xFF2563EB),
child: Text(
widget.userInitials,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
displayName,
style: const TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
user?.username ?? '--',
style: const TextStyle(color: Color(0xFF64748B)),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
const _MetaChip(label: '角色 --'),
const _MetaChip(label: '环境 --'),
_MetaChip(label: '到期 ${formatDateTime(user?.expiresAt)}'),
],
),
],
),
),
],
),
),
),
const SizedBox(height: 16),
if (widget.controller.isLoading && !widget.controller.hasData)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: <Widget>[
SkeletonCard(height: 120),
SizedBox(height: 12),
SkeletonCard(height: 120),
],
),
)
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
),
)
else
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppCard(
child: Wrap(
spacing: 10,
runSpacing: 10,
children: <Widget>[
_MetaChip(label: '输出目录 ${valueOrDash(widget.controller.settings?.outputRoot)}'),
_MetaChip(label: '轮询 ${widget.controller.settings?.pollingIntervalSeconds ?? '--'}'),
_MetaChip(
label:
'自动开录 ${widget.controller.settings?.autoStartRecordingOnLive == true ? '开启' : '关闭'}',
),
_MetaChip(
label: '存储守护 ${widget.controller.settings?.enableStorageGuard == true ? '开启' : '关闭'}',
),
],
),
),
),
const SizedBox(height: 16),
_EntryTile(
icon: Icons.lock_outline_rounded,
title: '账号安全',
subtitle: '修改当前账号密码',
onTap: () => _push(
SecurityPage(
sessionController: widget.dependencies.sessionController,
),
),
),
_EntryTile(
icon: Icons.cloud_outlined,
title: '连接设置',
subtitle: '修改后端地址并切换当前环境',
onTap: () => _push(
BackendSettingsPage(
bootstrapController: backendConfig,
),
),
),
_EntryTile(
icon: Icons.notifications_outlined,
title: '通知设置',
subtitle: '邮件和 Webhook 通知开关',
onTap: () => _push(
NotificationSettingsPage(
settingsRepository: widget.dependencies.settingsRepository,
initialSettings: widget.controller.settings,
),
),
),
_EntryTile(
icon: Icons.storage_rounded,
title: '存储管理',
subtitle: '查看存储守护和保留清理状态',
onTap: () => _push(
StoragePage(
controller: StorageController(
settingsRepository: widget.dependencies.settingsRepository,
recoveryRepository: widget.dependencies.recoveryRepository,
),
dependencies: widget.dependencies,
),
),
),
_EntryTile(
icon: Icons.receipt_long_rounded,
title: '操作日志',
subtitle: '真实系统日志筛选与查看',
onTap: () => _push(
LogsPage(
controller: LogsController(
logsRepository: widget.dependencies.logsRepository,
),
),
),
),
_EntryTile(
icon: Icons.settings_outlined,
title: '系统设置',
subtitle: '当前系统配置摘要',
onTap: () => _push(
SystemSummaryPage(
settingsRepository: widget.dependencies.settingsRepository,
initialSettings: widget.controller.settings,
),
),
),
_EntryTile(
icon: Icons.folder_outlined,
title: '文件浏览',
subtitle: '通过真实 /api/media 接口浏览录制目录',
onTap: () => _push(
MediaBrowserPage(
controller: MediaBrowserController(
mediaRepository: widget.dependencies.mediaRepository,
),
),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FilledButton.tonalIcon(
onPressed: () async => widget.dependencies.sessionController.logout(),
icon: const Icon(Icons.logout_rounded),
label: const Text('退出登录'),
),
),
],
),
);
},
);
}
}
class _EntryTile extends StatelessWidget {
const _EntryTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: AppCard(
onTap: onTap,
child: Row(
children: <Widget>[
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(16),
),
child: Icon(icon, color: const Color(0xFF2563EB)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(color: Color(0xFF64748B)),
),
],
),
),
const Icon(Icons.chevron_right_rounded, color: Color(0xFF94A3B8)),
],
),
),
);
}
}
class _MetaChip extends StatelessWidget {
const _MetaChip({
required this.label,
});
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(999),
),
child: Text(
label,
style: const TextStyle(
color: Color(0xFF475569),
fontWeight: FontWeight.w600,
fontSize: 12,
),
),
);
}
}
@@ -0,0 +1,363 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
import 'package:url_launcher/url_launcher.dart';
class RecordingDetailPage extends StatefulWidget {
const RecordingDetailPage({
super.key,
required this.dependencies,
required this.taskId,
});
final AppDependencies dependencies;
final String taskId;
@override
State<RecordingDetailPage> createState() => _RecordingDetailPageState();
}
class _RecordingDetailPageState extends State<RecordingDetailPage> {
late final RecordingDetailController _controller = RecordingDetailController(
recordingsRepository: widget.dependencies.recordingsRepository,
settingsRepository: widget.dependencies.settingsRepository,
mediaRepository: widget.dependencies.mediaRepository,
taskId: widget.taskId,
);
bool _previewLoading = false;
@override
void initState() {
super.initState();
_controller.refresh();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _openPreview() async {
setState(() {
_previewLoading = true;
});
try {
final ticket = await _controller.createPreviewTicket();
if (ticket.url.isEmpty) {
throw Exception('预览地址为空。');
}
await launchUrl(Uri.parse(ticket.url), mode: LaunchMode.externalApplication);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() {
_previewLoading = false;
});
}
}
}
Future<void> _openDownload() async {
final uri = _controller.downloadUri;
if (uri == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('当前任务暂无可下载文件。')),
);
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('录像详情')),
body: ListenableBuilder(
listenable: _controller,
builder: (BuildContext context, _) {
final detail = _controller.detail;
final task = detail?.task;
final result = detail?.result;
return RefreshIndicator(
onRefresh: () => _controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: <Widget>[
if (_controller.isLoading && !_controller.hasData)
const SkeletonCard(height: 240)
else if (_controller.errorMessage != null && !_controller.hasData)
AppErrorCard(
message: _controller.errorMessage!,
onRetry: () {
_controller.refresh();
},
)
else if (detail == null || task == null)
const AppEmptyState(
title: '暂无录像详情',
description: '当前任务没有返回可展示的真实详情。',
)
else ...<Widget>[
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
(task.outputFilePath ?? '').split(RegExp(r'[\\/]')).last.isEmpty
? '--'
: (task.outputFilePath ?? '').split(RegExp(r'[\\/]')).last,
style: const TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
),
StatusBadge(
status: task.status,
context: 'task',
label: taskStatusLabel(task.status),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
_DetailChip(label: '直播间', value: task.liveRoomTitle.isEmpty ? '--' : task.liveRoomTitle),
_DetailChip(label: 'Room ID', value: task.roomId.isEmpty ? '--' : task.roomId),
_DetailChip(label: '清晰度', value: qualityLabel(task.preferredQuality)),
_DetailChip(label: '输出格式', value: outputFormatLabel(task.outputFormat)),
_DetailChip(label: '创建时间', value: formatDateTime(task.createdAt)),
_DetailChip(label: '录制时长', value: formatDurationSeconds(result?.durationSeconds ?? task.durationSeconds)),
],
),
if ((task.errorMessage ?? '').trim().isNotEmpty) ...<Widget>[
const SizedBox(height: 12),
Text(
task.errorMessage!,
style: const TextStyle(
color: Color(0xFFDC2626),
height: 1.5,
),
),
],
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
FilledButton.tonalIcon(
onPressed: _previewLoading ? null : _openPreview,
icon: const Icon(Icons.play_circle_outline_rounded),
label: Text(_previewLoading ? '打开中...' : '预览'),
),
FilledButton.icon(
onPressed: _openDownload,
icon: const Icon(Icons.download_rounded),
label: const Text('下载'),
),
],
),
],
),
),
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'结果信息',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
if (result == null)
const AppEmptyState(
title: '暂无结果',
description: '任务仍在处理中,或后端尚未返回结果对象。',
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_DetailRow(label: '文件路径', value: result.filePath.isEmpty ? '--' : result.filePath),
_DetailRow(label: '文件大小', value: formatBytes(result.fileSizeBytes)),
_DetailRow(label: '时长', value: formatDurationSeconds(result.durationSeconds)),
_DetailRow(label: '最终状态', value: taskStatusLabel(result.finalStatus)),
_DetailRow(label: '上传状态', value: uploadStatusLabel(result.uploadStatus)),
_DetailRow(label: '最近上传时间', value: formatDateTime(result.lastUploadedAt)),
_DetailRow(label: '远端视频路径', value: valueOrDash(result.remoteVideoPath)),
if ((result.errorMessage ?? '').trim().isNotEmpty)
_DetailRow(label: '错误信息', value: result.errorMessage!),
if ((result.uploadErrorMessage ?? '').trim().isNotEmpty)
_DetailRow(label: '上传错误', value: result.uploadErrorMessage!),
],
),
],
),
),
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'任务日志',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
if (detail.logs.isEmpty)
const AppEmptyState(
title: '暂无日志',
description: '当前任务没有返回附带日志。',
)
else
...detail.logs.map((SystemLog log) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
log.message,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
Text(
formatDateTime(log.createdAt),
style: const TextStyle(
color: Color(0xFF94A3B8),
fontSize: 12,
),
),
],
),
const SizedBox(height: 6),
Text(
log.detail?.trim().isEmpty ?? true ? log.category : '${log.category} · ${log.detail}',
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.5,
),
),
const Divider(height: 20),
],
),
);
}),
],
),
),
],
],
),
);
},
),
);
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 96,
child: Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}
class _DetailChip extends StatelessWidget {
const _DetailChip({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$label · $value',
style: const TextStyle(
color: Color(0xFF475569),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_scope.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_search_bar.dart';
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/recording_detail_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/recording_file_card.dart';
import 'package:url_launcher/url_launcher.dart';
class RecordingsPage extends StatefulWidget {
const RecordingsPage({
super.key,
required this.controller,
required this.userInitials,
required this.onOpenLogs,
required this.onOpenProfile,
});
final RecordingsController controller;
final String userInitials;
final VoidCallback onOpenLogs;
final VoidCallback onOpenProfile;
@override
State<RecordingsPage> createState() => _RecordingsPageState();
}
class _RecordingsPageState extends State<RecordingsPage> {
late final TextEditingController _searchController = TextEditingController(text: widget.controller.query);
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _openDownload(RecordTask task) async {
final uri = widget.controller.downloadUriForTask(task);
if (uri == null) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('当前录像文件无法映射到下载接口。')),
);
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
final tasks = widget.controller.filteredTasks;
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
children: <Widget>[
MobileHeader(
eyebrow: '转码 · 归档 · 下载',
title: '录像文件',
userInitials: widget.userInitials,
onNotificationsPressed: widget.onOpenLogs,
onProfilePressed: widget.onOpenProfile,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppSearchBar(
controller: _searchController,
hintText: '搜索文件名 / 直播间',
onChanged: widget.controller.setQuery,
),
),
const SizedBox(height: 12),
if (widget.controller.isLoading && !widget.controller.hasData)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: SkeletonCard(height: 160),
)
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
),
)
else if (tasks.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: AppEmptyState(),
)
else
...tasks.map((RecordTask task) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: RecordingFileCard(
task: task,
detail: widget.controller.cachedDetail(task.id),
onVisible: () => widget.controller.ensureDetailLoaded(task.id),
onTap: () {
final dependencies = AppScope.of(context);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => RecordingDetailPage(
dependencies: dependencies,
taskId: task.id,
),
),
);
},
onDownload: () => _openDownload(task),
),
);
}),
],
),
);
},
);
}
}
@@ -0,0 +1,727 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
import 'package:url_launcher/url_launcher.dart';
class RoomDetailPage extends StatefulWidget {
const RoomDetailPage({
super.key,
required this.dependencies,
required this.roomId,
});
final AppDependencies dependencies;
final String roomId;
@override
State<RoomDetailPage> createState() => _RoomDetailPageState();
}
class _RoomDetailPageState extends State<RoomDetailPage> {
late final RoomDetailController _controller = RoomDetailController(
liveRoomsRepository: widget.dependencies.liveRoomsRepository,
recordingsRepository: widget.dependencies.recordingsRepository,
recoveryRepository: widget.dependencies.recoveryRepository,
roomId: widget.roomId,
);
bool _actionBusy = false;
@override
void initState() {
super.initState();
_controller.refresh();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
RecordSession? get _activeSession {
final sessions = _controller.sessions.where((RecordSession session) => isTaskActive(session.status)).toList(growable: false);
if (sessions.isEmpty) {
return null;
}
sessions.sort((RecordSession a, RecordSession b) => (b.startedAt ?? b.createdAt).compareTo(a.startedAt ?? a.createdAt));
return sessions.first;
}
Future<void> _runAction(Future<String> Function() action) async {
setState(() {
_actionBusy = true;
});
try {
final message = await action();
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
await _controller.refresh(silent: true);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
} finally {
if (mounted) {
setState(() {
_actionBusy = false;
});
}
}
}
Future<void> _openLiveRoom(LiveRoom room) async {
final uri = resolveLiveRoomWatchUri(room);
if (uri == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
);
return;
}
try {
final launched = await launchUrl(
uri,
mode: LaunchMode.inAppBrowserView,
);
if (!launched && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
);
}
} catch (_) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('当前直播间暂无可打开的真实链接')),
);
}
}
Future<void> _showStartRecordingSheet(LiveRoom room) async {
final qualityController = TextEditingController(text: room.effectiveSettings.preferredQuality);
final outputFormat = ValueNotifier<int>(room.effectiveSettings.outputFormat);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'开始录制',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
const SizedBox(height: 16),
TextField(
controller: qualityController,
decoration: const InputDecoration(labelText: '清晰度'),
),
const SizedBox(height: 14),
ValueListenableBuilder<int>(
valueListenable: outputFormat,
builder: (BuildContext context, int value, _) {
return DropdownButtonFormField<int>(
initialValue: value,
decoration: const InputDecoration(labelText: '输出格式'),
items: const <DropdownMenuItem<int>>[
DropdownMenuItem(value: 0, child: Text('MP4')),
DropdownMenuItem(value: 1, child: Text('TS')),
],
onChanged: (int? next) {
if (next != null) {
outputFormat.value = next;
}
},
);
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () async {
Navigator.of(context).pop();
await _runAction(() async {
await widget.dependencies.recordingsRepository.startRecording(
liveRoomId: room.id,
preferredQuality: qualityController.text.trim().isEmpty
? room.effectiveSettings.preferredQuality
: qualityController.text.trim(),
outputFormat: outputFormat.value,
);
return '录制任务已启动';
});
},
child: const Text('启动录制'),
),
),
],
),
);
},
);
qualityController.dispose();
outputFormat.dispose();
}
Future<void> _showEditSheet(LiveRoom room) async {
final remarkController = TextEditingController(text: room.remark ?? '');
final aliasController = TextEditingController(text: room.alias ?? '');
final pollingController = TextEditingController(text: room.pollingIntervalSecondsOverride?.toString() ?? '');
bool isPinned = room.isPinned;
bool isPriority = room.isPriority;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (BuildContext context, void Function(void Function()) setSheetState) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'编辑直播间',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
const SizedBox(height: 16),
TextField(
controller: remarkController,
decoration: const InputDecoration(labelText: '备注'),
),
const SizedBox(height: 14),
TextField(
controller: aliasController,
decoration: const InputDecoration(labelText: '主播别名'),
),
const SizedBox(height: 14),
TextField(
controller: pollingController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '单房间轮询间隔(秒)'),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: isPinned,
onChanged: (bool value) => setSheetState(() => isPinned = value),
title: const Text('置顶'),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: isPriority,
onChanged: (bool value) => setSheetState(() => isPriority = value),
title: const Text('重点主播'),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () async {
Navigator.of(context).pop();
await _runAction(() async {
await widget.dependencies.liveRoomsRepository.updateMetadata(
roomId: room.id,
payload: <String, dynamic>{
'remark': remarkController.text.trim().isEmpty ? null : remarkController.text.trim(),
'isPinned': isPinned,
'alias': aliasController.text.trim().isEmpty ? null : aliasController.text.trim(),
'isPriority': isPriority,
'pollingIntervalSecondsOverride': int.tryParse(pollingController.text.trim()),
},
);
return '房间信息已保存';
});
},
child: const Text('保存'),
),
),
],
),
),
);
},
);
},
);
remarkController.dispose();
aliasController.dispose();
pollingController.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('直播间详情')),
body: ListenableBuilder(
listenable: _controller,
builder: (BuildContext context, _) {
final activeSession = _activeSession;
final room = _controller.room;
final recoveryInfo = _controller.recoveryInfo;
final allTasks = _controller.sessions.expand((RecordSession session) => session.tasks).toList(growable: false);
return RefreshIndicator(
onRefresh: () => _controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: <Widget>[
if (_controller.isLoading && !_controller.hasData)
const SkeletonCard(height: 260)
else if (_controller.errorMessage != null && !_controller.hasData)
AppErrorCard(
message: _controller.errorMessage!,
onRetry: () {
_controller.refresh();
},
)
else if (room == null)
const AppEmptyState(
title: '暂无直播间详情',
description: '当前房间没有返回可展示的真实详情。',
)
else ...<Widget>[
_RoomHero(room: room),
const SizedBox(height: 12),
AppCard(
child: Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
if (hasLiveRoomWatchSource(room))
FilledButton.icon(
onPressed: _actionBusy ? null : () => _openLiveRoom(room),
icon: const Icon(Icons.play_circle_fill_rounded),
label: const Text('观看直播'),
),
FilledButton.tonalIcon(
onPressed: _actionBusy
? null
: () => _runAction(() async {
await widget.dependencies.liveRoomsRepository.refreshRoom(room.id);
return '直播状态已刷新';
}),
icon: const Icon(Icons.refresh_rounded),
label: const Text('刷新'),
),
FilledButton.tonalIcon(
onPressed: _actionBusy ? null : () => _showStartRecordingSheet(room),
icon: const Icon(Icons.fiber_manual_record_rounded),
label: const Text('开始录制'),
),
if (activeSession != null)
FilledButton.tonalIcon(
onPressed: _actionBusy
? null
: () => _runAction(() async {
await widget.dependencies.recordingsRepository.stopSession(activeSession.id);
return '停止录制请求已提交';
}),
icon: const Icon(Icons.stop_circle_outlined),
label: const Text('停止录制'),
),
if (recoveryInfo != null || (room.lastAutoStartDecisionCode ?? '').isNotEmpty)
FilledButton.tonalIcon(
onPressed: _actionBusy
? null
: () => _runAction(() async {
await widget.dependencies.recoveryRepository.retryLiveRoom(room.id);
return '重试请求已提交';
}),
icon: const Icon(Icons.restart_alt_rounded),
label: const Text('重试'),
),
FilledButton.tonalIcon(
onPressed: _actionBusy ? null : () => _showEditSheet(room),
icon: const Icon(Icons.edit_outlined),
label: const Text('编辑'),
),
],
),
),
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'状态信息',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
StatusBadge(
status: room.availabilityStatus,
context: 'availability',
label: availabilityLabel(room.availabilityStatus),
),
StatusBadge(
status: room.currentRecordingState,
context: 'recording',
label: recordingStateLabel(room.currentRecordingState),
),
if (room.isPinned) const StatusBadge(status: 'completed', label: '置顶'),
if (room.isPriority) const StatusBadge(status: 'retrying', label: '重点'),
],
),
const SizedBox(height: 16),
_DetailRow(label: '平台', value: room.platformName.isEmpty ? '--' : room.platformName),
_DetailRow(label: 'Room ID', value: room.roomId.isEmpty ? '--' : room.roomId),
_DetailRow(label: '在线人数', value: '--'),
_DetailRow(label: '码率', value: '--'),
_DetailRow(
label: '录制时长',
value: () {
if (activeSession == null) {
return '--';
}
final startedAt = DateTime.tryParse(activeSession.startedAt ?? activeSession.createdAt)?.toLocal();
if (startedAt == null) {
return '--';
}
return formatDurationSeconds(DateTime.now().difference(startedAt).inSeconds);
}(),
),
_DetailRow(label: '采集账号', value: '--'),
_DetailRow(
label: '最近事件',
value: recoveryInfo?.lastAutoStartDecisionSummary ??
room.lastAutoStartDecisionSummary ??
autoStartDecisionLabel(recoveryInfo?.lastAutoStartDecisionCode ?? room.lastAutoStartDecisionCode),
),
_DetailRow(label: '最近检查', value: formatDateTime(room.lastCheckedAt)),
],
),
),
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'录制策略',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
_DetailRow(label: '清晰度', value: qualityLabel(room.effectiveSettings.preferredQuality)),
_DetailRow(label: '输出格式', value: outputFormatLabel(room.effectiveSettings.outputFormat)),
_DetailRow(label: '保存模式', value: saveModeLabel(room.effectiveSettings.saveMode)),
_DetailRow(label: '录制模板', value: recordingTemplateLabel(room.effectiveSettings.recordingTemplate)),
_DetailRow(label: '分段时长', value: '${room.effectiveSettings.segmentDurationMinutes} 分钟'),
_DetailRow(label: '自动重连', value: room.effectiveSettings.enableAutoReconnect ? '开启' : '关闭'),
],
),
),
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'最近会话与文件',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
if (_controller.sessions.isEmpty)
const AppEmptyState(
title: '暂无会话',
description: '当前直播间还没有可展示的录制会话。',
)
else ...<Widget>[
..._controller.sessions.take(3).map((RecordSession session) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
'会话 ${session.id}',
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
StatusBadge(
status: session.status,
context: 'session',
label: taskStatusLabel(session.status),
),
],
),
const SizedBox(height: 6),
Text(
'${formatDateTime(session.startedAt ?? session.createdAt)} · 片段 ${session.segmentCount}',
style: const TextStyle(color: Color(0xFF64748B)),
),
const Divider(height: 18),
],
),
);
}),
if (allTasks.isNotEmpty)
...allTasks.take(5).map((RecordTask task) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _TaskRow(task: task),
);
}),
],
],
),
),
],
],
),
);
},
),
);
}
}
class _RoomHero extends StatelessWidget {
const _RoomHero({
required this.room,
});
final LiveRoom room;
@override
Widget build(BuildContext context) {
final preview = room.coverUrl;
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: AspectRatio(
aspectRatio: 16 / 9,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
if (preview != null && preview.isNotEmpty)
Image.network(
preview,
fit: BoxFit.cover,
errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) => const _FallbackHero(),
)
else
const _FallbackHero(),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
Colors.black.withValues(alpha: 0.15),
Colors.black.withValues(alpha: 0.60),
],
),
),
),
Positioned(
left: 16,
top: 16,
child: StatusBadge(
status: room.availabilityStatus,
context: 'availability',
label: availabilityLabel(room.availabilityStatus),
),
),
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
room.title ?? room.anchorName ?? room.roomId,
style: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'${room.platformName.isEmpty ? '--' : room.platformName} · Room ${room.roomId.isEmpty ? '--' : room.roomId}',
style: const TextStyle(color: Colors.white70),
),
],
),
),
],
),
),
);
}
}
class _FallbackHero extends StatelessWidget {
const _FallbackHero();
@override
Widget build(BuildContext context) {
return const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: <Color>[Color(0xFF0F172A), Color(0xFF1E293B)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Center(
child: Text(
'LiveRecorder',
style: TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
),
);
}
}
class _DetailRow extends StatelessWidget {
const _DetailRow({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 96,
child: Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}
class _TaskRow extends StatelessWidget {
const _TaskRow({
required this.task,
});
final RecordTask task;
@override
Widget build(BuildContext context) {
final fileName = (task.outputFilePath ?? '').split(RegExp(r'[\\/]')).last;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
fileName.isEmpty ? '--' : fileName,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'${formatDateTime(task.createdAt)} · ${formatDurationSeconds(task.durationSeconds)}',
style: const TextStyle(color: Color(0xFF64748B)),
),
],
),
),
StatusBadge(
status: task.status,
context: 'task',
label: taskStatusLabel(task.status),
),
],
),
);
}
}
@@ -0,0 +1,469 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_scope.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_search_bar.dart';
import 'package:live_recorder_mobile/core/widgets/mobile_header.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/main_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/room_detail_page.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/widgets/room_card.dart';
class RoomsPage extends StatefulWidget {
const RoomsPage({
super.key,
required this.controller,
required this.userInitials,
required this.onOpenLogs,
required this.onOpenProfile,
});
final RoomsController controller;
final String userInitials;
final VoidCallback onOpenLogs;
final VoidCallback onOpenProfile;
@override
State<RoomsPage> createState() => _RoomsPageState();
}
class _RoomsPageState extends State<RoomsPage> {
late final TextEditingController _searchController = TextEditingController(text: widget.controller.query);
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _showAddRoomDialog() async {
final TextEditingController controller = TextEditingController();
await showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('新增直播间'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: '直播间链接 / Room ID',
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () async {
Navigator.of(context).pop();
if (controller.text.trim().isEmpty) {
return;
}
await _runAction(() => widget.controller.createRoom(url: controller.text.trim()));
},
child: const Text('添加'),
),
],
);
},
);
controller.dispose();
}
Future<void> _runAction(Future<String> Function() action) async {
final messenger = ScaffoldMessenger.of(context);
try {
final message = await action();
messenger.showSnackBar(SnackBar(content: Text(message)));
} catch (error) {
messenger.showSnackBar(SnackBar(content: Text(error.toString())));
}
}
Future<void> _showStartRecordingSheet(LiveRoom room) async {
final qualityController = TextEditingController(text: room.effectiveSettings.preferredQuality);
final outputFormat = ValueNotifier<int>(room.effectiveSettings.outputFormat);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (BuildContext context) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'开始录制',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
const SizedBox(height: 16),
TextField(
controller: qualityController,
decoration: const InputDecoration(labelText: '清晰度'),
),
const SizedBox(height: 14),
ValueListenableBuilder<int>(
valueListenable: outputFormat,
builder: (BuildContext context, int value, _) {
return DropdownButtonFormField<int>(
initialValue: value,
decoration: const InputDecoration(labelText: '输出格式'),
items: const <DropdownMenuItem<int>>[
DropdownMenuItem(value: 0, child: Text('MP4')),
DropdownMenuItem(value: 1, child: Text('TS')),
],
onChanged: (int? next) {
if (next != null) {
outputFormat.value = next;
}
},
);
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () async {
Navigator.of(context).pop();
await _runAction(
() => widget.controller.startRecording(
room: room,
preferredQuality: qualityController.text.trim(),
outputFormat: outputFormat.value,
),
);
},
child: const Text('启动录制'),
),
),
],
),
);
},
);
qualityController.dispose();
outputFormat.dispose();
}
Future<void> _showEditSheet(LiveRoom room) async {
final remarkController = TextEditingController(text: room.remark ?? '');
final aliasController = TextEditingController(text: room.alias ?? '');
final pollingController = TextEditingController(text: room.pollingIntervalSecondsOverride?.toString() ?? '');
final qualityController = TextEditingController(text: room.overrides.preferredQuality ?? room.effectiveSettings.preferredQuality);
final segmentController = TextEditingController(
text: (room.overrides.segmentDurationMinutes ?? room.effectiveSettings.segmentDurationMinutes).toString(),
);
bool isPinned = room.isPinned;
bool isPriority = room.isPriority;
int outputFormat = room.overrides.outputFormat ?? room.effectiveSettings.outputFormat;
int saveMode = room.overrides.saveMode ?? room.effectiveSettings.saveMode;
int recordingTemplate = room.overrides.recordingTemplate ?? room.effectiveSettings.recordingTemplate;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (BuildContext context, void Function(void Function()) setSheetState) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 8,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'编辑直播间',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
const SizedBox(height: 16),
TextField(
controller: remarkController,
decoration: const InputDecoration(labelText: '备注'),
),
const SizedBox(height: 14),
TextField(
controller: aliasController,
decoration: const InputDecoration(labelText: '主播别名'),
),
const SizedBox(height: 14),
TextField(
controller: pollingController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '单房间轮询间隔(秒)'),
),
const SizedBox(height: 14),
SwitchListTile(
value: isPinned,
onChanged: (bool value) => setSheetState(() => isPinned = value),
title: const Text('置顶'),
),
SwitchListTile(
value: isPriority,
onChanged: (bool value) => setSheetState(() => isPriority = value),
title: const Text('重点主播'),
),
const Divider(height: 28),
const Text(
'录制设置',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
TextField(
controller: qualityController,
decoration: const InputDecoration(labelText: '默认清晰度'),
),
const SizedBox(height: 14),
DropdownButtonFormField<int>(
initialValue: outputFormat,
decoration: const InputDecoration(labelText: '默认输出格式'),
items: const <DropdownMenuItem<int>>[
DropdownMenuItem(value: 0, child: Text('MP4')),
DropdownMenuItem(value: 1, child: Text('TS')),
],
onChanged: (int? value) => setSheetState(() => outputFormat = value ?? outputFormat),
),
const SizedBox(height: 14),
DropdownButtonFormField<int>(
initialValue: saveMode,
decoration: const InputDecoration(labelText: '保存模式'),
items: const <DropdownMenuItem<int>>[
DropdownMenuItem(value: 0, child: Text('单文件')),
DropdownMenuItem(value: 1, child: Text('分段')),
],
onChanged: (int? value) => setSheetState(() => saveMode = value ?? saveMode),
),
const SizedBox(height: 14),
DropdownButtonFormField<int>(
initialValue: recordingTemplate,
decoration: const InputDecoration(labelText: '录制模板'),
items: const <DropdownMenuItem<int>>[
DropdownMenuItem(value: 0, child: Text('直接封装')),
DropdownMenuItem(value: 1, child: Text('均衡 MP4')),
DropdownMenuItem(value: 2, child: Text('归档 TS')),
],
onChanged: (int? value) => setSheetState(() => recordingTemplate = value ?? recordingTemplate),
),
const SizedBox(height: 14),
TextField(
controller: segmentController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '分段时长(分钟)'),
),
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () async {
Navigator.of(context).pop();
final pollingValue = int.tryParse(pollingController.text.trim());
final segmentValue = int.tryParse(segmentController.text.trim());
await _runAction(
() => widget.controller.saveMetadata(
room: room,
remark: remarkController.text,
isPinned: isPinned,
alias: aliasController.text,
isPriority: isPriority,
pollingIntervalSecondsOverride: pollingValue,
),
);
await _runAction(
() => widget.controller.saveRoomSettings(
room: room,
payload: <String, dynamic>{
'preferredQualityOverride': qualityController.text.trim(),
'outputFormatOverride': outputFormat,
'saveModeOverride': saveMode,
'recordingTemplateOverride': recordingTemplate,
'segmentDurationMinutesOverride': segmentValue,
},
),
);
},
child: const Text('保存'),
),
),
],
),
),
);
},
);
},
);
remarkController.dispose();
aliasController.dispose();
pollingController.dispose();
qualityController.dispose();
segmentController.dispose();
}
PopupMenuButton<String> _roomMenu(LiveRoom room) {
return PopupMenuButton<String>(
onSelected: (String value) {
switch (value) {
case 'refresh':
unawaited(_runAction(() => widget.controller.refreshRoom(room)));
return;
case 'toggle':
unawaited(_runAction(() => widget.controller.toggleRoomEnabled(room)));
return;
case 'start':
unawaited(_showStartRecordingSheet(room));
return;
case 'retry':
unawaited(_runAction(() => widget.controller.retryRoom(room)));
return;
case 'edit':
unawaited(_showEditSheet(room));
return;
}
},
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
const PopupMenuItem(value: 'refresh', child: Text('刷新状态')),
PopupMenuItem(value: 'toggle', child: Text(room.isEnabled ? '停用' : '启用')),
const PopupMenuItem(value: 'start', child: Text('开始录制')),
const PopupMenuItem(value: 'retry', child: Text('重试恢复')),
const PopupMenuItem(value: 'edit', child: Text('编辑')),
],
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
final filteredRooms = widget.controller.filteredRooms;
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 100),
children: <Widget>[
MobileHeader(
eyebrow: '直播状态 · 录制状态',
title: '直播间',
userInitials: widget.userInitials,
onNotificationsPressed: widget.onOpenLogs,
onProfilePressed: widget.onOpenProfile,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppSearchBar(
controller: _searchController,
hintText: '搜索主播 / Room ID / 状态',
onChanged: widget.controller.setQuery,
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: RoomFilter.values.map((RoomFilter filter) {
final label = switch (filter) {
RoomFilter.all => '全部',
RoomFilter.live => '直播中',
RoomFilter.recording => '录制中',
RoomFilter.error => '异常',
RoomFilter.retrying => '重试中',
};
return FilterChip(
selected: widget.controller.filter == filter,
onSelected: (_) => widget.controller.setFilter(filter),
label: Text(label),
);
}).toList(growable: false),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FilledButton.tonalIcon(
onPressed: _showAddRoomDialog,
icon: const Icon(Icons.add_rounded),
label: const Text('新增直播间'),
),
),
const SizedBox(height: 12),
if (widget.controller.isLoading && !widget.controller.hasData)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: SkeletonCard(height: 180),
)
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
),
)
else if (filteredRooms.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: AppEmptyState(),
)
else
...filteredRooms.map((LiveRoom room) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: RoomCard(
room: room,
session: widget.controller.sessionForRoom(room.id),
recentEvent: widget.controller.recentEventForRoom(room),
trailing: _roomMenu(room),
onTap: () {
final dependencies = AppScope.of(context);
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => RoomDetailPage(
dependencies: dependencies,
roomId: room.id,
),
),
);
},
),
);
}),
],
),
);
},
);
}
}
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/network/api_exception.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/app_session_controller.dart';
class SecurityPage extends StatefulWidget {
const SecurityPage({
super.key,
required this.sessionController,
});
final AppSessionController sessionController;
@override
State<SecurityPage> createState() => _SecurityPageState();
}
class _SecurityPageState extends State<SecurityPage> {
final TextEditingController _currentPasswordController = TextEditingController();
final TextEditingController _newPasswordController = TextEditingController();
final TextEditingController _confirmPasswordController = TextEditingController();
bool _submitting = false;
String? _errorMessage;
@override
void dispose() {
_currentPasswordController.dispose();
_newPasswordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _submit() async {
FocusScope.of(context).unfocus();
if (_newPasswordController.text != _confirmPasswordController.text) {
setState(() {
_errorMessage = '两次输入的新密码不一致。';
});
return;
}
setState(() {
_submitting = true;
_errorMessage = null;
});
try {
await widget.sessionController.changePassword(
currentPassword: _currentPasswordController.text,
newPassword: _newPasswordController.text,
);
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('密码修改成功。')),
);
_currentPasswordController.clear();
_newPasswordController.clear();
_confirmPasswordController.clear();
} on ApiException catch (error) {
setState(() {
_errorMessage = error.message;
});
} catch (error) {
setState(() {
_errorMessage = error.toString();
});
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('账号安全')),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'修改密码',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
const Text(
'调用现有 /api/auth/change-password 接口,不改认证逻辑。',
style: TextStyle(color: Color(0xFF64748B), height: 1.5),
),
const SizedBox(height: 18),
TextField(
controller: _currentPasswordController,
obscureText: true,
decoration: const InputDecoration(labelText: '当前密码'),
),
const SizedBox(height: 14),
TextField(
controller: _newPasswordController,
obscureText: true,
decoration: const InputDecoration(labelText: '新密码'),
),
const SizedBox(height: 14),
TextField(
controller: _confirmPasswordController,
obscureText: true,
decoration: const InputDecoration(labelText: '确认新密码'),
onSubmitted: (_) => _submit(),
),
if (_errorMessage != null) ...<Widget>[
const SizedBox(height: 12),
Text(
_errorMessage!,
style: const TextStyle(
color: Color(0xFFDC2626),
height: 1.5,
),
),
],
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('提交修改'),
),
),
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/app/app_dependencies.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_empty_state.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/controllers/detail_controllers.dart';
import 'package:live_recorder_mobile/features/live_recorder/presentation/pages/media_browser_page.dart';
class StoragePage extends StatefulWidget {
const StoragePage({
super.key,
required this.controller,
required this.dependencies,
});
final StorageController controller;
final AppDependencies dependencies;
@override
State<StoragePage> createState() => _StoragePageState();
}
class _StoragePageState extends State<StoragePage> {
bool _runningCleanup = false;
@override
void initState() {
super.initState();
if (!widget.controller.hasData) {
widget.controller.refresh();
}
}
@override
void dispose() {
widget.controller.dispose();
super.dispose();
}
Future<void> _runCleanup() async {
setState(() {
_runningCleanup = true;
});
try {
final result = await widget.controller.runRetentionCleanup();
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('清理任务已提交:${result.status}')),
);
await widget.controller.refresh(silent: true);
} catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error.toString())),
);
} finally {
if (mounted) {
setState(() {
_runningCleanup = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('存储管理')),
body: ListenableBuilder(
listenable: widget.controller,
builder: (BuildContext context, _) {
final settings = widget.controller.settings;
final recovery = widget.controller.recoveryOverview;
final storage = recovery?.storage;
return RefreshIndicator(
onRefresh: () => widget.controller.refresh(),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: <Widget>[
if (widget.controller.isLoading && !widget.controller.hasData)
const SkeletonCard(height: 220)
else if (widget.controller.errorMessage != null && !widget.controller.hasData)
AppErrorCard(
message: widget.controller.errorMessage!,
onRetry: () {
widget.controller.refresh();
},
)
else ...<Widget>[
if (storage != null)
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'存储守护',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
_StorageRow(label: '状态', value: storage.isEnabled ? '已启用' : '未启用'),
_StorageRow(label: '检查结果', value: storage.hasEnoughSpace ? '空间充足' : '空间不足'),
_StorageRow(label: '检查路径', value: storage.checkedPath.isEmpty ? '--' : storage.checkedPath),
_StorageRow(label: '可用空间', value: formatBytes(storage.availableBytes)),
_StorageRow(label: '最低要求', value: formatBytes(storage.requiredBytes)),
_StorageRow(label: '后端信息', value: storage.message.isEmpty ? '--' : storage.message),
],
),
),
if (settings != null) ...<Widget>[
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'保留清理',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
_StorageRow(label: '开关', value: settings.enableRetentionCleanup ? '开启' : '关闭'),
_StorageRow(label: '保留天数', value: '${settings.retentionDays}'),
_StorageRow(label: '删除文件', value: settings.retentionDeleteFiles ? '' : ''),
_StorageRow(label: '文件条件', value: settings.retentionVideoFileCondition),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: _runningCleanup ? null : _runCleanup,
icon: const Icon(Icons.cleaning_services_rounded),
label: Text(_runningCleanup ? '提交中...' : '立即执行清理'),
),
],
),
),
],
const SizedBox(height: 12),
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'恢复队列',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
_StorageRow(label: '待恢复直播间', value: '${recovery?.liveRooms.length ?? 0}'),
_StorageRow(label: '待补完录像', value: '${recovery?.finalizations.length ?? 0}'),
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => MediaBrowserPage(
controller: MediaBrowserController(
mediaRepository: widget.dependencies.mediaRepository,
),
),
),
);
},
icon: const Icon(Icons.folder_open_rounded),
label: const Text('打开文件浏览器'),
),
],
),
),
if ((recovery?.liveRooms.isEmpty ?? true) && (recovery?.finalizations.isEmpty ?? true))
const Padding(
padding: EdgeInsets.only(top: 12),
child: AppEmptyState(
title: '暂无恢复项',
description: '当前没有需要人工关注的恢复队列。',
),
),
],
],
),
);
},
),
);
}
}
class _StorageRow extends StatelessWidget {
const _StorageRow({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 96,
child: Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}
@@ -0,0 +1,161 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/network/api_exception.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/app_error_card.dart';
import 'package:live_recorder_mobile/core/widgets/skeleton_card.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/repositories/settings_repository.dart';
class SystemSummaryPage extends StatefulWidget {
const SystemSummaryPage({
super.key,
required this.settingsRepository,
required this.initialSettings,
});
final SettingsRepository settingsRepository;
final SystemSettings? initialSettings;
@override
State<SystemSummaryPage> createState() => _SystemSummaryPageState();
}
class _SystemSummaryPageState extends State<SystemSummaryPage> {
SystemSettings? _settings;
bool _loading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
if (widget.initialSettings != null) {
_settings = widget.initialSettings;
_loading = false;
} else {
_load();
}
}
Future<void> _load() async {
setState(() {
_loading = true;
_errorMessage = null;
});
try {
_settings = await widget.settingsRepository.getSettings();
} on ApiException catch (error) {
_errorMessage = error.message;
} catch (error) {
_errorMessage = error.toString();
} finally {
if (mounted) {
setState(() {
_loading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final settings = _settings;
return Scaffold(
appBar: AppBar(title: const Text('系统设置')),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
if (_loading)
const SkeletonCard(height: 220)
else if (_errorMessage != null && settings == null)
AppErrorCard(
message: _errorMessage!,
onRetry: () {
_load();
},
)
else if (settings != null)
AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'当前配置摘要',
style: TextStyle(
color: Color(0xFF0F172A),
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 16),
_SettingRow(label: '输出目录', value: settings.outputRoot.isEmpty ? '--' : settings.outputRoot),
_SettingRow(label: '全局轮询间隔', value: '${settings.pollingIntervalSeconds}'),
_SettingRow(label: '自动开播录制', value: settings.autoStartRecordingOnLive ? '开启' : '关闭'),
_SettingRow(label: '存储守护', value: settings.enableStorageGuard ? '开启' : '关闭'),
_SettingRow(
label: '低于阈值暂停',
value: '${settings.pauseRecordingWhenFreeSpaceBelowMegabytes} MB',
),
_SettingRow(
label: '高于阈值恢复',
value: '${settings.resumeRecordingWhenFreeSpaceAboveMegabytes} MB',
),
_SettingRow(label: '保留清理', value: settings.enableRetentionCleanup ? '开启' : '关闭'),
_SettingRow(label: '保留天数', value: '${settings.retentionDays}'),
_SettingRow(label: '删除本地文件', value: settings.retentionDeleteFiles ? '' : ''),
_SettingRow(label: '视频文件条件', value: settings.retentionVideoFileCondition),
_SettingRow(
label: '保留任务状态',
value: settings.retentionTaskStatuses.isEmpty
? '--'
: settings.retentionTaskStatuses.map(taskStatusLabel).join(' / '),
),
],
),
),
],
),
);
}
}
class _SettingRow extends StatelessWidget {
const _SettingRow({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 112,
child: Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
class BackendAddressFormCard extends StatelessWidget {
const BackendAddressFormCard({
super.key,
required this.title,
required this.description,
required this.controller,
required this.actionLabel,
required this.onSubmit,
required this.isSubmitting,
this.errorText,
this.note,
this.onFieldSubmitted,
});
final String title;
final String description;
final TextEditingController controller;
final String actionLabel;
final VoidCallback onSubmit;
final bool isSubmitting;
final String? errorText;
final String? note;
final ValueChanged<String>? onFieldSubmitted;
@override
Widget build(BuildContext context) {
return AppCard(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: const TextStyle(
color: Color(0xFF0F172A),
fontSize: 24,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
Text(
description,
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.6,
),
),
if (note != null) ...<Widget>[
const SizedBox(height: 18),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(16),
),
child: Text(
note!,
style: const TextStyle(
color: Color(0xFF1D4ED8),
height: 1.5,
fontWeight: FontWeight.w600,
),
),
),
],
const SizedBox(height: 20),
TextField(
controller: controller,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
autocorrect: false,
enableSuggestions: false,
onSubmitted: onFieldSubmitted,
decoration: const InputDecoration(
labelText: '后端地址',
hintText: 'https://api.example.com',
helperText: '支持 http/https,可保留子路径,例如 https://example.com/live-recorder',
prefixIcon: Icon(Icons.link_rounded),
),
),
if (errorText != null) ...<Widget>[
const SizedBox(height: 14),
Text(
errorText!,
style: const TextStyle(
color: Color(0xFFDC2626),
height: 1.5,
),
),
],
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: isSubmitting ? null : onSubmit,
child: isSubmitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(actionLabel),
),
),
],
),
);
}
}
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
class ClusterStatusCard extends StatelessWidget {
const ClusterStatusCard({
super.key,
required this.healthLabel,
required this.nodeCountLabel,
required this.concurrentRecordingLabel,
required this.storageLabel,
});
final String healthLabel;
final String nodeCountLabel;
final String concurrentRecordingLabel;
final String storageLabel;
@override
Widget build(BuildContext context) {
final items = <({String label, String value})>[
(label: '健康状态', value: healthLabel),
(label: '节点数量', value: nodeCountLabel),
(label: '并发录制', value: concurrentRecordingLabel),
(label: '存储状态', value: storageLabel),
];
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text(
'集群概览',
style: TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final isTwoColumn = constraints.maxWidth >= 320;
final itemWidth =
isTwoColumn ? (constraints.maxWidth - 12) / 2 : constraints.maxWidth;
return Wrap(
spacing: 12,
runSpacing: 12,
children: items.map((({String label, String value}) item) {
return SizedBox(
width: itemWidth,
child: _FactCard(
label: item.label,
value: item.value,
),
);
}).toList(growable: false),
);
},
),
],
),
);
}
}
class _FactCard extends StatelessWidget {
const _FactCard({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
value,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
height: 1.35,
),
),
],
),
);
}
}
@@ -0,0 +1,127 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class RecordingFileCard extends StatefulWidget {
const RecordingFileCard({
super.key,
required this.task,
required this.detail,
required this.onTap,
required this.onDownload,
required this.onVisible,
});
final RecordTask task;
final RecordTaskDetail? detail;
final VoidCallback onTap;
final VoidCallback? onDownload;
final VoidCallback onVisible;
@override
State<RecordingFileCard> createState() => _RecordingFileCardState();
}
class _RecordingFileCardState extends State<RecordingFileCard> {
@override
void initState() {
super.initState();
unawaited(Future<void>.microtask(widget.onVisible));
}
@override
Widget build(BuildContext context) {
final fileName = (widget.task.outputFilePath ?? '').split(RegExp(r'[\\/]')).last;
return AppCard(
onTap: widget.onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
fileName.isEmpty ? '--' : fileName,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
),
StatusBadge(
status: widget.task.status,
context: 'task',
label: taskStatusLabel(widget.task.status),
),
],
),
const SizedBox(height: 8),
Text(
widget.task.liveRoomTitle.isEmpty ? '--' : widget.task.liveRoomTitle,
style: const TextStyle(color: Color(0xFF64748B)),
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: <Widget>[
_InfoPill(label: '大小', value: formatBytes(widget.detail?.result?.fileSizeBytes)),
_InfoPill(label: '时长', value: formatDurationSeconds(widget.detail?.result?.durationSeconds ?? widget.task.durationSeconds)),
_InfoPill(label: '创建时间', value: formatDateTime(widget.task.createdAt)),
_InfoPill(label: '所属房间', value: widget.task.roomId.isEmpty ? '--' : widget.task.roomId),
],
),
const SizedBox(height: 12),
Row(
children: <Widget>[
FilledButton.tonal(
onPressed: widget.onTap,
child: const Text('详情'),
),
const SizedBox(width: 12),
FilledButton(
onPressed: widget.onDownload,
child: const Text('下载'),
),
],
),
],
),
);
}
}
class _InfoPill extends StatelessWidget {
const _InfoPill({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(14),
),
child: Text(
'$label · $value',
style: const TextStyle(
color: Color(0xFF475569),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class RoomCard extends StatelessWidget {
const RoomCard({
super.key,
required this.room,
required this.session,
required this.recentEvent,
required this.onTap,
this.trailing,
});
final LiveRoom room;
final RecordSession? session;
final String recentEvent;
final VoidCallback onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return AppCard(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
room.title ?? room.anchorName ?? room.roomId,
style: const TextStyle(
color: Color(0xFF0F172A),
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'${room.platformName.isEmpty ? '--' : room.platformName} · ${room.roomId.isEmpty ? '--' : room.roomId}',
style: const TextStyle(
color: Color(0xFF64748B),
),
),
],
),
),
?trailing,
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
StatusBadge(
status: room.availabilityStatus,
context: 'availability',
label: availabilityLabel(room.availabilityStatus),
),
StatusBadge(
status: room.currentRecordingState,
context: 'recording',
label: recordingStateLabel(room.currentRecordingState),
),
if (room.isPinned) ...const <Widget>[StatusBadge(status: 'completed', label: '置顶')],
if (room.isPriority) ...const <Widget>[StatusBadge(status: 'retrying', label: '重点')],
],
),
const SizedBox(height: 14),
Wrap(
spacing: 10,
runSpacing: 10,
children: <Widget>[
_Fact(label: '在线人数', value: '--'),
_Fact(label: '码率', value: '--'),
_Fact(label: '录制时长', value: formatDurationSeconds(_recordingDurationSeconds)),
_Fact(label: '采集账号', value: '--'),
],
),
const SizedBox(height: 14),
Text(
'最近事件 · $recentEvent',
style: const TextStyle(
color: Color(0xFF64748B),
height: 1.45,
),
),
],
),
);
}
num? get _recordingDurationSeconds {
final startedAt = DateTime.tryParse(session?.startedAt ?? '');
if (startedAt == null) {
return session?.tasks.isNotEmpty == true ? session?.tasks.last.durationSeconds : null;
}
return DateTime.now().difference(startedAt.toLocal()).inSeconds;
}
}
class _Fact extends StatelessWidget {
const _Fact({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(14),
),
child: Text(
'$label · $value',
style: const TextStyle(
color: Color(0xFF475569),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
);
}
}
@@ -0,0 +1,254 @@
import 'package:flutter/material.dart';
import 'package:live_recorder_mobile/core/utils/formatters.dart';
import 'package:live_recorder_mobile/core/utils/live_room_utils.dart';
import 'package:live_recorder_mobile/core/utils/status_labels.dart';
import 'package:live_recorder_mobile/core/widgets/app_card.dart';
import 'package:live_recorder_mobile/core/widgets/status_badge.dart';
import 'package:live_recorder_mobile/features/live_recorder/data/models/live_recorder_models.dart';
class RoomPreviewCard extends StatelessWidget {
const RoomPreviewCard({
super.key,
required this.room,
required this.session,
required this.recentEvent,
required this.onTap,
this.onWatchLive,
});
final LiveRoom room;
final RecordSession? session;
final String recentEvent;
final VoidCallback onTap;
final VoidCallback? onWatchLive;
@override
Widget build(BuildContext context) {
final preview = room.coverUrl;
final canWatchLive = hasLiveRoomWatchSource(room);
return AppCard(
onTap: onTap,
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
AspectRatio(
aspectRatio: 16 / 9,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: Stack(
fit: StackFit.expand,
children: <Widget>[
if (preview != null && preview.isNotEmpty)
Image.network(
preview,
fit: BoxFit.cover,
errorBuilder: (
BuildContext context,
Object error,
StackTrace? stackTrace,
) =>
_FallbackPreview(room: room),
)
else
_FallbackPreview(room: room),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[
Colors.black.withValues(alpha: 0.08),
Colors.black.withValues(alpha: 0.42),
],
),
),
),
Positioned(
left: 12,
top: 12,
child: StatusBadge(
status: room.availabilityStatus,
context: 'availability',
label: availabilityLabel(room.availabilityStatus),
),
),
if (canWatchLive && onWatchLive != null)
Positioned(
right: 12,
bottom: 12,
child: IconButton.filledTonal(
onPressed: onWatchLive,
tooltip: '观看直播',
icon: const Icon(Icons.play_circle_fill_rounded),
),
),
],
),
),
),
Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
room.title ?? room.anchorName ?? room.roomId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
const SizedBox(height: 6),
Text(
'${room.platformName.isEmpty ? '--' : room.platformName} · Room ${room.roomId.isEmpty ? '--' : room.roomId}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF64748B),
),
),
const SizedBox(height: 12),
StatusBadge(
status: room.currentRecordingState,
context: 'recording',
label: recordingStateLabel(room.currentRecordingState),
),
const SizedBox(height: 12),
const Row(
children: <Widget>[
Expanded(
child: _PreviewFact(
label: '在线人数',
value: '--',
),
),
SizedBox(width: 10),
Expanded(
child: _PreviewFact(
label: '码率',
value: '--',
),
),
],
),
const SizedBox(height: 10),
Row(
children: <Widget>[
Expanded(
child: _PreviewFact(
label: '录制时长',
value: formatDurationSeconds(_duration),
),
),
const SizedBox(width: 10),
Expanded(
child: _PreviewFact(
label: '最近事件',
value: recentEvent,
maxLines: 2,
),
),
],
),
],
),
),
],
),
);
}
num? get _duration {
final startedAt = DateTime.tryParse(session?.startedAt ?? '');
if (startedAt == null) {
return session?.tasks.isNotEmpty == true
? session?.tasks.last.durationSeconds
: null;
}
return DateTime.now().difference(startedAt.toLocal()).inSeconds;
}
}
class _PreviewFact extends StatelessWidget {
const _PreviewFact({
required this.label,
required this.value,
this.maxLines = 1,
});
final String label;
final String value;
final int maxLines;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
label,
style: const TextStyle(
color: Color(0xFF64748B),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
value.isEmpty ? '--' : value,
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF0F172A),
fontWeight: FontWeight.w700,
height: 1.35,
),
),
],
),
);
}
}
class _FallbackPreview extends StatelessWidget {
const _FallbackPreview({
this.room,
});
final LiveRoom? room;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: <Color>[Color(0xFF0F172A), Color(0xFF1E293B)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Center(
child: Text(
room?.platformName ?? 'LiveRecorder',
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
),
),
),
);
}
}
+13
View File
@@ -0,0 +1,13 @@
import 'package:flutter/widgets.dart';
import 'package:live_recorder_mobile/app/app.dart';
import 'package:live_recorder_mobile/core/config/api_config.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(
LiveRecorderBootstrap(
config: ApiConfig.fromEnvironment(),
),
);
}
+514
View File
@@ -0,0 +1,514 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.3"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.20.2"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.17.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.17.6"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.3.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.8"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.29"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.5"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4"
+24
View File
@@ -0,0 +1,24 @@
name: live_recorder_mobile
description: "LiveRecorder Android console built with Flutter."
publish_to: "none"
version: 0.1.0+1
environment:
sdk: ^3.11.0
dependencies:
flutter:
sdk: flutter
http: ^1.6.0
intl: ^0.20.2
path_provider: ^2.1.5
url_launcher: ^6.3.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
flutter:
uses-material-design: true

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