diff --git a/CHANGELOG.md b/CHANGELOG.md index bf3fd4b..26c009a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ - Mermaid 图表采用更清晰的科研配色与更精致的图框,新生成的流程图还会按数据、处理、判断、结果等角色使用协调的语义色,减少单调的灰白图。 -- 管理后台总览按“当前运行”和“运营资源”重新组织,执行容量改为精简摘要并可从右侧详情面板查看队列、容器、用户占用与宿主资源,异常状态和近期指标也更容易识别。 +- 管理后台改为常驻的紧凑总览与“运行、用量、用户、资源、集成、质量”分组工作区;总览卡可直接定位到同口径详情,执行容量仍可从侧栏快速查看,桌面端和移动端都能更快进入具体管理内容。 ## 0.70.0 — 2026-09-02 diff --git a/PROGRESS.md b/PROGRESS.md index d10c0a9..f8a2429 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-09-02(0.70.0 正式发布:容器共享容量、专业软件节点、模型与交互更新) +最后更新:2026-09-03(Unreleased:管理后台紧凑总览与分组工作区) --- @@ -20,6 +20,8 @@ --- ## 已完成关键能力 +- **09-03 / Unreleased / 管理后台紧凑总览与分组工作区**:Admin 由纵向长页改为顶部常驻的两级总览和“运行、用量、用户、资源、集成、质量”六个页签,四项运行状态使用主卡、四项运营指标收进紧凑指标栏;点击总览可切换页签并精确定位同口径详情,任务、容量、用户活跃、Token/成本和存储均补齐详情指标。页签 hash 支持刷新与前进后退,桌面端页签吸顶,移动端顶栏操作收进菜单、运营指标与页签可横向滑动;1440px/390px 浏览器渲染、六页签与深链交互检查通过,无 API、schema、migration 或运行方式变化。 + - **09-02 / Unreleased / 单用户重型执行容量与排队提示**:共享执行容量保持整机 6、后台 4,单用户上限由 2 提升到 3;前台重型任务首次等待时通过 SSE 告知单用户、整机、内存压力或队列顺序原因,放行后恢复正常执行提示,排队仍不计入命令 timeout 且可取消。无 schema、migration 或 API 变化。 - **09-02 / Unreleased / Admin Provider API 凭据管理与余额提醒**:新增代码内静态可信 Provider 注册表和单行当前凭据控制面,0039 以 AES-GCM 字段级 AAD 密文保存;Admin 可查看 DB/env 来源与尾号、先测后原子替换、手动测试和删除覆盖,LLM/媒体/平台来源/语音均按新请求热解析。DeepSeek 每 30 分钟检查官方 CNY 余额,低于 ¥30 及明确 402/认证失败进入统一状态与邮件提醒,蓝绿实例以 PostgreSQL advisory lock 单轮选主;持续异常每日最多一次、恢复重置。未连接生产数据库、未调用真实第三方 API。 diff --git a/RUN.md b/RUN.md index c0d07a8..b14e923 100644 --- a/RUN.md +++ b/RUN.md @@ -177,7 +177,7 @@ 容器留着复用即可(alpine 版很轻);schema 升级后重跑一次 `db upgrade head`。 - **Auth env**:`PLATFORM_KEY` + `JWT_SECRET` 任一缺失 web 启动 fail-fast。生成随机串:`python -c "import secrets; print(secrets.token_urlsafe(48))"`。 - **用户管理**(`users.email/password_hash/role`,0005 UNIQUE(email)、0009 role):dev SPA 登录后端。发用户两条路径任选:CLI `main.py user add`(下方),或在登录页右下角"+ 管理员添加用户"链接(需先设 `ZCBOT_ADMIN_TOKEN` env,弹窗输入 email/密码/管理员口令/角色)。撤用户 `DELETE FROM users WHERE email=...`(先 DELETE 该 user 的 tasks)。**用户自助改密**:登录后顶栏「改密码」按钮(走 `POST /v1/auth/change_password`,需知道旧密码);改邮箱 / 用户忘了旧密码无法自助 → 手动 SQL(见故障兜底)。 -- **角色与管理后台**(`users.role` ∈ `user`/`admin`):admin 才显顶栏"管理"入口 → `/static/admin.html`(非 admin 403)。页面:左侧目录(点击滚到对应区)+ 运行态/任务/用户用量/按模型/各用户用量/存储;「按模型」「各用户用量」支持时间筛选(全部/近7天/近30天)+ 排序(按成本/按用量),「各用户用量」「存储」分页;顶栏「导出 PDF」走浏览器打印(在打印对话框选"另存为 PDF",列表取前 10)。提管理员 `main.py user role --email X --role admin`(改完即时生效,role 走 DB 查不进 JWT)。`ZCBOT_ADMIN_TOKEN` 是另一回事(发用户共享口令),与 role 互不相干。 +- **角色与管理后台**(`users.role` ∈ `user`/`admin`):admin 才显顶栏"管理"入口 → `/static/admin.html`(非 admin 403)。页面顶部常驻紧凑总览,下方按“运行/用量/用户/资源/集成/质量”页签切换;点击总览卡会进入对应页签并定位同口径详情。执行容量也可从详情侧栏查看;「按模型」「各用户用量」支持时间筛选(全部/近7天/近30天)+ 排序(按成本/按用量),「各用户用量」「存储」分页;顶栏「导出 PDF」走浏览器打印(在打印对话框选"另存为 PDF",列表取前 10)。提管理员 `main.py user role --email X --role admin`(改完即时生效,role 走 DB 查不进 JWT)。`ZCBOT_ADMIN_TOKEN` 是另一回事(发用户共享口令),与 role 互不相干。 --- diff --git a/tests/test_static_vendor.py b/tests/test_static_vendor.py index 3ab1148..3315a3f 100644 --- a/tests/test_static_vendor.py +++ b/tests/test_static_vendor.py @@ -96,12 +96,14 @@ class StaticVendorTests(unittest.TestCase): self.assertIn("永久删除", admin_js) self.assertNotIn("localStorage.setItem", admin_js) - def test_admin_consolidates_overview_into_eight_summary_cards(self) -> None: + def test_admin_uses_compact_overview_and_grouped_workspace_tabs(self) -> None: html = ADMIN_HTML.read_text(encoding="utf-8") admin_js = (JS_DIR / "admin.js").read_text(encoding="utf-8") self.assertIn('id="ops-summary" class="ops-summary"', admin_js) self.assertIn(".ops-summary {", html) + self.assertIn('class="summary-primary"', admin_js) + self.assertIn('class="summary-secondary"', admin_js) labels = ( "任务运行", "执行容量", @@ -117,24 +119,30 @@ class StaticVendorTests(unittest.TestCase): self.assertIn(f'label: "{label}"', admin_js) positions.append(admin_js.index(f'label: "{label}"')) self.assertEqual(positions, sorted(positions)) - self.assertNotIn("function renderRuntime", admin_js) - self.assertNotIn("function renderTasks", admin_js) - self.assertNotIn("function renderUsersAndUsage", admin_js) - self.assertIn('["s-overview", "总览"]', admin_js) - self.assertIn('["s-usage", "用量趋势"]', admin_js) + self.assertIn("function renderRuntimeDetails()", admin_js) + self.assertIn("function renderCapacitySection()", admin_js) + self.assertIn("function renderUsageSection(d)", admin_js) + for tab in ("运行", "用量", "用户", "资源", "集成", "质量"): + self.assertIn(f'"{tab}"', admin_js) + self.assertIn('class="workspace-tabs"', admin_js) + self.assertIn('role="tabpanel"', admin_js) + self.assertIn('tab: "run", target: "s-runtime"', admin_js) + self.assertIn('tab: "run", target: "s-sandbox-capacity"', admin_js) + self.assertIn('tab: "resources", target: "s-windows-node"', admin_js) + self.assertIn('tab: "quality", target: "s-toolfail"', admin_js) + self.assertIn('tab: "users", target: "s-users"', admin_js) + self.assertIn('tab: "usage", target: "s-usage"', admin_js) + self.assertIn('tab: "resources", target: "s-storage"', admin_js) self.assertIn('unit: softwareNodesLoaded ? "在线 / 总数"', admin_js) self.assertIn('value: fmtTokens(tokens7d), unit: "近 7 天"', admin_js) self.assertIn("storageTotals.physical_bytes", admin_js) self.assertIn("repeat(auto-fit, minmax(190px, 1fr))", html) self.assertIn('apiGet("/v1/admin/storage/users?page=0&page_size=1")', admin_js) - self.assertIn('target: "s-windows-node"', admin_js) - self.assertIn('target: "s-toolfail"', admin_js) - self.assertNotIn('"s-sandbox-capacity"', admin_js) - self.assertNotIn("function renderSandboxCapacity", admin_js) - self.assertIn('["s-sandbox-packages", "容器依赖"]', admin_js) + self.assertIn('id="s-sandbox-capacity"', admin_js) + self.assertIn('id="s-sandbox-packages"', admin_js) self.assertIn('sandboxCapacityData = await apiGet("/v1/admin/sandbox/capacity")', admin_js) self.assertNotIn("containerStatusHTML(capacity)", admin_js) - self.assertIn('data-summary-action="${escapeHtml(action)}"', admin_js) + self.assertIn('data-summary-tab="${escapeHtml(tab)}"', admin_js) self.assertIn("function renderCapacityDrawer()", admin_js) self.assertIn("function openCapacityDrawer()", admin_js) self.assertIn('id="capacity-drawer" class="capacity-drawer"', html) diff --git a/web/static/admin.html b/web/static/admin.html index 21eea1c..77e0b24 100644 --- a/web/static/admin.html +++ b/web/static/admin.html @@ -43,6 +43,9 @@ header .spacer { flex: 1; } header .meta { color: var(--muted); font-size: 12px; font-family: var(--mono); } header .actions { display: flex; align-items: center; gap: 6px; } + .action-menu { display: contents; } + .action-menu > summary { display: none; } + .action-menu-body { display: flex; align-items: center; gap: 6px; } header a, header button { min-height: 30px; display: inline-flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 550; color: var(--text); text-decoration: none; @@ -58,28 +61,9 @@ .msg { padding: 28px 14px; text-align: center; color: var(--muted); } .msg a { color: var(--accent); } - /* 目录 + 内容两栏;目录 sticky 跟随滚动 */ - #layout { display: grid; grid-template-columns: 158px minmax(0, 1fr); gap: 14px; align-items: start; } - #toc { - position: sticky; top: 70px; display: flex; flex-direction: column; gap: 3px; - padding: 8px; background: rgba(255,255,255,.88); border: 1px solid var(--border); - border-radius: var(--r-lg); box-shadow: var(--shadow-soft); - } - #toc .toc-label { padding: 4px 9px 7px; color: #8a9299; font-size: 10px; font-weight: 700; letter-spacing: .8px; } - #toc a { - position: relative; display: flex; align-items: center; gap: 8px; min-height: 34px; - color: var(--muted); text-decoration: none; font-size: 12.5px; padding: 6px 8px; - border-radius: 6px; border: 1px solid transparent; transition: .15s ease; - } - #toc .nav-index { width: 20px; flex: 0 0 auto; color: #9aa2a9; font: 10px/1 var(--mono); } - #toc a > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - #toc a:hover { background: var(--panel-soft); color: var(--text-strong); } - #toc a.active { color: var(--accent); border-color: #f0d5d2; background: var(--accent-soft); font-weight: 650; } - #toc a.active::before { content: ""; position: absolute; left: -1px; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--accent); } - #toc a.active .nav-index { color: var(--accent); } - #content { min-width: 0; } + #layout, #content { min-width: 0; } .page-intro { - position: relative; overflow: hidden; margin-bottom: 10px; padding: 13px 16px; + position: relative; overflow: hidden; padding: 12px 16px 10px; border: 1px solid #e6d9d7; border-radius: var(--r-lg); background: linear-gradient(120deg, #fff 0%, #fff 54%, #fdf4f2 100%); box-shadow: var(--shadow-soft); } @@ -87,22 +71,29 @@ content: ""; position: absolute; right: -55px; top: -75px; width: 170px; height: 170px; border: 32px solid rgba(184,58,49,.045); border-radius: 50%; pointer-events: none; } - .page-intro h1 { position: relative; margin: 0; color: var(--text-strong); font-size: 17px; line-height: 1.3; } - .page-intro p { position: relative; margin: 3px 0 0; color: var(--muted); font-size: 11px; } - .anchor { scroll-margin-top: 68px; } + .overview-heading { position: relative; display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } + .page-intro h1 { margin: 0; color: var(--text-strong); font-size: 17px; line-height: 1.3; } + .page-intro p { margin: 2px 0 0; color: var(--muted); font-size: 11px; } + .overview-health { position: relative; flex: 0 0 auto; } + .anchor { scroll-margin-top: 116px; } - /* 首屏运营摘要:第一排业务运行,第二排基础设施与质量。 */ - .ops-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; margin-bottom: 10px; } + /* 总览分两级:运行状态使用主卡,运营指标使用紧凑指标栏。 */ + .ops-summary { position: relative; margin-top: 10px; } + .summary-primary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } + .summary-secondary { + display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin-top: 8px; + overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-lg); background: var(--panel); + } .summary-card { - min-width: 0; min-height: 142px; display: flex; flex-direction: column; padding: 13px 14px 11px; + min-width: 0; min-height: 82px; display: flex; flex-direction: column; padding: 10px 12px; color: inherit; text-decoration: none; background: var(--panel); border: 1px solid var(--border); border-radius: var(--r-lg); box-shadow: var(--shadow-soft); transition: .15s ease; text-align: left; } button.summary-card { width: 100%; appearance: none; font: inherit; cursor: pointer; } - .summary-card[href]:hover, button.summary-card:hover { border-color: #c9ced2; box-shadow: 0 3px 10px rgba(18,24,30,.06); transform: translateY(-1px); } - .summary-card[href]:focus-visible, button.summary-card:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; border-color: var(--accent); } + button.summary-card:hover { border-color: #c9ced2; box-shadow: 0 3px 10px rgba(18,24,30,.06); transform: translateY(-1px); } + button.summary-card:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: 2px; border-color: var(--accent); } .summary-card .label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } - .summary-card .label { min-width: 0; color: #626b73; font-size: 13px; font-weight: 650; } + .summary-card .label { min-width: 0; color: #626b73; font-size: 12px; font-weight: 650; } .summary-card-actions { min-width: 0; display: flex; align-items: center; justify-content: flex-end; gap: 6px; } .summary-card .jump { flex: 0 0 auto; color: #929aa1; font-size: 14px; } .summary-detail { color: #899198; font-size: 10px; white-space: nowrap; } @@ -113,10 +104,10 @@ .summary-badge.ok { color: var(--ok); background: #e8f5e9; } .summary-badge.warn { color: var(--warn); background: #fff0d8; } .summary-badge.danger { color: var(--danger); background: var(--accent-soft); } - .summary-card .summary-value { margin-top: 6px; color: var(--text-strong); font: 680 28px/1.15 var(--mono); letter-spacing: -.7px; } - .summary-card .summary-value .unit { color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: 0; white-space: nowrap; } - .summary-card .summary-sub { margin-top: 5px; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } - .summary-card .summary-meta { display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; margin-top: auto; padding-top: 8px; color: var(--muted); font-size: 11px; } + .summary-card .summary-value { margin-top: 3px; color: var(--text-strong); font: 680 22px/1.12 var(--mono); letter-spacing: -.5px; } + .summary-card .summary-value .unit { color: var(--muted); font-size: 10px; font-weight: 600; letter-spacing: 0; white-space: nowrap; } + .summary-card .summary-sub { margin-top: 3px; overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } + .summary-card .summary-meta { display: none; } .summary-card .summary-meta span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .summary-card .summary-meta span:last-child { text-align: right; } .summary-card .summary-meter { height: 4px; margin-top: 6px; overflow: hidden; border-radius: 999px; background: #e8ebed; } @@ -127,6 +118,47 @@ .summary-card.danger { border-color: #edc1bd; background: var(--accent-soft); } .summary-card.danger .summary-value { color: var(--danger); } .summary-card.danger .summary-meter > i { background: var(--danger); } + .summary-secondary .summary-card { + min-height: 58px; padding: 8px 12px; border: 0; border-right: 1px solid var(--border-soft); + border-radius: 0; box-shadow: none; + } + .summary-secondary .summary-card:last-child { border-right: 0; } + .summary-secondary .summary-card .summary-value { margin-top: 2px; font-size: 18px; } + .summary-secondary .summary-card .summary-sub, + .summary-secondary .summary-card .summary-badge { display: none; } + .summary-secondary .summary-card:hover { transform: none; box-shadow: none; background: var(--panel-soft); } + + /* 总览下方是唯一工作区;tab 保持可见,panel 只显示当前分组。 */ + .workspace { margin-top: 10px; } + .workspace-tabs { + position: sticky; top: 54px; z-index: 6; display: flex; gap: 3px; padding: 5px; + overflow-x: auto; border: 1px solid var(--border); border-radius: var(--r-lg); + background: rgba(255,255,255,.94); backdrop-filter: blur(10px); box-shadow: var(--shadow-soft); + scrollbar-width: none; + } + .workspace-tabs::-webkit-scrollbar { display: none; } + .workspace-tab { + min-width: 76px; min-height: 34px; flex: 1 0 auto; padding: 5px 13px; border: 1px solid transparent; + border-radius: 6px; color: var(--muted); background: transparent; font-size: 12px; font-weight: 600; cursor: pointer; + } + .workspace-tab:hover { color: var(--text-strong); background: var(--panel-soft); } + .workspace-tab[aria-selected="true"] { color: var(--accent); border-color: #f0d5d2; background: var(--accent-soft); } + .tab-panel { padding-top: 10px; } + .tab-panel[hidden] { display: none; } + .tab-intro { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 2px 2px 9px; } + .tab-intro h2 { margin: 0; color: var(--text-strong); font-size: 15px; } + .tab-intro p { margin: 0; color: var(--muted); font-size: 11px; text-align: right; } + .detail-anchor { scroll-margin-top: 108px; } + .detail-anchor.focus-pulse { animation: focus-pulse 1.2s ease; } + @keyframes focus-pulse { 0%, 100% { opacity: 1; } 35% { opacity: .62; } } + .detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } + .detail-stat { min-width: 0; padding: 11px 12px; border: 1px solid var(--border-soft); border-radius: var(--r-lg); background: var(--panel-soft); } + .detail-stat span { display: block; color: var(--muted); font-size: 10px; } + .detail-stat strong { display: block; margin-top: 2px; color: var(--text-strong); font: 700 20px/1.2 var(--mono); } + .detail-stat.warn { border-color: #efd8b7; background: #fffaf2; } + .detail-stat.danger { border-color: #edc1bd; background: var(--accent-soft); } + .detail-stat.warn strong { color: var(--warn); } + .detail-stat.danger strong { color: var(--danger); } .card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; } .card-head h2 { margin: 0; } .ctrl { display: flex; gap: 6px; } @@ -239,6 +271,11 @@ font-size: 12px; padding: 4px 8px; border: 1px solid var(--border); border-radius: var(--r-md); background: #fff; cursor: pointer; } + #s-sandbox-capacity button { + min-height: 30px; padding: 4px 9px; border: 1px solid var(--border); + border-radius: var(--r-md); color: var(--text); background: #fff; cursor: pointer; + } + #s-sandbox-capacity button:disabled { opacity: .45; cursor: default; } #s-provider-credentials button:disabled { opacity: .45; cursor: default; } .capacity-drawer.show { display: flex; } .capacity-drawer-panel { @@ -384,23 +421,35 @@ } select:focus, input:focus, textarea:focus { border-color: var(--accent) !important; box-shadow: 0 0 0 3px var(--accent-ring); outline: none; } - @media (min-width: 821px) and (max-width: 1240px) { - #layout { grid-template-columns: 148px minmax(0, 1fr); gap: 12px; } - #toc .nav-index { display: none; } - .ops-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + @media (min-width: 821px) and (max-width: 1100px) { + .summary-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 820px) { main { padding: 10px; } .grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); } /* header 紧凑化:缩 padding/gap、字号,gen-at 时间戳截断不撑宽 */ - header { padding: 8px 10px; gap: 8px; flex-wrap: wrap; } + header { min-height: 52px; padding: 8px 10px; gap: 8px; flex-wrap: nowrap; } header .title { font-size: 14px; } header .eyebrow { display: none; } header .meta { font-size: 11px; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } header a, header button { padding: 4px 8px; } - header .actions { order: 3; width: 100%; overflow-x: auto; padding-bottom: 1px; } - header .actions > * { flex: 0 0 auto; } + header .actions { position: relative; flex: 0 0 auto; } + .action-menu { position: relative; display: block; } + .action-menu > summary { + min-height: 30px; display: inline-flex; align-items: center; padding: 4px 8px; + border: 1px solid var(--border); border-radius: var(--r-md); background: #fff; + color: var(--text); font-size: 12px; font-weight: 550; cursor: pointer; list-style: none; + } + .action-menu > summary::-webkit-details-marker { display: none; } + .action-menu-body { + position: absolute; top: calc(100% + 7px); right: 0; width: 170px; display: none; + padding: 7px; border: 1px solid var(--border); border-radius: var(--r-lg); background: #fff; + box-shadow: 0 10px 26px rgba(18,24,30,.14); + } + .action-menu[open] .action-menu-body { display: grid; gap: 5px; } + .action-menu-body > * { width: 100%; justify-content: flex-start; } /* card-head 允许换行,标题长 + 下拉不再撑出横向溢出 */ .card-head { flex-wrap: wrap; } .ctrl { flex-wrap: wrap; } @@ -408,28 +457,31 @@ .ext-form-grid { grid-template-columns: 1fr; } .ext-form-grid > label, .ext-form-section { grid-column: 1 !important; } .node-code-wrap { flex-direction: column; } - /* 窄屏:目录变顶部横向 chip 条 */ - #layout { grid-template-columns: 1fr; gap: 10px; } - #toc { - flex-direction: row; overflow-x: auto; gap: 6px; top: 109px; - background: rgba(244,246,248,.94); backdrop-filter: blur(8px); padding: 6px 0; z-index: 5; - border: 0; border-radius: 0; box-shadow: none; - } - #toc .toc-label, #toc .nav-index { display: none; } - #toc a { min-height: 32px; white-space: nowrap; border: 1px solid var(--border); background: #fff; } - #toc a.active { border-color: var(--accent); } .page-intro { padding: 12px 14px; } .page-intro h1 { font-size: 17px; } - .ops-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .summary-card { min-height: 140px; } - .summary-card .summary-value { font-size: 26px; } - .anchor { scroll-margin-top: 150px; } + .summary-primary { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .summary-card { min-height: 76px; padding: 9px 10px; } + .summary-card .summary-value { font-size: 20px; } + .summary-secondary { display: flex; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: none; } + .summary-secondary::-webkit-scrollbar { display: none; } + .summary-secondary .summary-card { min-width: 145px; flex: 1 0 42%; scroll-snap-align: start; } + .workspace-tabs { top: 52px; margin-inline: -10px; padding-inline: 10px; border-inline: 0; border-radius: 0; } + .workspace-tab { min-width: 68px; } + .tab-intro { display: block; } + .tab-intro p { margin-top: 2px; text-align: left; } + .detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .anchor, .detail-anchor { scroll-margin-top: 100px; } .card { padding: 12px; } } @media (max-width: 480px) { - .ops-summary { grid-template-columns: 1fr; } - .summary-card { min-height: 138px; } + header .meta { display: none; } + .overview-heading p { max-width: 250px; } + .summary-card .summary-badge { padding-inline: 5px; } + .summary-detail { display: none; } + .detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .detail-stat { padding: 9px 10px; } + .detail-stat strong { font-size: 18px; } .capacity-drawer { align-items: flex-end; } .capacity-drawer-panel { width: 100%; height: min(90vh, 760px); border: 0; border-radius: 14px 14px 0 0; } .capacity-hero { grid-template-columns: repeat(3, minmax(0, 1fr)); } @@ -472,10 +524,15 @@
- - - 返回控制台 +
+ 更多 +
+ + + 返回控制台 +
+
diff --git a/web/static/js/admin.js b/web/static/js/admin.js index 3c32f9b..bfeda66 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -1,6 +1,6 @@ // zcbot 管理后台(/static/admin.html)独立脚本 — admin-only。 // 复用主应用的 localStorage token(zcbot.token)与 format 工具,不挂主应用模块图。 -// 结构:左侧目录(点击平滑滚动)+ 右侧内容。overview(固定指标)10s 轮询; +// 结构:紧凑总览常驻页面顶部 + 六个分组页签。overview(固定指标)10s 轮询; // 「按模型」「各用户用量」带时间筛选+排序、「各用户用量」「存储」分页 —— 各自独立 fetch、 // 自管状态(range/sort/page),overview tick 顺手刷新但不丢状态。导出 PDF 走客户端打印。 import { humanSize, fmtTime, fmtTimeAgo, fmtTokens, escapeHtml } from "./format.js"; @@ -11,14 +11,13 @@ const REFRESH_MS = 10000; const PAGE_SIZE = 20; const RANGE_OPTS = [["all", "全部"], ["7d", "近7天"], ["30d", "近30天"]]; const SORT_OPTS = [["cost", "按成本"], ["tokens", "按用量"]]; -const SECTIONS = [ - ["s-overview", "总览"], ["s-usage", "用量趋势"], ["s-models", "按模型"], - ["s-users", "各用户"], ["s-storage", "存储"], - ["s-sandbox-packages", "容器依赖"], - ["s-windows-node", "专业软件节点"], - ["s-provider-credentials", "API 凭据"], - ["s-external", "外部系统"], - ["s-toolfail", "工具失败"], +const TABS = [ + ["run", "运行", "任务状态、执行容量与实时队列"], + ["usage", "用量", "Token、缓存、成本和模型分布"], + ["users", "用户", "活跃情况、用户用量与模型档位"], + ["resources", "资源", "存储、容器依赖与专业软件节点"], + ["integrations", "集成", "平台 API 凭据与外部系统"], + ["quality", "质量", "工具失败、链路健康与质量门"], ]; const $ = (id) => document.getElementById(id); @@ -98,20 +97,19 @@ function ctrlHTML(prefix, range, sort) { function rangeLabel(r) { return (RANGE_OPTS.find(o => o[0] === r) || [, "全部"])[1]; } // ───── 渲染各 section ───── -function summaryCard({ target = "", action = "", label, value = "", unit = "", sub = "", meta = [], badge = null, tone = "", progress = null, extraClass = "" }) { +function summaryCard({ tab, target, label, value = "", unit = "", sub = "", meta = [], badge = null, tone = "", progress = null, extraClass = "" }) { const meter = progress == null ? "" : ``; const metaHTML = meta.length ? `
${escapeHtml(meta[0] || "")}${escapeHtml(meta[1] || "")}
` : ""; - const tag = target ? "a" : (action ? "button" : "article"); - const attrs = target - ? ` href="#${target}"` - : (action ? ` type="button" data-summary-action="${escapeHtml(action)}" aria-controls="capacity-drawer"` : ""); + const tag = tab ? "button" : "article"; + const attrs = tab + ? ` type="button" data-summary-tab="${escapeHtml(tab)}" data-summary-target="${escapeHtml(target || "")}"` + : ""; const badgeHTML = badge && badge.text ? `${escapeHtml(badge.text)}` : ""; - const affordance = target ? `` - : (action ? `查看详情` : ""); + const affordance = tab ? `` : ""; return `<${tag} class="summary-card ${tone} ${extraClass}"${attrs}>` + `
${escapeHtml(label)}${badgeHTML}${affordance}
` + (value ? `
${value}${unit ? ` ${escapeHtml(unit)}` : ""}
` : "") @@ -196,15 +194,16 @@ function renderOpsSummary() { const cost = Number(usage.cost_cny) || 0; const cost7d = byDay.reduce((sum, row) => sum + (Number(row.cost_cny) || 0), 0); - root.innerHTML = [ + const primaryCards = [ summaryCard({ + tab: "run", target: "s-runtime", label: "任务运行", value: String(runningTasks), unit: "正在运行", sub: cancellingTasks ? `取消中 ${cancellingTasks}` : (errorTasks ? "存在执行错误记录" : "当前无执行异常"), meta: [`活动任务 ${activeTasks}`, `累计 ${tasks.total || 0}`], badge: errorTasks ? { text: `错误 ${errorTasks}`, tone: "danger" } : { text: "正常", tone: "ok" }, }), summaryCard({ - action: capacityEnabled ? "open-capacity" : "", label: "执行容量", + tab: "run", target: "s-sandbox-capacity", label: "执行容量", value: !capacity ? "汇总中" : (capacityEnabled ? `${activeExecs} / ${activeLimit || "—"}` : "未启用"), unit: capacityEnabled ? "使用中 / 上限" : "", sub: !capacity ? "正在读取容器状态" : (capacityEnabled @@ -219,7 +218,7 @@ function renderOpsSummary() { tone: capacityTone, progress: capacityEnabled && activeLimit ? capacityPct : null, }), summaryCard({ - target: "s-windows-node", label: "专业软件节点", + tab: "resources", target: "s-windows-node", label: "专业软件节点", value: softwareNodesLoaded ? `${nodeOnline} / ${nodeTotal}` : "汇总中", unit: softwareNodesLoaded ? "在线 / 总数" : "", sub: softwareNodesLoaded ? `活动任务 ${nodeJobs}` : "正在读取节点状态", meta: softwareNodesLoaded ? [`离线 ${nodeOffline}`, `禁用 ${nodeDisabled}`] : [], @@ -228,7 +227,7 @@ function renderOpsSummary() { : null, }), summaryCard({ - target: "s-toolfail", label: "工具健康", + tab: "quality", target: "s-toolfail", label: "工具健康", value: toolFailuresData ? (failureCount ? `${failureCount} 次失败` : "正常") : "汇总中", unit: toolFailuresData ? "近 24 小时" : "", sub: toolFailuresData @@ -239,27 +238,43 @@ function renderOpsSummary() { ? (toolTone ? { text: systemicCount || malformed ? "需处理" : "有记录", tone: toolTone } : { text: "正常", tone: "ok" }) : null, }), + ]; + const secondaryCards = [ summaryCard({ - target: "s-users", label: "活跃用户", value: String(activeUsers), unit: "近 7 天活跃", + tab: "users", target: "s-users", label: "活跃用户", value: String(activeUsers), unit: "近 7 天活跃", sub: `活跃率 ${activeRate}% · 总用户 ${userTotal}`, meta: [`活跃 ${activeUsers}`, `未活跃 ${Math.max(0, userTotal - activeUsers)}`], progress: activeRate, }), summaryCard({ - target: "s-usage", label: "Token 与缓存", value: fmtTokens(tokens7d), unit: "近 7 天", + tab: "usage", target: "s-usage", label: "Token 与缓存", value: fmtTokens(tokens7d), unit: "近 7 天", sub: `缓存命中 ${hitRate}%`, meta: [`输入 ${fmtTokens(tokensIn7d)}`, `输出 ${fmtTokens(tokensOut7d)}`], progress: hitRate, }), summaryCard({ - target: "s-usage", label: "成本", value: fmtCNY(cost7d), unit: "近 7 天", + tab: "usage", target: "s-usage", label: "成本", value: fmtCNY(cost7d), unit: "近 7 天", sub: `累计 ${fmtCNY(cost)}`, }), summaryCard({ - target: "s-storage", label: "存储", value: storageValue, unit: storage ? "物理总占用" : "", + tab: "resources", target: "s-storage", label: "存储", value: storageValue, unit: storage ? "物理总占用" : "", sub: storageSub, meta: storageMeta, }), - ].join(""); - const capacityCard = root.querySelector('[data-summary-action="open-capacity"]'); - if (capacityCard) capacityCard.onclick = openCapacityDrawer; + ]; + root.innerHTML = `
${primaryCards.join("")}
` + + `
${secondaryCards.join("")}
`; + root.querySelectorAll("[data-summary-tab]").forEach(card => { + card.onclick = () => activateTab(card.dataset.summaryTab, card.dataset.summaryTarget); + }); + + const attentionCount = Number(Boolean(errorTasks)) + + Number(Boolean(capacityEnabled && (capacity.memory_paused || queuedExecs))) + + Number(Boolean(nodeTone)) + Number(Boolean(toolTone)); + const health = $("overview-health"); + if (health) { + health.className = `summary-badge overview-health ${attentionCount ? "warn" : "ok"}`; + health.textContent = attentionCount ? `${attentionCount} 项需关注` : "全部正常"; + } + renderRuntimeDetails(); + renderCapacitySection(); if ($("capacity-drawer").classList.contains("show")) renderCapacityDrawer(); } @@ -269,18 +284,37 @@ function capacityDetailItem(label, value, hint = "", tone = "") { + (hint ? `${escapeHtml(hint)}` : "") + ``; } -function renderCapacityDrawer() { - const capacity = sandboxCapacityData; - const body = $("capacity-drawer-body"); - const status = $("capacity-drawer-status"); - if (!capacity || !body || !status) return; - if (capacity.enabled === false) { - status.className = "summary-badge"; - status.textContent = "未启用"; - body.innerHTML = `
Docker Sandbox 未启用,当前没有可展示的执行容量。
`; - return; - } +function detailStat(label, value, tone = "") { + return `
${escapeHtml(label)}` + + `${escapeHtml(String(value))}
`; +} +function renderRuntimeDetails() { + const root = $("s-runtime"); + if (!root || !overviewData) return; + const runtime = overviewData.runtime || {}; + const tasks = overviewData.tasks || {}; + const runStatuses = tasks.by_run_status || {}; + const taskStatuses = tasks.by_status || {}; + const running = (Number(runStatuses.running) || 0) + (Number(runStatuses.cancelling) || 0); + const errors = Number(runStatuses.error) || 0; + root.innerHTML = `

