From af97dd7c62a28bc555c13c76a82ad9c825df1ee9 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Thu, 4 Jun 2026 15:00:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E6=96=87=E4=BB=B6=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=BA=95=E9=83=A8=E5=B1=95=E7=A4=BA=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=B7=B2=E7=94=A8=E5=AD=98=E5=82=A8=20+=20=E9=85=8D=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端已有 user_disk_usage 表(后台 15min 扫描落库)但无对外查询口, 加 GET /v1/user/storage(require_user)返 {bytes_used, file_count, limit_bytes, scanned_at};limit_bytes 由 parse_bytes(quotas. disk_bytes_per_user) 得,≤0/None=不限。disk_quota.get_user_usage 扩为返 (bytes,count,scanned_at) 三元组(复用而非新开函数,顺手改唯一 调用方 check_disk_quota 解包)。 前端 dev.html 右侧文件面板底部钉一条进度条+文字:#pane-right 改 flex 列让 file-list 独占滚动、存储条钉底;loadStorage() 在 enterApp 拉一次; 不限额时只显已用、隐进度条;超额变红;hover 显文件数+统计时间。样式用 class 选择器压低特异性,让折叠/手机隐藏规则能盖住它。 Co-Authored-By: Claude Opus 4.8 (1M context) --- PROGRESS.md | 1 + core/storage/disk_quota.py | 16 ++++++---- web/app.py | 24 +++++++++++++++ web/static/dev.html | 61 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 94 insertions(+), 8 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index cb996a6..6a9ee93 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -23,6 +23,7 @@ ### 2026-06-04 +- **前端顶栏展示用户已用存储**:后端已有 `user_disk_usage` 表(后台 15min 扫描落库),但无对外查询口。加 `GET /v1/user/storage`(`Depends(require_user)`),返 `{bytes_used, file_count, limit_bytes, scanned_at}`,`limit_bytes` 由 `parse_bytes(quotas.disk_bytes_per_user)` 得(≤0/None=不限)。`disk_quota.get_user_usage` 扩为返 `(bytes,count,scanned_at)` 三元组(复用而非新开函数,顺手改唯一调用方 `check_disk_quota` 解包)。前端 `dev.html` 右侧「文件」面板底部钉一条进度条+文字指示器(`#pane-right` 改 flex 列让 `#file-list` 独占滚动、存储条钉底;`loadStorage()` 在 `enterApp` 拉一次;不限额时只显已用、隐进度条;超额变红;hover 显文件数+统计时间)。 - **sandbox 容器 env 收编到一处 + shell 也注入(修两个只读 rootfs 副作用)**:① `PYTHONPATH=/sandbox:/workspace` 原先只 `run_python` 注入,shell 里 `python -c "from skills..."` 撞 ModuleNotFoundError;② `--read-only` rootfs 下 `/home/zcbot` 不可写,matplotlib/fontconfig 往 `~/.config`/`~/.cache` 写缓存刷 "Read-only file system" / "No writable cache" 噪音。改:`executor_docker.py` 抽 `_CONTAINER_ENV = {PYTHONPATH, HOME=/tmp}`,shell/run_python/fs 三路共用(`-e` 确定性覆盖)—— `HOME=/tmp` 一刀让缓存落 tmpfs(matplotlib→/tmp/.config、fontconfig→/tmp/.cache),不用逐个 MPLCONFIGDIR/XDG_CACHE_HOME。纯代码改,重启 web 生效,免重建镜像。 ### 2026-06-03 diff --git a/core/storage/disk_quota.py b/core/storage/disk_quota.py index 3138fc4..f13adbd 100644 --- a/core/storage/disk_quota.py +++ b/core/storage/disk_quota.py @@ -16,6 +16,7 @@ from __future__ import annotations import os import re +from datetime import datetime from pathlib import Path from typing import Iterable, List, Optional, Tuple from uuid import UUID @@ -128,16 +129,19 @@ def upsert_user_usage(user_id: UUID, bytes_used: int, file_count: int) -> None: s.execute(stmt) -def get_user_usage(user_id: UUID) -> Optional[Tuple[int, int]]: - """读最近一次扫描结果 (bytes, count);无记录返 None。""" +def get_user_usage(user_id: UUID) -> Optional[Tuple[int, int, Optional[datetime]]]: + """读最近一次扫描结果 (bytes, count, scanned_at);无记录返 None。""" with session_scope() as s: row = s.execute( - select(UserDiskUsage.bytes_used, UserDiskUsage.file_count) - .where(UserDiskUsage.user_id == user_id) + select( + UserDiskUsage.bytes_used, + UserDiskUsage.file_count, + UserDiskUsage.scanned_at, + ).where(UserDiskUsage.user_id == user_id) ).first() if row is None: return None - return int(row[0]), int(row[1]) + return int(row[0]), int(row[1]), row[2] def check_disk_quota(user_id: UUID, limit_bytes: int) -> Optional[str]: @@ -151,7 +155,7 @@ def check_disk_quota(user_id: UUID, limit_bytes: int) -> Optional[str]: usage = get_user_usage(user_id) if usage is None: return None # 首次,放行,首次扫描后下次 gate 才生效 - used, _ = usage + used, _, _ = usage if used >= limit_bytes: used_mb = used / (1024 ** 2) limit_mb = limit_bytes / (1024 ** 2) diff --git a/web/app.py b/web/app.py index 616e6b5..53b194a 100644 --- a/web/app.py +++ b/web/app.py @@ -1472,6 +1472,30 @@ def create_app() -> FastAPI: # ───────────── Files(user-rooted,不绑 task) ───────────── + @app.get("/v1/user/storage", tags=["user"]) + def user_storage(user_id: UUID = Depends(require_user)): + """当前用户磁盘用量 + 配额。 + + 数据来自后台扫描落库的 user_disk_usage(默 15min 一次),非实时; + 无扫描记录(新用户 / 首次扫描前)bytes_used/file_count=0、scanned_at=None。 + limit_bytes<=0 或 None → 不限(前端不画进度条)。 + """ + from core.agent_builder import load_config as _load_cfg + from core.storage.disk_quota import get_user_usage, parse_bytes + _quotas_cfg = (_load_cfg().get("quotas") or {}) + _limit = parse_bytes(_quotas_cfg.get("disk_bytes_per_user")) + usage = get_user_usage(user_id) + if usage is None: + bytes_used, file_count, scanned_at = 0, 0, None + else: + bytes_used, file_count, scanned_at = usage + return { + "bytes_used": bytes_used, + "file_count": file_count, + "limit_bytes": (_limit if (_limit and _limit > 0) else None), + "scanned_at": scanned_at.isoformat() if scanned_at else None, + } + @app.get("/v1/files", tags=["files"]) def list_files( path: str = "", diff --git a/web/static/dev.html b/web/static/dev.html index 92b57e6..7fd4a25 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -231,7 +231,10 @@ #task-scroll { flex: 1; min-height: 0; overflow: auto; } /* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: auto 顶出) */ #pane-mid { grid-area: mid; display: flex; flex-direction: column; border-right: 1px solid var(--border); background: var(--panel); min-height: 0; min-width: 0; overflow: hidden; } - #pane-right { grid-area: right; border-right: none; overflow: auto; background: var(--panel); min-height: 0; } + /* flex 列:pane-head / crumbs / 上传状态固定,#file-list 独占滚动,存储条钉底 */ + #pane-right { grid-area: right; border-right: none; display: flex; flex-direction: column; overflow: hidden; background: var(--panel); min-height: 0; } + #pane-right > .pane-head, #pane-right > #file-crumbs, #pane-right > .upload-status { flex-shrink: 0; } + #file-list { flex: 1 1 auto; overflow: auto; min-height: 0; } .splitter { min-width: 6px; background: var(--bg); cursor: col-resize; @@ -516,6 +519,29 @@ background: var(--accent); transition: width .12s linear; } + /* 存储用量条:钉在文件面板底部。用量来自后台扫描(默 15min),非实时;超额变红。 + 用 class 选择器(非 #id)压低特异性,让折叠/手机隐藏规则能盖住它 */ + .storage-foot { + display: none; flex-shrink: 0; align-items: center; gap: 8px; + padding: 7px 12px; border-top: 1px solid var(--border); background: #fff; + font-size: 11px; color: var(--muted); font-family: var(--mono); cursor: default; + } + .storage-foot.show { display: flex; } + .storage-foot .lbl { flex-shrink: 0; } + .storage-foot .bar { + flex: 1 1 auto; min-width: 0; height: 6px; border-radius: 3px; + background: var(--border-soft); overflow: hidden; + } + .storage-foot .bar > i { + display: block; height: 100%; width: 0; + background: linear-gradient(90deg, var(--accent), #8e2a20); + transition: width .3s ease; + } + .storage-foot .txt { flex-shrink: 0; white-space: nowrap; } + .storage-foot.nolimit .bar { display: none; } + .storage-foot.over .bar > i { background: #c0392b; } + .storage-foot.over .txt { color: #c0392b; font-weight: 600; } + /* ───── source picker modal(选入文件:勾源 + 复制/移动到主区当前目录) ───── */ #src-picker-modal { z-index: 95; } #src-picker-modal .card { @@ -694,7 +720,7 @@ } body.mv-left #pane-left { display: block; } body.mv-mid #pane-mid { display: flex; } - body.mv-right #pane-right { display: block; } + body.mv-right #pane-right { display: flex; } /* 折叠按钮在手机不可见 */ #pane-toggle-left, #pane-toggle-right, .splitter { display: none !important; } @@ -958,6 +984,11 @@
加载中…
+
+ 存储 + + +
松开以上传到当前目录
@@ -1523,6 +1554,32 @@ function enterApp() { loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标 loadFolderSuggestions(); // 灌 filter-wd select(modal 打开时会重拉,这里让左 pane 先有选项) + loadStorage(); // 顶栏存储用量(后台扫描快照,非实时) +} + +// 存储用量:拉 /v1/user/storage 渲染文件面板底部进度条。用量来自后台 15min 扫描, +// 故无需高频刷新 —— enterApp 拉一次即可。无配额上限时只显已用、不画进度条(nolimit)。 +async function loadStorage() { + let s; + try { s = await api("GET", "/v1/user/storage"); } catch (e) { return; } + const el = $("storage-foot"); + const used = s.bytes_used || 0; + const limit = s.limit_bytes; + if (limit && limit > 0) { + const pct = Math.min(100, Math.round(used / limit * 100)); + $("storage-foot-bar").style.width = pct + "%"; + $("storage-foot-txt").textContent = `${humanSize(used)} / ${humanSize(limit)}`; + el.classList.remove("nolimit"); + el.classList.toggle("over", used >= limit); + } else { + // 不限额:只显已用,隐藏进度条 + $("storage-foot-txt").textContent = humanSize(used); + el.classList.add("nolimit"); + el.classList.remove("over"); + } + const when = s.scanned_at ? fmtTime(s.scanned_at) : "尚未统计"; + el.title = `已用 ${humanSize(used)} · ${s.file_count || 0} 个文件\n统计于 ${when}(后台每 15 分钟扫描,非实时)`; + el.classList.add("show"); } async function loadModels() {