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