任务运行

` + + `
与顶部总览使用同一份实时数据
` + + `
` + + detailStat("正在运行", running, errors ? "warn" : "") + + detailStat("取消中", Number(runStatuses.cancelling) || 0) + + detailStat("错误记录", errors, errors ? "danger" : "") + + detailStat("活动任务", Number(taskStatuses.active) || 0) + + detailStat("累计任务", Number(tasks.total) || 0) + + detailStat("执行线程", `${Number(runtime.active_runs) || 0} / ${Number(runtime.max_workers) || "—"}`) + + detailStat("SSE 订阅", Number(runtime.sse_subs) || 0) + + detailStat("内存峰值", runtime.rss_peak_mb == null ? "—" : `${Math.round(runtime.rss_peak_mb)} MB`) + + `
`; +} + +function capacityContentHTML(capacity) { + if (!capacity) return `
正在读取执行容量…
`; + if (capacity.enabled === false) return `
Docker Sandbox 未启用,当前没有可展示的执行容量。
`; const limits = capacity.limits || {}; const foregroundRunning = Number(capacity.foreground_running) || 0; const backgroundRunning = Number(capacity.background_running) || 0; @@ -292,10 +326,7 @@ function renderCapacityDrawer() { const available = Math.max(0, Number(capacity.admit_available) || 0); const users = Object.entries(capacity.per_user || {}).sort((a, b) => (Number(b[1]) || 0) - (Number(a[1]) || 0)); const cpuLoad = (capacity.cpu_load || []).map(value => Number(value).toFixed(2)).join(" / ") || "—"; - - status.className = `summary-badge ${capacity.memory_paused ? "danger" : (queued ? "warn" : "ok")}`; - status.textContent = capacity.memory_paused ? "暂停放行" : (queued ? `排队 ${queued}` : "运行正常"); - body.innerHTML = (capacity.memory_paused + return (capacity.memory_paused ? `
内存压力已暂停新任务放行运行中的任务不受影响;资源恢复后会继续调度。
` : "") + `
` @@ -323,6 +354,29 @@ function renderCapacityDrawer() { + `
`; } +function renderCapacitySection() { + const root = $("s-sandbox-capacity"); + if (!root) return; + const enabled = sandboxCapacityData && sandboxCapacityData.enabled !== false; + root.innerHTML = `

