Polish web workspace visual foundation

This commit is contained in:
caoqianming 2026-07-29 14:34:46 +08:00
parent ff3bc75fed
commit 07eba5c402
6 changed files with 621 additions and 32 deletions

View File

@ -5,6 +5,10 @@
> 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。 > 所以不是每个版本号都有条目。条目格式 `## <版本> — <日期>`,新条目加在最上面。
> 工程口径的完整记录见 `PROGRESS.md` / git log。 > 工程口径的完整记录见 `PROGRESS.md` / git log。
## 0.60.8 — 2026-07-29
- 网页工作台完成一轮整体视觉优化:任务、对话和文件三栏层级更清晰,长回复阅读更舒适,按钮、输入框、弹窗、状态反馈及手机端触控尺寸也更加统一。
## 0.60.7 — 2026-07-29 ## 0.60.7 — 2026-07-29
- 新对话的目录选择框现在也能直接创建工作目录:输入新名称后选择创建即可,不再需要旁边的额外按钮和第二个输入框。 - 新对话的目录选择框现在也能直接创建工作目录:输入新名称后选择创建即可,不再需要旁边的额外按钮和第二个输入框。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-07-29(组合框内直接新建目录,bump 0.60.7) 最后更新:2026-07-29(Web 工作台 Visual Polish v1,bump 0.60.8)
--- ---
@ -23,6 +23,7 @@
### 2026-07-29 ### 2026-07-29
- **07-29 / 0.60.8 / Web 工作台 Visual Polish v1**:新增零构建 `web/static/ui.css` 作为内部轻量视觉基础层,集中颜色、间距、圆角、阴影与 focus/disabled 等控件状态;收敛三栏层级、任务/资源导航、对话正文与工具过程块、输入区、文件栏、modal/toast 及 ≤640px 触控尺寸,不改 DOM 契约、API 或业务交互。新增 CSS 文件接入、MIME 与 `no-cache` 回归测试;相关 Python 20 项 + Node 5 项通过,浏览器连接不可用故截图级视觉验收留真实页面复核。
- **07-29 / 0.60.7 / 组合框内直接新建工作目录**:新对话目录 combobox 增加 allow-create 语义:输入时继续过滤已有目录,无完全同名项时在同一下拉内提供“创建工作目录「名称」”,明确选择后才进入新建状态,失焦搜索词不会误提交;删除外部新建按钮与二级输入框。纯前端改动,无 API、schema、migration 或目录绑定语义变化;全量 387 测试通过17 项按环境跳过)。 - **07-29 / 0.60.7 / 组合框内直接新建工作目录**:新对话目录 combobox 增加 allow-create 语义:输入时继续过滤已有目录,无完全同名项时在同一下拉内提供“创建工作目录「名称」”,明确选择后才进入新建状态,失焦搜索词不会误提交;删除外部新建按钮与二级输入框。纯前端改动,无 API、schema、migration 或目录绑定语义变化;全量 387 测试通过17 项按环境跳过)。
- **07-29 / 0.60.6 / 新对话目录选择改可搜索组合框**:将上一版独立搜索框 + 原生 select 合并为单一 combobox同一输入区完成已选目录展示、即时过滤和下拉选择并补方向键、Enter、Esc、失焦恢复与 ARIA 状态;最近目录置顶、明确选择 working_dir 和新建目录入口均保持不变。纯前端改动无依赖、API、schema 或 migration全量 387 测试通过17 项按环境跳过)。 - **07-29 / 0.60.6 / 新对话目录选择改可搜索组合框**:将上一版独立搜索框 + 原生 select 合并为单一 combobox同一输入区完成已选目录展示、即时过滤和下拉选择并补方向键、Enter、Esc、失焦恢复与 ARIA 状态;最近目录置顶、明确选择 working_dir 和新建目录入口均保持不变。纯前端改动无依赖、API、schema 或 migration全量 387 测试通过17 项按环境跳过)。
- **07-29 / 0.60.5 / 新对话目录选择提速**:保留首发前明确选择 working_dir 的归档约束;本机最近实际使用目录在候选中自动选中、置顶并标记,目录达到 6 个时按需显示搜索框,目录较少时维持原有简洁界面;同步精简“文件保存位置”文案。纯前端改动,无 API、schema、migration 或目录绑定语义变化;相关静态资源与无 DB 路由 18 项测试通过。 - **07-29 / 0.60.5 / 新对话目录选择提速**:保留首发前明确选择 working_dir 的归档约束;本机最近实际使用目录在候选中自动选中、置顶并标记,目录达到 6 个时按需显示搜索框,目录较少时维持原有简洁界面;同步精简“文件保存位置”文案。纯前端改动,无 API、schema、migration 或目录绑定语义变化;相关静态资源与无 DB 路由 18 项测试通过。
@ -268,7 +269,7 @@ web/{background,scheduler_runner,wechat_runner}.py ← lifespan 后台协程按
web/{runs,common,schemas,model_gate,userfiles}.py ← BG worker/共享 helper/请求体/档位门控/路径安全 web/{runs,common,schemas,model_gate,userfiles}.py ← BG worker/共享 helper/请求体/档位门控/路径安全
web/auth.py ~190 ← 邮箱密码 + platform_key → JWT web/auth.py ~190 ← 邮箱密码 + platform_key → JWT
web/broker.py / sinks.py / pptx_render.py / admin.py web/broker.py / sinks.py / pptx_render.py / admin.py
web/static/dev.html + js/*.js ← dev SPA 零构建 ES module(main.js 入口;skills.js=技能 modal;kb.js=知识库 modal) web/static/dev.html + ui.css + js/*.js ← dev SPA 零构建ui.css=轻量视觉基础层main.js=入口
web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx web/static/vendor/ ~1 MB ← jszip / docx-preview / xlsx
tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控) tests/ 360 项 ← golden/rendering 基线 + 路由两套(nodb/db)+ DB 套件(ZCBOT_TEST_DB_URL 门控)
───────────────────────────────── ─────────────────────────────────

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.60.7" __version__ = "0.60.8"

View File

@ -11,6 +11,7 @@ from web.static_files import NoCacheStaticFiles
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
STATIC_DIR = ROOT / "web" / "static" STATIC_DIR = ROOT / "web" / "static"
DEV_HTML = STATIC_DIR / "dev.html" DEV_HTML = STATIC_DIR / "dev.html"
UI_CSS = STATIC_DIR / "ui.css"
JS_DIR = STATIC_DIR / "js" JS_DIR = STATIC_DIR / "js"
VENDOR_DIR = STATIC_DIR / "vendor" / "markdown" VENDOR_DIR = STATIC_DIR / "vendor" / "markdown"
@ -42,6 +43,16 @@ class StaticVendorTests(unittest.TestCase):
self.assertTrue(path.exists(), f"missing vendored asset: {path}") self.assertTrue(path.exists(), f"missing vendored asset: {path}")
self.assertGreater(path.stat().st_size, 0, f"empty vendored asset: {path}") self.assertGreater(path.stat().st_size, 0, f"empty vendored asset: {path}")
def test_dev_html_uses_local_ui_foundation(self) -> None:
html = DEV_HTML.read_text(encoding="utf-8")
self.assertIn('href="ui.css"', html)
self.assertTrue(UI_CSS.exists(), f"missing UI foundation: {UI_CSS}")
css = UI_CSS.read_text(encoding="utf-8")
self.assertIn("--accent:", css)
self.assertIn(":focus-visible", css)
self.assertIn("@media (max-width: 640px)", css)
def test_frontend_surfaces_context_and_cache_stats(self) -> None: def test_frontend_surfaces_context_and_cache_stats(self) -> None:
src = _frontend_source() src = _frontend_source()
@ -62,6 +73,18 @@ class StaticVendorTests(unittest.TestCase):
self.assertEqual(resp.status_code, 200) self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.headers.get("cache-control"), "no-cache") self.assertEqual(resp.headers.get("cache-control"), "no-cache")
def test_ui_css_is_served_as_css_with_revalidation(self) -> None:
app = FastAPI()
app.mount("/static", NoCacheStaticFiles(directory=str(STATIC_DIR)), name="static")
client = TestClient(app)
resp = client.get("/static/ui.css")
self.assertEqual(resp.status_code, 200)
self.assertTrue(resp.headers.get("content-type", "").startswith("text/css"))
self.assertEqual(resp.headers.get("cache-control"), "no-cache")
self.assertIn("--accent:", resp.text)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -13,35 +13,6 @@
<link rel="stylesheet" href="vendor/markdown/github.min.css" /> <link rel="stylesheet" href="vendor/markdown/github.min.css" />
<style> <style>
:root {
--bg: #f7f7f7;
--panel: #ffffff;
--border: #e3e3e3;
--border-soft: #ececec;
--text: #222;
--muted: #888;
--accent: #c0392b;
--accent-soft: #fde9e7;
--hover: #f0f0f0;
--code-bg: #f4f4f4;
--user-bg: #eef4fb;
--asst-bg: #ffffff;
/* 语义色组:done/export/clear/abandon/delete 按钮 + dd-item + badge 共用 */
--c-green: #27ae60; --c-green-bg: #e9f7ef; --c-green-bd: #a9dfbf;
--c-blue: #2980b9; --c-blue-bg: #ebf5fb; --c-blue-bd: #aed6f1;
--c-purple: #8e44ad; --c-purple-bg: #f5eef8; --c-purple-bd: #d2b4de;
--c-orange: #e67e22; --c-orange-bg: #fef5e7; --c-orange-bd: #f5cba7;
--c-red: #c0392b; --c-red-bg: #fdedec; --c-red-bd: #f5b7b1;
/* 圆角:各档下调一档(没那么圆润) */
--r-sm: 3px; /* code / 小标签 */
--r-md: 4px; /* button / input / 消息气泡 / 卡片元素 */
--r-lg: 6px; /* modal card / 中型容器 */
--r-xl: 8px; /* 大 modal card(登录卡) */
--shadow-card: 0 12px 32px rgba(0,0,0,.18);
--shadow-card-lg: 0 20px 60px rgba(0,0,0,.12), 0 2px 6px rgba(0,0,0,.04);
--mono: ui-monospace, "Cascadia Code", "SF Mono", Consolas, monospace;
--t: all .15s;
}
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { height: 100%; margin: 0; } html, body { height: 100%; margin: 0; }
body { body {
@ -1518,6 +1489,7 @@
button:active:not(:disabled) { transform: none; } button:active:not(:disabled) { transform: none; }
} }
</style> </style>
<link rel="stylesheet" href="ui.css" />
</head> </head>
<body> <body>

