feat(web): 文件面板底部展示用户已用存储 + 配额

后端已有 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) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-06-04 15:00:36 +08:00
parent 42755e246e
commit af97dd7c62
4 changed files with 94 additions and 8 deletions

View File

@ -23,6 +23,7 @@
### 2026-06-04 ### 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 生效,免重建镜像。 - **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 ### 2026-06-03

View File

@ -16,6 +16,7 @@ from __future__ import annotations
import os import os
import re import re
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Iterable, List, Optional, Tuple from typing import Iterable, List, Optional, Tuple
from uuid import UUID 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) s.execute(stmt)
def get_user_usage(user_id: UUID) -> Optional[Tuple[int, int]]: def get_user_usage(user_id: UUID) -> Optional[Tuple[int, int, Optional[datetime]]]:
"""读最近一次扫描结果 (bytes, count);无记录返 None。""" """读最近一次扫描结果 (bytes, count, scanned_at);无记录返 None。"""
with session_scope() as s: with session_scope() as s:
row = s.execute( row = s.execute(
select(UserDiskUsage.bytes_used, UserDiskUsage.file_count) select(
.where(UserDiskUsage.user_id == user_id) UserDiskUsage.bytes_used,
UserDiskUsage.file_count,
UserDiskUsage.scanned_at,
).where(UserDiskUsage.user_id == user_id)
).first() ).first()
if row is None: if row is None:
return 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]: 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) usage = get_user_usage(user_id)
if usage is None: if usage is None:
return None # 首次,放行,首次扫描后下次 gate 才生效 return None # 首次,放行,首次扫描后下次 gate 才生效
used, _ = usage used, _, _ = usage
if used >= limit_bytes: if used >= limit_bytes:
used_mb = used / (1024 ** 2) used_mb = used / (1024 ** 2)
limit_mb = limit_bytes / (1024 ** 2) limit_mb = limit_bytes / (1024 ** 2)

View File

@ -1472,6 +1472,30 @@ def create_app() -> FastAPI:
# ───────────── Files(user-rooted,不绑 task) ───────────── # ───────────── 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=0scanned_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"]) @app.get("/v1/files", tags=["files"])
def list_files( def list_files(
path: str = "", path: str = "",

View File

@ -231,7 +231,10 @@
#task-scroll { flex: 1; min-height: 0; overflow: auto; } #task-scroll { flex: 1; min-height: 0; overflow: auto; }
/* min-height: 0 + overflow: hidden 让内部 flex 子项的 overflow: auto 真正生效(否则被默认 min-height: 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-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 { .splitter {
min-width: 6px; background: var(--bg); cursor: col-resize; min-width: 6px; background: var(--bg); cursor: col-resize;
@ -516,6 +519,29 @@
background: var(--accent); transition: width .12s linear; 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(选入文件:勾源 + 复制/移动到主区当前目录) ───── */ /* ───── source picker modal(选入文件:勾源 + 复制/移动到主区当前目录) ───── */
#src-picker-modal { z-index: 95; } #src-picker-modal { z-index: 95; }
#src-picker-modal .card { #src-picker-modal .card {
@ -694,7 +720,7 @@
} }
body.mv-left #pane-left { display: block; } body.mv-left #pane-left { display: block; }
body.mv-mid #pane-mid { display: flex; } 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; } #pane-toggle-left, #pane-toggle-right, .splitter { display: none !important; }
@ -958,6 +984,11 @@
<div id="file-upload-status" class="upload-status"></div> <div id="file-upload-status" class="upload-status"></div>
<div id="file-crumbs" class="crumbs muted">加载中…</div> <div id="file-crumbs" class="crumbs muted">加载中…</div>
<div id="file-list"></div> <div id="file-list"></div>
<div id="storage-foot" class="storage-foot" title="">
<span class="lbl">存储</span>
<span class="bar"><i id="storage-foot-bar"></i></span>
<span class="txt" id="storage-foot-txt"></span>
</div>
<div id="file-droparea">松开以上传到当前目录</div> <div id="file-droparea">松开以上传到当前目录</div>
<input type="file" id="upload-input" multiple style="display:none;" /> <input type="file" id="upload-input" multiple style="display:none;" />
</div> </div>
@ -1523,6 +1554,32 @@ function enterApp() {
loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root loadFiles(); // 文件面板与 task 解耦 — 启动即拉 user_root
loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标 loadModels(); // 模型清单缓存:chat-meta 下拉 + 新建对话框 + 历史小标
loadFolderSuggestions(); // 灌 filter-wd select(modal 打开时会重拉,这里让左 pane 先有选项) 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() { async function loadModels() {