执行容量

` + + `
队列、容器生命周期、单用户占用与宿主资源
` + + `
` + + capacityContentHTML(sandboxCapacityData) + `
`; + if ($("capacity-inline-open")) $("capacity-inline-open").onclick = openCapacityDrawer; +} + +function renderCapacityDrawer() { + const capacity = sandboxCapacityData; + const body = $("capacity-drawer-body"); + const status = $("capacity-drawer-status"); + if (!capacity || !body || !status) return; + const queued = (Number(capacity.foreground_queued) || 0) + (Number(capacity.background_queued) || 0); + status.className = `summary-badge ${capacity.memory_paused ? "danger" : (queued ? "warn" : "ok")}`; + status.textContent = capacity.enabled === false ? "未启用" + : (capacity.memory_paused ? "暂停放行" : (queued ? `排队 ${queued}` : "运行正常")); + body.innerHTML = capacityContentHTML(capacity); +} + function openCapacityDrawer() { if (!sandboxCapacityData || sandboxCapacityData.enabled === false) return; capacityDrawerTrigger = document.activeElement; @@ -344,7 +398,7 @@ function closeCapacityDrawer() { document.body.classList.remove("drawer-open"); const nextTrigger = capacityDrawerTrigger && document.contains(capacityDrawerTrigger) ? capacityDrawerTrigger - : document.querySelector('[data-summary-action="open-capacity"]'); + : $("capacity-inline-open"); if (nextTrigger) nextTrigger.focus(); capacityDrawerTrigger = null; } @@ -381,6 +435,26 @@ function nodeStatusHTML(status) { return `${labels[value]}`; } +function renderUsageSection(d) { + const usage = (d.usage || {}).total || {}; + const rows = (d.usage || {}).by_day_7d || []; + const input7d = rows.reduce((sum, row) => sum + (Number(row.tokens_in) || 0), 0); + const output7d = rows.reduce((sum, row) => sum + (Number(row.tokens_out) || 0), 0); + const cost7d = rows.reduce((sum, row) => sum + (Number(row.cost_cny) || 0), 0); + const hitRate = Number(usage.tokens_in) ? Math.round((Number(usage.tokens_cache_hit) || 0) / Number(usage.tokens_in) * 100) : 0; + return `

