From 94f6e08dea79b22540980f0cc65e414d1fdbfae6 Mon Sep 17 00:00:00 2001 From: nanxun Date: Sun, 26 Apr 2026 02:18:03 +0800 Subject: [PATCH] feat: redesign responsive control room ui --- frontend/src/App.vue | 6 + frontend/src/components/layout/MainLayout.vue | 799 ++++++++++++------ frontend/src/composables/useUiPreferences.ts | 134 +++ frontend/src/composables/useViewport.ts | 14 +- frontend/src/router/index.ts | 19 + frontend/src/styles/main.css | 721 +++++++++++----- frontend/src/types.ts | 213 +++++ frontend/src/views/DailyReviewsView.vue | 383 +++++++++ frontend/src/views/LiveRoomsView.vue | 208 ++++- frontend/src/views/LoginView.vue | 316 ++++--- frontend/src/views/LogsView.vue | 126 +-- .../src/views/RecordSessionDetailView.vue | 714 ++++++++++++++++ frontend/src/views/RecordTaskDetailView.vue | 20 +- frontend/src/views/RecordTasksView.vue | 194 ++++- frontend/src/views/RecoveryView.vue | 452 ++++++++++ frontend/src/views/SettingsView.vue | 796 ++++++++++++----- 16 files changed, 4189 insertions(+), 926 deletions(-) create mode 100644 frontend/src/composables/useUiPreferences.ts create mode 100644 frontend/src/views/DailyReviewsView.vue create mode 100644 frontend/src/views/RecordSessionDetailView.vue create mode 100644 frontend/src/views/RecoveryView.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 98240ae..57ee0a2 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,3 +1,9 @@ + + diff --git a/frontend/src/components/layout/MainLayout.vue b/frontend/src/components/layout/MainLayout.vue index 940d9ad..62f8b5a 100644 --- a/frontend/src/components/layout/MainLayout.vue +++ b/frontend/src/components/layout/MainLayout.vue @@ -4,13 +4,21 @@ import { useRouter, useRoute } from "vue-router"; import { useAuthStore } from "@/stores/auth"; import { useBackendStatus } from "@/composables/useBackendStatus"; import { useViewport } from "@/composables/useViewport"; +import { useUiPreferences } from "@/composables/useUiPreferences"; import { House, VideoCamera, + Document, Tickets, + RefreshRight, Setting, SwitchButton, - Operation + Operation, + Fold, + Expand, + Sunny, + Moon, + Monitor } from "@element-plus/icons-vue"; const router = useRouter(); @@ -18,19 +26,115 @@ const route = useRoute(); const authStore = useAuthStore(); const { isMobile } = useViewport(); const { backendUnavailable, backendMessage } = useBackendStatus(); +const { themeMode, density, resolvedTheme, sidebarCollapsed, cycleThemeMode, toggleSidebarCollapsed } = + useUiPreferences(); const mobileNavVisible = ref(false); const displayName = computed(() => authStore.user?.displayName ?? "Operator"); -const menuItems = [ - { index: "/live-rooms", label: "直播间管理", icon: House }, - { index: "/record-tasks", label: "录制任务", icon: VideoCamera }, - { index: "/logs", label: "系统日志", icon: Tickets }, - { index: "/settings", label: "系统设置", icon: Setting } + +const navigationGroups = [ + { + key: "monitor", + title: "直播监控", + items: [ + { index: "/live-rooms", label: "直播间", icon: House }, + { index: "/record-tasks", label: "录制任务", icon: VideoCamera }, + { index: "/recovery", label: "恢复中心", icon: RefreshRight } + ] + }, + { + key: "review", + title: "回顾分析", + items: [ + { index: "/daily-reviews", label: "回顾日报", icon: Document }, + { index: "/logs", label: "系统日志", icon: Tickets } + ] + }, + { + key: "system", + title: "系统管理", + items: [{ index: "/settings", label: "系统设置", icon: Setting }] + } ]; +const pageMeta = computed(() => { + if (route.name === "live-rooms") { + return { + title: "直播监控控制台", + subtitle: "统一管理直播间、自动开录、实时状态和异常处理。" + }; + } + + if (route.name === "record-tasks") { + return { + title: "录制任务工作区", + subtitle: "按会话观察录制进度、分片状态、转码后处理和删除操作。" + }; + } + + if (route.name === "record-session-detail") { + return { + title: "会话事件时间轴", + subtitle: "用整场视角查看分片、脚本、Webhook 与弹幕热度。" + }; + } + + if (route.name === "record-task-detail") { + return { + title: "分片详情", + subtitle: "查看单个分片的产物、状态、输出结果和原始日志。" + }; + } + + if (route.name === "daily-reviews") { + return { + title: "回顾日报", + subtitle: "按天回看录制规模、热点时刻、异常分布与重点会话。" + }; + } + + if (route.name === "logs") { + return { + title: "系统日志", + subtitle: "面向排障的原始事件流,支持快速缩小范围和定位异常。" + }; + } + + if (route.name === "recovery") { + return { + title: "恢复中心", + subtitle: "集中处理未自动拉起的直播间和等待恢复的 MP4 转码任务。" + }; + } + + if (route.name === "settings") { + return { + title: "系统设置", + subtitle: "录制、存储、通知、脚本和平台策略统一在这里收口。" + }; + } + + return { + title: "Live Recorder", + subtitle: "专业直播录制运维控制台。" + }; +}); + +const currentThemeIcon = computed(() => { + if (themeMode.value === "light") { + return Sunny; + } + + if (themeMode.value === "dark") { + return Moon; + } + + return Monitor; +}); + watch( - () => route.path, + () => route.fullPath, () => { mobileNavVisible.value = false; } @@ -44,94 +148,157 @@ async function handleLogout() { diff --git a/frontend/src/composables/useUiPreferences.ts b/frontend/src/composables/useUiPreferences.ts new file mode 100644 index 0000000..718d029 --- /dev/null +++ b/frontend/src/composables/useUiPreferences.ts @@ -0,0 +1,134 @@ +import { computed, reactive } from "vue"; + +type ThemeMode = "light" | "dark" | "system"; +type DensityMode = "comfortable" | "compact"; + +const STORAGE_KEYS = { + themeMode: "live-recorder-ui-theme", + density: "live-recorder-ui-density", + sidebarCollapsed: "live-recorder-ui-sidebar-collapsed" +} as const; + +const state = reactive({ + themeMode: "system" as ThemeMode, + density: "comfortable" as DensityMode, + sidebarCollapsed: false, + systemTheme: "light" as "light" | "dark", + initialized: false +}); + +let mediaQueryList: MediaQueryList | null = null; + +function readStoredThemeMode(): ThemeMode { + const stored = window.localStorage.getItem(STORAGE_KEYS.themeMode); + return stored === "light" || stored === "dark" || stored === "system" ? stored : "system"; +} + +function readStoredDensity(): DensityMode { + return window.localStorage.getItem(STORAGE_KEYS.density) === "compact" ? "compact" : "comfortable"; +} + +function readStoredSidebarCollapsed() { + return window.localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === "true"; +} + +function resolveTheme(): "light" | "dark" { + return state.themeMode === "system" ? state.systemTheme : state.themeMode; +} + +function applyDocumentPreferences() { + if (typeof document === "undefined") { + return; + } + + const resolvedTheme = resolveTheme(); + document.documentElement.dataset.theme = resolvedTheme; + document.documentElement.dataset.themeMode = state.themeMode; + document.documentElement.dataset.density = state.density; +} + +function syncSystemTheme() { + state.systemTheme = mediaQueryList?.matches ? "dark" : "light"; + applyDocumentPreferences(); +} + +function ensureInitialized() { + if (state.initialized || typeof window === "undefined") { + return; + } + + state.themeMode = readStoredThemeMode(); + state.density = readStoredDensity(); + state.sidebarCollapsed = readStoredSidebarCollapsed(); + mediaQueryList = window.matchMedia("(prefers-color-scheme: dark)"); + + if (typeof mediaQueryList.addEventListener === "function") { + mediaQueryList.addEventListener("change", syncSystemTheme); + } else { + mediaQueryList.addListener(syncSystemTheme); + } + + syncSystemTheme(); + state.initialized = true; +} + +function persistPreferences() { + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem(STORAGE_KEYS.themeMode, state.themeMode); + window.localStorage.setItem(STORAGE_KEYS.density, state.density); + window.localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, String(state.sidebarCollapsed)); +} + +export function useUiPreferences() { + ensureInitialized(); + + const resolvedTheme = computed(() => resolveTheme()); + + function setThemeMode(value: ThemeMode) { + state.themeMode = value; + persistPreferences(); + applyDocumentPreferences(); + } + + function cycleThemeMode() { + const nextValue = + state.themeMode === "light" ? "dark" : state.themeMode === "dark" ? "system" : "light"; + setThemeMode(nextValue); + } + + function setDensity(value: DensityMode) { + state.density = value; + persistPreferences(); + applyDocumentPreferences(); + } + + function setSidebarCollapsed(value: boolean) { + state.sidebarCollapsed = value; + persistPreferences(); + } + + function toggleSidebarCollapsed() { + setSidebarCollapsed(!state.sidebarCollapsed); + } + + return { + themeMode: computed({ + get: () => state.themeMode, + set: (value: ThemeMode) => setThemeMode(value) + }), + resolvedTheme, + density: computed({ + get: () => state.density, + set: (value: DensityMode) => setDensity(value) + }), + sidebarCollapsed: computed({ + get: () => state.sidebarCollapsed, + set: (value: boolean) => setSidebarCollapsed(value) + }), + cycleThemeMode, + toggleSidebarCollapsed + }; +} diff --git a/frontend/src/composables/useViewport.ts b/frontend/src/composables/useViewport.ts index 0558918..6969def 100644 --- a/frontend/src/composables/useViewport.ts +++ b/frontend/src/composables/useViewport.ts @@ -1,9 +1,11 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue"; const MOBILE_BREAKPOINT = 768; +const TABLET_BREAKPOINT = 1024; +const DESKTOP_BREAKPOINT = 1280; export function useViewport() { - const width = ref(typeof window === "undefined" ? 1280 : window.innerWidth); + const width = ref(typeof window === "undefined" ? DESKTOP_BREAKPOINT : window.innerWidth); function syncViewport() { width.value = window.innerWidth; @@ -18,10 +20,16 @@ export function useViewport() { window.removeEventListener("resize", syncViewport); }); - const isMobile = computed(() => width.value <= MOBILE_BREAKPOINT); + const isMobile = computed(() => width.value < MOBILE_BREAKPOINT); + const isTablet = computed(() => width.value >= MOBILE_BREAKPOINT && width.value < TABLET_BREAKPOINT); + const isDesktop = computed(() => width.value >= TABLET_BREAKPOINT); + const isWideDesktop = computed(() => width.value >= DESKTOP_BREAKPOINT); return { width, - isMobile + isMobile, + isTablet, + isDesktop, + isWideDesktop }; } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 47e8b7f..bcbc306 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -6,7 +6,10 @@ const MainLayout = () => import("@/components/layout/MainLayout.vue"); const LiveRoomsView = () => import("@/views/LiveRoomsView.vue"); const RecordTasksView = () => import("@/views/RecordTasksView.vue"); const RecordTaskDetailView = () => import("@/views/RecordTaskDetailView.vue"); +const RecordSessionDetailView = () => import("@/views/RecordSessionDetailView.vue"); +const DailyReviewsView = () => import("@/views/DailyReviewsView.vue"); const LogsView = () => import("@/views/LogsView.vue"); +const RecoveryView = () => import("@/views/RecoveryView.vue"); const SettingsView = () => import("@/views/SettingsView.vue"); const router = createRouter({ @@ -42,11 +45,27 @@ const router = createRouter({ component: RecordTaskDetailView, props: true }, + { + path: "record-sessions/:id", + name: "record-session-detail", + component: RecordSessionDetailView, + props: true + }, + { + path: "daily-reviews", + name: "daily-reviews", + component: DailyReviewsView + }, { path: "logs", name: "logs", component: LogsView }, + { + path: "recovery", + name: "recovery", + component: RecoveryView + }, { path: "settings", name: "settings", diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 399d79c..1180cc9 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -1,45 +1,123 @@ :root { color-scheme: light; - color: #171614; - background: #f6f4ef; - font-family: "SF Pro Display", "SF Pro Text", "PingFang SC", "Helvetica Neue", sans-serif; + font-family: "Inter", "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif; - --bg-base: #f6f4ef; - --bg-soft: #fbfaf7; - --surface: rgba(255, 255, 255, 0.82); - --surface-strong: #fffcf7; - --surface-muted: #f3f0ea; - --border-light: rgba(82, 74, 63, 0.08); - --border-base: rgba(82, 74, 63, 0.12); - --border-strong: rgba(82, 74, 63, 0.18); - --text-primary: #171614; - --text-secondary: #6d675f; - --text-tertiary: #948d83; - --accent: #3e5f7c; - --accent-strong: #2f4c66; - --accent-soft: rgba(62, 95, 124, 0.12); - --danger-soft: rgba(183, 80, 70, 0.1); - --shadow-soft: 0 10px 28px rgba(31, 24, 18, 0.05); - --shadow-card: 0 18px 48px rgba(31, 24, 18, 0.07); - --shadow-hover: 0 20px 40px rgba(31, 24, 18, 0.1); + --bg-base: #f3f7fb; + --bg-subtle: #eef3f8; + --bg-emphasis: #e6eef7; + --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); + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 10px; + --page-gap: 24px; + --content-padding: 28px; + --content-padding-mobile: 16px; + --control-height: 40px; + --control-height-sm: 34px; + --table-row-padding: 16px; + --header-row-height: 68px; --el-color-primary: var(--accent); - --el-color-primary-light-3: #6683a0; - --el-color-primary-light-5: #8da4ba; - --el-color-primary-light-7: #bcc9d7; - --el-color-primary-light-8: #d8e0e8; - --el-color-primary-light-9: #edf2f6; + --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-dark-2: var(--accent-strong); + --el-color-success: var(--success); + --el-color-warning: var(--warning); + --el-color-danger: var(--danger); + --el-text-color-primary: var(--text-primary); + --el-text-color-regular: var(--text-secondary); + --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-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); +} + +html[data-density="compact"] { + --page-gap: 20px; + --content-padding: 22px; + --content-padding-mobile: 14px; + --control-height: 36px; + --control-height-sm: 30px; + --table-row-padding: 12px; + --header-row-height: 62px; +} + +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; + --text-primary: #eaf2fb; + --text-secondary: #bdd0e3; + --text-muted: #8ca0b6; + --text-soft: #6f8399; + --text-inverse: #08111a; + --accent: #5ba8ff; + --accent-strong: #3f8de0; + --accent-soft: rgba(91, 168, 255, 0.16); + --info: #5ba8ff; + --success: #31c48d; + --warning: #f0a23c; + --danger: #ef6666; + --focus-ring: rgba(91, 168, 255, 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); + + --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-dark-2: var(--accent-strong); --el-text-color-primary: var(--text-primary); --el-text-color-regular: var(--text-secondary); - --el-text-color-secondary: var(--text-tertiary); + --el-text-color-secondary: var(--text-muted); --el-border-color: var(--border-base); - --el-border-color-light: var(--border-light); - --el-border-radius-base: 10px; - --el-bg-color: transparent; - --el-fill-color-blank: rgba(255, 255, 255, 0.72); - --el-fill-color-light: rgba(244, 241, 236, 0.94); - --el-mask-color: rgba(17, 19, 17, 0.35); + --el-border-color-light: var(--border-subtle); + --el-fill-color-blank: var(--surface); + --el-fill-color-light: var(--surface-muted); + --el-fill-color-lighter: var(--surface-strong); + --el-mask-color: rgba(3, 7, 14, 0.72); } * { @@ -47,8 +125,8 @@ } *::selection { - color: #ffffff; - background: rgba(62, 95, 124, 0.75); + color: var(--text-inverse); + background: rgba(47, 111, 180, 0.82); } html, @@ -61,11 +139,11 @@ body, body { color: var(--text-primary); background: - radial-gradient(circle at top left, rgba(255, 255, 255, 0.8) 0, rgba(255, 255, 255, 0) 32%), - radial-gradient(circle at top right, rgba(62, 95, 124, 0.08) 0, rgba(62, 95, 124, 0) 28%), - linear-gradient(180deg, #faf8f3 0%, #f6f4ef 42%, #f1eee9 100%); - -webkit-font-smoothing: antialiased; + 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%), + linear-gradient(180deg, var(--bg-base) 0%, var(--bg-subtle) 100%); text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; overflow-x: hidden; } @@ -89,12 +167,16 @@ select:focus-visible { outline: none; } +.page-stack { + display: grid; + gap: var(--page-gap); +} + .page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; - margin-bottom: 4px; } .page-header > div:first-child { @@ -104,114 +186,104 @@ select:focus-visible { .page-title { margin: 0; - font-size: clamp(28px, 3vw, 38px); - font-weight: 700; - letter-spacing: -0.04em; - line-height: 1.06; + font-size: clamp(28px, 2vw, 40px); + font-weight: 750; + letter-spacing: -0.045em; + line-height: 1.02; color: var(--text-primary); } .page-subtitle { - max-width: 760px; + max-width: 76ch; margin: 12px 0 0; color: var(--text-secondary); font-size: 14px; - line-height: 1.8; + line-height: 1.75; +} + +.page-kicker { + margin-bottom: 12px; + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.page-toolbar, +.header-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; } .surface-card { position: relative; overflow: hidden; - border-radius: 12px; - border: 1px solid var(--border-base); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.9) 0%, rgba(255, 252, 247, 0.84) 100%); + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: linear-gradient(180deg, var(--surface-raised) 0%, var(--surface) 100%); box-shadow: var(--shadow-soft); - backdrop-filter: blur(14px); transition: - transform 0.22s ease, - box-shadow 0.22s ease, - border-color 0.22s ease; -} - -.surface-card::before { - content: ""; - position: absolute; - inset: 0 0 auto; - height: 1px; - background: linear-gradient(90deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0)); - pointer-events: none; + transform 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + background-color 0.2s ease; } .surface-card:hover { - transform: translateY(-2px); - border-color: var(--border-strong); + border-color: var(--border-base); box-shadow: var(--shadow-card); + transform: translateY(-1px); } .surface-card .el-card__body { - padding: 22px; + padding: 24px; } .stats-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(208px, 1fr)); + grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 16px; - margin-bottom: 2px; } .stat-card { - position: relative; - overflow: hidden; - padding: 18px 18px 16px; - border-radius: 12px; - border: 1px solid var(--border-light); + grid-column: span 3; + display: grid; + gap: 10px; + padding: 18px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); background: - radial-gradient(circle at top right, rgba(62, 95, 124, 0.08) 0, rgba(62, 95, 124, 0) 48%), - linear-gradient(180deg, rgba(255, 255, 255, 0.86) 0%, rgba(250, 248, 244, 0.92) 100%); - box-shadow: 0 12px 26px rgba(31, 24, 18, 0.05); - transition: - transform 0.22s ease, - box-shadow 0.22s ease, - border-color 0.22s ease; + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.42)), + var(--surface-muted); + box-shadow: var(--shadow-soft); } -.stat-card::after { - content: ""; - position: absolute; - top: 14px; - right: 14px; - width: 10px; - height: 10px; - border-radius: 999px; - background: rgba(62, 95, 124, 0.12); - box-shadow: 0 0 0 6px rgba(62, 95, 124, 0.05); -} - -.stat-card:hover { - transform: translateY(-3px); - border-color: var(--border-base); - box-shadow: 0 18px 32px rgba(31, 24, 18, 0.08); +html[data-theme="dark"] .stat-card { + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)), + var(--surface-muted); } .stat-card__label { + color: var(--text-muted); font-size: 11px; font-weight: 700; - letter-spacing: 0.08em; + letter-spacing: 0.1em; text-transform: uppercase; - color: var(--text-tertiary); } .stat-card__value { - margin-top: 14px; - font-size: 32px; - font-weight: 700; - letter-spacing: -0.05em; color: var(--text-primary); + font-size: clamp(26px, 2vw, 36px); + font-weight: 760; + letter-spacing: -0.05em; + line-height: 1; } .stat-card__hint { - margin-top: 8px; color: var(--text-secondary); font-size: 13px; line-height: 1.65; @@ -219,17 +291,90 @@ select:focus-visible { .section-title { margin: 0 0 6px; - font-size: 17px; - font-weight: 650; - letter-spacing: -0.02em; color: var(--text-primary); + font-size: 18px; + font-weight: 700; + letter-spacing: -0.03em; } .section-subtitle { - margin: 0 0 18px; + margin: 0; color: var(--text-secondary); font-size: 13px; - line-height: 1.75; + line-height: 1.7; +} + +.section-header, +.toolbar-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 18px; + border-bottom: 1px solid var(--border-subtle); +} + +.toolbar-row { + margin-bottom: 18px; +} + +.table-scroll-shell { + width: 100%; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; +} + +.page-error-alert, +.page-stream-alert, +.backend-alert { + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); +} + +.cell-title { + color: var(--text-primary); + font-size: 15px; + font-weight: 700; + line-height: 1.45; +} + +.cell-subtitle { + margin-top: 6px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.6; +} + +.cell-mono, +.table-date-text, +.monospace { + font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", "Consolas", monospace; +} + +.cell-mono { + margin-top: 8px; + color: var(--text-muted); + font-size: 12px; + line-height: 1.6; + word-break: break-all; +} + +.table-date-text { + color: var(--text-muted); + font-size: 12px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.path-text, +.log-detail, +.detail-popover { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; } .token-list { @@ -242,25 +387,152 @@ select:focus-visible { .token-chip { display: inline-flex; align-items: center; - padding: 7px 11px; + min-height: 28px; + padding: 0 10px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); font-size: 12px; font-weight: 600; - letter-spacing: -0.01em; } -.monospace { - font-family: "SF Mono", "Cascadia Code", "JetBrains Mono", "Consolas", monospace; - word-break: break-all; +.data-card-list { + display: grid; + gap: 12px; } -.table-scroll-shell { - width: 100%; - overflow-x: auto; - overflow-y: hidden; - -webkit-overflow-scrolling: touch; +.data-card { + display: grid; + gap: 14px; + padding: 16px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--surface-muted); +} + +.data-card__header { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.data-card__title { + color: var(--text-primary); + font-size: 15px; + font-weight: 700; + line-height: 1.4; +} + +.data-card__subtitle { + margin-top: 4px; + color: var(--text-secondary); + font-size: 13px; +} + +.data-card__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 12px; + margin: 0; +} + +.data-card__grid > div { + display: grid; + gap: 4px; +} + +.data-card__grid dt { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.data-card__grid dd { + margin: 0; + color: var(--text-primary); + font-size: 13px; + line-height: 1.6; +} + +.data-card__actions, +.action-row { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.badge-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.info-pill { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid var(--border-subtle); + background: var(--surface); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; +} + +.highlight-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.highlight-item { + display: grid; + gap: 12px; + padding: 18px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: var(--surface-muted); +} + +.highlight-item__label { + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.highlight-item__title { + color: var(--text-primary); + font-size: 16px; + font-weight: 700; +} + +.highlight-item__meta, +.highlight-item__stats, +.highlight-item__footer { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + color: var(--text-secondary); + font-size: 12px; +} + +.highlight-item__summary { + color: var(--text-secondary); + font-size: 13px; + line-height: 1.7; +} + +.desktop-only { + display: block; +} + +.mobile-only { + display: none; } .el-card { @@ -269,81 +541,67 @@ select:focus-visible { } .el-button { - height: 38px; + min-height: var(--control-height); padding: 0 15px; - border-radius: 10px; + border-radius: var(--radius-md); font-weight: 600; - color: var(--text-primary); - letter-spacing: -0.01em; + letter-spacing: 0; transition: transform 0.18s ease, - box-shadow 0.18s ease, border-color 0.18s ease, background-color 0.18s ease, + box-shadow 0.18s ease, color 0.18s ease; } -.el-button.el-button--default:not(.is-text):not(.is-link) { - border-color: rgba(91, 110, 129, 0.16); - background: rgba(255, 255, 255, 0.86); - color: #22303e; -} - .el-button:not(.is-disabled):hover { transform: translateY(-1px); - box-shadow: 0 10px 20px rgba(31, 24, 18, 0.08); } -.el-button:not(.is-disabled):active { - transform: translateY(0); - box-shadow: 0 6px 14px rgba(31, 24, 18, 0.06); +.el-button.el-button--default:not(.is-text):not(.is-link) { + border-color: var(--border-base); + background: var(--surface); + color: var(--text-primary); + box-shadow: none; } .el-button.el-button--primary { - background: linear-gradient(180deg, #4a6984 0%, #3e5f7c 100%) !important; - border-color: #35516a !important; - color: #f7fafc !important; - box-shadow: 0 12px 22px rgba(62, 95, 124, 0.2); + background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%) !important; + border-color: var(--accent-strong) !important; + color: #f7fbff !important; + box-shadow: 0 10px 24px rgba(47, 111, 180, 0.22); } .el-button.el-button--primary:not(.is-disabled):hover { - background: linear-gradient(180deg, #3d5b75 0%, #2f4c66 100%) !important; - border-color: #2f4c66 !important; - color: #f7fafc !important; -} - -.el-button.el-button--primary:not(.is-disabled):active { - background: #2f4c66 !important; - border-color: #29445c !important; + background: linear-gradient(180deg, #3479c1 0%, var(--accent-strong) 100%) !important; + border-color: var(--accent-strong) !important; } .el-button.is-plain:not(.el-button--danger):not(.el-button--primary) { - background: rgba(62, 95, 124, 0.08) !important; - border-color: rgba(62, 95, 124, 0.18) !important; - color: #35536d !important; + background: var(--accent-soft) !important; + border-color: rgba(47, 111, 180, 0.24) !important; + color: var(--accent) !important; } .el-button.el-button--danger.is-plain { - background: rgba(183, 80, 70, 0.08) !important; - border-color: rgba(183, 80, 70, 0.22) !important; - color: #a1473d !important; + background: rgba(199, 81, 81, 0.12) !important; + border-color: rgba(199, 81, 81, 0.24) !important; + color: var(--danger) !important; +} + +.el-button.is-text, +.el-button.is-link { + min-height: auto; + padding-inline: 8px; } .el-button.is-disabled, .el-button.is-disabled:hover, .el-button.is-disabled:active { - background: rgba(233, 237, 241, 0.92) !important; - border-color: rgba(91, 110, 129, 0.12) !important; - color: #9aa5b1 !important; box-shadow: none !important; transform: none !important; } -.el-button.is-text { - padding-left: 8px; - padding-right: 8px; -} - .el-input, .el-select, .el-date-editor, @@ -357,36 +615,35 @@ select:focus-visible { .el-input-number, .el-input-number__decrease, .el-input-number__increase { - border-radius: 10px; + border-radius: var(--radius-md); } .el-input__wrapper, .el-select__wrapper, .el-textarea__inner, .el-input-number { - background: rgba(255, 255, 255, 0.72); - box-shadow: inset 0 0 0 1px rgba(82, 74, 63, 0.1) !important; + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--border-subtle) !important; transition: box-shadow 0.18s ease, - background-color 0.18s ease, - transform 0.18s ease; + background-color 0.18s ease; } .el-input__wrapper:hover, .el-select__wrapper:hover, .el-textarea__inner:hover, .el-input-number:hover { - box-shadow: inset 0 0 0 1px rgba(82, 74, 63, 0.16) !important; + box-shadow: inset 0 0 0 1px var(--border-base) !important; } .el-input__wrapper.is-focus, .el-select__wrapper.is-focused, .el-textarea__inner:focus, .el-input-number:focus-within { - background: rgba(255, 255, 255, 0.92); + background: var(--surface-raised); box-shadow: - inset 0 0 0 1px rgba(62, 95, 124, 0.34), - 0 0 0 4px rgba(62, 95, 124, 0.08) !important; + inset 0 0 0 1px var(--accent), + 0 0 0 4px var(--focus-ring) !important; } .el-textarea__inner { @@ -399,52 +656,52 @@ select:focus-visible { .el-form-item__label { padding-bottom: 8px; + color: var(--text-primary) !important; font-size: 13px; font-weight: 600; - color: var(--text-primary) !important; } .el-switch { --el-switch-on-color: var(--accent); - --el-switch-off-color: rgba(120, 114, 105, 0.28); + --el-switch-off-color: var(--border-base); } .el-tag { - border: none; + border: 1px solid transparent; border-radius: 999px; - height: 28px; + min-height: 28px; padding: 0 10px; font-weight: 600; - letter-spacing: -0.01em; + letter-spacing: 0; } .el-tag--success { - background: rgba(40, 132, 95, 0.12); - color: #1d6d53; + background: rgba(31, 138, 99, 0.14); + color: var(--success); } .el-tag--warning { - background: rgba(189, 131, 37, 0.12); - color: #8a6221; + background: rgba(186, 123, 31, 0.14); + color: var(--warning); } .el-tag--danger { - background: rgba(183, 80, 70, 0.12); - color: #9c4036; + background: rgba(199, 81, 81, 0.14); + color: var(--danger); } .el-tag--info, .el-tag.el-tag--primary { - background: rgba(101, 95, 86, 0.12); - color: #5d554d; + background: rgba(47, 111, 180, 0.1); + color: var(--accent); } .el-table { --el-table-border-color: transparent; - --el-table-row-hover-bg-color: rgba(62, 95, 124, 0.05); - --el-table-current-row-bg-color: rgba(62, 95, 124, 0.07); --el-table-header-bg-color: transparent; --el-table-bg-color: transparent; + --el-table-row-hover-bg-color: rgba(47, 111, 180, 0.06); + --el-table-current-row-bg-color: rgba(47, 111, 180, 0.08); --el-fill-color-blank: transparent; background: transparent; } @@ -455,58 +712,58 @@ select:focus-visible { } .el-table th.el-table__cell { - padding: 4px 0 12px; - border-bottom: 1px solid var(--border-base); + padding: 6px 0 12px; + border-bottom: 1px solid var(--border-subtle); background: transparent; } .el-table th.el-table__cell > .cell { + color: var(--text-muted); font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; - color: var(--text-tertiary); } .el-table td.el-table__cell { - padding: 16px 0; - border-bottom: 1px solid rgba(82, 74, 63, 0.06); + padding: var(--table-row-padding) 0; + border-bottom: 1px solid var(--border-subtle); background: transparent; } -.el-table .cell { - line-height: 1.45; -} - .el-table tr { background: transparent; } +.premium-table { + min-width: 920px; +} + .el-descriptions { - --el-descriptions-table-border: 1px solid rgba(82, 74, 63, 0.08); + --el-descriptions-table-border: 1px solid var(--border-subtle); } .el-descriptions__body .el-descriptions__table { - border-radius: 10px; + border-radius: var(--radius-md); overflow: hidden; } .el-descriptions__body .el-descriptions__label.el-descriptions__cell.is-bordered-label { background: var(--surface-muted); - color: var(--text-tertiary); - font-weight: 600; + color: var(--text-muted); + font-weight: 700; } .el-descriptions__body .el-descriptions__content.el-descriptions__cell.is-bordered-content { - background: rgba(255, 255, 255, 0.72); + background: var(--surface); color: var(--text-primary); } .el-dialog { - border-radius: 16px; + border-radius: 12px; border: 1px solid var(--border-base); - background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(252, 249, 244, 0.94)); - box-shadow: var(--shadow-hover); + background: linear-gradient(180deg, var(--surface-raised), var(--surface)); + box-shadow: var(--shadow-float); } .el-dialog__header { @@ -515,10 +772,10 @@ select:focus-visible { } .el-dialog__title { + color: var(--text-primary); font-size: 18px; font-weight: 700; - letter-spacing: -0.02em; - color: var(--text-primary); + letter-spacing: -0.03em; } .el-dialog__body { @@ -530,46 +787,62 @@ select:focus-visible { } .el-empty__description, -.el-result__subtitle, -.el-empty__description p { +.el-empty__description p, +.el-result__subtitle { color: var(--text-secondary); } -@media (max-width: 1200px) { - .stats-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); +@media (max-width: 1280px) { + .stat-card { + grid-column: span 6; + } + + .highlight-grid { + grid-template-columns: 1fr; } } @media (max-width: 768px) { - .page-header { + .page-header, + .section-header, + .toolbar-row { flex-direction: column; - align-items: flex-start; - gap: 14px; } .page-title { - font-size: clamp(22px, 7vw, 28px); - line-height: 1.08; + font-size: clamp(24px, 8vw, 32px); } - .stats-grid { - grid-template-columns: 1fr; + .page-subtitle { + margin-top: 10px; + font-size: 13px; + line-height: 1.7; } .surface-card .el-card__body { padding: 18px; } - .page-subtitle { - margin-top: 10px; - font-size: 12px; - line-height: 1.62; + .stats-grid, + .data-card__grid { + grid-template-columns: 1fr; + } + + .stat-card { + grid-column: 1 / -1; + } + + .desktop-only { + display: none !important; + } + + .mobile-only { + display: block !important; } .el-dialog { - width: min(100vw - 20px, 560px) !important; - margin: 6vh auto 0 !important; + width: min(100vw - 16px, 560px) !important; + margin: 3vh auto 0 !important; } .el-dialog__header { @@ -583,12 +856,4 @@ select:focus-visible { .el-dialog__footer { padding: 12px 18px 18px; } - - .el-space { - width: 100%; - } - - .el-space__item { - max-width: 100%; - } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 74d8f25..b942172 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -28,6 +28,10 @@ export interface LiveRoom { effectiveSettings: LiveRoomEffectiveSettings; isEnabled: boolean; availabilityStatus: number; + lastAutoStartDecisionCode?: string; + lastAutoStartDecisionSummary?: string; + lastAutoStartDecisionDetail?: string; + lastAutoStartDecisionAt?: string; lastCheckedAt?: string; createdAt: string; updatedAt: string; @@ -187,9 +191,120 @@ export interface RecordPreviewTicket { export interface RecordSessionDetail { session: RecordSession; + timeline: RecordSessionTimeline; logs: SystemLog[]; } +export interface RecordSessionTimeline { + anchorAt: string; + totalDurationSeconds: number; + segments: RecordSessionTimelineSegment[]; + events: RecordSessionTimelineEvent[]; + heatBuckets: RecordSessionHeatBucket[]; +} + +export interface RecordSessionTimelineSegment { + recordTaskId: string; + segmentIndex: number; + status: number; + startedAt: string; + endedAt: string; + offsetSeconds: number; + durationSeconds: number; + label?: string; + detail?: string; +} + +export interface RecordSessionTimelineEvent { + id: string; + layer: string; + title: string; + detail?: string; + recordTaskId?: string; + segmentIndex?: number; + level?: number; + occurredAt: string; + offsetSeconds: number; +} + +export interface RecordSessionHeatBucket { + recordTaskId: string; + segmentIndex: number; + bucketStartedAt: string; + offsetSeconds: number; + durationSeconds: number; + messageCount: number; +} + +export interface DailyReviewReport { + date: string; + utcOffsetMinutes: number; + windowStartUtc: string; + windowEndUtc: string; + summary: DailyReviewSummary; + rooms: DailyReviewRoom[]; + highlights: DailyReviewSessionHighlight[]; + moments: DailyReviewMoment[]; +} + +export interface DailyReviewSummary { + activeLiveRoomCount: number; + sessionCount: number; + segmentCount: number; + totalDurationSeconds: number; + warningCount: number; + errorCount: number; + totalDanmakuCount: number; +} + +export interface DailyReviewRoom { + liveRoomId: string; + platformName: string; + roomId: string; + title?: string; + anchorName?: string; + sessionCount: number; + segmentCount: number; + totalDurationSeconds: number; + warningCount: number; + errorCount: number; + danmakuCount: number; +} + +export interface DailyReviewSessionHighlight { + key: string; + label: string; + recordSessionId: string; + liveRoomId: string; + platformName: string; + roomId: string; + liveRoomTitle: string; + anchorName?: string; + status: number; + segmentCount: number; + durationSeconds: number; + danmakuCount: number; + warningCount: number; + errorCount: number; + startedAt?: string; + endedAt?: string; + summary?: string; +} + +export interface DailyReviewMoment { + liveRoomId: string; + recordSessionId: string; + recordTaskId: string; + segmentIndex: number; + platformName: string; + roomId: string; + liveRoomTitle: string; + anchorName?: string; + bucketStartedAt: string; + bucketEndedAt: string; + danmakuCount: number; +} + export interface SystemSettings { ffmpegPath: string; outputRoot: string; @@ -205,6 +320,9 @@ export interface SystemSettings { enableStorageGuard: boolean; pauseRecordingWhenFreeSpaceBelowMegabytes: number; resumeRecordingWhenFreeSpaceAboveMegabytes: number; + enableRetentionCleanup: boolean; + retentionDays: number; + retentionDeleteFiles: boolean; enableAutoReconnect: boolean; reconnectDelayMaxSeconds: number; readWriteTimeoutMilliseconds: number; @@ -241,11 +359,90 @@ export interface SystemSettings { emailLiveStartedBodyTemplateHtml: string; emailExceptionSubjectTemplate: string; emailExceptionBodyTemplateHtml: string; + enableWebhookNotification: boolean; + webhookUrl: string; + webhookHeaders: string; + webhookTimeoutSeconds: number; + notifyWebhookOnLiveStarted: boolean; + notifyWebhookOnException: boolean; douyinUserAgent: string; douyinReferer: string; douyinCookie: string; } +export interface EventScriptTestResult { + success: boolean; + message: string; + detail?: string; + customLogOutput?: string; +} + +export interface WebhookTestResult { + success: boolean; + message: string; + detail?: string; +} + +export interface RetentionCleanupResult { + deletedSessionCount: number; + deletedTaskCount: number; + deletedResultCount: number; + deletedLogCount: number; + deletedFileCount: number; + deletedDanmakuFileCount: number; + warnings: string[]; +} + +export interface RecoveryOverview { + storage: StorageGuardStatus; + liveRooms: RecoverableLiveRoom[]; + finalizations: RecoverableFinalization[]; +} + +export interface StorageGuardStatus { + isEnabled: boolean; + hasEnoughSpace: boolean; + checkedPath: string; + availableBytes: number; + requiredBytes: number; + message: string; +} + +export interface RecoverableLiveRoom { + liveRoomId: string; + platformName: string; + roomId: string; + title?: string; + anchorName?: string; + lastAutoStartDecisionCode?: string; + lastAutoStartDecisionSummary?: string; + lastAutoStartDecisionDetail?: string; + lastAutoStartDecisionAt?: string; + lastCheckedAt?: string; +} + +export interface RecoverableFinalization { + recordTaskId: string; + recordSessionId: string; + liveRoomId: string; + liveRoomTitle: string; + roomId: string; + platformName: string; + segmentIndex: number; + status: number; + outputFilePath?: string; + reason?: string; + createdAt: string; + endedAt?: string; +} + +export interface RecoveryActionResult { + requestedCount: number; + successCount: number; + failedCount: number; + messages: string[]; +} + export const availabilityLabelMap: Record = { 0: "Unknown", 1: "Offline", @@ -287,3 +484,19 @@ export const recordingTemplateLabelMap: Record = { 1: "Balanced MP4", 2: "Archive TS" }; + +export const autoStartDecisionLabelMap: Record = { + started: "Started", + skipped_disabled: "Disabled", + skipped_storage: "Low Storage", + skipped_active_session: "Active Session", + skipped_offline: "Offline", + failed_startup: "Startup Failed" +}; + +export const platformLabelMap: Record = { + 0: "Unknown", + 1: "Douyin", + 2: "Bilibili", + 3: "Huya" +}; diff --git a/frontend/src/views/DailyReviewsView.vue b/frontend/src/views/DailyReviewsView.vue new file mode 100644 index 0000000..3213ad5 --- /dev/null +++ b/frontend/src/views/DailyReviewsView.vue @@ -0,0 +1,383 @@ + + + + + diff --git a/frontend/src/views/LiveRoomsView.vue b/frontend/src/views/LiveRoomsView.vue index 4a7d708..d7c8ccc 100644 --- a/frontend/src/views/LiveRoomsView.vue +++ b/frontend/src/views/LiveRoomsView.vue @@ -5,6 +5,7 @@ import apiClient, { getApiErrorMessage } from "@/api/client"; import { useViewport } from "@/composables/useViewport"; import type { BatchLiveRoomsResult, ImportLiveRoomsResult, LiveRoom, RecordTask } from "@/types"; import { + autoStartDecisionLabelMap, availabilityLabelMap, outputFormatLabelMap, recordingTemplateLabelMap, @@ -395,6 +396,30 @@ function getRoomAvatarText(room: LiveRoom) { return source.slice(0, 1).toUpperCase(); } +function autoStartDecisionLabel(code?: string) { + if (!code) { + return "未记录"; + } + + return autoStartDecisionLabelMap[code] ?? code; +} + +function autoStartDecisionTagType(code?: string) { + if (code === "started") { + return "success"; + } + + if (code === "failed_startup") { + return "danger"; + } + + if (code === "skipped_storage" || code === "skipped_active_session") { + return "warning"; + } + + return "info"; +} + function formatDate(value?: string) { return value ? new Date(value).toLocaleString() : "-"; } @@ -451,18 +476,19 @@ onMounted(loadRooms);
@@ -496,7 +522,7 @@ onMounted(loadRooms);

直播间列表

支持刷新状态、手动启动录制、单房间配置覆盖,以及按需删除直播间。

- + 已选 {{ selectedRoomCount }} 个 批量启用 批量停用 @@ -506,7 +532,78 @@ onMounted(loadRooms); -
+
+
+
+ + {{ getRoomAvatarText(row) }} + + +
+
{{ row.title || row.anchorName || row.roomId }}
+
{{ row.anchorName || "未知主播" }}
+
{{ row.roomId }}
+
+
+ +
+ + {{ availabilityLabelMap[row.availabilityStatus] }} + + {{ row.platformName }} + + {{ autoStartDecisionLabel(row.lastAutoStartDecisionCode) }} + +
+ +
+
+
自动开录
+
{{ row.lastAutoStartDecisionSummary || "暂无自动开录摘要" }}
+
+
+
最近巡检
+
{{ formatDate(row.lastCheckedAt) }}
+
+
+
房间配置
+
+ {{ row.effectiveSettings.preferredQuality }} · + {{ outputFormatLabelMap[row.effectiveSettings.outputFormat] }} · + {{ saveModeLabelMap[row.effectiveSettings.saveMode] }} · + {{ row.effectiveSettings.enableDanmakuRecording ? "弹幕开" : "弹幕关" }} +
+
+
+
决策详情
+
{{ row.lastAutoStartDecisionDetail }}
+
+
+ +
+ 录制开关 + +
+ +
+ 刷新 + 配置 + + 录制 + + 删除 +
+
+
+ +
+ + + +