diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..7028445 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,29 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|apply_patch", + "hooks": [ + { + "type": "command", + "command": "[ ! -f \"/home/nanxunai/.agents/skills/impeccable/scripts/hook.mjs\" ] || node \"/home/nanxunai/.agents/skills/impeccable/scripts/hook.mjs\"", + "timeout": 5, + "statusMessage": "Checking UI changes" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "[ ! -f \"/home/nanxunai/.agents/skills/impeccable/scripts/hook.mjs\" ] || node \"/home/nanxunai/.agents/skills/impeccable/scripts/hook.mjs\"", + "timeout": 30, + "statusMessage": "Design deep pass" + } + ] + } + ] + } +} diff --git a/README.md b/README.md index 015e56a..e6a3742 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ docker compose up -d ```bash ./scripts/build-fnos-package.sh ./scripts/smoke-fnos-package.sh \ - artifacts/fnos/liverecorder-1.2.13-x86_64.fpk \ + artifacts/fnos/liverecorder-1.2.19-x86_64.fpk \ /path/to/nxsir-postgresql-15.1.1-x86_64.fpk ``` diff --git a/docs/postgresql-migration.md b/docs/postgresql-migration.md index 8b91922..3086453 100644 --- a/docs/postgresql-migration.md +++ b/docs/postgresql-migration.md @@ -35,7 +35,7 @@ PostgreSQL 共享服务由独立仓库构建和发布: -import { computed, ref, watch } from "vue"; +import { computed } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useAuthStore } from "@/stores/auth"; import { useBackendStatus } from "@/composables/useBackendStatus"; import { useUiPreferences } from "@/composables/useUiPreferences"; -import { useViewport } from "@/composables/useViewport"; -import { - Bell, - Collection, - DataAnalysis, - Document, - FolderOpened, - Fold, - House, - Menu, - Moon, - RefreshRight, - Setting, - Sunny, - Tickets, - Upload, - VideoCamera -} from "@element-plus/icons-vue"; +import { Bell, Collection, DataAnalysis, Menu, Moon, Setting, Sunny, Tickets, VideoCamera } from "@element-plus/icons-vue"; const router = useRouter(); const route = useRoute(); const authStore = useAuthStore(); -const { isMobile } = useViewport(); const { backendUnavailable, backendMessage, backendLastChangedAt } = useBackendStatus(); const { resolvedTheme, sidebarCollapsed, cycleThemeMode, toggleSidebarCollapsed } = useUiPreferences(); -const mobileNavVisible = ref(false); - const navigationGroups = [ { - key: "overview", - title: "总览", + key: "workspace", + title: "工作区", items: [ - { index: "/", label: "仪表盘", icon: DataAnalysis }, + { index: "/", label: "运行中心", shortLabel: "运行", icon: DataAnalysis, names: ["dashboard"] }, + { index: "/live-rooms", label: "直播与录制", shortLabel: "录制", icon: VideoCamera, names: ["live-rooms", "record-tasks", "record-task-detail", "record-session-detail", "recovery"] }, + { index: "/transcode-tasks", label: "处理与归档", shortLabel: "归档", icon: Collection, names: ["transcode-tasks", "upload-tasks", "media-browser"] }, + { index: "/daily-reviews", label: "分析与排障", shortLabel: "分析", icon: Tickets, names: ["daily-reviews", "logs"] } ] }, { - key: "monitor", - title: "监控录制", + key: "manage", + title: "管理", items: [ - { index: "/live-rooms", label: "直播间", icon: House }, - { index: "/record-tasks", label: "录制任务", icon: VideoCamera }, - { index: "/recovery", label: "恢复中心", icon: RefreshRight } - ] - }, - { - key: "media", - title: "媒资归档", - items: [ - { index: "/transcode-tasks", label: "转码任务", icon: Collection }, - { index: "/upload-tasks", label: "上传任务", icon: Upload }, - { index: "/media-browser", label: "文件库", icon: FolderOpened } - ] - }, - { - key: "analysis", - title: "分析", - items: [ - { index: "/daily-reviews", label: "回顾日报", icon: Document }, - { index: "/logs", label: "系统日志", icon: Tickets } - ] - }, - { - key: "system", - title: "系统", - items: [ - { index: "/settings", label: "系统设置", icon: Setting } + { index: "/settings", label: "系统设置", shortLabel: "设置", icon: Setting, names: ["settings"] } ] } ]; +const bottomNavigation = computed(() => navigationGroups.flatMap((group) => group.items)); + +const workspaceTabs = computed(() => { + const name = String(route.name ?? ""); + if (["live-rooms", "record-tasks", "record-task-detail", "record-session-detail", "recovery"].includes(name)) { + return [ + { name: "live-rooms", label: "直播间" }, + { name: "record-tasks", label: "录制会话" }, + { name: "recovery", label: "恢复中心" } + ]; + } + if (["transcode-tasks", "upload-tasks", "media-browser"].includes(name)) { + return [ + { name: "transcode-tasks", label: "转码队列" }, + { name: "upload-tasks", label: "上传队列" }, + { name: "media-browser", label: "文件库" } + ]; + } + if (["daily-reviews", "logs"].includes(name)) { + return [ + { name: "daily-reviews", label: "回顾日报" }, + { name: "logs", label: "系统日志" } + ]; + } + return []; +}); + const userDisplayName = computed(() => authStore.user?.displayName || authStore.user?.username || "管理员"); const userAvatarText = computed(() => userDisplayName.value.trim().slice(0, 1).toUpperCase() || "录"); -function isNavItemActive(index: string) { - return route.path === index || route.path.startsWith(`${index}/`); +function isNavItemActive(item: { names: string[] }) { + return item.names.includes(String(route.name ?? "")); } function navigate(index: string) { void router.push(index); } +function isWorkspaceTabActive(name: string) { + const current = String(route.name ?? ""); + if (name === "record-tasks") return ["record-tasks", "record-task-detail", "record-session-detail"].includes(current); + return current === name; +} + const pageBreadcrumb = computed(() => { const m: Record = { - dashboard: "总览 / 仪表盘", - "live-rooms": "监控录制 / 直播间", - "record-tasks": "监控录制 / 录制任务", - "transcode-tasks": "媒资归档 / 转码任务", - "upload-tasks": "媒资归档 / 上传任务", - "media-browser": "媒资归档 / 文件库", - "record-task-detail": "监控录制 / 录制任务 / 分片详情", - "record-session-detail": "监控录制 / 录制任务 / 会话详情", - "daily-reviews": "分析 / 回顾日报", - logs: "分析 / 系统日志", - recovery: "监控录制 / 恢复中心", + dashboard: "工作台 / 运行中心", + "live-rooms": "直播与录制 / 直播间", + "record-tasks": "直播与录制 / 录制会话", + "transcode-tasks": "处理与归档 / 转码队列", + "upload-tasks": "处理与归档 / 上传队列", + "media-browser": "处理与归档 / 文件库", + "record-task-detail": "直播与录制 / 分片详情", + "record-session-detail": "直播与录制 / 会话详情", + "daily-reviews": "分析与排障 / 回顾日报", + logs: "分析与排障 / 系统日志", + recovery: "直播与录制 / 恢复中心", settings: "系统 / 系统设置" }; return m[String(route.name)] || "控制台"; }); async function handleLogout() { - mobileNavVisible.value = false; await authStore.logout(); await router.push({ name: "login" }); } -function openMobileNav() { mobileNavVisible.value = true; } -function closeMobileNav() { mobileNavVisible.value = false; } - -watch(() => route.fullPath, () => { mobileNavVisible.value = false; }); - diff --git a/frontend/src/components/ui/StorageCapacity.vue b/frontend/src/components/ui/StorageCapacity.vue index 818a343..9dab83f 100644 --- a/frontend/src/components/ui/StorageCapacity.vue +++ b/frontend/src/components/ui/StorageCapacity.vue @@ -43,6 +43,9 @@ const barColor = computed(() => { if (tier.value === "yellow") return "var(--warning)"; return "var(--danger)"; }); +const ringStyle = computed(() => ({ + background: `conic-gradient(${barColor.value} ${percentage.value}%, var(--surface-strong) 0)` +})); function formatBytes(bytes: number) { if (!Number.isFinite(bytes) || bytes < 0) return "--"; @@ -59,58 +62,58 @@ function formatBytes(bytes: number) { diff --git a/frontend/src/composables/useViewport.ts b/frontend/src/composables/useViewport.ts index 6969def..2590c0f 100644 --- a/frontend/src/composables/useViewport.ts +++ b/frontend/src/composables/useViewport.ts @@ -1,6 +1,8 @@ import { computed, onBeforeUnmount, onMounted, ref } from "vue"; -const MOBILE_BREAKPOINT = 768; +// CSS mobile rules use `max-width: 768px`, so 768px must follow the same +// rendering branch instead of receiving the desktop/table markup. +const MOBILE_BREAKPOINT = 769; const TABLET_BREAKPOINT = 1024; const DESKTOP_BREAKPOINT = 1280; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 16dbc76..5dbf0dd 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -13,65 +13,73 @@ -webkit-font-smoothing: antialiased; /* neutrals */ - --bg-base: #eef1f6; - --bg-subtle: #e7ebf2; + --bg-base: #f4f7fc; + --bg-subtle: #edf2f9; --surface: #ffffff; --surface-raised: #ffffff; - --surface-muted: #f4f7fb; - --surface-strong: #eef2f8; - --surface-hover: #eef2f8; - --border-subtle: #e3e7ef; - --border-base: #d2d8e3; - --text-primary: #0f172a; - --text-secondary: #45526a; - --text-muted: #6a7689; - --text-soft: #97a1b3; + --surface-muted: #f8fafe; + --surface-subtle: #f8fafe; + --surface-strong: #edf2f9; + --surface-hover: #f1f6ff; + --border-subtle: #e3eaf4; + --border-base: #cfd9e8; + --text-primary: #17233a; + --text-secondary: #42526d; + --text-muted: #62718a; + --text-soft: #68768d; /* sidebar */ - --side-bg: #f3f5f9; - --side-border: #e3e7ef; - --side-title: #7a8699; - --side-item: #44526b; - --side-item-hover-bg: rgba(26, 86, 219, 0.07); - --side-item-hover: #1e40af; - --side-active-bg: rgba(26, 86, 219, 0.10); - --side-active: #1a56db; + --side-bg: #fbfcff; + --side-border: #e3eaf4; + --side-title: #68768d; + --side-item: #40506a; + --side-item-hover-bg: #f8fafe; + --side-item-hover: #17233a; + --side-active-bg: #f1f6ff; + --side-active: #185dcc; /* brand */ - --accent: #1a56db; - --accent-strong: #1e40af; - --accent-soft: rgba(26, 86, 219, 0.12); - --accent-soft-2: rgba(26, 86, 219, 0.07); - --info: #2563eb; - --info-soft: rgba(37, 99, 235, 0.11); - --success: #16a34a; - --success-soft: rgba(22, 163, 74, 0.12); - --warning: #d97706; - --warning-soft: rgba(217, 119, 6, 0.13); - --danger: #dc2626; - --danger-soft: rgba(220, 38, 38, 0.11); - --purple: #7c3aed; - --purple-soft: rgba(124, 58, 237, 0.11); + --accent: #185dcc; + --accent-strong: #144ba8; + --accent-soft: #eaf2ff; + --accent-soft-2: #f1f6ff; + --action: #246bfe; + --action-hover: #1856d7; + --info: #185dcc; + --info-soft: #eaf2ff; + --cyan: #0d89ad; + --cyan-soft: #e7f7fb; + --success: #137e5c; + --success-soft: #e6f7f0; + --warning: #a45f00; + --warning-soft: #fff4dd; + --danger: #b83342; + --danger-soft: #ffebef; + --purple: #6558d9; + --purple-soft: #f0edff; + --ambient-blue: rgba(86, 137, 255, .13); + --ambient-violet: rgba(146, 119, 255, .09); + --ambient-warm: rgba(255, 187, 112, .08); /* elevation */ - --shadow-xs: 0 1px 3px rgba(16, 24, 40, 0.09), 0 1px 2px rgba(16, 24, 40, 0.05); - --shadow-sm: 0 2px 8px rgba(16, 24, 40, 0.10), 0 1px 3px rgba(16, 24, 40, 0.06); - --shadow-md: 0 8px 20px rgba(16, 24, 40, 0.12), 0 2px 6px rgba(16, 24, 40, 0.07); - --shadow-lg: 0 16px 40px rgba(16, 24, 40, 0.16), 0 4px 10px rgba(16, 24, 40, 0.08); - --shadow-float: 0 24px 56px rgba(16, 24, 40, 0.22); + --shadow-xs: 0 2px 8px rgba(37, 61, 98, .045); + --shadow-sm: 0 8px 24px rgba(37, 61, 98, .08); + --shadow-md: 0 14px 38px rgba(37, 61, 98, .10); + --shadow-lg: 0 20px 52px rgba(22, 38, 68, .16); + --shadow-float: 0 28px 76px rgba(22, 38, 68, .22); /* radius */ --radius-xs: 7px; --radius-sm: 9px; - --radius-md: 14px; - --radius-lg: 20px; + --radius-md: 12px; + --radius-lg: 18px; /* layout */ - --sidebar-w: 244px; + --sidebar-w: 224px; --sidebar-w-collapsed: 72px; - --topbar-h: 62px; - --page-max: 1320px; - --page-gap: 16px; + --topbar-h: 64px; + --page-max: 1440px; + --page-gap: 14px; --content-padding: 20px; --content-padding-mobile: 14px; --control-height: 40px; @@ -88,42 +96,56 @@ html[data-density="compact"] { --control-height-sm: 30px; --table-row-padding: 12px; --header-row-height: 56px; - --topbar-h: 56px; + --topbar-h: 64px; } html[data-theme="dark"] { color-scheme: dark; - --bg-base: #0a1120; - --bg-subtle: #0f172a; - --surface: #121c30; - --surface-raised: #162238; - --surface-muted: #16223899; - --surface-strong: #1b2942; - --surface-hover: #1b2942; - --border-subtle: #1f2c44; - --border-base: #2c3a55; - --text-primary: #e9eefb; - --text-secondary: #b6c4dd; - --text-muted: #8295b3; - --text-soft: #647a9a; + --bg-base: #0f1726; + --bg-subtle: #111d30; + --surface: #172235; + --surface-raised: #1b2940; + --surface-muted: #1b2940; + --surface-subtle: #1b2940; + --surface-strong: #22324a; + --surface-hover: #22324a; + --border-subtle: #2c3c55; + --border-base: #40516a; + --text-primary: #f2f6fd; + --text-secondary: #d2dbea; + --text-muted: #a9b7cb; + --text-soft: #9aa9be; - --side-bg: #0c1424; - --side-border: #1a2740; - --side-title: #4a607d; - --side-item: #8295b3; - --side-item-hover-bg: rgba(255, 255, 255, 0.05); - --side-item-hover: #d4def0; - --side-active-bg: rgba(96, 165, 250, 0.18); - --side-active: #93c5fd; + --side-bg: #131e30; + --side-border: #2c3c55; + --side-title: #a1aec2; + --side-item: #d6e0ee; + --side-item-hover-bg: #1b2940; + --side-item-hover: #f2f6fd; + --side-active-bg: #1b2b43; + --side-active: #79a9ff; - --accent: #60a5fa; - --accent-strong: #3b82f6; - --accent-soft: rgba(96, 165, 250, 0.18); - --accent-soft-2: rgba(96, 165, 250, 0.10); - --info: #60a5fa; - --success: #34d399; - --warning: #fbbf24; - --danger: #f87171; + --accent: #79a9ff; + --accent-strong: #a5c6ff; + --accent-soft: rgba(86, 143, 238, .20); + --accent-soft-2: rgba(86, 143, 238, .12); + --action: #2763d2; + --action-hover: #3575e6; + --info: #79a9ff; + --info-soft: rgba(86, 143, 238, .20); + --cyan: #62c9e4; + --cyan-soft: rgba(43, 165, 196, .17); + --success: #65d0a4; + --success-soft: rgba(40, 159, 114, .20); + --warning: #f0b75c; + --warning-soft: rgba(207, 139, 35, .20); + --danger: #ff8b94; + --danger-soft: rgba(211, 68, 82, .22); + --purple: #aa9cff; + --purple-soft: rgba(123, 102, 221, .20); + --ambient-blue: rgba(58, 112, 220, .17); + --ambient-violet: rgba(119, 88, 207, .13); + --ambient-warm: rgba(205, 132, 55, .07); --shadow-xs: 0 1px 3px rgba(0, 0, 0, 0.4); --shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.45); @@ -133,6 +155,7 @@ html[data-theme="dark"] { } /* ============================ Element Plus overrides ============================ */ +:root, #app { --el-color-primary: var(--accent); --el-color-primary-light-3: #5b8cf0; @@ -162,16 +185,18 @@ html[data-theme="dark"] { --el-mask-color: rgba(15, 23, 42, 0.54); } +html[data-theme="dark"], html[data-theme="dark"] #app { - --el-color-primary-light-3: #93c5fd; - --el-color-primary-light-5: #bfdbfe; - --el-color-primary-light-7: #dbeafe; - --el-color-primary-light-9: #eff6ff; + --el-color-primary-light-3: #5d8fe7; + --el-color-primary-light-5: #426baf; + --el-color-primary-light-7: #2c4775; + --el-color-primary-light-9: #1b2b43; --el-mask-color: rgba(3, 7, 14, 0.72); } /* ============================ Base ============================ */ * { box-sizing: border-box; } +html { -webkit-tap-highlight-color: transparent; } html, body, #app { margin: 0; width: 100%; height: 100%; min-height: 100%; } body { color: var(--text-primary); @@ -182,6 +207,14 @@ body { a { color: inherit; text-decoration: none; } button { font-family: inherit; cursor: pointer; } +/* Keep keyboard focus visible without leaving a browser-default frame after + mouse or touch activation. */ +:where(a, button, [role="button"], input, textarea, select):focus { outline: none; } +:where(a, button, [role="button"], input, textarea, select):focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent) 34%, transparent); + outline-offset: 2px; +} + *::-webkit-scrollbar { width: 10px; height: 10px; } *::-webkit-scrollbar-thumb { background: var(--border-base); border-radius: 99px; border: 3px solid transparent; background-clip: content-box; } *::-webkit-scrollbar-thumb:hover { background: var(--text-soft); background-clip: content-box; } @@ -193,9 +226,9 @@ button { font-family: inherit; cursor: pointer; } .page-stack { display: grid; gap: var(--page-gap); width: min(100%, var(--page-max)); margin: 0 auto; padding-bottom: 24px; } .page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; } .page-header > div:first-child { flex: 1; min-width: 0; } -.page-title { margin: 0; font-size: 24px; font-weight: 750; letter-spacing: -.02em; line-height: 1.25; color: var(--text-primary); } -.page-subtitle { max-width: 76ch; margin: 8px 0 0; color: var(--text-muted); font-size: 13.5px; line-height: 1.6; } -.page-kicker { margin-bottom: 7px; color: var(--accent); font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; } +.page-title { margin: 0; font-size: clamp(24px, 2vw, 28px); font-weight: 780; letter-spacing: -.035em; line-height: 1.2; color: var(--text-primary); } +.page-subtitle { max-width: 72ch; margin: 7px 0 0; color: var(--text-muted); font-size: 12.5px; line-height: 1.55; } +.page-kicker { margin-bottom: 5px; color: var(--accent); font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } .page-toolbar, .header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; flex-shrink: 0; } /* cards */ @@ -241,6 +274,7 @@ button { font-family: inherit; cursor: pointer; } .cell-mono { margin-top: 4px; color: var(--text-muted); font-size: 12px; word-break: break-all; } .table-date-text { color: var(--text-muted); font-size: 12px; 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; } +.platform-line { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 5px; min-width: 0; } /* badges/tags */ .badge-row { display: flex; flex-wrap: wrap; gap: 8px; } @@ -288,12 +322,12 @@ button { font-family: inherit; cursor: pointer; } /* primary button — flat, blue glow */ .el-button.el-button--primary { - background: var(--accent) !important; border-color: var(--accent-strong) !important; color: #fff !important; - box-shadow: 0 1px 3px rgba(26, 86, 219, 0.4), 0 4px 14px rgba(26, 86, 219, 0.2); + background: var(--action) !important; border-color: var(--action) !important; color: #fff !important; + box-shadow: 0 7px 18px color-mix(in srgb, var(--action) 22%, transparent); } .el-button.el-button--primary:not(.is-disabled):hover { - background: var(--accent-strong) !important; border-color: var(--accent-strong) !important; color: #fff !important; - box-shadow: 0 2px 5px rgba(26, 86, 219, 0.45), 0 6px 18px rgba(26, 86, 219, 0.28); + background: var(--action-hover) !important; border-color: var(--action-hover) !important; color: #fff !important; + box-shadow: 0 9px 22px color-mix(in srgb, var(--action) 28%, transparent); } .el-button.is-plain:not(.el-button--danger):not(.el-button--primary) { background: var(--accent-soft) !important; border-color: transparent !important; color: var(--accent) !important; } .el-button.el-button--danger.is-plain { background: var(--danger-soft) !important; border-color: transparent !important; color: var(--danger) !important; } @@ -327,10 +361,10 @@ button { font-family: inherit; cursor: pointer; } background: transparent; } .el-table::before, .el-table__inner-wrapper::before { display: none; } -.el-table th.el-table__cell { padding: 12px 20px; border-bottom: 2px solid var(--border-base); background: #eef2f8; } -html[data-theme="dark"] .el-table th.el-table__cell { background: #16223a; } +.el-table th.el-table__cell { padding: 11px 16px; border-bottom: 1px solid var(--border-subtle); background: var(--surface-muted); } +html[data-theme="dark"] .el-table th.el-table__cell { background: var(--surface-muted); } .el-table th.el-table__cell > .cell { color: var(--text-muted); font-size: 12px; font-weight: 700; letter-spacing: .02em; text-transform: uppercase; } -.el-table td.el-table__cell { padding: 14px 20px; border-bottom: 1px solid var(--border-subtle); background: transparent; } +.el-table td.el-table__cell { padding: 13px 16px; border-bottom: 1px solid var(--border-subtle); background: transparent; } .el-table tr { background: transparent; } .el-table tbody tr:nth-child(even) { background: rgba(15, 23, 42, 0.018); } html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.02); } @@ -351,7 +385,7 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25 border-radius: var(--radius-md); border: 1px solid var(--border-base); background: var(--surface); box-shadow: var(--shadow-float); } -.el-message-box, .el-popover.el-popper, .el-select__popper.el-popper, .el-picker__popper.el-popper { border-color: var(--border-subtle); background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow-float); } +.el-message-box, .el-popover.el-popper, .el-select__popper.el-popper, .el-picker__popper.el-popper, .el-dropdown__popper.el-popper { border-color: var(--border-subtle); background: var(--surface); color: var(--text-primary); box-shadow: var(--shadow-float); } .el-dropdown__popper.el-popper .el-dropdown-menu { border-color: var(--border-subtle); background: var(--surface); box-shadow: var(--shadow-md); border-radius: var(--radius-sm); } .el-dropdown-menu__item.danger-menu-item { color: var(--danger); } .el-dialog__header { margin: 0; padding: 20px 24px 10px; } @@ -368,6 +402,8 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25 .el-tabs--border-card > .el-tabs__header { background: transparent; } .el-tabs--border-card > .el-tabs__header .el-tabs__item { color: var(--text-secondary); } .el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active { color: var(--text-primary); background: var(--surface); } +.session-recovery-alert { margin-block-end: 16px; } +.session-recovery-alert .el-alert__description, .recovery-status-copy { overflow-wrap: anywhere; } /* ============================ Skeleton loading ============================ */ @keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } } @@ -388,7 +424,7 @@ html[data-theme="dark"] .el-table tbody tr:nth-child(even) { background: rgba(25 @media (max-width: 768px) { .page-header, .section-header, .toolbar-row { flex-direction: column; } .page-title { font-size: 22px; } - .page-subtitle { display: none; } + .page-subtitle { display: block; font-size: 11px; } .surface-card .el-card__body { padding: 14px; } .stats-grid, .data-card__grid { grid-template-columns: repeat(2, 1fr); gap: 8px; } .stat-card { padding: 14px; gap: 8px; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8c807a1..bac1006 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -141,6 +141,8 @@ export interface RecordTask { startedAt?: string; endedAt?: string; durationSeconds?: number; + isHiddenArtifactSource: boolean; + mergedIntoRecordTaskId?: string; uploadStatus?: number; postProcessStage?: string; postProcessProgressPercent?: number; @@ -170,6 +172,7 @@ export interface RecordSession { id: string; liveRoomId: string; liveRoomTitle: string; + anchorName?: string; platform: number; roomId: string; status: number; @@ -180,6 +183,8 @@ export interface RecordSession { segmentCount: number; recorderProcessId?: number; errorMessage?: string; + isRecovering: boolean; + recoveryReason?: string; createdAt: string; startedAt?: string; endedAt?: string; @@ -318,6 +323,16 @@ export interface UploadTaskListResponse { queuedCount: number; uploadingCount: number; waitingRetryCount: number; + matchingRetryableCount: number; + queueHealth: UploadQueueHealth; +} + +export interface UploadQueueHealth { + state: "Healthy" | "RateLimited" | "AuthenticationBlocked" | "Disabled" | string; + isPaused: boolean; + reason?: string; + retryAt?: string; + lastErrorAt?: string; } export interface ManualSegmentCompletedTriggerResult { @@ -668,6 +683,19 @@ export interface RecoveryOverview { storage: StorageGuardStatus; liveRooms: RecoverableLiveRoom[]; finalizations: RecoverableFinalization[]; + mergedArtifacts: MergedArtifactRecord[]; +} + +export interface MergedArtifactRecord { + sourceRecordTaskId: string; + recordSessionId: string; + mergedIntoRecordTaskId?: string; + sourceVideoPath?: string; + mergedVideoPath?: string; + recoveryDirectory?: string; + manifestPath?: string; + sourceDurationSeconds?: number; + createdAt: string; } export interface StorageGuardStatus { @@ -722,6 +750,34 @@ export interface RecoveryActionResult { messages: string[]; } +export interface RecordingFailureListResponse { + items: RecordingFailureItem[]; + totalCount: number; +} + +export interface RecordingFailureItem { + recordTaskId: string; + recordSessionId: string; + liveRoomId: string; + liveRoomTitle: string; + roomId: string; + platformName: string; + segmentIndex: number; + failureKind: string; + failureLabel: string; + recommendedAction: string; + errorMessage?: string; + filePath?: string; + fileSizeBytes?: number; + durationSeconds?: number; + fileExists: boolean; + canAccept: boolean; + canRepair: boolean; + canRetryRoom: boolean; + isRepairing: boolean; + createdAt: string; +} + export interface TranscodeTaskItem { task: RecordTask; result?: RecordResult; diff --git a/frontend/src/views/DailyReviewsView.vue b/frontend/src/views/DailyReviewsView.vue index b3a1343..01023ba 100644 --- a/frontend/src/views/DailyReviewsView.vue +++ b/frontend/src/views/DailyReviewsView.vue @@ -6,6 +6,7 @@ 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 PlatformMark from "@/components/ui/PlatformMark.vue"; import { useViewport } from "@/composables/useViewport"; import type { DailyReviewPushResult, @@ -194,7 +195,9 @@ onMounted(loadReport);
{{ row.anchorName || row.roomId }}
- + + + @@ -231,7 +234,7 @@ onMounted(loadReport);
{{ item.label }}
{{ item.liveRoomTitle }}
- {{ item.platformName }} + {{ item.roomId }} {{ formatDuration(item.durationSeconds) }}
@@ -273,7 +276,9 @@ onMounted(loadReport);
分片 #{{ row.segmentIndex }}
- + + +