Token、缓存与成本

` + + `
顶部总览的完整指标口径
` + + detailStat("近 7 天 Token", fmtTokens(input7d + output7d)) + + detailStat("近 7 天输入", fmtTokens(input7d)) + + detailStat("近 7 天输出", fmtTokens(output7d)) + + detailStat("缓存命中率", `${hitRate}%`) + + detailStat("近 7 天成本", fmtCNY(cost7d)) + + detailStat("累计成本", fmtCNY(usage.cost_cny)) + + detailStat("累计输入", fmtTokens(usage.tokens_in)) + + detailStat("累计输出", fmtTokens(usage.tokens_out)) + + `
` + renderByDay(rows); +} + function nodeCapabilityRuntimes(runtime) { const entries = Object.entries(runtime.capability_runtime || {}) .filter(([, value]) => value && typeof value === "object"); @@ -920,7 +994,17 @@ function renderUserUsage(d) { + `${r.last_used_at ? fmtTimeAgo(r.last_used_at) : "—"}` + ``; }).join("") || `无数据`; - $("s-users").innerHTML = `
` + const userOverview = (overviewData && overviewData.users) || {}; + const userTotal = Number(userOverview.total) || 0; + const activeUsers = Number(userOverview.active_7d) || 0; + const activity = `

用户活跃

