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
- **前端顶栏展示用户已用存储**:后端已有 `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

View File

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

View File

@ -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=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"])
def list_files(
path: str = "",

View File

@ -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 @@
<div id="file-upload-status" class="upload-status"></div>
<div id="file-crumbs" class="crumbs muted">加载中…</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>
<input type="file" id="upload-input" multiple style="display:none;" />
</div>
@ -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() {