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 @@