` + + `
近 7 天口径,与顶部总览一致
` + + detailStat("近 7 天活跃", activeUsers) + + detailStat("总用户", userTotal) + + detailStat("活跃率", `${userTotal ? Math.round(activeUsers / userTotal * 100) : 0}%`) + + detailStat("近 7 天未活跃", Math.max(0, userTotal - activeUsers)) + + `
`; + $("s-users").innerHTML = activity + `
` + `

各用户用量(${rangeLabel(d.range)})

${ctrlHTML("u", d.range, d.sort)}
` + tierLegendHTML() + `
` @@ -997,7 +1081,17 @@ function renderStorage(d) { + `` + ``; }).join("") || ``; - $("s-storage").innerHTML = `

存储用量(${quotaLabel})

` + const totals = d.totals || {}; + const overview = `

存储概况

` + + `
物理占用包含配额内文件与产物回收站
` + + detailStat("物理总占用", humanSize(totals.physical_bytes || 0)) + + detailStat("配额内占用", humanSize(totals.bytes_used || 0)) + + detailStat("回收站", humanSize(totals.trash_bytes || 0)) + + detailStat("文件数", Number(totals.file_count) || 0) + + detailStat("已扫描用户", Number(d.total) || 0) + + detailStat("人均配额", quota && quota > 0 ? humanSize(quota) : "无限制") + + `
`; + $("s-storage").innerHTML = overview + `

