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:
parent
42755e246e
commit
af97dd7c62
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
24
web/app.py
24
web/app.py
|
|
@ -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=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"])
|
@app.get("/v1/files", tags=["files"])
|
||||||
def list_files(
|
def list_files(
|
||||||
path: str = "",
|
path: str = "",
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue