diff --git a/frontend/.dockerignore b/frontend/.dockerignore index b6dddf6..93f1361 100644 --- a/frontend/.dockerignore +++ b/frontend/.dockerignore @@ -1,3 +1,2 @@ node_modules -dist npm-debug.log diff --git a/frontend/Dockerfile.arm64 b/frontend/Dockerfile.arm64 new file mode 100644 index 0000000..6b998cf --- /dev/null +++ b/frontend/Dockerfile.arm64 @@ -0,0 +1,10 @@ +ARG NGINX_IMAGE=nginx:1.27-alpine + +FROM ${NGINX_IMAGE} + +COPY nginx/default.conf /etc/nginx/conf.d/default.conf +COPY dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/src/components/layout/MainLayout.vue b/frontend/src/components/layout/MainLayout.vue index c0b040c..d904456 100644 --- a/frontend/src/components/layout/MainLayout.vue +++ b/frontend/src/components/layout/MainLayout.vue @@ -6,19 +6,20 @@ import { useBackendStatus } from "@/composables/useBackendStatus"; import { useUiPreferences } from "@/composables/useUiPreferences"; import { useViewport } from "@/composables/useViewport"; import { - ArrowDown, Bell, + Collection, DataAnalysis, Document, + FolderOpened, Fold, House, - Operation, - QuestionFilled, + Menu, + Moon, RefreshRight, - Search, Setting, - SwitchButton, + Sunny, Tickets, + Upload, VideoCamera } from "@element-plus/icons-vue"; @@ -30,22 +31,36 @@ const { backendUnavailable, backendMessage, backendLastChangedAt } = useBackendS const { sidebarCollapsed, toggleSidebarCollapsed } = useUiPreferences(); const mobileNavVisible = ref(false); -const globalSearch = ref(""); const navigationGroups = [ { - key: "monitor", - title: "直播监控", + key: "overview", + title: "总览", items: [ { index: "/", label: "仪表盘", icon: DataAnalysis }, + ] + }, + { + key: "monitor", + title: "监控录制", + items: [ { index: "/live-rooms", label: "直播间", icon: House }, { index: "/record-tasks", label: "录制任务", icon: VideoCamera }, { index: "/recovery", label: "恢复中心", icon: RefreshRight } ] }, { - key: "review", - title: "回顾分析", + 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 } @@ -53,88 +68,46 @@ const navigationGroups = [ }, { key: "system", - title: "系统管理", - items: [{ index: "/settings", label: "系统设置", icon: Setting }] + title: "系统", + items: [ + { index: "/settings", label: "系统设置", icon: Setting } + ] } ]; const userDisplayName = computed(() => authStore.user?.displayName || authStore.user?.username || "管理员"); -const userName = computed(() => authStore.user?.username || "--"); -const userId = computed(() => authStore.user?.userId || "--"); -const userExpiresAt = computed(() => authStore.user?.expiresAt || "--"); const userAvatarText = computed(() => userDisplayName.value.trim().slice(0, 1).toUpperCase() || "录"); -const unreadNotificationCount = computed(() => null); -const hasUnreadNotifications = computed( - () => typeof unreadNotificationCount.value === "number" && unreadNotificationCount.value > 0 -); -const backendStatusTitle = computed(() => (backendUnavailable.value ? "后端连接异常" : "系统状态正常")); -const backendStatusDescription = computed(() => - backendUnavailable.value ? backendMessage.value : "暂无集群数据" -); -const backendStatusTone = computed(() => (backendUnavailable.value ? "is-danger" : "is-healthy")); -const pageEyebrow = computed(() => { - switch (route.name) { - case "dashboard": - return "系统概览"; - case "live-rooms": - return "直播间监控"; - case "record-tasks": - return "录制工作台"; - case "recovery": - return "异常恢复"; - case "daily-reviews": - return "回顾分析"; - case "logs": - return "系统日志"; - case "settings": - return "系统设置"; - default: - return "控制台"; - } -}); + +const isDark = ref(false); +try { isDark.value = localStorage.getItem("lr-theme") === "dark"; } catch { /* noop */ } +function toggleTheme() { + isDark.value = !isDark.value; + document.documentElement.dataset.theme = isDark.value ? "dark" : "light"; + try { localStorage.setItem("lr-theme", isDark.value ? "dark" : "light"); } catch { /* noop */ } +} function isNavItemActive(index: string) { return route.path === index || route.path.startsWith(`${index}/`); } +function navigate(index: string) { void router.push(index); } -function navigate(index: string) { - void router.push(index); -} - -function navigateToSettingsHash(hash: "#profile" | "#security" | "#preferences") { - mobileNavVisible.value = false; - - if (route.name === "settings" && route.hash === hash) { - document.querySelector(hash)?.scrollIntoView({ behavior: "smooth", block: "start" }); - return; - } - - void router.push({ name: "settings", hash }); -} - -function handleUserCommand(command: string) { - if (command === "profile") { - navigateToSettingsHash("#profile"); - return; - } - - if (command === "security") { - navigateToSettingsHash("#security"); - return; - } - - if (command === "preferences") { - navigateToSettingsHash("#preferences"); - } -} - -function openHelpCenter() { - void router.push({ name: "settings" }); -} - -function openNotifications() { - void router.push({ name: "logs" }); -} +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: "监控录制 / 恢复中心", + settings: "系统 / 系统设置" + }; + return m[String(route.name)] || "控制台"; +}); async function handleLogout() { mobileNavVisible.value = false; @@ -142,41 +115,48 @@ async function handleLogout() { await router.push({ name: "login" }); } -watch( - () => route.fullPath, - () => { - mobileNavVisible.value = false; - } -); +function openMobileNav() { mobileNavVisible.value = true; } +function closeMobileNav() { mobileNavVisible.value = false; } + +watch(() => route.fullPath, () => { mobileNavVisible.value = false; }); + +// sync dark class on mount +watch(isDark, (v) => { document.documentElement.dataset.theme = v ? "dark" : "light"; }, { immediate: true }); diff --git a/frontend/src/components/ui/MetricCard.vue b/frontend/src/components/ui/MetricCard.vue index 34fde5d..255260e 100644 --- a/frontend/src/components/ui/MetricCard.vue +++ b/frontend/src/components/ui/MetricCard.vue @@ -8,78 +8,27 @@ withDefaults( description?: string; icon?: Component | null; }>(), - { - description: "", - icon: null - } + { description: "", icon: null } ); diff --git a/frontend/src/components/ui/RightDrawer.vue b/frontend/src/components/ui/RightDrawer.vue index 19ab210..c94688e 100644 --- a/frontend/src/components/ui/RightDrawer.vue +++ b/frontend/src/components/ui/RightDrawer.vue @@ -59,7 +59,7 @@ const visible = computed({ display: flex; min-height: 100%; flex-direction: column; - background: linear-gradient(180deg, var(--surface-raised), var(--surface)); + background: var(--surface); } .right-drawer__header { diff --git a/frontend/src/components/ui/StatusBadge.vue b/frontend/src/components/ui/StatusBadge.vue index 014dfae..355e386 100644 --- a/frontend/src/components/ui/StatusBadge.vue +++ b/frontend/src/components/ui/StatusBadge.vue @@ -238,21 +238,21 @@ const displayLabel = computed(() => { } .status-badge--blue { - background: rgba(37, 99, 235, 0.1); - border-color: rgba(37, 99, 235, 0.16); - color: #2563eb; + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 20%, transparent); + color: var(--accent); } .status-badge--yellow { - background: rgba(245, 158, 11, 0.12); - border-color: rgba(245, 158, 11, 0.18); - color: #b45309; + background: var(--warning-soft); + border-color: color-mix(in srgb, var(--warning) 22%, transparent); + color: var(--warning); } .status-badge--red { - background: rgba(239, 68, 68, 0.1); - border-color: rgba(239, 68, 68, 0.16); - color: #dc2626; + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 20%, transparent); + color: var(--danger); } .status-badge--orange { @@ -262,9 +262,9 @@ const displayLabel = computed(() => { } .status-badge--indigo { - background: rgba(99, 102, 241, 0.12); - border-color: rgba(99, 102, 241, 0.18); - color: #4f46e5; + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 22%, transparent); + color: var(--accent); } :global(html[data-theme="dark"]) .status-badge--gray { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e8cba85..6f5c787 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -7,6 +7,7 @@ const DashboardView = () => import("@/views/DashboardView.vue"); const LiveRoomsView = () => import("@/views/LiveRoomsView.vue"); const RecordTasksView = () => import("@/views/RecordTasksView.vue"); const TranscodeTasksView = () => import("@/views/TranscodeTasksView.vue"); +const UploadTasksView = () => import("@/views/UploadTasksView.vue"); const RecordTaskDetailView = () => import("@/views/RecordTaskDetailView.vue"); const RecordSessionDetailView = () => import("@/views/RecordSessionDetailView.vue"); const MediaBrowserView = () => import("@/views/MediaBrowserView.vue"); @@ -48,6 +49,11 @@ const router = createRouter({ name: "transcode-tasks", component: TranscodeTasksView }, + { + path: "upload-tasks", + name: "upload-tasks", + component: UploadTasksView + }, { path: "media-browser", name: "media-browser", diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index afa2607..0f07ca5 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -1,54 +1,143 @@ +/* ============================================================= + Live Recorder · Design System + 商业级 SaaS · 浅色侧栏 + 层次投影 + 移动适配 + Base: Element Plus 2.x overrides + ============================================================= */ + :root { color-scheme: light; - font-family: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", sans-serif; + --font-sans: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + --font-mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", Menlo, monospace; + font-family: var(--font-sans); + font-feature-settings: "kern" 1, "liga" 1, "calt" 1; + -webkit-font-smoothing: antialiased; - --bg-base: #f6f8fb; - --bg-subtle: #f1f5f9; - --bg-emphasis: #e8eef7; + /* neutrals */ + --bg-base: #eef1f6; + --bg-subtle: #e7ebf2; --surface: #ffffff; --surface-raised: #ffffff; - --surface-muted: #f8fafc; - --surface-strong: #eff6ff; - --border-subtle: #e2e8f0; - --border-base: #cbd5e1; - --border-strong: #94a3b8; + --surface-muted: #f4f7fb; + --surface-strong: #eef2f8; + --surface-hover: #eef2f8; + --border-subtle: #e3e7ef; + --border-base: #d2d8e3; --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); + --text-secondary: #45526a; + --text-muted: #6a7689; + --text-soft: #97a1b3; + + /* 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; + + /* 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; - --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.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; - --page-max-width: 1280px; - --content-padding: 24px; - --content-padding-mobile: 16px; + --danger-soft: rgba(220, 38, 38, 0.11); + --purple: #7c3aed; + --purple-soft: rgba(124, 58, 237, 0.11); + + /* 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); + + /* radius */ + --radius-xs: 7px; + --radius-sm: 9px; + --radius-md: 14px; + --radius-lg: 20px; + + /* layout */ + --sidebar-w: 244px; + --sidebar-w-collapsed: 72px; + --topbar-h: 62px; + --page-max: 1320px; + --page-gap: 22px; + --content-padding: 28px; + --content-padding-mobile: 14px; --control-height: 40px; --control-height-sm: 34px; - --table-row-padding: 16px; + --table-row-padding: 14; --header-row-height: 64px; +} +html[data-density="compact"] { + --page-gap: 18px; + --content-padding: 20px; + --content-padding-mobile: 12px; + --control-height: 36px; + --control-height-sm: 30px; + --table-row-padding: 12px; + --header-row-height: 56px; + --topbar-h: 56px; +} + +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; + + --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; + + --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; + + --shadow-xs: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.45); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.6); + --shadow-float: 0 24px 56px rgba(0, 0, 0, 0.65); +} + +/* ============================ Element Plus overrides ============================ */ +#app { --el-color-primary: var(--accent); --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); @@ -59,900 +148,249 @@ --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-sm); + --el-border-radius-base: var(--radius-xs); --el-bg-color: transparent; --el-bg-color-page: var(--bg-base); --el-bg-color-overlay: var(--surface); --el-fill-color: var(--surface-muted); --el-fill-color-blank: var(--surface); --el-fill-color-light: var(--surface-muted); - --el-fill-color-lighter: var(--surface-strong); - --el-fill-color-dark: var(--surface-raised); - --el-fill-color-darker: var(--surface-strong); + --el-fill-color-lighter: var(--surface-muted); --el-disabled-bg-color: var(--surface-muted); --el-disabled-text-color: var(--text-soft); --el-text-color-placeholder: var(--text-soft); --el-mask-color: rgba(15, 23, 42, 0.54); } -html[data-density="compact"] { - --page-gap: 20px; - --content-padding: 20px; - --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: #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: #bfd0e6; - --text-muted: #8ea3bc; - --text-soft: #6f849d; - --text-inverse: #08111a; - --accent: #60a5fa; - --accent-strong: #3b82f6; - --accent-soft: rgba(96, 165, 250, 0.18); - --info: #60a5fa; - --success: #31c48d; - --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.04), rgba(255, 255, 255, 0)), - var(--bg-subtle); - --topbar-shell-bg: rgba(15, 23, 42, 0.84); - - --el-color-primary: var(--accent); +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-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); - --el-text-color-secondary: var(--text-muted); - --el-border-color: var(--border-base); - --el-border-color-light: var(--border-subtle); - --el-bg-color-page: var(--bg-base); - --el-bg-color-overlay: var(--surface); - --el-fill-color: var(--surface-muted); - --el-fill-color-blank: var(--surface); - --el-fill-color-light: var(--surface-muted); - --el-fill-color-lighter: var(--surface-strong); - --el-fill-color-dark: var(--surface-raised); - --el-fill-color-darker: var(--surface-strong); - --el-disabled-bg-color: var(--surface-muted); - --el-disabled-text-color: var(--text-soft); - --el-text-color-placeholder: var(--text-soft); --el-mask-color: rgba(3, 7, 14, 0.72); } -* { - box-sizing: border-box; -} - -*::selection { - color: var(--text-inverse); - background: rgba(47, 111, 180, 0.82); -} - -html, -body, -#app { - margin: 0; - min-height: 100%; -} - +/* ============================ Base ============================ */ +* { box-sizing: border-box; } +html, body, #app { margin: 0; min-height: 100%; } body { color: var(--text-primary); - background: - 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%); + background: var(--bg-base); text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; overflow-x: hidden; } +a { color: inherit; text-decoration: none; } +button { font-family: inherit; cursor: pointer; } -* { - scrollbar-width: thin; - scrollbar-color: rgba(148, 163, 184, 0.6) transparent; -} +*::-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; } +*::-webkit-scrollbar-track { background: transparent; } -*::-webkit-scrollbar { - width: 10px; - height: 10px; -} +::selection { background: var(--accent-soft); } -*::-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, -textarea, -select { - font: inherit; -} - -a { - color: inherit; - text-decoration: none; -} - -button:focus-visible, -input:focus-visible, -textarea:focus-visible, -select:focus-visible { - outline: none; -} - -.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: 24px; - padding: 4px 0; -} - -.page-header > div:first-child { - flex: 1; - min-width: 0; -} - -.page-title { - margin: 0; - font-size: clamp(30px, 2vw, 42px); - font-weight: 780; - letter-spacing: -0.045em; - line-height: 1; - color: var(--text-primary); -} - -.page-subtitle { - max-width: 76ch; - margin: 14px 0 0; - color: var(--text-secondary); - font-size: 14px; - 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; -} +/* ============================ Page layout ============================ */ +.page-stack { display: grid; gap: var(--page-gap); width: min(100%, var(--page-max)); margin: 0 auto; } +.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; animation: fadeUp .3s ease both; } +.page-header > div:first-child { flex: 1; min-width: 0; } +.page-title { margin: 0; font-size: 28px; font-weight: 800; letter-spacing: -.025em; line-height: 1.2; 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-toolbar, .header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; flex-shrink: 0; } +/* cards */ .surface-card { - position: relative; - overflow: hidden; --el-card-bg-color: transparent; - border-radius: var(--radius-md); - 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); - transition: - transform 0.2s ease, - border-color 0.2s ease, - box-shadow 0.2s ease, - background-color 0.2s ease; -} - -.surface-card:hover { - border-color: var(--border-base); - box-shadow: var(--shadow-card); - transform: translateY(-1px); -} - -.surface-card .el-card__body { - padding: 24px; -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 16px; + border-radius: var(--radius-md); border: 1px solid var(--border-subtle); + background: var(--surface); box-shadow: var(--shadow-sm); transition: box-shadow .18s ease; animation: fadeUp .3s ease both; } +.surface-card:hover { box-shadow: var(--shadow-md); } +.surface-card .el-card__body { padding: 24px; } +/* stat grid / KPI cards */ +.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; } .stat-card { - display: grid; - 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.98), rgba(248, 250, 252, 0.98)), - var(--surface); - box-shadow: var(--shadow-soft); + display: grid; gap: 11px; min-height: auto; padding: 20px; border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); background: var(--surface); box-shadow: var(--shadow-sm); + transition: box-shadow .18s, transform .18s; position: relative; overflow: hidden; + animation: fadeUp .35s ease both; } +.stat-card:nth-child(2) { animation-delay: .05s; } +.stat-card:nth-child(3) { animation-delay: .10s; } +.stat-card:nth-child(4) { animation-delay: .15s; } +.stat-card:nth-child(5) { animation-delay: .20s; } +.stat-card:nth-child(6) { animation-delay: .25s; } +.stat-card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); } +.stat-card__label { color: var(--text-muted); font-size: 12.5px; font-weight: 600; } +.stat-card__value { color: var(--text-primary); font-size: 31px; font-weight: 800; letter-spacing: -.04em; line-height: 1; font-variant-numeric: tabular-nums; } +.stat-card__hint { color: var(--text-muted); font-size: 12.5px; line-height: 1.65; font-weight: 500; } -html[data-theme="dark"] .stat-card { - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)), - var(--surface-muted); +/* section */ +.section-title { margin: 0 0 6px; color: var(--text-primary); font-size: 16px; font-weight: 700; letter-spacing: -.01em; } +.section-subtitle { margin: 0; color: var(--text-muted); font-size: 12.5px; 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 */ +.table-scroll-shell { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; } +.cell-title { color: var(--text-primary); font-size: 15px; font-weight: 700; line-height: 1.45; } +.cell-subtitle { margin-top: 2px; color: var(--text-muted); font-size: 12px; line-height: 1.6; font-weight: 500; } +.cell-mono, .table-date-text, .monospace { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.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; } + +/* badges/tags */ +.badge-row { display: flex; flex-wrap: wrap; gap: 8px; } +.info-pill, .token-chip { + display: inline-flex; align-items: center; min-height: 28px; padding: 0 10px; + border-radius: 99px; border: 1px solid var(--border-subtle); background: var(--surface); + color: var(--text-secondary); font-size: 12px; font-weight: 600; } +.token-chip { background: var(--accent-soft); color: var(--accent); border-color: transparent; } -.stat-card__label { - color: var(--text-muted); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; -} +/* mobile data cards */ +.data-card-list { display: grid; gap: 12px; } +.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: .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; } -.stat-card__value { - color: var(--text-primary); - font-size: clamp(30px, 2vw, 40px); - font-weight: 760; - letter-spacing: -0.05em; - line-height: 1; -} +/* highlights */ +.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: .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; } -.stat-card__hint { - color: var(--text-secondary); - font-size: 13px; - line-height: 1.65; -} +/* alerts */ +.page-error-alert, .page-stream-alert, .backend-alert { border-radius: var(--radius-md); border: 1px solid var(--border-subtle); } -.section-title { - margin: 0 0 6px; - color: var(--text-primary); - font-size: 18px; - font-weight: 700; - letter-spacing: -0.03em; -} +/* responsive helpers */ +.desktop-only { display: block; } +.mobile-only { display: none; } -.section-subtitle { - margin: 0; - color: var(--text-secondary); - font-size: 13px; - line-height: 1.7; -} +/* ============================ Element Plus component overrides ============================ */ -.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 { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 14px; -} - -.token-chip { - display: inline-flex; - align-items: center; - min-height: 28px; - padding: 0 10px; - border-radius: 999px; - background: var(--accent-soft); - color: var(--accent); - font-size: 12px; - font-weight: 600; -} - -.data-card-list { - display: grid; - gap: 12px; -} - -.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 { - --el-card-border-color: transparent; - --el-card-bg-color: transparent; - background: transparent; -} - -.el-tabs--border-card { - border-color: var(--border-subtle); - background: var(--surface); -} - -.el-tabs--border-card > .el-tabs__content { - background: transparent; - color: var(--text-primary); -} - -.el-tabs--border-card > .el-tabs__header { - background: transparent; -} - -.el-tabs--border-card > .el-tabs__header .el-tabs__item { - color: var(--text-secondary); -} - -.el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active { - color: var(--text-primary); - background: var(--surface-raised); -} - -.el-button { - min-height: var(--control-height); - padding: 0 15px; - border-radius: var(--radius-md); - font-weight: 600; - letter-spacing: 0; - transition: - transform 0.18s ease, - border-color 0.18s ease, - background-color 0.18s ease, - box-shadow 0.18s ease, - color 0.18s ease; -} - -.el-button:not(.is-disabled):hover { - transform: translateY(-1px); -} - -.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; -} +/* buttons */ +.el-button { min-height: var(--control-height); padding: 0 15px; border-radius: var(--radius-sm); font-weight: 700; letter-spacing: 0; transition: all .15s ease; } +.el-button:not(.is-disabled):hover { transform: translateY(-1px); } +.el-button.el-button--default:not(.is-text):not(.is-link) { border-color: var(--border-base); background: var(--surface); color: var(--text-secondary); box-shadow: none; } +.el-button.el-button--default:not(.is-text):not(.is-link):hover { border-color: var(--text-soft); color: var(--text-primary); box-shadow: var(--shadow-xs); } +/* primary button — flat, blue glow */ .el-button.el-button--primary { - 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); + 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); } - .el-button.el-button--primary:not(.is-disabled):hover { - background: linear-gradient(180deg, #3479c1 0%, var(--accent-strong) 100%) !important; - border-color: var(--accent-strong) !important; + 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); } +.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; } +.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 { box-shadow: none !important; transform: none !important; } -.el-button.is-plain:not(.el-button--danger):not(.el-button--primary) { - background: var(--accent-soft) !important; - border-color: rgba(47, 111, 180, 0.24) !important; - color: var(--accent) !important; -} +/* inputs */ +.el-input, .el-select, .el-date-editor, .el-input-number { width: 100%; } +.el-input__wrapper, .el-select__wrapper, .el-textarea__inner, .el-input-number { border-radius: var(--radius-sm); background: var(--surface); box-shadow: inset 0 0 0 1px var(--border-subtle) !important; } +.el-input__wrapper:hover, .el-select__wrapper:hover, .el-textarea__inner:hover { 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: var(--surface); box-shadow: inset 0 0 0 1px var(--accent), 0 0 0 3px var(--accent-soft) !important; } +.el-textarea__inner { line-height: 1.7; } -.el-button.el-button--danger.is-plain { - background: rgba(199, 81, 81, 0.12) !important; - border-color: rgba(199, 81, 81, 0.24) !important; - color: var(--danger) !important; -} +/* forms */ +.el-form-item { margin-bottom: 18px; } +.el-form-item__label { padding-bottom: 8px; color: var(--text-primary) !important; font-size: 13px; font-weight: 600; } +.el-switch { --el-switch-on-color: var(--accent); --el-switch-off-color: var(--border-base); } -.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 { - box-shadow: none !important; - transform: none !important; -} - -.el-input, -.el-select, -.el-date-editor, -.el-input-number { - width: 100%; -} - -.el-input__wrapper, -.el-select__wrapper, -.el-textarea__inner, -.el-input-number, -.el-input-number__decrease, -.el-input-number__increase { - border-radius: var(--radius-md); -} - -.el-input__wrapper, -.el-select__wrapper, -.el-textarea__inner, -.el-input-number { - 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; -} - -.el-input__wrapper:hover, -.el-select__wrapper:hover, -.el-textarea__inner:hover, -.el-input-number:hover { - 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: var(--surface-raised); - box-shadow: - inset 0 0 0 1px var(--accent), - 0 0 0 4px var(--focus-ring) !important; -} - -.el-textarea__inner { - line-height: 1.7; -} - -.el-form-item { - margin-bottom: 18px; -} - -.el-form-item__label { - padding-bottom: 8px; - color: var(--text-primary) !important; - font-size: 13px; - font-weight: 600; -} - -.el-switch { - --el-switch-on-color: var(--accent); - --el-switch-off-color: var(--border-base); -} - -.el-tag { - border: 1px solid transparent; - border-radius: 999px; - min-height: 28px; - padding: 0 10px; - font-weight: 600; - letter-spacing: 0; -} - -.el-tag--success { - background: rgba(31, 138, 99, 0.14); - color: var(--success); -} - -.el-tag--warning { - background: rgba(186, 123, 31, 0.14); - color: var(--warning); -} - -.el-tag--danger { - background: rgba(199, 81, 81, 0.14); - color: var(--danger); -} - -.el-tag--info, -.el-tag.el-tag--primary { - background: rgba(47, 111, 180, 0.1); - color: var(--accent); -} +/* tags */ +.el-tag { border: 1px solid transparent; border-radius: 99px; min-height: 22px; padding: 0 8px; font-weight: 600; font-size: 12px; } +.el-tag--success { background: var(--success-soft); color: var(--success); } +.el-tag--warning { background: var(--warning-soft); color: var(--warning); } +.el-tag--danger { background: var(--danger-soft); color: var(--danger); } +.el-tag--info, .el-tag.el-tag--primary { background: var(--accent-soft); color: var(--accent); } +/* tables */ .el-table { - --el-table-border-color: transparent; - --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; + --el-table-border-color: transparent; --el-table-header-bg-color: transparent; + --el-table-bg-color: transparent; --el-table-row-hover-bg-color: var(--surface-hover); + --el-table-current-row-bg-color: var(--accent-soft); --el-fill-color-blank: transparent; 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 > .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 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); } +.premium-table { min-width: 920px; } -.el-table::before, -.el-table__inner-wrapper::before { - display: none; +/* descriptions */ +.el-descriptions { --el-descriptions-table-border: 1px solid var(--border-subtle); } +.el-descriptions__body .el-descriptions__table { border-radius: var(--radius-sm); overflow: hidden; } +.el-descriptions__body .el-descriptions__label.el-descriptions__cell.is-bordered-label { background: var(--surface-muted); color: var(--text-muted); font-weight: 600; } +.el-descriptions__body .el-descriptions__content.el-descriptions__cell.is-bordered-content { background: var(--surface); color: var(--text-primary); } + +/* dialogs */ +.el-dialog { 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-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-dialog__header { margin: 0; padding: 20px 24px 10px; } +.el-dialog__title { color: var(--text-primary); font-size: 18px; font-weight: 700; letter-spacing: -.03em; } +.el-dialog__body { padding: 12px 24px 6px; } +.el-dialog__footer { padding: 12px 24px 20px; } +.el-empty__description, .el-empty__description p, .el-result__subtitle { color: var(--text-secondary); } + +/* misc */ +.el-badge__content.is-fixed.is-dot { top: 10px; right: 10px; } +.el-card { --el-card-border-color: transparent; --el-card-bg-color: transparent; background: transparent; } +.el-tabs--border-card { border-color: var(--border-subtle); background: var(--surface); border-radius: var(--radius-sm); } +.el-tabs--border-card > .el-tabs__content { background: transparent; color: var(--text-primary); } +.el-tabs--border-card > .el-tabs__header { background: transparent; } +.el-tabs--border-card > .el-tabs__header .el-tabs__item { color: var(--text-secondary); } +.el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active { color: var(--text-primary); background: var(--surface); } + +/* ============================ Skeleton loading ============================ */ +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } } +.skel { + background: linear-gradient(90deg, var(--border-subtle) 25%, var(--surface-muted) 37%, var(--border-subtle) 63%); + background-size: 800px 100%; animation: shimmer 1.6s ease-in-out infinite; border-radius: var(--radius-xs); } +.skel--text { height: 14px; width: 100%; } +.skel--text.short { width: 60%; } +.skel--title { height: 18px; width: 50%; } -.el-table th.el-table__cell { - 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; -} - -.el-table td.el-table__cell { - padding: var(--table-row-padding) 0; - border-bottom: 1px solid var(--border-subtle); - background: transparent; -} - -.el-table tr { - background: transparent; -} - -.premium-table { - min-width: 920px; -} - -.el-descriptions { - --el-descriptions-table-border: 1px solid var(--border-subtle); -} - -.el-descriptions__body .el-descriptions__table { - 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-muted); - font-weight: 700; -} - -.el-descriptions__body .el-descriptions__content.el-descriptions__cell.is-bordered-content { - background: var(--surface); - color: var(--text-primary); -} - -.el-dialog { - border-radius: 18px; - border: 1px solid var(--border-base); - background: linear-gradient(180deg, var(--surface-raised), var(--surface)); - box-shadow: var(--shadow-float); -} - -.el-message-box, -.el-popover.el-popper, -.el-select__popper.el-popper, -.el-picker__popper.el-popper, -.el-dropdown__popper.el-popper .el-dropdown-menu { - border-color: var(--border-subtle); - background: linear-gradient(180deg, var(--surface-raised), var(--surface)); - color: var(--text-primary); - box-shadow: var(--shadow-float); -} - -.el-dialog__header { - margin: 0; - padding: 22px 24px 10px; -} - -.el-dialog__title { - color: var(--text-primary); - font-size: 18px; - font-weight: 700; - letter-spacing: -0.03em; -} - -.el-dialog__body { - padding: 12px 24px 6px; -} - -.el-dialog__footer { - padding: 12px 24px 22px; -} - -.el-empty__description, -.el-empty__description p, -.el-result__subtitle { - 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) { - .stats-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .highlight-grid { - grid-template-columns: 1fr; - } -} +/* ============================ Motion ============================ */ +@keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +@media (prefers-reduced-motion: reduce) { * { animation: none !important; } } +/* ============================ Responsive ============================ */ +@media (max-width: 1280px) { .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .highlight-grid { grid-template-columns: 1fr; } } +@media (max-width: 1100px) { .stats-grid { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 768px) { - .page-header, - .section-header, - .toolbar-row { - flex-direction: column; - } - - .page-title { - font-size: clamp(24px, 8vw, 32px); - } - - .page-subtitle { - margin-top: 10px; - font-size: 13px; - line-height: 1.7; - } - - .surface-card .el-card__body { - padding: 18px; - } - - .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 - 16px, 560px) !important; - margin: 3vh auto 0 !important; - } - - .el-dialog__header { - padding: 18px 18px 8px; - } - - .el-dialog__body { - padding: 10px 18px 4px; - } - - .el-dialog__footer { - padding: 12px 18px 18px; - } + .page-header, .section-header, .toolbar-row { flex-direction: column; } + .page-title { font-size: 22px; } + .page-subtitle { margin-top: 6px; font-size: 12.5px; line-height: 1.6; } + .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; } + .stat-card__value { font-size: 24px; } + .desktop-only { display: none !important; } + .mobile-only { display: block !important; } + .el-dialog { width: min(100vw - 16px, 560px) !important; margin: 3vh auto 0 !important; } + .el-dialog__header { padding: 16px 16px 8px; } + .el-dialog__body { padding: 10px 16px 4px; } + .el-dialog__footer { padding: 10px 16px 16px; } + .el-table th.el-table__cell { padding: 10px 12px; font-size: 11px; } + .el-table td.el-table__cell { padding: 10px 12px; font-size: 12.5px; line-height: 1.45; } } +@media (max-width: 400px) { .stats-grid { grid-template-columns: 1fr; } } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0121e0a..d275cc2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -141,6 +141,7 @@ export interface RecordTask { startedAt?: string; endedAt?: string; durationSeconds?: number; + uploadStatus?: number; postProcessStage?: string; postProcessProgressPercent?: number; postProcessDetail?: string; @@ -184,6 +185,9 @@ export interface RecordSession { endedAt?: string; totalFileSizeBytes: number; totalDanmakuMessageCount: number; + uploadedSegmentCount: number; + failedUploadSegmentCount: number; + uploadingSegmentCount: number; tasks: RecordTask[]; } @@ -262,6 +266,36 @@ export interface RecordArtifactUploadBatchResult { items: RecordArtifactUploadItemResult[]; } +export interface UploadTaskItem { + recordTaskId: string; + recordSessionId: string; + liveRoomId: string; + liveRoomTitle: string; + platform: number; + roomId: string; + segmentIndex: number; + outputFormat: string; + filePath?: string; + fileSizeBytes?: number; + danmakuFilePath?: string; + uploadStatus: number; + lastUploadProvider?: string; + remoteVideoPath?: string; + remoteDanmakuPath?: string; + lastUploadedAt?: string; + uploadErrorMessage?: string; + deletedLocalFilesAfterUpload: boolean; + createdAt: string; +} + +export interface UploadTaskListResponse { + items: UploadTaskItem[]; + totalCount: number; + notUploadedCount: number; + succeededCount: number; + failedCount: number; +} + export interface ManualSegmentCompletedTriggerResult { recordTaskId: string; success: boolean; @@ -771,13 +805,15 @@ export const platformLabelMap: Record = { export const uploadTargetLabelMap: Record = { 0: "不上传", 1: "WebDAV", - 2: "S3" + 2: "S3", + 3: "OpenList" }; export const uploadStatusLabelMap: Record = { 0: "未上传", 1: "已上传", - 2: "上传失败" + 2: "上传失败", + 3: "上传中" }; export interface DanmakuEvent { diff --git a/frontend/src/views/DashboardView.vue b/frontend/src/views/DashboardView.vue index a0c1812..00aaf92 100644 --- a/frontend/src/views/DashboardView.vue +++ b/frontend/src/views/DashboardView.vue @@ -32,7 +32,7 @@ function formatDate(value?: string) { return value ? new Date(value).toLocaleString() : "-"; } -function sessionStatusTagType(status: number) { +function sessionStatusTagType(status: number): "" | "success" | "warning" | "danger" | "info" { if (status === 2) return "success"; if (status === 5) return "danger"; if (status === 4 || status === 6) return "info"; @@ -73,12 +73,13 @@ onMounted(loadData);
@@ -86,28 +87,23 @@ onMounted(loadData); diff --git a/frontend/src/views/RecordTasksView.vue b/frontend/src/views/RecordTasksView.vue index ddd0788..5d77f9d 100644 --- a/frontend/src/views/RecordTasksView.vue +++ b/frontend/src/views/RecordTasksView.vue @@ -29,7 +29,8 @@ import { outputFormatLabelMap, saveModeLabelMap, sessionStatusLabelMap, - taskStatusLabelMap + taskStatusLabelMap, + uploadStatusLabelMap } from "@/types"; const router = useRouter(); @@ -1043,7 +1044,15 @@ onBeforeUnmount(() => { #{{ task.segmentIndex }}
{{ formatDate(task.startedAt || task.createdAt) }}
- +
+ + +
@@ -1135,6 +1144,9 @@ onBeforeUnmount(() => { {{ saveModeLabelMap[session.saveMode] }} {{ outputFormatLabelMap[session.outputFormat] }} 分片 {{ session.segmentCount }} + + 上传 {{ session.uploadedSegmentCount }}/{{ session.segmentCount }} + {{ formatFileSize(session.totalFileSizeBytes) }}
@@ -1221,13 +1233,25 @@ onBeforeUnmount(() => { + + + + - + @@ -1420,13 +1444,11 @@ onBeforeUnmount(() => { .record-feature-card { display: grid; gap: 10px; - padding: 18px; - border-radius: 16px; + padding: 20px; + border-radius: var(--radius-md); 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); + background: var(--surface); + box-shadow: var(--shadow-sm); } .record-feature-card__eyebrow { @@ -1673,6 +1695,13 @@ onBeforeUnmount(() => { gap: 12px; } +.session-task-card__badges { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + .session-task-card__progress { display: grid; gap: 8px; diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index dd01468..35c7192 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -2119,7 +2119,7 @@ watch( width: 56px; height: 56px; border-radius: 999px; - background: linear-gradient(180deg, #2563eb 0%, #4338ca 100%); + background: var(--accent); color: #ffffff; font-size: 20px; font-weight: 800; @@ -2214,7 +2214,7 @@ watch( border: 1px solid var(--border-subtle); border-bottom: 0; border-radius: 12px 12px 0 0; - background: linear-gradient(180deg, var(--surface-raised), var(--surface-muted)); + background: var(--surface-muted); box-shadow: var(--shadow-soft); } @@ -2255,7 +2255,7 @@ watch( border: 1px solid var(--border-subtle); border-top: 0; border-radius: 0 0 12px 12px; - background: linear-gradient(180deg, var(--surface-raised), var(--surface)); + background: var(--surface); box-shadow: var(--shadow-soft); } @@ -2303,7 +2303,7 @@ watch( gap: 10px; padding: 16px 18px; border-radius: 12px; - background: linear-gradient(180deg, var(--surface-muted), var(--surface)); + background: var(--surface); border: 1px solid var(--border-subtle); } @@ -2420,7 +2420,7 @@ watch( padding: 18px 18px 4px; border-radius: 12px; border: 1px solid var(--border-subtle); - background: linear-gradient(180deg, var(--surface-muted), var(--surface)); + background: var(--surface); } .template-section__header { @@ -2526,11 +2526,10 @@ watch( justify-content: space-between; gap: 16px; padding: 14px 18px; - border-radius: 14px; + border-radius: var(--radius-md); border: 1px solid var(--border-base); - background: - linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0)), - var(--surface-raised); + background: var(--surface-raised); + box-shadow: var(--shadow-lg); box-shadow: var(--shadow-float); backdrop-filter: blur(18px); pointer-events: auto; diff --git a/frontend/src/views/UploadTasksView.vue b/frontend/src/views/UploadTasksView.vue new file mode 100644 index 0000000..983250e --- /dev/null +++ b/frontend/src/views/UploadTasksView.vue @@ -0,0 +1,466 @@ + + + + + diff --git a/prototype/README.md b/prototype/README.md new file mode 100644 index 0000000..c8271f9 --- /dev/null +++ b/prototype/README.md @@ -0,0 +1,44 @@ +# Live Recorder · 高保真原型 + +商业级 SaaS 风格 UI 重设计原型(参考 Ant Design Pro / 飞书 / 云控制台 / Linear)。 +纯静态 HTML + CSS + 少量 JS,**无需构建**,直接用浏览器打开即可浏览。 + +## 如何查看 + +直接双击打开 `login.html` 或 `index.html`,通过左侧导航在各页之间跳转。 + +> 建议用本地静态服务器以获得最佳体验(可选): +> `npx serve prototype` 或 VSCode Live Server。 + +## 页面清单 + +| 文件 | 页面 | 说明 | +|---|---|---| +| `login.html` | 登录 | 品牌左栏 + 表单右栏 | +| `index.html` | 仪表盘 | KPI + 存储水位 + 队列 + 最近会话/热门房间 | +| `live-rooms.html` | 直播间控制台 | 收敛表格 + 分段筛选 + 详情抽屉(**重点重构**) | +| `record-tasks.html` | 录制任务 | 实时态 + 折叠会话卡 + 嵌套分片表 | +| `session-detail.html` | 会话详情 | 时间轴 + 弹幕热力 + 回放播放器 | +| `record-task-detail.html` | 分片详情 | 预览播放器 + 描述列表 + 日志 | +| `transcode-tasks.html` | 转码任务 | 进度条 + 分段筛选 | +| `upload-tasks.html` | 上传任务 | 上传状态 + 批量重传 | +| `media-browser.html` | 文件库 | 面包屑目录浏览 | +| `daily-reviews.html` | 回顾日报 | 维度表 + 高光会话 + 高光时刻 | +| `logs.html` | 系统日志 | 多维筛选 + 实体下钻 | +| `recovery.html` | 恢复中心 | 存储守护 + 可恢复房间/收尾 | +| `settings.html` | 系统设置 | 左锚点 + 分组卡片 + 吸底保存栏 | + +## 交互说明 + +- **主题切换**:顶栏月亮/太阳图标,切换深/浅色(localStorage 记忆)。 +- **侧栏折叠**:顶栏菜单图标。 +- **详情抽屉**:直播间页点行尾「⋯」打开右侧抽屉。 +- **折叠会话**:录制任务页点会话头部展开/收起分片表。 +- **设置锚点**:设置页左侧锚点定位到对应分组。 + +## 资源 + +- `assets/design-system.css` — 设计系统(tokens + 全部组件样式,含暗色/密度)。 +- `assets/shell.js` — 应用外壳注入(侧栏/顶栏/主题/折叠/抽屉)。 + +所有页面共享同一套设计系统与外壳,与第四阶段《设计规范》一致。 diff --git a/prototype/assets/design-system.css b/prototype/assets/design-system.css new file mode 100644 index 0000000..caeb724 --- /dev/null +++ b/prototype/assets/design-system.css @@ -0,0 +1,701 @@ +/* ============================================================= + Live Recorder · Design System v2 (high-fidelity prototype) + 商业级 SaaS · 深色侧栏 + 层次投影 + 玻璃磨砂 + 微动画 + 参考 Linear / Vercel / 飞书 / 云控制台 / Ant Design Pro + ============================================================= */ + +:root { + color-scheme: light; + --font-sans: "Segoe UI Variable", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif; + --font-mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", Menlo, monospace; + + /* neutrals — 冷蓝灰统一基调 */ + --bg-base: #eef1f6; + --bg-subtle: #e7ebf2; + --surface: #ffffff; + --surface-muted: #f4f7fb; + --surface-hover: #eef2f8; + --border-subtle: #e3e7ef; + --border-base: #d2d8e3; + --text-primary: #0f172a; + --text-secondary: #45526a; + --text-muted: #6a7689; + --text-soft: #97a1b3; + + /* sidebar (light — subtle grey, blends with page) */ + --side-bg: #f3f5f9; + --side-bg-2: #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; + + /* brand */ + --accent: #1a56db; + --accent-strong: #1e40af; + --accent-soft: rgba(26, 86, 219, 0.12); + --accent-soft-2: rgba(26, 86, 219, 0.07); + + /* semantic */ + --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); + --info: #2563eb; + --info-soft: rgba(37, 99, 235, 0.11); + --purple: #7c3aed; + --purple-soft: rgba(124, 58, 237, 0.11); + + /* 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); + + /* radius */ + --radius-xs: 7px; + --radius-sm: 9px; + --radius-md: 14px; + --radius-lg: 20px; + + /* layout */ + --sidebar-w: 244px; + --sidebar-w-collapsed: 72px; + --topbar-h: 62px; + --page-max: 1320px; +} + +html[data-theme="dark"] { + color-scheme: dark; + --bg-base: #0a1120; + --bg-subtle: #0f172a; + --surface: #121c30; + --surface-muted: #16223899; + --surface-hover: #1b2942; + --border-subtle: #1f2c44; + --border-base: #2c3a55; + --text-primary: #e9eefb; + --text-secondary: #b6c4dd; + --text-muted: #8295b3; + --text-soft: #647a9a; + + --side-bg: #0c1424; + --side-bg-2: #0a111e; + --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(99, 102, 241, 0.32); + --side-active: #93c5fd; + + --accent: #60a5fa; + --accent-strong: #3b82f6; + --accent-soft: rgba(96, 165, 250, 0.18); + --accent-soft-2: rgba(96, 165, 250, 0.10); + --success: #34d399; + --success-soft: rgba(52, 211, 153, 0.15); + --warning: #fbbf24; + --warning-soft: rgba(251, 191, 36, 0.15); + --danger: #f87171; + --danger-soft: rgba(248, 113, 113, 0.15); + --info: #5b9bff; + --info-soft: rgba(91, 155, 255, 0.15); + --purple: #a78bfa; + --purple-soft: rgba(167, 139, 250, 0.15); + + --shadow-xs: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.45); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.6); + --shadow-float: 0 24px 56px rgba(0, 0, 0, 0.65); +} + +/* — dark mode component overrides (brand / health) — */ +html[data-theme="dark"] .brand__mark { background: #1a2438; box-shadow: 0 2px 8px rgba(0,0,0,.5); } +html[data-theme="dark"] .brand__name { color: #eaf0fb; } +html[data-theme="dark"] .brand__sub { color: #647a9a; } +html[data-theme="dark"] .health { background: rgba(52,211,153,.12); border-color: rgba(52,211,153,.22); } +html[data-theme="dark"] .health__dot { background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,.22); } +html[data-theme="dark"] .health__txt { color: #6ee7b7; } + +html[data-density="compact"] { --topbar-h: 56px; } + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.6; + font-weight: 400; + color: var(--text-primary); + background: var(--bg-base); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + font-feature-settings: "kern" 1, "liga" 1, "calt" 1; +} +a { color: inherit; text-decoration: none; } +button { font-family: inherit; cursor: pointer; } +h1, h2, h3, h4, p { margin: 0; } +::selection { background: var(--accent-soft); } + +*::-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; } + +/* ============================ Icons ============================ */ +svg.ic { width: 18px; height: 18px; flex-shrink: 0; display: inline-block; vertical-align: middle; } +.empty__ic svg.ic { width: 24px; height: 24px; } + +/* ============================ Motion ============================ */ +@keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +/* ============================ App Shell ============================ */ +.app-shell { display: flex; min-height: 100vh; } + +.sidebar { + position: fixed; inset: 0 auto 0 0; z-index: 30; + width: var(--sidebar-w); + display: flex; flex-direction: column; + background: var(--side-bg); + border-right: 1px solid var(--side-border); + transition: width .2s ease; +} +.sidebar.collapsed { width: var(--sidebar-w-collapsed); } + +.brand { + display: flex; align-items: center; gap: 11px; + height: var(--topbar-h); padding: 0 18px; flex-shrink: 0; + border-bottom: 1px solid var(--side-border); +} +.brand__mark { + width: 36px; height: 36px; flex-shrink: 0; border-radius: 8px; + display: grid; place-items: center; color: #fff; + background: #1a56db; + box-shadow: 0 2px 8px rgba(26, 86, 219, 0.25); +} +.brand__name { font-weight: 800; font-size: 16px; letter-spacing: -.02em; color: var(--text-primary); white-space: nowrap; } +.brand__sub { font-size: 11px; color: var(--text-muted); white-space: nowrap; margin-top: 1px; } +.sidebar.collapsed .brand__copy { display: none; } + +.nav { flex: 1; overflow-y: auto; padding: 14px 12px 8px; } +.nav__group { margin-bottom: 16px; } +.nav__title { + padding: 6px 10px; font-size: 11px; font-weight: 700; letter-spacing: .09em; + text-transform: uppercase; color: var(--side-title); +} +.sidebar.collapsed .nav__title { font-size: 0; padding: 6px 0; text-align: center; } +.sidebar.collapsed .nav__title::after { content: "•"; font-size: 13px; } + +.nav__item { + display: flex; align-items: center; gap: 11px; + padding: 9px 11px; margin-bottom: 2px; border-radius: 6px; + color: var(--side-item); font-weight: 600; font-size: 14px; + position: relative; transition: background .15s, color .15s; white-space: nowrap; +} +.nav__item:hover { background: var(--side-item-hover-bg); color: var(--side-item-hover); } +.nav__item.active { background: var(--side-active-bg); color: var(--side-active); } +.nav__item.active::before { + content: ""; position: absolute; left: -12px; top: 50%; transform: translateY(-50%); + width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--side-active); +} +.nav__item .ic { width: 18px; height: 18px; opacity: .92; } +.nav__badge { margin-left: auto; font-size: 11px; font-weight: 700; + background: var(--danger); color: #fff; border-radius: 99px; padding: 1px 6px; min-width: 18px; text-align: center; } +.sidebar.collapsed .nav__item { justify-content: center; padding: 9px 0; } +.sidebar.collapsed .nav__label, .sidebar.collapsed .nav__badge { display: none; } + +.sidebar__foot { padding: 12px; border-top: 1px solid var(--side-border); } +.health { + display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; + background: rgba(22, 163, 74, 0.10); border: 1px solid rgba(22, 163, 74, 0.20); +} +.health__dot { width: 8px; height: 8px; border-radius: 99px; background: #16a34a; box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.18); } +.health__txt { font-size: 12.5px; font-weight: 600; color: #15803d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sidebar.collapsed .health__txt { display: none; } +.sidebar.collapsed .health { justify-content: center; padding: 10px 0; } +@media (max-width: 768px) { .sidebar .health { min-width: 0; } .sidebar .health__txt { min-width: 0; flex: 1; } } + +/* main column */ +.main-col { flex: 1; margin-left: var(--sidebar-w); min-width: 0; transition: margin .2s ease; } +.sidebar.collapsed ~ .main-col { margin-left: var(--sidebar-w-collapsed); } + +.topbar { + position: sticky; top: 0; z-index: 20; + height: var(--topbar-h); display: flex; align-items: center; gap: 14px; + padding: 0 28px; background: color-mix(in srgb, var(--surface) 70%, transparent); + backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4); + border-bottom: 1px solid rgba(15, 23, 42, 0.07); +} +html[data-theme="dark"] .topbar { border-bottom-color: rgba(255, 255, 255, 0.06); } +.icon-btn { + width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center; + border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent; + color: var(--text-secondary); position: relative; transition: background .15s, color .15s; +} +.icon-btn:hover { background: var(--surface-hover); color: var(--text-primary); } +.icon-btn .dot { position: absolute; top: 7px; right: 8px; width: 7px; height: 7px; + border-radius: 99px; background: var(--danger); border: 1.5px solid var(--surface); } + +.breadcrumb { display: flex; align-items: center; gap: 7px; font-size: 13.5px; color: var(--text-muted); min-width: 0; } +.breadcrumb b { color: var(--text-primary); font-weight: 700; } +.breadcrumb .sep { color: var(--text-soft); } + +.searchbar { + margin-left: 8px; flex: 1; max-width: 440px; display: flex; align-items: center; gap: 9px; + height: 38px; padding: 0 12px; border-radius: var(--radius-sm); + background: var(--surface-muted); border: 1px solid var(--border-subtle); color: var(--text-muted); + transition: border-color .15s, box-shadow .15s; +} +.searchbar:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.searchbar input { flex: 1; border: 0; background: transparent; outline: none; color: var(--text-primary); font-size: 13.5px; } +.searchbar input::placeholder { color: var(--text-soft); } +.searchbar kbd { font-family: var(--font-sans); font-size: 11px; color: var(--text-muted); + background: var(--surface); border: 1px solid var(--border-base); border-radius: 5px; padding: 1px 6px; } +.topbar__right { margin-left: auto; display: flex; align-items: center; gap: 8px; } + +.user-chip { + display: flex; align-items: center; gap: 9px; padding: 4px 10px 4px 4px; + border: 1px solid var(--border-subtle); border-radius: 99px; background: var(--surface); + transition: background .15s, border-color .15s; +} +.user-chip:hover { background: var(--surface-hover); border-color: var(--border-base); } +.avatar { + width: 30px; height: 30px; flex-shrink: 0; border-radius: 99px; display: grid; place-items: center; + color: #fff; font-weight: 700; font-size: 13px; background: #1a56db; +} +.user-chip__name { font-size: 13px; font-weight: 700; } +.user-chip__meta { font-size: 11px; color: var(--text-muted); } + +/* page */ +.page { max-width: var(--page-max); margin: 0 auto; padding: 28px 32px; display: flex; flex-direction: column; gap: 22px; } +.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; animation: fadeUp .3s ease both; } +.page-head__kicker { font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--accent); margin-bottom: 7px; } +.page-head h1 { font-size: 28px; font-weight: 800; letter-spacing: -.025em; line-height: 1.2; } +.page-head p { margin-top: 8px; color: var(--text-muted); font-size: 13.5px; max-width: 720px; line-height: 1.6; } +.page-head__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } + +/* ============================ Buttons ============================ */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 7px; + height: 38px; padding: 0 15px; border-radius: var(--radius-sm); font-size: 13.5px; font-weight: 700; + border: 1px solid var(--border-base); background: var(--surface); color: var(--text-secondary); + transition: all .15s ease; white-space: nowrap; +} +.btn:hover { border-color: var(--text-soft); color: var(--text-primary); background: var(--surface); box-shadow: var(--shadow-xs); } +.btn .ic { width: 16px; height: 16px; } +.btn--primary { + background: #1a56db; + border-color: #1e40af; color: #fff; + box-shadow: 0 1px 3px rgba(26, 86, 219, 0.4), 0 4px 14px rgba(26, 86, 219, 0.2); +} +.btn--primary:hover { color: #fff; background: #1e40af; border-color: #1e40af; transform: translateY(-1px); box-shadow: 0 2px 5px rgba(26, 86, 219, 0.45), 0 6px 18px rgba(26, 86, 219, 0.28); } +.btn--primary:active { transform: translateY(0); } +.btn--ghost { border-color: transparent; background: transparent; } +.btn--ghost:hover { background: var(--surface-hover); box-shadow: none; } +.btn--danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 35%, transparent); background: transparent; } +.btn--danger:hover { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); box-shadow: none; } +.btn--sm { height: 30px; padding: 0 11px; font-size: 12.5px; border-radius: var(--radius-xs); } +.btn--link { border: 0; background: transparent; color: var(--accent); padding: 0 6px; height: auto; font-weight: 600; } +.btn--link:hover { background: transparent; color: var(--accent-strong); text-decoration: underline; box-shadow: none; } +.btn[disabled] { opacity: .45; pointer-events: none; } + +/* ============================ Cards ============================ */ +.card { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); transition: box-shadow .18s ease; animation: fadeUp .3s ease both; } +.card:hover { box-shadow: var(--shadow-md); } +.card__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 24px; border-bottom: 1px solid var(--border-subtle); } +.card__title { font-size: 16px; font-weight: 700; letter-spacing: -.01em; } +.card__sub { font-size: 12.5px; color: var(--text-muted); margin-top: 3px; font-weight: 500; } +.card__body { padding: 24px; } +.card__body--flush { padding: 0; } + +/* stat card */ +.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; } +.stat { + background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); + padding: 20px; display: flex; flex-direction: column; gap: 11px; box-shadow: var(--shadow-sm); + transition: box-shadow .18s, transform .18s; position: relative; overflow: hidden; + animation: fadeUp .35s ease both; +} +.stat:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); } +.stat:nth-child(2) { animation-delay: .05s; } +.stat:nth-child(3) { animation-delay: .10s; } +.stat:nth-child(4) { animation-delay: .15s; } +.stat:nth-child(5) { animation-delay: .20s; } +.stat:nth-child(6) { animation-delay: .25s; } +.stat__top { display: flex; align-items: center; justify-content: space-between; } +.stat__label { font-size: 12.5px; font-weight: 600; color: var(--text-muted); } +.stat__ic { width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent); } +.stat__ic.is-green { background: var(--success-soft); color: var(--success); } +.stat__ic.is-amber { background: var(--warning-soft); color: var(--warning); } +.stat__ic.is-red { background: var(--danger-soft); color: var(--danger); } +.stat__ic.is-purple { background: var(--purple-soft); color: var(--purple); } +.stat__value { font-size: 31px; font-weight: 800; letter-spacing: -.04em; line-height: 1; font-variant-numeric: tabular-nums; } +.stat__value small { font-size: 15px; font-weight: 600; color: var(--text-muted); letter-spacing: 0; } +.stat__foot { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--text-muted); font-weight: 500; } +.trend { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: 12px; } +.trend.up { color: var(--success); } +.trend.down { color: var(--danger); } + +/* ============================ Badges / Tags ============================ */ +.badge { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; font-weight: 600; color: var(--text-secondary); } +.badge .pip { width: 7px; height: 7px; border-radius: 99px; background: var(--text-soft); } +.badge.is-success { color: var(--success); } .badge.is-success .pip { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); } +.badge.is-danger { color: var(--danger); } .badge.is-danger .pip { background: var(--danger); box-shadow: 0 0 0 3px var(--danger-soft); } +.badge.is-warning { color: var(--warning); } .badge.is-warning .pip { background: var(--warning); box-shadow: 0 0 0 3px var(--warning-soft); } +.badge.is-info { color: var(--info); } .badge.is-info .pip { background: var(--info); box-shadow: 0 0 0 3px var(--info-soft); } +.badge.live .pip { animation: pulse 1.4s infinite; } +@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } } + +.tag { + display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; + border-radius: var(--radius-xs); font-size: 12px; font-weight: 600; + background: var(--surface-muted); color: var(--text-secondary); border: 1px solid var(--border-subtle); +} +.tag.is-blue { background: var(--info-soft); color: var(--info); border-color: transparent; } +.tag.is-green { background: var(--success-soft); color: var(--success); border-color: transparent; } +.tag.is-amber { background: var(--warning-soft); color: var(--warning); border-color: transparent; } +.tag.is-red { background: var(--danger-soft); color: var(--danger); border-color: transparent; } +.tag.is-purple { background: var(--purple-soft); color: var(--purple); border-color: transparent; } + +/* ============================ Table ============================ */ +.table-wrap { width: 100%; overflow-x: auto; } +table.tbl { width: 100%; border-collapse: collapse; font-size: 13.5px; } +.tbl thead th { + position: sticky; top: 0; text-align: left; font-size: 12px; font-weight: 700; letter-spacing: .02em; + color: var(--text-muted); text-transform: uppercase; padding: 12px 20px; + background: #eef2f8; border-bottom: 2px solid var(--border-base); white-space: nowrap; +} +html[data-theme="dark"] .tbl thead th { background: #16223a; } +.tbl tbody td { padding: 14px 20px; border-bottom: 1px solid var(--border-subtle); vertical-align: middle; } +.tbl tbody tr:last-child td { border-bottom: 0; } +.tbl tbody tr { transition: background .12s; } +.tbl tbody tr:nth-child(even) { background: rgba(15, 23, 42, 0.018); } +html[data-theme="dark"] .tbl tbody tr:nth-child(even) { background: rgba(255, 255, 255, 0.02); } +.tbl tbody tr:hover { background: var(--surface-hover); } +.tbl .num { text-align: right; font-variant-numeric: tabular-nums; } +.cell-title { font-weight: 700; color: var(--text-primary); } +.cell-sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; font-weight: 500; } +.cell-mono { font-family: var(--font-mono); font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; } +.row-actions { display: flex; align-items: center; gap: 4px; justify-content: flex-end; opacity: 0; transition: opacity .15s; } +.tbl tbody tr:hover .row-actions { opacity: 1; } +@media (hover: none) { .row-actions { opacity: 1; } } + +.checkbox { width: 16px; height: 16px; border: 1.5px solid var(--border-base); border-radius: 4px; display: inline-block; vertical-align: middle; background: var(--surface); } +.checkbox.checked { background: var(--accent); border-color: var(--accent); position: relative; } +.checkbox.checked::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); } + +.identity { display: flex; align-items: center; gap: 11px; } +.identity .avatar { width: 40px; height: 40px; border-radius: 11px; font-size: 15px; } +.meta-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; font-size: 12px; color: var(--text-muted); } +.meta-row .dot-sep { width: 3px; height: 3px; border-radius: 99px; background: var(--text-soft); } + +/* ============================ Toolbar / filters ============================ */ +.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 16px 24px; border-bottom: 1px solid var(--border-subtle); } +.toolbar__spacer { flex: 1; } +.segmented { display: inline-flex; padding: 3px; gap: 2px; background: var(--surface-muted); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); } +.segmented button { border: 0; background: transparent; padding: 6px 13px; border-radius: 6px; font-size: 13px; font-weight: 600; color: var(--text-muted); transition: all .15s; } +.segmented button.active { background: var(--surface); color: var(--accent); box-shadow: var(--shadow-xs); } +.segmented button:hover:not(.active) { color: var(--text-primary); } +.field { + display: inline-flex; align-items: center; gap: 8px; height: 36px; padding: 0 12px; + background: var(--surface); border: 1px solid var(--border-base); border-radius: var(--radius-sm); + font-size: 13px; color: var(--text-secondary); +} +.field input, .field select { border: 0; background: transparent; outline: none; color: var(--text-primary); font-size: 13px; font-family: inherit; } +.field--search { color: var(--text-muted); min-width: 220px; } +.field--search input { flex: 1; } +.sel-count { font-size: 13px; color: var(--text-muted); font-weight: 600; } +.sel-count b { color: var(--accent); } + +/* ============================ Progress ============================ */ +.bar { height: 8px; border-radius: 99px; background: var(--surface-hover); overflow: hidden; } +.bar > span { display: block; height: 100%; border-radius: 99px; background: #1a56db; } +.bar > span.is-green { background: #16a34a; } +.bar > span.is-amber { background: #d97706; } +.bar > span.is-red { background: #dc2626; } + +.ring { --p: 32; width: 92px; height: 92px; border-radius: 99px; display: grid; place-items: center; + background: conic-gradient(var(--success) calc(var(--p)*1%), var(--surface-hover) 0); } +.ring__inner { width: 70px; height: 70px; border-radius: 99px; background: var(--surface); display: grid; place-items: center; text-align: center; } +.ring__num { font-size: 20px; font-weight: 800; line-height: 1; font-variant-numeric: tabular-nums; } +.ring__cap { font-size: 10.5px; color: var(--text-muted); margin-top: 2px; } + +/* ============================ Drawer ============================ */ +.scrim { position: fixed; inset: 0; background: rgba(15,23,42,.5); backdrop-filter: blur(2px); z-index: 40; opacity: 0; pointer-events: none; transition: opacity .2s; } +.scrim.open { opacity: 1; pointer-events: auto; } +.drawer { + position: fixed; top: 0; right: 0; bottom: 0; width: 448px; max-width: 92vw; z-index: 41; + background: var(--surface); border-left: 1px solid var(--border-subtle); box-shadow: var(--shadow-float); + transform: translateX(100%); transition: transform .26s cubic-bezier(.32,.72,0,1); + display: flex; flex-direction: column; +} +.drawer.open { transform: translateX(0); } +.drawer__head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px; border-bottom: 1px solid var(--border-subtle); } +.drawer__title { font-size: 16px; font-weight: 700; } +.drawer__sub { font-size: 12.5px; color: var(--text-muted); margin-top: 3px; } +.drawer__body { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 18px; } +.drawer__foot { padding: 14px 20px; border-top: 1px solid var(--border-subtle); display: flex; gap: 8px; justify-content: flex-end; } + +.desc { display: grid; gap: 1px; background: var(--border-subtle); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); overflow: hidden; } +.desc__row { display: grid; grid-template-columns: 120px 1fr; gap: 1px; } +.desc__row dt { background: var(--surface-muted); padding: 10px 12px; font-size: 12.5px; color: var(--text-muted); font-weight: 600; } +.desc__row dd { background: var(--surface); padding: 10px 12px; margin: 0; font-size: 13px; color: var(--text-primary); } + +/* ============================ Forms ============================ */ +.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 18px 20px; } +.form-item { display: flex; flex-direction: column; gap: 7px; } +.form-item.full { grid-column: 1 / -1; } +.form-item label { font-size: 13px; font-weight: 600; color: var(--text-secondary); } +.form-item label .req { color: var(--danger); margin-left: 3px; } +.form-item .hint { font-size: 12px; color: var(--text-muted); font-weight: 500; } +.input, .select-box, .textarea { + height: 38px; padding: 0 12px; border: 1px solid var(--border-base); border-radius: var(--radius-sm); + background: var(--surface); color: var(--text-primary); font-size: 13.5px; font-family: inherit; outline: none; + transition: border-color .15s, box-shadow .15s; width: 100%; +} +.input:focus, .select-box:focus, .textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } +.textarea { height: auto; padding: 10px 12px; resize: vertical; min-height: 84px; line-height: 1.6; } +select.select-box { appearance: none; background-image: url("data:image/svg+xml;utf8,"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 32px; } + +.switch { width: 40px; height: 22px; border-radius: 99px; background: var(--border-base); position: relative; transition: background .18s; flex-shrink: 0; } +.switch.on { background: var(--accent); } +.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 99px; background: #fff; box-shadow: var(--shadow-sm); transition: left .18s; } +.switch.on::after { left: 20px; } +.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--surface-muted); } +.toggle-row__t { font-size: 13.5px; font-weight: 600; } +.toggle-row__h { font-size: 12px; color: var(--text-muted); margin-top: 2px; font-weight: 500; } + +.anchor-layout { display: grid; grid-template-columns: 200px 1fr; gap: 24px; align-items: start; } +.anchor { position: sticky; top: calc(var(--topbar-h) + 24px); display: flex; flex-direction: column; gap: 2px; } +.anchor a { padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13.5px; font-weight: 600; color: var(--text-muted); border-left: 2px solid transparent; transition: all .15s; } +.anchor a:hover { background: var(--surface-hover); color: var(--text-primary); } +.anchor a.active { background: var(--accent-soft); color: var(--accent); border-left-color: var(--accent); } + +.savebar { + position: sticky; bottom: 16px; margin-top: 4px; + display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 13px 18px; border-radius: var(--radius-md); background: var(--surface); + border: 1px solid var(--border-base); box-shadow: var(--shadow-lg); +} +.savebar__t { font-size: 13px; color: var(--text-muted); } +.savebar__t b { color: var(--warning); } + +/* ============================ Misc ============================ */ +.alert { display: flex; gap: 11px; padding: 13px 16px; border-radius: var(--radius-sm); font-size: 13px; align-items: flex-start; } +.alert .ic { width: 18px; height: 18px; flex-shrink: 0; margin-top: 1px; } +.alert--info { background: var(--info-soft); color: var(--info); } +.alert--warn { background: var(--warning-soft); color: var(--warning); } +.alert--error { background: var(--danger-soft); color: var(--danger); } +.alert--success { background: var(--success-soft); color: var(--success); } +.alert b { font-weight: 700; } +.alert__body { color: var(--text-secondary); } +.alert__body b { color: var(--text-primary); } + +.empty { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 48px 20px; text-align: center; color: var(--text-muted); } +.empty__ic { width: 56px; height: 56px; border-radius: 50%; display: grid; place-items: center; background: var(--surface-muted); color: var(--text-soft); } +.empty__t { font-size: 14px; font-weight: 700; color: var(--text-secondary); } +.empty__d { font-size: 12.5px; } + +.pagination { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 24px; border-top: 1px solid var(--border-subtle); font-size: 13px; color: var(--text-muted); } +.pagination__pages { display: flex; align-items: center; gap: 4px; } +.pg { min-width: 32px; height: 32px; padding: 0 8px; display: grid; place-items: center; border-radius: var(--radius-xs); border: 1px solid var(--border-subtle); background: var(--surface); font-size: 13px; font-weight: 600; color: var(--text-secondary); transition: all .15s; } +.pg.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.pg:hover:not(.active) { border-color: var(--text-soft); } + +.divider { height: 1px; background: var(--border-subtle); border: 0; margin: 0; } +.section-label { font-size: 13px; font-weight: 700; color: var(--text-primary); display: flex; align-items: center; gap: 8px; margin: 4px 0; } +.section-label::before { content: ""; width: 3px; height: 14px; border-radius: 2px; background: var(--accent); } +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.grid-12-7-5 { display: grid; grid-template-columns: 7fr 5fr; gap: 20px; } +@media (max-width: 1100px) { .grid-2, .grid-12-7-5 { grid-template-columns: 1fr; } } + +/* ≤980px — hide search, tighten page */ +@media (max-width: 980px) { .searchbar { display: none; } .page { padding: 18px 16px; } } + +/* ≤768px — mobile */ +@media (max-width: 768px) { + :root { --sidebar-w: 0px; --sidebar-w-collapsed: 0px; --topbar-h: 56px; } + .sidebar { top: 0; height: 100dvh; transform: translateX(-100%); transition: transform .24s cubic-bezier(.4,0,.2,1); } + .sidebar.mobile-open { transform: translateX(0); width: 280px; z-index: 50; top: 0; } + .sidebar:not(.mobile-open) .brand { display: none; } + .sidebar:not(.mobile-open) .sidebar__foot { display: none; } + .sidebar:not(.mobile-open) .nav { padding-top: 0; } + .sidebar.mobile-open .brand { height: var(--topbar-h); } + .sidebar.mobile-open ~ .scrim { opacity: 1; pointer-events: auto; } + .main-col { margin-left: 0 !important; } + + /* page spacing */ + .page { padding: 12px 12px; gap: 12px; max-width: 100%; } + .page-head h1 { font-size: 20px; } + .page-head { flex-direction: column; gap: 10px; } + .page-head p { font-size: 12.5px; max-width: 100%; word-break: break-word; } + .page-head__actions { width: 100%; flex-wrap: wrap; } + .page-head__actions .btn { flex: 1; justify-content: center; min-width: 0; } + + /* KPI cards */ + .stat-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; } + .stat { padding: 12px; gap: 8px; min-width: 0; } + .stat__value { font-size: 22px; word-break: break-word; } + .stat__value small { font-size: 13px; } + .stat__label { font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .stat__foot { font-size: 11px; flex-wrap: wrap; } + .stat__ic { width: 28px; height: 28px; border-radius: 6px; } + + /* topbar */ + .topbar { padding: 0 10px; gap: 6px; min-width: 0; flex-wrap: nowrap; } + .topbar > * { min-width: 0; } + .topbar__right { gap: 2px; flex-shrink: 0; } + .breadcrumb { font-size: 11.5px; min-width: 0; max-width: 130px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; flex-shrink: 1; } + .icon-btn { width: 30px; height: 30px; flex-shrink: 0; } + .user-chip__copy { display: none; } + .user-chip { padding: 2px 6px 2px 2px; } + .user-chip .avatar { width: 24px; height: 24px; font-size: 11px; } + .user-chip__name { font-size: 11px; } + .searchbar { display: none; } + + /* toolbar */ + .toolbar { padding: 10px 12px; gap: 8px; flex-wrap: wrap; } + .toolbar .segmented { width: 100%; } + .toolbar .segmented button { flex: 1; text-align: center; font-size: 11.5px; padding: 5px 6px; } + .toolbar .field { height: 32px; font-size: 12px; } + .toolbar .field--search { min-width: 140px; } + .sel-count { font-size: 11px; } + + /* table — force scroll + prevent overflow */ + .table-wrap { -webkit-overflow-scrolling: touch; } + .tbl thead th { padding: 8px 8px; font-size: 10.5px; white-space: nowrap; } + .tbl tbody td { padding: 9px 8px; font-size: 12px; line-height: 1.45; } + .tbl thead th[style*="width"], .tbl tbody td[style*="width"] { min-width: auto !important; } + .cell-title { font-size: 12.5px; word-break: break-word; } + .cell-sub { font-size: 11px; } + .cell-mono { font-size: 11px; word-break: break-all; } + .meta-row { flex-wrap: wrap; gap: 4px; font-size: 11px; } + .identity .avatar { width: 32px; height: 32px; border-radius: 8px; font-size: 13px; } + + /* table actions — compact */ + .row-actions { opacity: 1 !important; flex-wrap: wrap; gap: 2px; } + .row-actions .btn--sm { height: 26px; padding: 0 7px; font-size: 11px; } + .row-actions .btn--link { padding: 0 4px; font-size: 11px; } + + /* card */ + .card__head { padding: 12px 14px; gap: 8px; flex-wrap: wrap; } + .card__title { font-size: 14px; } + .card__body { padding: 14px; } + .card__body .session__head + .session__body .tbl tbody td { font-size: 11px; padding: 7px 6px; } + + /* stat card in card context */ + .card .stat-grid { grid-template-columns: repeat(2, 1fr); gap: 6px; } + .card .stat { padding: 10px; } + .card .stat .stat__value { font-size: 18px; } + + /* anchor (settings) */ + .anchor-layout { grid-template-columns: 1fr; } + .anchor { flex-direction: row; overflow-x: auto; gap: 2px; position: static; padding-bottom: 4px; } + .anchor a { border-left: none; border-bottom: 2px solid transparent; padding: 7px 9px; white-space: nowrap; font-size: 11.5px; } + .anchor a.active { border-left: none; border-bottom-color: var(--accent); } + + /* forms */ + .form-grid { grid-template-columns: 1fr; gap: 12px 14px; } + .form-item { gap: 5px; } + .toggle-row { padding: 10px 12px; gap: 10px; } + .toggle-row__t { font-size: 12.5px; } + .toggle-row__h { font-size: 11px; } + .desc__row { grid-template-columns: 90px 1fr; } + .desc__row dt, .desc__row dd { padding: 7px 8px; font-size: 11.5px; } + + /* drawer */ + .drawer { width: 100vw; max-width: 100vw; } + .drawer__head { padding: 14px; } + .drawer__body { padding: 14px; gap: 12px; } + .drawer__foot { padding: 10px 14px; } + + /* pagination */ + .pagination { flex-direction: column; align-items: stretch; gap: 8px; padding: 10px 14px; } + + /* session collapse */ + .session__head { flex-wrap: wrap; padding: 10px 12px; gap: 8px; } + .session__metrics { gap: 12px; margin-left: 0; width: 100%; justify-content: space-between; } + .session__metrics .m b { font-size: 13px; } + .session__metrics .m span { font-size: 10px; } + + /* misc */ + .ring { width: 68px; height: 68px; } + .ring__inner { width: 50px; height: 50px; } + .ring__num { font-size: 16px; } + .chip { font-size: 11px; padding: 3px 8px; } + .badge { font-size: 11.5px; } + .tag { font-size: 11px; height: 20px; padding: 0 6px; } + .savebar { flex-direction: column; align-items: stretch; gap: 10px; padding: 10px 14px; } + .savebar__t { font-size: 12px; } + .alert { font-size: 12px; padding: 10px 12px; } + .empty { padding: 32px 16px; } + + /* grid/pair always stack */ + .grid-2, .grid-12-7-5 { grid-template-columns: 1fr; gap: 12px; } + .stat-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; } +} + +/* very narrow (≤400px) — KPI goes 1-col, even more compact */ +@media (max-width: 400px) { + .stat-grid { grid-template-columns: 1fr; } + .page-head__actions { flex-direction: column; } + .tbl thead th { font-size: 10px; padding: 6px 5px; } + .tbl tbody td { font-size: 11px; padding: 7px 5px; } +} + +/* mobile hamburger */ +.mobile-hamburger { display: none; } +@media (max-width: 768px) { .mobile-hamburger { display: grid; } } + +/* mobile sidebar scrim (below drawer scrim 40) */ +#mobile-scrim { z-index: 39; } + +.chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 99px; background: var(--surface-muted); border: 1px solid var(--border-subtle); font-size: 12px; color: var(--text-secondary); font-weight: 600; } + +.timeline-track { position: relative; height: 56px; border-radius: var(--radius-sm); background: var(--surface-muted); overflow: hidden; border: 1px solid var(--border-subtle); } +.seg-block { position: absolute; top: 8px; bottom: 8px; border-radius: 5px; background: var(--accent); opacity: .9; } +.seg-block.rec { background: #dc2626; } +.heat-row { display: flex; gap: 2px; align-items: flex-end; height: 60px; } +.heat-bar { flex: 1; border-radius: 3px 3px 0 0; background: var(--accent); opacity: .85; min-height: 3px; } + +.player { aspect-ratio: 16/9; border-radius: var(--radius-md); background: #0f172a; display: grid; place-items: center; color: #cbd5e1; position: relative; overflow: hidden; border: 1px solid var(--border-base); } +.player__play { width: 56px; height: 56px; border-radius: 99px; background: rgba(255,255,255,.16); display: grid; place-items: center; backdrop-filter: blur(4px); cursor: pointer; transition: transform .15s, background .15s; } +.player__play:hover { transform: scale(1.08); background: rgba(255,255,255,.24); } +.player__bar { position: absolute; left: 16px; right: 16px; bottom: 14px; display: flex; align-items: center; gap: 10px; color: #fff; font-size: 12px; } +.player__bar .bar { flex: 1; background: rgba(255,255,255,.2); } +.player__bar .bar > span { background: #fff; width: 38%; } +.danmaku { position: absolute; font-size: 13px; color: #fff; text-shadow: 0 1px 3px rgba(0,0,0,.6); white-space: nowrap; font-weight: 600; } + +@media (max-width: 980px) { .searchbar { display: none; } .page { padding: 18px 16px; } } +@media (prefers-reduced-motion: reduce) { * { animation: none !important; } } + +/* ============================ Skeleton ============================ */ +@keyframes shimmer { 0% { background-position: -400px 0; } 100% { background-position: 400px 0; } } +.skel { + background: linear-gradient(90deg, var(--border-subtle) 25%, var(--surface-muted) 37%, var(--border-subtle) 63%); + background-size: 800px 100%; + animation: shimmer 1.6s ease-in-out infinite; + border-radius: var(--radius-xs); +} +.skel--text { height: 14px; width: 100%; } +.skel--text.short { width: 60%; } +.skel--title { height: 18px; width: 50%; } +.skel--stat-value { height: 34px; width: 50%; margin-bottom: 8px; } +.skel--stat-label { height: 12px; width: 80%; } +.skel--btn { height: 38px; width: 100px; border-radius: var(--radius-sm); } +.skel--avatar { width: 40px; height: 40px; border-radius: 99px; flex-shrink: 0; } +.skel--pill { height: 22px; width: 56px; border-radius: 99px; } +.skel--row { display: flex; align-items: center; gap: 16px; padding: 14px 20px; } +.skel--row + .skel--row { border-top: 1px solid var(--border-subtle); } +.skel-card { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); overflow: hidden; } +.skel-card__body { padding: 24px; display: flex; flex-direction: column; gap: 14px; } +.skel-stat { background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); padding: 20px; box-shadow: var(--shadow-sm); display: flex; flex-direction: column; gap: 12px; } diff --git a/prototype/assets/shell.js b/prototype/assets/shell.js new file mode 100644 index 0000000..4bd4e3a --- /dev/null +++ b/prototype/assets/shell.js @@ -0,0 +1,169 @@ +/* ============================================================= + App Shell injector — sidebar + topbar, theme + collapse. + Each page: + Content lives in
...
+ ============================================================= */ +(function () { + // ---- inline icon set (lucide-style, 24x24, stroke) ---- + const I = { + gauge: '', + home: '', + video: '', + refresh: '', + convert: '', + upload: '', + folder: '', + report: '', + logs: '', + settings: '', + search: '', + bell: '', + sun: '', + moon: '', + menu: '', + chevron: '', + recovery: '', + }; + function svg(name, cls) { + return '' + (I[name] || '') + ''; + } + + const NAV = [ + { title: '总览', items: [ + { id: 'dashboard', label: '仪表盘', icon: 'gauge', href: 'index.html' }, + ]}, + { title: '监控录制', items: [ + { id: 'live-rooms', label: '直播间', icon: 'home', href: 'live-rooms.html' }, + { id: 'record-tasks', label: '录制任务', icon: 'video', href: 'record-tasks.html', badge: '3' }, + { id: 'recovery', label: '恢复中心', icon: 'recovery', href: 'recovery.html' }, + ]}, + { title: '媒资归档', items: [ + { id: 'transcode-tasks', label: '转码任务', icon: 'convert', href: 'transcode-tasks.html' }, + { id: 'upload-tasks', label: '上传任务', icon: 'upload', href: 'upload-tasks.html' }, + { id: 'media-browser', label: '文件库', icon: 'folder', href: 'media-browser.html' }, + ]}, + { title: '分析', items: [ + { id: 'daily-reviews', label: '回顾日报', icon: 'report', href: 'daily-reviews.html' }, + { id: 'logs', label: '系统日志', icon: 'logs', href: 'logs.html' }, + ]}, + { title: '系统', items: [ + { id: 'settings', label: '系统设置', icon: 'settings', href: 'settings.html' }, + ]}, + ]; + + const page = document.body.dataset.page || 'dashboard'; + const crumb = document.body.dataset.crumb || ''; + + // ---- sidebar ---- + let navHtml = ''; + NAV.forEach(g => { + navHtml += ''; + }); + + const sidebar = document.createElement('aside'); + sidebar.className = 'sidebar'; + const LOGO = + '' + + '' + + '' + + '' + + '' + + ''; + sidebar.innerHTML = + '
' + + '
' + LOGO + '
' + + '
Live Recorder
直播监控录制平台
' + + '
' + + '' + + ''; + + // ---- topbar ---- + const crumbHtml = crumb.split('/').map((s, i, a) => + (i === a.length - 1 ? '' + s.trim() + '' : '' + s.trim() + '') + ).join('/'); + + const topbar = document.createElement('header'); + topbar.className = 'topbar'; + topbar.innerHTML = + '' + + '' + + '' + + '
' + + '' + + '' + + '' + + '
'; + + // ---- assemble ---- + const view = document.getElementById('view'); + const shell = document.createElement('div'); + shell.className = 'app-shell'; + const mainCol = document.createElement('div'); + mainCol.className = 'main-col'; + mainCol.appendChild(topbar); + if (view) mainCol.appendChild(view); + shell.appendChild(sidebar); + shell.appendChild(mainCol); + document.body.insertBefore(shell, document.body.firstChild); + + // mobile scrim (closes sidebar on tap) + const scrim = document.createElement('div'); + scrim.className = 'scrim'; + scrim.id = 'mobile-scrim'; + document.body.appendChild(scrim); + + function closeMobileSidebar() { sidebar.classList.remove('mobile-open'); scrim.classList.remove('open'); } + + // ---- interactions ---- + const collapseBtn = document.getElementById('collapseBtn'); + collapseBtn.addEventListener('click', () => { + if (window.innerWidth <= 768) { + sidebar.classList.toggle('mobile-open'); + scrim.classList.toggle('open'); + } else { + sidebar.classList.toggle('collapsed'); + } + }); + scrim.addEventListener('click', closeMobileSidebar); + + // close mobile sidebar on resize back to desktop + window.addEventListener('resize', () => { if (window.innerWidth > 768) closeMobileSidebar(); }); + const themeBtn = document.getElementById('themeBtn'); + function applyTheme(t) { + document.documentElement.dataset.theme = t; + themeBtn.innerHTML = svg(t === 'dark' ? 'sun' : 'moon'); + try { localStorage.setItem('lr-theme', t); } catch (e) {} + } + themeBtn.addEventListener('click', () => applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')); + let saved = 'light'; + try { saved = localStorage.getItem('lr-theme') || 'light'; } catch (e) {} + applyTheme(saved); + + // generic drawer open/close hooks: [data-drawer-open="id"] and .drawer .scrim + document.addEventListener('click', e => { + const opener = e.target.closest('[data-drawer-open]'); + if (opener) { + const d = document.getElementById(opener.dataset.drawerOpen); + const s = document.getElementById(opener.dataset.drawerOpen + '-scrim'); + if (d) d.classList.add('open'); + if (s) s.classList.add('open'); + } + const closer = e.target.closest('[data-drawer-close]'); + if (closer) { + document.querySelectorAll('.drawer.open').forEach(d => d.classList.remove('open')); + document.querySelectorAll('.scrim.open').forEach(s => s.classList.remove('open')); + } + }); +})(); diff --git a/prototype/daily-reviews.html b/prototype/daily-reviews.html new file mode 100644 index 0000000..747f27b --- /dev/null +++ b/prototype/daily-reviews.html @@ -0,0 +1,58 @@ + + + + +回顾日报 · Live Recorder + + + +
+
+
+
回顾分析

回顾日报

按天聚合录制活跃度、Top 房间、高光会话与弹幕峰值时刻,支持推送到通知渠道。

+
+
+
+
活跃房间
12
+
会话
28
+
分片
142
+
时长
38h
+
弹幕
86k
+
警告 / 错误
5 / 1
+
+
+
+
房间维度
+
+ + + + + + +
房间会话分片时长弹幕
熊宇一
抖音
4223h12m18,420会话
游戏老王
B站
2162h40m21,003会话
音乐电台
虎牙
3151h55m12,880会话
+
+
+
高光会话
+
+
⏱ 最长会话
熊宇一 · 才艺专场 · 3h12m
查看
+
🔥 最热会话
游戏老王 · 巅峰赛 · 21,003 弹幕
查看
+
⚠ 异常会话
深夜美食档 · 录制失败 ×1
查看
+
+
+
+
+
高光时刻
弹幕峰值时间段(每 5 分钟桶)
+
+ + + + + +
房间时间段弹幕峰值操作
游戏老王
21:35 – 21:401,204查看分片
熊宇一
10:50 – 10:55980查看分片
+
+
+
+ + + diff --git a/prototype/index.html b/prototype/index.html new file mode 100644 index 0000000..a6db6a0 --- /dev/null +++ b/prototype/index.html @@ -0,0 +1,132 @@ + + + + + +仪表盘 · Live Recorder + + + +
+
+
+
+
系统概览
+

仪表盘

+

系统运行状态一览:直播间、录制会话、弹幕吞吐与存储队列概况。

+
+
+ + +
+
+ + +
+
+
正在录制
+
3
+
录制通道活跃
+
+
+
直播间
+
12 / 28 离线
+
共接入 40 个直播间
+
+
+
今日录制时长
+
6h 20m
+
▲ 12% 较昨日
+
+
+
今日数据量
+
48.2 GB
+
平均码率 4.2 Mbps
+
+
+
今日弹幕
+
12,304
+
▲ 8% 较昨日
+
+
+
24h 异常
+
0
+
系统运行正常
+
+
+ + +
+
+
存储水位
录制输出路径剩余空间与保护阈值
正常
+
+
32%
已使用
+
+
可用空间
412 GB / 600 GB
+
+
+
挂载路径
/records
+
暂停阈值
1 GB
+
恢复阈值
4 GB
+
+
+
+
+ +
+
处理队列
待转码与待上传的文件积压
去处理 →
+
+
+
待转码
FFmpeg 后处理队列
+
2
+
+
+
待上传
WebDAV / S3 / OpenList
+
5
+
+
+
积压数据量
等待归档的本地文件总量
+
18.4 GB
+
+
+
+
+ + +
+
+
最近会话
全部 →
+
+
+ + + + + + + +
直播间状态分片开始时间
熊宇一 · 才艺专场录制中310:24:05查看
游戏老王 · 巅峰赛处理中809:12:40查看
音乐电台 LIVE已完成502:00:11查看
深夜美食档失败101:31:55查看
+
+
+ +
+
今日热门直播间
Top 5 · 按时长
+
+
+ + + + + + + + +
直播间平台会话时长
熊宇一抖音43h 12m查看
游戏老王B站22h 40m查看
音乐电台虎牙31h 55m查看
户外阿强快手11h 08m查看
英文角 TalkYouTube152m查看
+
+
+
+
+
+ + + diff --git a/prototype/live-rooms.html b/prototype/live-rooms.html new file mode 100644 index 0000000..832b1cc --- /dev/null +++ b/prototype/live-rooms.html @@ -0,0 +1,196 @@ + + + + + +直播间 · Live Recorder + + + +
+
+
+
+
直播监控
+

直播间控制台

+

聚合直播状态、自动开录决策与单房间录制配置,面向开播检测、异常巡检与手动介入。

+
+
+ + + + +
+
+ + +
+
在线直播间
12
最近巡检识别为开播中
+
录制中任务
3
当前处于录制状态
+
启用房间
34
参与自动检测与录制
+
重点监控
6
优先巡检与关注
+
+ + +
+ +
+
+ + + + +
+ + +
+ 已选 2 + + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
直播间直播状态录制状态最近事件房间配置开关操作
+
熊宇一 · 才艺专场 重点
+
抖音845878323112
+
开播中录制中
自动开录 ✓ 10:24:05
检测到开播,已启动录制任务
原画 · MP4 · 分段
弹幕开 · 30min
+
游戏老王 · 巅峰赛
+
B站123456
+
开播中处理中
自动开录 ✓ 09:12:40
分片收尾中(MP4 finalize)
超清 · TS · 分段
弹幕开 · 60min
+
音乐电台 LIVE
+
虎牙998877
+
未开播未录制
已禁用 08:00:00
房间未开播,跳过自动开录
高清 · MP4 · 单文件
弹幕关 · —
+
户外阿强 置顶
+
快手556677
+
未开播未录制
存储不足 07:45:00
磁盘低于暂停阈值,跳过开录
原画 · MP4 · 分段
弹幕开 · 30min
T
+
英文角 Talk
+
YouTubedQw4w9
+
未知未录制
轮询失败 06:30:00
远端请求超时,将自动重试
高清 · MP4 · 单文件
弹幕关 · —
+ + + +
+
+
+ + +
+ + + + + diff --git a/prototype/login.html b/prototype/login.html new file mode 100644 index 0000000..3201fc9 --- /dev/null +++ b/prototype/login.html @@ -0,0 +1,50 @@ + + + + +登录 · Live Recorder + + + +
+ + + +
+
+

登录

+

欢迎回来,请使用管理员账号登录控制台。

+
+
+
+ 登录 +
+
+
+
+ + + diff --git a/prototype/logs.html b/prototype/logs.html new file mode 100644 index 0000000..f74aa1d --- /dev/null +++ b/prototype/logs.html @@ -0,0 +1,56 @@ + + + + + +系统日志 · Live Recorder + + + +
+
+
+
+
排障中心
+

系统日志

+

贯穿巡检、录制、转码、上传的全链路日志,支持按级别、分类与关键词过滤并下钻关联实体。

+
+
+ + +
+
+ +
+
+ + + + +
+ +
+ +
+ + + + + + + + + + +
时间级别分类消息关联实体
10:24:05错误录制FFmpeg 进程异常退出,退出码 1:Connection reset by peer
▸ 展开堆栈详情
会话↗分片↗
10:23:50警告巡检抖音房间 845878 轮询超时,将在 8s 后重试(第 2 次)房间↗
10:24:05信息录制检测到开播,已启动录制任务(会话 #3)会话↗
10:18:11信息上传分片 #1 已上传至 OpenList:/archive/2026/06/25/熊宇一/…上传↗
10:15:02警告存储磁盘可用空间 980MB 低于暂停阈值 1024MB,已暂停 2 个录制任务恢复↗
10:12:40跟踪转码分片 #4 转码完成,耗时 42s,输出 410MB转码↗
10:08:33信息巡检本轮巡检完成:40 个房间,开播 12,耗时 3.2s
+ + +
+
+
+ + + diff --git a/prototype/media-browser.html b/prototype/media-browser.html new file mode 100644 index 0000000..885cb1d --- /dev/null +++ b/prototype/media-browser.html @@ -0,0 +1,35 @@ + + + + +文件库 · Live Recorder + + + +
+
+
+
文件浏览

文件库

浏览录制输出目录,预览视频、查看弹幕 XML,或对源文件发起转码。

+
+
+
+
+ +
+ +
+
+ + + + + + + +
名称类型大小修改时间操作
📁熊宇一
目录10:24
🎬103505_原画_熊宇一_才艺_01.mp4
视频420 MB10:54
📄103505_原画_熊宇一_才艺_01.xml
弹幕1.2 MB10:54
🎬105405_原画_熊宇一_才艺_02.mp4
视频440 MB11:24
+
+
+
+ + + diff --git a/prototype/record-task-detail.html b/prototype/record-task-detail.html new file mode 100644 index 0000000..d118e47 --- /dev/null +++ b/prototype/record-task-detail.html @@ -0,0 +1,57 @@ + + + + +分片详情 · Live Recorder + + + +
+
+
+
分片任务详情

分片 #1 · 熊宇一 才艺专场

抖音 · 845878323112 · 会话 #3 · 10:24:05 – 10:54:05

+
返回会话
+
+
+
+
视频预览
已完成
+
+
+
+
00:00
30:00
+
+
+
+
+
分片信息
+
+
+
状态
已完成
+
时长
30m 00s
+
文件大小
420 MB
+
画质 / 格式
原画 · MP4
+
分辨率
1920 × 1080 · 30fps
+
编码
H.264 / AAC
+
弹幕数
2,940
+
上传状态
已上传 · OpenList
+
文件路径
/records/抖音/2026/06/25/熊宇一/103505_…_01.mp4
+
+
+
+
+
+
分片日志
+
+ + + + + + +
时间级别消息
10:54:05跟踪MP4 finalize 成功,moov 写入完成
10:54:02信息分片达到分段时长,开始收尾
10:24:05信息分片开始录制,拉流成功(origin)
+
+
+
+ + + diff --git a/prototype/record-tasks.html b/prototype/record-tasks.html new file mode 100644 index 0000000..a17dff2 --- /dev/null +++ b/prototype/record-tasks.html @@ -0,0 +1,150 @@ + + + + + +录制任务 · Live Recorder + + + + +
+
+
+
+
录制工作台
+

录制任务

+

按会话聚合录制分片,支持实时刷新、停止录制、上传归档与条件化清理。

+
+
+ 实时已连接 + + + +
+
+ +
+
活动会话
3
正在录制 / 处理中
+
总分片
142
全部会话累计
+
总弹幕
86,420
已采集消息数
+
待上传分片
5
等待归档
+
+ + +
+ 清理进行中 +
+ 已处理 12 / 40 会话 · 删除文件 8 · 释放 6.2 GB + +
+ + +
+
+ +
+ +
+ 已选 0 + + + +
+ +
+ +
+
+ + + +
+
熊宇一 · 才艺专场 录制中
+
抖音845878323112会话 #3 · 开始 10:24
+
+
+
3分片
+
1.2 GB数据量
+
8,420弹幕
+
+
+ + + 详情 +
+
+
+
+ + + + + + +
分片状态时间段时长大小上传操作
#1已完成10:24:05 – 10:54:0530m420 MB已上传详情
#2已完成10:54:05 – 11:24:0530m440 MB未上传详情
#3录制中11:24:05 – …进行中340 MB详情
+
+
+ + +
+
+ + + +
+
游戏老王 · 巅峰赛 处理中
+
B站123456会话 #7 · 开始 09:12
+
+
+
8分片
+
3.4 GB数据量
+
21,003弹幕
+
+
+ + 详情 +
+
+
+ + +
+
+ + + +
+
音乐电台 LIVE 已完成
+
虎牙998877会话 #5 · 02:00 – 03:55
+
+
+
5分片
+
2.1 GB数据量
+
12,880弹幕
+
+
+ + 详情 +
+
+
+
+
+
+ + + diff --git a/prototype/recovery.html b/prototype/recovery.html new file mode 100644 index 0000000..f18b030 --- /dev/null +++ b/prototype/recovery.html @@ -0,0 +1,42 @@ + + + + +恢复中心 · Live Recorder + + + +
+
+
+
恢复处理

恢复中心

存储守护状态、可恢复直播间与未完成 MP4 收尾的一键恢复。

+
+
+ +
存储守护正常 · 可用 412 GB,需求 1 GB,检测路径 /records
+ +
+
可恢复直播间
因异常中断、可重新触发自动开录的房间
+
+ + + + + +
直播间最近决策最近巡检操作
户外阿强
快手556677
存储不足07:45:00
英文角 Talk
YouTubedQw4w9
轮询失败06:30:00
+
+ +
+
未完成收尾
中断时未完成 MP4 finalize 的分片,可重新收尾
+
+ + + + +
分片直播间原因结束时间操作
会话#7 · #8游戏老王 B站进程被强制结束,MP4 索引未写入09:58:02
+
+
+
+ + + diff --git a/prototype/session-detail.html b/prototype/session-detail.html new file mode 100644 index 0000000..8d0259b --- /dev/null +++ b/prototype/session-detail.html @@ -0,0 +1,102 @@ + + + + + +会话详情 · Live Recorder + + + +
+
+
+
+
会话回顾
+

熊宇一 · 才艺专场 录制中

+

抖音 · 845878323112 · 会话 #3 · 开始于 2026-06-25 10:24:05

+
+
+ 返回列表 + + +
+
+ + +
+
分片数
3
+
总时长
1h 02m
+
数据量
1.2 GB
+
弹幕
8,420
+
已上传 / 失败
1 / 0
+
+ + +
+
录制时间轴
分片录制中▲ 事件
+
+
+
+
+
+
+
+
+
10:2410:5411:24现在
+
+
弹幕热力(每 5 分钟)
+
+
+
+
+
+
+
+
+
+ + +
+
+
弹幕回放
分片 #1
+
+
+
主播好强!!
+
2333333
+
送上小心心 ♥
+
+
10:35
30:00
+
+
+
+
+
分片列表
+
+ + + + + + +
分片状态时长
#1已完成30m查看
#2已完成30m查看
#3录制中查看
+
+
+ + +
+
会话日志
+
+ + + + + + + +
时间级别消息
11:24:05信息分片 #3 开始录制
10:54:05跟踪分片 #2 收尾完成,MP4 finalize 成功
10:39:12警告弹幕轮询短暂超时,已自动恢复
10:24:05信息检测到开播,已启动录制会话 #3
+
+
+
+ + + diff --git a/prototype/settings.html b/prototype/settings.html new file mode 100644 index 0000000..4ad0ea0 --- /dev/null +++ b/prototype/settings.html @@ -0,0 +1,187 @@ + + + + + +系统设置 · Live Recorder + + + +
+
+
+
+
系统设置
+

系统设置

+

录制、存储、弹幕、上传归档、巡检、事件脚本与通知的全局默认配置。

+
+
+ + +
+
+ + +
+
+ +
管理员
admin · 账号到期 2027-01-01
+ +
+
+
主题
+
密度
+
+
+ + +
+ + +
+ +
+
录制基础
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
存储保护
磁盘不足时自动暂停录制
+
+
启用存储守护
低于阈值时暂停录制,恢复后自动继续
+
+
+
+
+
+
+
+
+ + +
+
保留清理
+
+
启用保留清理
按天数自动清理过期录制
+
+
+
+
关闭时仅删除数据库记录,保留磁盘文件
+
+
+
+ + +
+
弹幕录制
+
+
启用弹幕录制
录制时同步抓取弹幕到 XML
+
记录非聊天事件
礼物、进场、点赞等系统事件
+
+
+
+
+
+
+ + +
+
路径模板
+
+
可用变量:{platform} {yyyy} {MM} {dd} {anchor} {roomId}
+
+
+
+ + +
+
巡检与上传归档
+
+
启用后台巡检
周期性检测开播状态
+
开播自动录制
检测到开播立即启动录制任务
+
+
+
+
+
上传后删除本地文件
归档成功后释放磁盘空间
+
+
+ + +
+
平台代理与请求头
抖音 / B站 / 虎牙 …
+
+
为各平台单独配置 Cookie、UA、Referer 与代理,提升抓流成功率。
+
+
+
+
+
+
+
+ + +
+
事件脚本
+
+
启用事件脚本
开播 / 结束 / 分片完成时触发自定义脚本
+
+
+
+
+
+
+ + +
+
通知
+
+
Webhook 通知
开播与异常时推送到 Webhook
+
邮件通知
SMTP 邮件提醒
+
+
+
+
+
+ + +
+
检测到 3 项未保存的修改
+
+
+
+
+
+
+ + + + diff --git a/prototype/skeleton-demo.html b/prototype/skeleton-demo.html new file mode 100644 index 0000000..97596c7 --- /dev/null +++ b/prototype/skeleton-demo.html @@ -0,0 +1,122 @@ + + + + + +Skeleton 加载态 · Live Recorder + + + +
+
+
+
+
系统概览
+

仪表盘

+

+ +

+
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+ + +
+
+
+ +
+ +
+ + + +
+
+
+
+
+
+ + + + +
+
+
+ + +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+ + +
+
+
+ + +
+
+ + + + + + + +
+
+ + + + + + + +
+
+ + + + + + + +
+
+
+
+
+ + + diff --git a/prototype/transcode-tasks.html b/prototype/transcode-tasks.html new file mode 100644 index 0000000..0e66d3b --- /dev/null +++ b/prototype/transcode-tasks.html @@ -0,0 +1,40 @@ + + + + +转码任务 · Live Recorder + + + +
+
+
+
后处理工作台

转码任务

录制产物的 FFmpeg 后处理队列,支持手动转码与失败重试。

+ +
+
+
待转码
2
+
转码中
1
+
已完成
88
+
失败
0
+
+
+
+
+ +
+
+ + + + + + + +
分片源文件大小状态进度操作
游戏老王 · #6
B站
…_老王_06.ts520 MB转码中
62% · 预计 18s
查分片
音乐电台 · #4
虎牙
…_电台_04.ts410 MB待转码排队中查分片
户外阿强 · #1
快手
…_阿强_01.ts300 MB待转码排队中查分片
熊宇一 · #1
抖音
…_熊宇一_01.ts420 MB已完成
耗时 42s
查分片
+
+
+
+ + + diff --git a/prototype/upload-tasks.html b/prototype/upload-tasks.html new file mode 100644 index 0000000..ad912aa --- /dev/null +++ b/prototype/upload-tasks.html @@ -0,0 +1,44 @@ + + + + +上传任务 · Live Recorder + + + +
+
+
+
录制工作台

上传任务

录制产物的归档上传状态,支持手动重传与跳转分片 / 会话。

+
录制任务
+
+
+
总计
142
+
未上传
5
+
已上传
135
+
上传失败
2
+
+
+
+
+ +
+ + +
+
+ + + + + + + +
直播间 / 分片文件大小状态目标操作
熊宇一 · #2
抖音845878
…_熊宇一_才艺_02.mp4440 MB未上传OpenList详情
游戏老王 · #5
B站123456
…_老王_巅峰赛_05.ts512 MB上传失败OpenList详情
熊宇一 · #1
抖音845878
…_熊宇一_才艺_01.mp4420 MB已上传OpenList详情
音乐电台 · #3
虎牙998877
…_电台_LIVE_03.mp4388 MB已上传OpenList详情
+ +
+
+
+ + + diff --git a/scripts/build-arm64-image.sh b/scripts/build-arm64-image.sh index 697d276..c5c8cdf 100644 --- a/scripts/build-arm64-image.sh +++ b/scripts/build-arm64-image.sh @@ -1,7 +1,14 @@ #!/usr/bin/env bash # -# Build the LiveRecorder WebApi image for linux/arm64 and export it as a -# gzip-compressed `docker load`-able archive. +# Build both the LiveRecorder WebApi and Frontend (nginx) images for +# linux/arm64 and export them as a single gzip-compressed `docker load`-able +# archive. +# +# The docker-compose.yml uses a split architecture: +# nginx (frontend) — Vue SPA served by nginx, proxies /api/* to api:8080 +# api (backend) — .NET 8 WebApi +# +# Both images must be ARM64 for a full deployment on an ARM64 host. # # Target environment: Windows + Docker Desktop (WSL2 backend) + a host HTTP proxy. # It encodes the workarounds discovered while building from a network where the @@ -18,8 +25,9 @@ # scripts/build-arm64-image.sh # # Override defaults via env vars: -# PROXY=http://127.0.0.1:10808 IMAGE_TAG=live-recorder-webapi:arm64 -# OUTPUT=live-recorder-webapi-arm64.tar.gz WSL_DISTRO=Ubuntu +# PROXY=http://127.0.0.1:10808 +# OUTPUT=live-recorder-arm64.tar.gz +# WSL_DISTRO=Ubuntu set -euo pipefail # --- Resolve repo root (script lives in /scripts) --- @@ -30,30 +38,26 @@ cd "$ROOT_DIR" # --- Config --- PROXY="${PROXY:-http://127.0.0.1:10808}" PROXY_HOST="${PROXY_HOST:-http://host.docker.internal:${PROXY##*:}}" # container-visible proxy -IMAGE_TAG="${IMAGE_TAG:-live-recorder-webapi:arm64}" -OUTPUT="${OUTPUT:-live-recorder-webapi-arm64.tar.gz}" -DOCKERFILE="${DOCKERFILE:-src/LiveRecorder.WebApi/Dockerfile}" +API_IMAGE_TAG="${API_IMAGE_TAG:-live-recorder-webapi:arm64}" +FRONTEND_IMAGE_TAG="${FRONTEND_IMAGE_TAG:-live-recorder-frontend:arm64}" +OUTPUT="${OUTPUT:-live-recorder-arm64.tar.gz}" WSL_DISTRO="${WSL_DISTRO:-Ubuntu}" -SDK_IMAGE="mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim" -RUNTIME_IMAGE="mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim" +DOTNET_SDK_IMAGE="mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim" +DOTNET_RUNTIME_IMAGE="mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim" +NGINX_IMAGE="${NGINX_IMAGE:-nginx:1.27-alpine}" NO_PROXY_HOSTS="mirrors.tuna.tsinghua.edu.cn,.tsinghua.edu.cn,localhost,127.0.0.1" log() { printf '\n\033[1;36m[%s] %s\033[0m\n' "$(date +%H:%M:%S)" "$*"; } die() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } # --- 1. Ensure ARM64 emulation works ----------------------------------------- -# binfmt_misc lives in the (shared) WSL2 kernel and is lost on `wsl --shutdown`/reboot, -# so this is idempotent and re-runs the registration whenever emulation is missing. ensure_arm64_emulation() { - if docker run --rm --platform linux/arm64 "$RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64; then + if docker run --rm --platform linux/arm64 "$DOTNET_RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64; then log "ARM64 emulation already works — skipping binfmt registration." return 0 fi log "Registering qemu-aarch64 emulation via the '$WSL_DISTRO' WSL distro..." - # Install the statically-linked emulator inside the distro, then re-register it pointing - # directly at the static binary with the F (fix-binary) flag — the kernel opens the - # interpreter at registration time so the held fd works inside Docker build containers. wsl.exe -d "$WSL_DISTRO" -u root -- bash -lc ' set -e if [ ! -x /usr/bin/qemu-aarch64-static ]; then @@ -67,7 +71,7 @@ ensure_arm64_emulation() { > /proc/sys/fs/binfmt_misc/register ' || die "binfmt registration failed (need a Debian/Ubuntu WSL distro named '$WSL_DISTRO' with apt)." - docker run --rm --platform linux/arm64 "$RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64 \ + docker run --rm --platform linux/arm64 "$DOTNET_RUNTIME_IMAGE" uname -m 2>/dev/null | grep -q aarch64 \ || die "ARM64 emulation still not working after registration." log "ARM64 emulation registered and verified." } @@ -85,33 +89,53 @@ pull_with_retry() { docker image inspect "$img" >/dev/null 2>&1 || die "Could not pull $img (network)." } -# --- 3. Build ---------------------------------------------------------------- -build_image() { - log "Building $IMAGE_TAG for linux/arm64 (restore via proxy, apt via mirror)..." +# --- 3. Build API image ------------------------------------------------------ +build_api_image() { + log "Building $API_IMAGE_TAG for linux/arm64 (restore via proxy, apt via mirror)..." docker buildx build \ --platform linux/arm64 \ - -f "$DOCKERFILE" \ - -t "$IMAGE_TAG" \ + -f src/LiveRecorder.WebApi/Dockerfile \ + -t "$API_IMAGE_TAG" \ --build-arg "HTTP_PROXY=$PROXY_HOST" \ --build-arg "HTTPS_PROXY=$PROXY_HOST" \ --build-arg "NO_PROXY=$NO_PROXY_HOSTS" \ + --build-arg "DOTNET_SDK_IMAGE=$DOTNET_SDK_IMAGE" \ + --build-arg "DOTNET_RUNTIME_IMAGE=$DOTNET_RUNTIME_IMAGE" \ --pull=false \ --load \ . } -# --- 4. Export + compress ---------------------------------------------------- -export_image() { - log "Exporting $IMAGE_TAG -> $OUTPUT ..." +# --- 4. Build Frontend (nginx) image ----------------------------------------- +# Frontend dist is pre-built locally (npm run build inside frontend/) and copied +# directly — avoids running Node.js under QEMU ARM64 emulation which is prohibitively +# slow. Build context is frontend/ (not repo root) to bypass .dockerignore rules. +build_frontend_image() { + log "Building $FRONTEND_IMAGE_TAG for linux/arm64 (pre-built dist -> nginx)..." + docker buildx build \ + --platform linux/arm64 \ + -f frontend/Dockerfile.arm64 \ + -t "$FRONTEND_IMAGE_TAG" \ + --pull=false \ + --load \ + frontend +} + +# --- 5. Export both images as a single archive ------------------------------- +export_images() { + log "Exporting $API_IMAGE_TAG + $FRONTEND_IMAGE_TAG -> $OUTPUT ..." local tar="${OUTPUT%.gz}" - docker save "$IMAGE_TAG" -o "$tar" + docker save "$API_IMAGE_TAG" "$FRONTEND_IMAGE_TAG" -o "$tar" gzip -f "$tar" log "Done: $(ls -lh "$OUTPUT" | awk '{print $5, $9}')" log "Load on an arm64 host with: docker load < $OUTPUT" + log "Then start with: docker compose up -d" } ensure_arm64_emulation -pull_with_retry "$SDK_IMAGE" -pull_with_retry "$RUNTIME_IMAGE" -build_image -export_image +pull_with_retry "$DOTNET_SDK_IMAGE" +pull_with_retry "$DOTNET_RUNTIME_IMAGE" +pull_with_retry "$NGINX_IMAGE" +build_api_image +build_frontend_image +export_images diff --git a/scripts/segment_completed_openlist.sh b/scripts/segment_completed_openlist.sh new file mode 100644 index 0000000..55a3ddf --- /dev/null +++ b/scripts/segment_completed_openlist.sh @@ -0,0 +1,173 @@ +#!/bin/sh + +# ========================= +# 基础配置 +# ========================= + +OPENLIST_BASE_URL="http://192.168.6.145:5244" +OPENLIST_USERNAME="${OPENLIST_USERNAME:-admin}" +OPENLIST_PASSWORD="${OPENLIST_PASSWORD:-768788}" + +segment_path="$LIVE_RECORDER_SEGMENT_FILE_PATH" +danmaku_path="$LIVE_RECORDER_DANMAKU_FILE_PATH" +platform="$LIVE_RECORDER_PLATFORM" + +log_file="$LIVE_RECORDER_SCRIPT_LOG_PATH" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$log_file" +} + +# ========================= +# 参数检查 +# ========================= + +if [ -z "$segment_path" ]; then + log "错误:LIVE_RECORDER_SEGMENT_FILE_PATH 为空" + exit 1 +fi + +if [ -z "$danmaku_path" ]; then + log "错误:LIVE_RECORDER_DANMAKU_FILE_PATH 为空" + exit 1 +fi + +if [ -z "$platform" ]; then + log "错误:LIVE_RECORDER_PLATFORM 为空" + exit 1 +fi + +# ========================= +# 简单 JSON 字符串转义 +# ========================= + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +# 从响应里抽取业务 code +extract_code() { + printf '%s' "$1" | sed -n 's/.*"code"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p' +} + +# ========================= +# 1. 获取主播名和日期 +# ========================= + +anchor_name=$(printf '%s\n' "$segment_path" | awk -F'/' '{print $(NF-2)}') +record_date=$(printf '%s\n' "$segment_path" | awk -F'/' '{print $(NF-1)}') + +video_filename=$(basename "$segment_path") +danmaku_filename=$(basename "$danmaku_path") + +log "主播名: $anchor_name" +log "日期: $record_date" +log "视频文件名: $video_filename" +log "弹幕文件名: $danmaku_filename" + +remote_dir="/yidongpan/records/$anchor_name/$record_date" +src_dir="/local/home/nanxun/live_recorder/records/$platform/$anchor_name/$record_date" + +log "OpenList 源目录: $src_dir" +log "OpenList 目标目录: $remote_dir" + +# 转义后用于 JSON +username_json=$(json_escape "$OPENLIST_USERNAME") +password_json=$(json_escape "$OPENLIST_PASSWORD") +remote_dir_json=$(json_escape "$remote_dir") +src_dir_json=$(json_escape "$src_dir") +video_filename_json=$(json_escape "$video_filename") +danmaku_filename_json=$(json_escape "$danmaku_filename") + +# ========================= +# 2. 登录获取 token +# ========================= + +login_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/auth/login" \ + --header 'Content-Type: application/json' \ + --data-raw "{\"username\":\"$username_json\",\"password\":\"$password_json\"}") + +login_code=$(extract_code "$login_resp") +token=$(printf '%s' "$login_resp" | sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') + +if [ "$login_code" != "200" ] || [ -z "$token" ]; then + log "错误:登录失败,响应: $login_resp" + exit 1 +fi + +log "OpenList 登录成功" + +# ========================= +# 3. 递归创建目标目录 +# OpenList 的 /api/fs/mkdir 在部分网盘驱动(如移动云盘)上 +# 不会自动创建父目录,父目录缺失时会报 +# "failed to get parent dir [...]: object not found"。 +# 因此这里从根开始逐级 list + mkdir,确保每一层父目录都已存在。 +# ========================= + +# 先判断完整目标目录是否已存在,存在则直接跳过创建 +list_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/list" \ + --header "Authorization: $token" \ + --header 'Content-Type: application/json' \ + --data-raw "{\"path\":\"$remote_dir_json\",\"password\":\"\",\"refresh\":false,\"page\":1,\"per_page\":1}") +list_code=$(extract_code "$list_resp") + +if [ "$list_code" = "200" ]; then + log "目标目录已存在: $remote_dir" +else + log "目标目录不存在,逐级创建: $remote_dir" + + accum="" + old_ifs="$IFS" + IFS='/' + for seg in $remote_dir; do + [ -z "$seg" ] && continue + accum="$accum/$seg" + accum_json=$(json_escape "$accum") + + # 该层是否已存在?存在则跳过,避免无谓 mkdir 报错 + seg_list=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/list" \ + --header "Authorization: $token" \ + --header 'Content-Type: application/json' \ + --data-raw "{\"path\":\"$accum_json\",\"password\":\"\",\"refresh\":false,\"page\":1,\"per_page\":1}") + seg_code=$(extract_code "$seg_list") + if [ "$seg_code" = "200" ]; then + continue + fi + + mk_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/mkdir" \ + --header "Authorization: $token" \ + --header 'Content-Type: application/json' \ + --data-raw "{\"path\":\"$accum_json\"}") + mk_code=$(extract_code "$mk_resp") + if [ "$mk_code" != "200" ]; then + IFS="$old_ifs" + log "错误:创建目录失败 ($accum),响应: $mk_resp" + exit 1 + fi + log "已创建: $accum" + done + IFS="$old_ifs" + + log "目标目录创建成功: $remote_dir" +fi + +# ========================= +# 4. 移动文件到目标目录 +# ========================= + +move_resp=$(curl -sS --location --request POST "$OPENLIST_BASE_URL/api/fs/move" \ + --header 'Content-Type: application/json' \ + --header "Authorization: $token" \ + --data-raw "{\"src_dir\":\"$src_dir_json\",\"dst_dir\":\"$remote_dir_json\",\"names\":[\"$danmaku_filename_json\",\"$video_filename_json\"]}") + +move_code=$(extract_code "$move_resp") + +if [ "$move_code" = "200" ]; then + log "文件移动成功: $danmaku_filename, $video_filename -> $remote_dir" +else + log "错误:文件移动失败,响应: $move_resp" + exit 1 +fi + +exit 0 diff --git a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs index 2ca06e9..523880a 100644 --- a/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs +++ b/src/LiveRecorder.Application/Abstractions/Persistence/PersistenceContracts.cs @@ -90,6 +90,16 @@ public interface IRecordResultRepository Task SumPendingUploadBytesAsync(CancellationToken cancellationToken = default); + Task> ListUploadStatusAsync( + RecordArtifactUploadStatus? uploadStatusFilter, + int skip, + int take, + CancellationToken cancellationToken = default); + + Task CountUploadStatusAsync( + RecordArtifactUploadStatus? uploadStatusFilter, + CancellationToken cancellationToken = default); + Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default); void Update(RecordResult recordResult); diff --git a/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs b/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs index edf0a2d..22db750 100644 --- a/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs +++ b/src/LiveRecorder.Application/Models/RecordTasks/RecordSessionModels.cs @@ -42,6 +42,12 @@ public sealed class RecordSessionDto public int TotalDanmakuMessageCount { get; init; } + public int UploadedSegmentCount { get; init; } + + public int FailedUploadSegmentCount { get; init; } + + public int UploadingSegmentCount { get; init; } + public required IReadOnlyList Tasks { get; init; } } diff --git a/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs b/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs index a3ba92e..3b175b4 100644 --- a/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs +++ b/src/LiveRecorder.Application/Models/RecordTasks/RecordTaskModels.cs @@ -85,6 +85,8 @@ public sealed class RecordTaskDto public double? DurationSeconds { get; init; } + public RecordArtifactUploadStatus? UploadStatus { get; init; } + public string? PostProcessStage { get; init; } public double? PostProcessProgressPercent { get; init; } @@ -169,3 +171,57 @@ public sealed class ManualSegmentCompletedTriggerResultDto public required string Message { get; init; } } + +public sealed class UploadTaskItemDto +{ + public Guid RecordTaskId { get; init; } + + public Guid RecordSessionId { get; init; } + + public Guid LiveRoomId { get; init; } + + public required string LiveRoomTitle { get; init; } + + public int Platform { get; init; } + + public required string RoomId { get; init; } + + public int SegmentIndex { get; init; } + + public string OutputFormat { get; init; } = string.Empty; + + public string? FilePath { get; init; } + + public long? FileSizeBytes { get; init; } + + public string? DanmakuFilePath { get; init; } + + public RecordArtifactUploadStatus UploadStatus { get; init; } + + public string? LastUploadProvider { get; init; } + + public string? RemoteVideoPath { get; init; } + + public string? RemoteDanmakuPath { get; init; } + + public DateTimeOffset? LastUploadedAt { get; init; } + + public string? UploadErrorMessage { get; init; } + + public bool DeletedLocalFilesAfterUpload { get; init; } + + public DateTimeOffset CreatedAt { get; init; } +} + +public sealed class UploadTaskListResponse +{ + public IReadOnlyList Items { get; init; } = []; + + public int TotalCount { get; init; } + + public int NotUploadedCount { get; init; } + + public int SucceededCount { get; init; } + + public int FailedCount { get; init; } +} diff --git a/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs b/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs index c686a2e..04bf3fe 100644 --- a/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs +++ b/src/LiveRecorder.Application/Models/Settings/SettingsModels.cs @@ -82,6 +82,17 @@ public sealed class S3UploadSettingsDto public bool ForcePathStyle { get; set; } } +public sealed class OpenListUploadSettingsDto +{ + public string BaseUrl { get; set; } = string.Empty; + + public string Username { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; + + public string BasePath { get; set; } = string.Empty; +} + public sealed class SystemSettingsDto { public string FfmpegPath { get; set; } = "ffmpeg"; @@ -155,6 +166,8 @@ public sealed class SystemSettingsDto public S3UploadSettingsDto S3Upload { get; set; } = new(); + public OpenListUploadSettingsDto OpenListUpload { get; set; } = new(); + public bool EnableEventScripts { get; set; } = false; public bool EnableLiveStartedScript { get; set; } = false; @@ -434,6 +447,8 @@ public sealed class UpdateSystemSettingsRequest public S3UploadSettingsDto S3Upload { get; set; } = new(); + public OpenListUploadSettingsDto OpenListUpload { get; set; } = new(); + public bool EnableEventScripts { get; set; } = false; public bool EnableLiveStartedScript { get; set; } = false; diff --git a/src/LiveRecorder.Application/Services/RecordModelMapper.cs b/src/LiveRecorder.Application/Services/RecordModelMapper.cs index 8b9bf56..e7a9a89 100644 --- a/src/LiveRecorder.Application/Services/RecordModelMapper.cs +++ b/src/LiveRecorder.Application/Services/RecordModelMapper.cs @@ -27,6 +27,7 @@ internal static class RecordModelMapper StartedAt = recordTask.StartedAt, EndedAt = recordTask.EndedAt, DurationSeconds = recordTask.DurationSeconds, + UploadStatus = recordTask.Result?.UploadStatus, PostProcessStage = runtimeState?.Stage, PostProcessProgressPercent = runtimeState?.ProgressPercent, PostProcessDetail = runtimeState?.Detail @@ -87,6 +88,9 @@ internal static class RecordModelMapper EndedAt = recordSession.EndedAt, TotalFileSizeBytes = totalFileSizeBytes, TotalDanmakuMessageCount = totalDanmakuMessageCount, + UploadedSegmentCount = orderedTasks.Count(item => item.Result?.UploadStatus == RecordArtifactUploadStatus.Succeeded), + FailedUploadSegmentCount = orderedTasks.Count(item => item.Result?.UploadStatus == RecordArtifactUploadStatus.Failed), + UploadingSegmentCount = orderedTasks.Count(item => item.Result?.UploadStatus == RecordArtifactUploadStatus.Uploading), Tasks = orderedTasks.Select(item => MapTaskWithFallback( item, recordSession, @@ -122,6 +126,7 @@ internal static class RecordModelMapper StartedAt = recordTask.StartedAt, EndedAt = recordTask.EndedAt, DurationSeconds = recordTask.DurationSeconds, + UploadStatus = recordTask.Result?.UploadStatus, PostProcessStage = runtimeState?.Stage, PostProcessProgressPercent = runtimeState?.ProgressPercent, PostProcessDetail = runtimeState?.Detail diff --git a/src/LiveRecorder.Application/Services/SystemSettingsService.cs b/src/LiveRecorder.Application/Services/SystemSettingsService.cs index 6c6da70..f141c66 100644 --- a/src/LiveRecorder.Application/Services/SystemSettingsService.cs +++ b/src/LiveRecorder.Application/Services/SystemSettingsService.cs @@ -54,6 +54,10 @@ public sealed class SystemSettingsService : ISystemSettingsService private const string S3SecretKeyKey = "upload.s3.secret_key"; private const string S3PrefixKey = "upload.s3.prefix"; private const string S3ForcePathStyleKey = "upload.s3.force_path_style"; + private const string OpenListBaseUrlKey = "upload.openlist.base_url"; + private const string OpenListUsernameKey = "upload.openlist.username"; + private const string OpenListPasswordKey = "upload.openlist.password"; + private const string OpenListBasePathKey = "upload.openlist.base_path"; private const string DouyinProxyEnabledKey = "platform_proxy.douyin.enabled"; private const string DouyinProxyUrlKey = "platform_proxy.douyin.url"; private const string BilibiliProxyEnabledKey = "platform_proxy.bilibili.enabled"; @@ -180,6 +184,13 @@ public sealed class SystemSettingsService : ISystemSettingsService Prefix = GetValue(lookup, S3PrefixKey, string.Empty), ForcePathStyle = bool.TryParse(GetValue(lookup, S3ForcePathStyleKey, "false"), out var s3ForcePathStyle) && s3ForcePathStyle }, + OpenListUpload = new OpenListUploadSettingsDto + { + BaseUrl = GetValue(lookup, OpenListBaseUrlKey, string.Empty), + Username = GetValue(lookup, OpenListUsernameKey, string.Empty), + Password = GetValue(lookup, OpenListPasswordKey, string.Empty), + BasePath = GetValue(lookup, OpenListBasePathKey, string.Empty) + }, EnableEventScripts = bool.TryParse(GetValue(lookup, EnableEventScriptsKey, "false"), out var enableEventScripts) && enableEventScripts, EnableLiveStartedScript = GetEventScriptEnabled( lookup, @@ -348,6 +359,11 @@ public sealed class SystemSettingsService : ISystemSettingsService await UpsertAsync(S3SecretKeyKey, s3Upload.SecretKey, now, cancellationToken); await UpsertAsync(S3PrefixKey, s3Upload.Prefix.Trim(), now, cancellationToken); await UpsertAsync(S3ForcePathStyleKey, s3Upload.ForcePathStyle.ToString(), now, cancellationToken); + var openListUpload = request.OpenListUpload ?? new OpenListUploadSettingsDto(); + await UpsertAsync(OpenListBaseUrlKey, openListUpload.BaseUrl.Trim(), now, cancellationToken); + await UpsertAsync(OpenListUsernameKey, openListUpload.Username.Trim(), now, cancellationToken); + await UpsertAsync(OpenListPasswordKey, openListUpload.Password, now, cancellationToken); + await UpsertAsync(OpenListBasePathKey, openListUpload.BasePath.Trim(), now, cancellationToken); foreach (var platformDefinition in LivePlatformCatalog.All) { var platformRequestSettings = request.GetPlatformRequestSettings(platformDefinition.Type); diff --git a/src/LiveRecorder.Domain/Entities/RecordResult.cs b/src/LiveRecorder.Domain/Entities/RecordResult.cs index 7723da0..d638415 100644 --- a/src/LiveRecorder.Domain/Entities/RecordResult.cs +++ b/src/LiveRecorder.Domain/Entities/RecordResult.cs @@ -85,6 +85,15 @@ public class RecordResult ErrorMessage = errorMessage; } + public void MarkUploadStarted(string provider, DateTimeOffset startedAt) + { + UploadStatus = RecordArtifactUploadStatus.Uploading; + LastUploadProvider = NormalizeNullable(provider); + LastUploadedAt = startedAt; + UploadErrorMessage = null; + DeletedLocalFilesAfterUpload = false; + } + public void MarkUploadSucceeded( string provider, string? remoteVideoPath, diff --git a/src/LiveRecorder.Domain/Enums/RecordArtifactUploadStatus.cs b/src/LiveRecorder.Domain/Enums/RecordArtifactUploadStatus.cs index 5b997e0..db95ff9 100644 --- a/src/LiveRecorder.Domain/Enums/RecordArtifactUploadStatus.cs +++ b/src/LiveRecorder.Domain/Enums/RecordArtifactUploadStatus.cs @@ -4,5 +4,6 @@ public enum RecordArtifactUploadStatus { NotUploaded = 0, Succeeded = 1, - Failed = 2 + Failed = 2, + Uploading = 3 } diff --git a/src/LiveRecorder.Domain/Enums/UploadTargetType.cs b/src/LiveRecorder.Domain/Enums/UploadTargetType.cs index ba051df..29d3977 100644 --- a/src/LiveRecorder.Domain/Enums/UploadTargetType.cs +++ b/src/LiveRecorder.Domain/Enums/UploadTargetType.cs @@ -4,5 +4,6 @@ public enum UploadTargetType { None = 0, WebDav = 1, - S3 = 2 + S3 = 2, + OpenList = 3 } diff --git a/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs index cfa2ff3..60d258f 100644 --- a/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs +++ b/src/LiveRecorder.Infrastructure/Persistence/Repositories/Repositories.cs @@ -277,6 +277,48 @@ public sealed class RecordResultRepository : IRecordResultRepository .Where(item => item.UploadStatus == RecordArtifactUploadStatus.NotUploaded) .SumAsync(item => item.FileSizeBytes ?? 0L, cancellationToken); + public async Task> ListUploadStatusAsync( + RecordArtifactUploadStatus? uploadStatusFilter, + int skip, + int take, + CancellationToken cancellationToken = default) + { + var query = _dbContext.RecordResults + .Include(item => item.RecordTask!) + .ThenInclude(task => task.LiveRoom) + .AsQueryable(); + + if (uploadStatusFilter.HasValue) + { + query = query.Where(item => item.UploadStatus == uploadStatusFilter.Value); + } + + var results = await query + .OrderByDescending(item => item.CreatedAt) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + + return results + .Where(item => item.RecordTask is not null) + .Select(item => (item, item.RecordTask!)) + .ToList(); + } + + public Task CountUploadStatusAsync( + RecordArtifactUploadStatus? uploadStatusFilter, + CancellationToken cancellationToken = default) + { + var query = _dbContext.RecordResults.AsQueryable(); + + if (uploadStatusFilter.HasValue) + { + query = query.Where(item => item.UploadStatus == uploadStatusFilter.Value); + } + + return query.CountAsync(cancellationToken); + } + public Task AddAsync(RecordResult recordResult, CancellationToken cancellationToken = default) => _dbContext.RecordResults.AddAsync(recordResult, cancellationToken).AsTask(); diff --git a/src/LiveRecorder.Infrastructure/Services/RecordUploadService.cs b/src/LiveRecorder.Infrastructure/Services/RecordUploadService.cs index 6419c17..dc2a2aa 100644 --- a/src/LiveRecorder.Infrastructure/Services/RecordUploadService.cs +++ b/src/LiveRecorder.Infrastructure/Services/RecordUploadService.cs @@ -1,6 +1,7 @@ using System.Net; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using LiveRecorder.Application.Abstractions.Logging; using LiveRecorder.Application.Abstractions.Settings; using LiveRecorder.Application.Models.RecordTasks; @@ -147,6 +148,8 @@ public sealed class RecordUploadService } var uploader = CreateUploader(settings); + recordResult.MarkUploadStarted(uploader.ProviderName, DateTimeOffset.UtcNow); + await _dbContext.SaveChangesAsync(cancellationToken); try { var remoteVideoPath = recordResult.RemoteVideoPath; @@ -325,6 +328,7 @@ public sealed class RecordUploadService { UploadTargetType.WebDav => new WebDavRecordArtifactUploader(settings.WebDavUpload), UploadTargetType.S3 => new S3RecordArtifactUploader(settings.S3Upload), + UploadTargetType.OpenList => new OpenListRecordArtifactUploader(settings.OpenListUpload), _ => throw new InvalidOperationException("No supported upload target is configured.") }; } @@ -620,3 +624,137 @@ internal sealed class S3RecordArtifactUploader : IRecordArtifactUploader private static string ConvertToHex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant(); } + +internal sealed class OpenListRecordArtifactUploader : IRecordArtifactUploader +{ + private readonly OpenListUploadSettingsDto _settings; + + public OpenListRecordArtifactUploader(OpenListUploadSettingsDto settings) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public string ProviderName => "openlist"; + + public async Task UploadFileAsync(string localPath, string relativeRemotePath, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_settings.BaseUrl)) + { + throw new InvalidOperationException("OpenList base URL is not configured."); + } + + var baseUrl = _settings.BaseUrl.Trim().TrimEnd('/'); + var remotePath = BuildRemotePath(_settings.BasePath, relativeRemotePath); + var token = await LoginAsync(baseUrl, cancellationToken); + await EnsureDirectoriesAsync(baseUrl, token, remotePath, cancellationToken); + return await PutFileAsync(baseUrl, token, remotePath, localPath, cancellationToken); + } + + private async Task LoginAsync(string baseUrl, CancellationToken cancellationToken) + { + using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + var payload = JsonSerializer.Serialize(new { username = _settings.Username, password = _settings.Password }); + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var response = await client.PostAsync($"{baseUrl}/api/auth/login", content, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; + var code = root.TryGetProperty("code", out var codeElement) ? codeElement.GetInt32() : -1; + if (code != 200) + { + var message = root.TryGetProperty("message", out var msgElement) ? msgElement.GetString() : body; + throw new InvalidOperationException($"OpenList login failed with code {code}: {message}"); + } + + if (!root.TryGetProperty("data", out var dataElement) || + !dataElement.TryGetProperty("token", out var tokenElement) || + string.IsNullOrWhiteSpace(tokenElement.GetString())) + { + throw new InvalidOperationException("OpenList login response did not contain a token."); + } + + return tokenElement.GetString()!; + } + + private static async Task EnsureDirectoriesAsync(string baseUrl, string token, string remotePath, CancellationToken cancellationToken) + { + var segments = remotePath.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length <= 1) + { + return; + } + + using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + var accumulatedPath = string.Empty; + for (var i = 0; i < segments.Length - 1; i++) + { + accumulatedPath += "/" + segments[i]; + await MkdirAsync(client, baseUrl, token, accumulatedPath, cancellationToken); + } + } + + private static async Task MkdirAsync(HttpClient client, string baseUrl, string token, string path, CancellationToken cancellationToken) + { + var payload = JsonSerializer.Serialize(new { path }); + using var content = new StringContent(payload, Encoding.UTF8, "application/json"); + using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/api/fs/mkdir") + { + Content = content + }; + request.Headers.TryAddWithoutValidation("Authorization", token); + + using var response = await client.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + using var doc = JsonDocument.Parse(body); + var code = doc.RootElement.TryGetProperty("code", out var codeElement) ? codeElement.GetInt32() : -1; + if (code != 200) + { + var message = doc.RootElement.TryGetProperty("message", out var msgElement) ? msgElement.GetString() : body; + // AList returns code 500 with "already exists" or "exist" when directory already exists — that's acceptable + if (message != null && message.Contains("exist", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + throw new InvalidOperationException($"OpenList mkdir failed with code {code} for path '{path}': {message}"); + } + } + + private static async Task PutFileAsync(string baseUrl, string token, string remotePath, string localPath, CancellationToken cancellationToken) + { + using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(10) }; + using var request = new HttpRequestMessage(HttpMethod.Put, $"{baseUrl}/api/fs/put"); + request.Headers.TryAddWithoutValidation("Authorization", token); + request.Headers.TryAddWithoutValidation("File-Path", Uri.EscapeDataString(remotePath)); + request.Content = new StreamContent(File.OpenRead(localPath)); + + using var response = await client.SendAsync(request, cancellationToken); + var body = await response.Content.ReadAsStringAsync(cancellationToken); + + using var doc = JsonDocument.Parse(body); + var code = doc.RootElement.TryGetProperty("code", out var codeElement) ? codeElement.GetInt32() : -1; + if (code != 200) + { + var message = doc.RootElement.TryGetProperty("message", out var msgElement) ? msgElement.GetString() : body; + throw new InvalidOperationException($"OpenList file upload failed with code {code}: {message}"); + } + + return $"{baseUrl}{remotePath}"; + } + + private static string BuildRemotePath(string? basePath, string relativeRemotePath) + { + var parts = new[] + { + basePath?.Trim(), + relativeRemotePath.Trim() + } + .Where(static item => !string.IsNullOrWhiteSpace(item)) + .Select(static item => item!.Trim('/')) + .ToArray(); + + return "/" + string.Join('/', parts); + } +} diff --git a/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs index a0c0816..d1f9adc 100644 --- a/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs +++ b/src/LiveRecorder.WebApi/Controllers/RecordTasksController.cs @@ -1,6 +1,9 @@ +using LiveRecorder.Application.Abstractions.Persistence; using LiveRecorder.Application.Models.RecordTasks; using LiveRecorder.Application.Services; using LiveRecorder.Application.Abstractions.Recording; +using LiveRecorder.Domain.Entities; +using LiveRecorder.Domain.Enums; using LiveRecorder.Infrastructure.Services; using Microsoft.AspNetCore.Mvc; @@ -12,6 +15,7 @@ public sealed class RecordTasksController : ControllerBase { private readonly RecordService _recordService; private readonly RecordUploadService _recordUploadService; + private readonly IRecordResultRepository _recordResultRepository; private readonly IRecordMediaService _recordMediaService; private readonly IDanmakuService _danmakuService; private readonly LinkGenerator _linkGenerator; @@ -19,12 +23,14 @@ public sealed class RecordTasksController : ControllerBase public RecordTasksController( RecordService recordService, RecordUploadService recordUploadService, + IRecordResultRepository recordResultRepository, IRecordMediaService recordMediaService, IDanmakuService danmakuService, LinkGenerator linkGenerator) { _recordService = recordService; _recordUploadService = recordUploadService; + _recordResultRepository = recordResultRepository; _recordMediaService = recordMediaService; _danmakuService = danmakuService; _linkGenerator = linkGenerator; @@ -34,6 +40,62 @@ public sealed class RecordTasksController : ControllerBase public async Task>> List([FromQuery] Guid? liveRoomId, CancellationToken cancellationToken) => Ok(await _recordService.ListAsync(liveRoomId, cancellationToken)); + [HttpGet("upload-status")] + public async Task> ListUploadStatus( + [FromQuery] int? uploadStatus, + [FromQuery] int skip = 0, + [FromQuery] int take = 50, + CancellationToken cancellationToken = default) + { + var filter = uploadStatus.HasValue && Enum.IsDefined(typeof(RecordArtifactUploadStatus), uploadStatus.Value) + ? (RecordArtifactUploadStatus)uploadStatus.Value + : (RecordArtifactUploadStatus?)null; + + var items = await _recordResultRepository.ListUploadStatusAsync(filter, skip, take, cancellationToken); + var totalCount = await _recordResultRepository.CountUploadStatusAsync(filter, cancellationToken); + var notUploadedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.NotUploaded, cancellationToken); + var succeededCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Succeeded, cancellationToken); + var failedCount = await _recordResultRepository.CountUploadStatusAsync(RecordArtifactUploadStatus.Failed, cancellationToken); + + return Ok(new UploadTaskListResponse + { + Items = items.Select(MapUploadTaskItem).ToList(), + TotalCount = totalCount, + NotUploadedCount = notUploadedCount, + SucceededCount = succeededCount, + FailedCount = failedCount + }); + } + + private static UploadTaskItemDto MapUploadTaskItem((RecordResult Result, RecordTask Task) pair) + { + var (result, task) = pair; + var liveRoom = task.LiveRoom; + + return new UploadTaskItemDto + { + RecordTaskId = task.Id, + RecordSessionId = task.RecordSessionId, + LiveRoomId = task.LiveRoomId, + LiveRoomTitle = liveRoom?.Title ?? liveRoom?.AnchorName ?? liveRoom?.RoomId ?? "Unknown Room", + Platform = (int)(liveRoom?.Platform ?? LivePlatformType.Unknown), + RoomId = liveRoom?.RoomId ?? string.Empty, + SegmentIndex = task.SegmentIndex, + OutputFormat = task.OutputFormat.ToString(), + FilePath = result.FilePath, + FileSizeBytes = result.FileSizeBytes, + DanmakuFilePath = result.DanmakuFilePath, + UploadStatus = result.UploadStatus, + LastUploadProvider = result.LastUploadProvider, + RemoteVideoPath = result.RemoteVideoPath, + RemoteDanmakuPath = result.RemoteDanmakuPath, + LastUploadedAt = result.LastUploadedAt, + UploadErrorMessage = result.UploadErrorMessage, + DeletedLocalFilesAfterUpload = result.DeletedLocalFilesAfterUpload, + CreatedAt = result.CreatedAt + }; + } + [HttpGet("{id:guid}")] public async Task> Get(Guid id, CancellationToken cancellationToken) {