存储用量(${quotaLabel})

` + `
${r.scanned_at ? fmtTime(r.scanned_at) : "—"}
无数据
` + `` + `${body}
用户配额占用占配额文件数回收站物理合计扫描于
` @@ -1112,46 +1206,75 @@ function wirePager(prefix, page, maxPage, go) { if (next) next.onclick = () => go(page + 1); } -// ───── 骨架 + 目录 ───── +// ───── 骨架 + 分组页签 ───── function ensureSkeleton() { if ($("layout")) return; $("main").innerHTML = `
` - + `` - + `

系统运行概览

` - + `

集中查看服务状态、资源用量与外部能力,数据默认每 10 秒自动更新。

` - + `
` + SECTIONS.slice(1).map(([id]) => - `
`).join("") + `
` + + `
` + + `

系统总览

` + + `

运行状态与关键运营指标,每 10 秒自动更新

` + + `加载中
` + + `
` + + `
` + + tabPanel("run", `
`) + + tabPanel("usage", `
`) + + tabPanel("users", `
`) + + tabPanel("resources", `
`) + + tabPanel("integrations", `
`) + + tabPanel("quality", `
`) + + `
` + `
`; - renderOpsSummary(); - document.querySelectorAll("#toc a").forEach(a => { - a.onclick = (e) => { - e.preventDefault(); - const el = $(a.dataset.target); - if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); - }; + document.querySelectorAll(".workspace-tab").forEach(button => { + button.onclick = () => activateTab(button.dataset.tab); }); - setupScrollSpy(); + const initial = parseTabHash(); + activateTab(initial.tab, initial.target, { updateHash: false, scroll: false }); + renderOpsSummary(); } -// 滚动高亮当前目录项(IntersectionObserver,粗粒度即可) -function setupScrollSpy() { - const links = {}; - document.querySelectorAll("#toc a").forEach(a => { links[a.dataset.target] = a; }); - const obs = new IntersectionObserver((entries) => { - entries.forEach(en => { - if (!en.isIntersecting) return; - Object.values(links).forEach(l => l.classList.remove("active")); - if (links[en.target.id]) links[en.target.id].classList.add("active"); - }); - }, { rootMargin: "-70px 0px -65% 0px", threshold: 0 }); - SECTIONS.forEach(([id]) => { const el = $(id); if (el) obs.observe(el); }); +function tabPanel(id, content) { + const tab = TABS.find(item => item[0] === id); + return ``; +} + +function parseTabHash() { + const raw = decodeURIComponent(location.hash.replace(/^#/, "")); + const [tab, target = ""] = raw.split("/"); + return TABS.some(item => item[0] === tab) ? { tab, target } : { tab: "run", target: "" }; +} + +function activateTab(tab, target = "", options = {}) { + if (!TABS.some(item => item[0] === tab)) tab = "run"; + document.querySelectorAll(".workspace-tab").forEach(button => { + const selected = button.dataset.tab === tab; + button.setAttribute("aria-selected", selected ? "true" : "false"); + button.tabIndex = selected ? 0 : -1; + }); + document.querySelectorAll(".tab-panel").forEach(panel => { + panel.hidden = panel.id !== `panel-${tab}`; + }); + const selectedButton = $(`tab-${tab}`); + if (selectedButton) selectedButton.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); + const nextHash = `#${tab}${target ? `/${target}` : ""}`; + if (options.updateHash !== false && location.hash !== nextHash) history.pushState(null, "", nextHash); + if (target && options.scroll !== false) requestAnimationFrame(() => { + const el = $(target); + if (!el) return; + el.scrollIntoView({ behavior: "smooth", block: "start" }); + el.classList.remove("focus-pulse"); + requestAnimationFrame(() => el.classList.add("focus-pulse")); + }); } function renderMetrics(d) { overviewData = d; $("gen-at").textContent = d.generated_at ? "更新于 " + fmtTime(d.generated_at) : ""; - $("s-usage").innerHTML = renderByDay((d.usage || {}).by_day_7d); + $("s-usage").innerHTML = renderUsageSection(d); renderWindowsNodes(); renderOpsSummary(); } @@ -1537,6 +1660,29 @@ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && $("capacity-drawer").classList.contains("show")) closeCapacityDrawer(); else if (e.key === "Escape" && $("node-enrollment-modal").classList.contains("show")) closeNodeEnrollmentModal(); }); +document.addEventListener("keydown", (e) => { + const current = e.target.closest && e.target.closest(".workspace-tab"); + if (!current || !["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return; + e.preventDefault(); + const buttons = [...document.querySelectorAll(".workspace-tab")]; + let index = buttons.indexOf(current); + if (e.key === "Home") index = 0; + else if (e.key === "End") index = buttons.length - 1; + else index = (index + (e.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length; + buttons[index].focus(); + activateTab(buttons[index].dataset.tab); +}); +window.addEventListener("popstate", () => { + if (!$("workspace-tabs")) return; + const state = parseTabHash(); + activateTab(state.tab, state.target, { updateHash: false }); +}); +const compactHeader = window.matchMedia("(max-width: 820px)"); +function syncActionMenu() { + $("action-menu").open = !compactHeader.matches; +} +compactHeader.addEventListener("change", syncActionMenu); +syncActionMenu(); // 切到后台标签暂停轮询,回前台立即刷一次再续上(省请求) document.addEventListener("visibilitychange", () => { if (document.hidden) stopAuto();