589
web/static/ui.css Normal file
View File

@ -0,0 +1,589 @@
/*
* zcbot UI foundation
*
* The application intentionally stays build-free. This file is the shared
* visual layer for the main SPA: tokens, control states and restrained
* workspace polish. Feature-specific layout remains next to dev.html until
* it is reused by a second surface.
*/
:root {
color-scheme: light;
/* Neutral workspace palette */
--bg: #f3f5f7;
--panel: #ffffff;
--panel-subtle: #f8f9fb;
--panel-muted: #f4f6f8;
--border: #dde2e7;
--border-soft: #e9edf1;
--text: #20242a;
--text-strong: #15181d;
--muted: #6f7782;
/* Institute red is deliberately reserved for primary emphasis. */
--accent: #b83a31;
--accent-hover: #a9322a;
--accent-soft: #fbecea;
--accent-ring: rgba(184, 58, 49, 0.16);
--hover: #f0f3f5;
--code-bg: #f3f5f7;
--user-bg: #f1f5fa;
--asst-bg: #ffffff;
--c-green: #238653;
--c-green-bg: #eaf6ef;
--c-green-bd: #a9d9bd;
--c-blue: #2d6f9f;
--c-blue-bg: #edf5fa;
--c-blue-bd: #b5d5e7;
--c-purple: #7c5192;
--c-purple-bg: #f5eff8;
--c-purple-bd: #d8c4e1;
--c-orange: #bf6a20;
--c-orange-bg: #fdf3e8;
--c-orange-bd: #ebc79f;
--c-red: #b83a31;
--c-red-bg: #fbecea;
--c-red-bd: #e7b3ae;
/* One compact radius and spacing vocabulary. */
--r-sm: 4px;
--r-md: 6px;
--r-lg: 8px;
--r-xl: 10px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--shadow-float: 0 10px 30px rgba(24, 32, 41, 0.12);
--shadow-card: 0 18px 48px rgba(24, 32, 41, 0.16);
--shadow-card-lg:
0 24px 72px rgba(24, 32, 41, 0.14),
0 2px 8px rgba(24, 32, 41, 0.06);
--mono: ui-monospace, "Cascadia Code", "SF Mono", Consolas, monospace;
--t: background-color 0.15s ease, border-color 0.15s ease,
color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
body {
font-size: 14px;
line-height: 1.55;
color: var(--text);
background: var(--bg);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button,
input,
textarea,
select {
transition: var(--t);
}
button {
min-height: 32px;
padding: 5px 11px;
font-weight: 500;
}
button.small {
min-height: 28px;
}
button:hover:not(:disabled) {
border-color: #cbd2d9;
}
button.primary {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 1px 2px rgba(73, 25, 21, 0.16);
}
button.primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
filter: none;
}
button:disabled {
opacity: 0.46;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]),
textarea,
select {
min-height: 34px;
border-color: var(--border);
}
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):hover,
textarea:hover,
select:hover {
border-color: #c7ced5;
}
input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-ring);
}
::placeholder {
color: #9aa1aa;
}
/* Application shell */
#app.ready {
background: var(--bg);
}
header {
min-height: 48px;
padding: 9px 16px;
border-bottom-color: var(--border);
box-shadow: 0 1px 0 rgba(24, 32, 41, 0.02);
}
header .brand {
gap: 10px;
}
header .brand .logo {
width: 26px;
height: 26px;
box-shadow: 0 2px 8px rgba(184, 58, 49, 0.2);
}
header .title {
color: var(--text-strong);
font-size: 15px;
letter-spacing: 0.1px;
}
header .who {
color: var(--muted);
}
.pane {
border-color: var(--border);
}
#pane-left,
#pane-right {
background: var(--panel-subtle);
}
#pane-mid {
background: var(--panel);
}
.splitter {
background: var(--bg);
}
.splitter::before {
background: #d7dde3;
}
.pane-head {
min-height: 44px;
padding: 8px 12px;
background: rgba(248, 249, 251, 0.96);
border-bottom-color: var(--border-soft);
}
.pane-head .label {
color: var(--text-strong);
font-size: 13px;
letter-spacing: 0.2px;
}
#pane-left .pane-head + .pane-head {
background: var(--panel);
}
#task-scroll,
#file-list {
scrollbar-color: #cbd1d7 transparent;
scrollbar-width: thin;
}
/* Task and resource navigation */
#channel-cards {
padding: 8px 10px 4px;
}
.channel-card {
border-color: rgba(7, 158, 82, 0.26);
background: rgba(7, 158, 82, 0.045);
}
#task-list {
padding: 4px 8px 8px;
}
.task-row {
margin: 3px 0;
padding: 9px 10px;
border: 1px solid transparent;
border-radius: var(--r-md);
}
.task-row:hover {
background: var(--panel);
border-color: var(--border-soft);
box-shadow: 0 1px 3px rgba(24, 32, 41, 0.04);
}
.task-row.active {
padding-left: 8px;
background: var(--accent-soft);
border-color: #f0d2cf;
border-left: 3px solid var(--accent);
box-shadow: none;
}
.task-row .desc {
color: var(--text-strong);
}
.task-row .meta {
color: var(--muted);
}
.task-row .badge,
.sk-badge {
border-radius: var(--r-sm);
}
#rail-resources {
padding: 8px;
border-top-color: var(--border-soft);
background: var(--panel);
}
#rail-resources > button {
min-height: 48px;
border-color: transparent;
color: #59616b;
background: transparent;
}
#rail-resources > button:hover {
color: var(--accent);
border-color: #f0d2cf;
background: var(--accent-soft);
}
/* Menus and lightweight cards */
#floating-menu,
#media-model-pop {
border-color: var(--border);
border-radius: var(--r-lg);
box-shadow: var(--shadow-float);
}
.dd-item {
padding: 7px 14px;
}
.dd-item:hover {
background: var(--panel-muted);
}
.sk-item {
padding: 9px 10px;
border-color: var(--border-soft);
background: var(--panel);
}
.sk-item:hover {
border-color: #d2a09b;
background: var(--accent-soft);
}
.sk-item.active {
border-color: var(--accent);
background: var(--accent-soft);
}
.empty,
.sk-empty {
color: var(--muted);
line-height: 1.7;
}
/* Conversation: assistant content reads like a document, not a bubble stack. */
#chat-meta {
min-height: 40px;
padding: 8px 16px;
background: var(--panel);
border-bottom-color: var(--border-soft);
}
#chat-stream {
gap: 14px;
padding: 20px clamp(16px, 3vw, 36px) 24px;
background: var(--panel);
scrollbar-color: #cbd1d7 transparent;
scrollbar-width: thin;
}
.msg {
padding: 10px 13px;
border-color: var(--border-soft);
border-radius: var(--r-lg);
}
.msg.user {
background: var(--user-bg);
border-color: #dce6f0;
}
.msg.assistant {
padding: 8px 0;
border-color: transparent;
border-radius: 0;
}
.msg.assistant.live-run {
padding: 12px 14px;
border-color: rgba(184, 58, 49, 0.2);
border-radius: var(--r-lg);
box-shadow:
0 0 0 1px rgba(184, 58, 49, 0.05),
0 8px 24px rgba(184, 58, 49, 0.06);
}
.msg .role {
margin-bottom: 4px;
color: #7d858f;
font-family: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
}
.msg .body {
color: var(--text);
font-size: 15px;
line-height: 1.7;
}
.msg .body p {
margin: 0.65em 0;
}
.msg .body h1,
.msg .body h2,
.msg .body h3 {
color: var(--text-strong);
line-height: 1.35;
}
.msg-actions {
margin-top: 8px;
}
.tool-call,
.reasoning {
color: #4f5863;
}
.tool-call summary,
.reasoning summary {
padding: 6px 8px;
border: 1px solid var(--border-soft);
border-radius: var(--r-md);
background: var(--panel-muted);
}
.tool-call summary:hover,
.reasoning summary:hover {
background: var(--hover);
}
.tool-call pre {
border: 1px solid var(--border-soft);
background: var(--code-bg);
}
#chat-form {
gap: 8px;
padding: 12px 16px 14px;
border-top-color: var(--border-soft);
background: var(--panel);
box-shadow: 0 -8px 24px rgba(24, 32, 41, 0.035);
}
#chat-input {
min-height: 72px;
padding: 10px 12px;
line-height: 1.6;
border-radius: var(--r-lg);
background: #fcfcfd;
}
#chat-form .row {
align-items: center;
}
#chat-action {
min-width: 64px;
}
/* File pane */
.crumbs {
min-height: 38px;
padding: 9px 12px;
background: var(--panel);
border-bottom-color: var(--border-soft);
}
.file-row {
min-height: 38px;
padding: 7px 12px;
border-bottom-color: var(--border-soft);
}
.file-row:hover {
background: var(--panel);
}
.file-row .name {
color: #30363d;
}
.storage-foot {
padding: 8px 12px;
border-top-color: var(--border-soft);
background: var(--panel);
}
.storage-foot .bar {
height: 5px;
background: #e5e9ed;
}
/* Dialogs and notifications */
.modal {
padding: 16px;
background: rgba(22, 28, 35, 0.46);
}
.modal > .card {
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.72);
box-shadow: var(--shadow-card);
}
#chpw-modal h3,
#app-dialog h3,
#changelog-modal h3,
#skills-modal h3,
#wechat-modal h3,
#crons-modal h3,
#memory-modal h3,
#kb-modal h3 {
min-height: 48px;
padding: 13px 18px;
color: var(--text-strong);
border-bottom-color: var(--border-soft);
}
#app-dialog .body,
#chpw-modal .body {
padding: 18px;
}
#app-dialog .actions,
#chpw-modal .actions,
#src-picker-modal .actions {
padding: 12px 18px;
border-top-color: var(--border-soft);
background: var(--panel-subtle);
}
.toast,
.app-msg {
border: 1px solid var(--border-soft);
border-radius: var(--r-lg);
box-shadow: var(--shadow-float);
}
/* Respect system motion preferences for polish-only transitions too. */
@media (prefers-reduced-motion: reduce) {
button,
input,
textarea,
select {
transition: none;
}
}
@media (max-width: 640px) {
header {
min-height: 46px;
padding: 7px 10px;
}
.pane-head {
min-height: 42px;
}
#chat-stream {
gap: 12px;
padding: 14px 12px 18px;
}
.msg {
max-width: 97%;
}
.msg .body {
font-size: 15px;
line-height: 1.68;
}
#chat-form {
padding: 10px;
}
#chat-input {
min-height: 64px;
}
#chat-form .row button {
min-height: 36px;
}
.modal {
padding: 12px;
}
}