ui(dev SPA): 任务列表行加最近操作时间(updated_at 相对显示) + 新建弹框工作目录改 <select> 下拉

- 列表行 meta 加 fmtTimeAgo helper(刚刚/N 分钟前/N 小时前/昨天 HH:MM/MM-DD/YYYY-MM-DD), title 出完整 locale 串
- 新建任务工作目录: input + datalist → <select> 既有目录列表 + "+ 新建目录..." 展开 text input(保留新名建目录能力)
- folders-datalist 保留供左 pane filter-wd 继续 autocomplete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-05-20 10:57:16 +08:00
parent 515684e60b
commit 97d838a9ec
2 changed files with 78 additions and 28 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`
最后更新:2026-05-20(dev SPA 主页轻量美化:header 加 brand logo / 左 pane filter 行轻分隔 / 顶栏语义按钮改 hover 上色 / 圆角阴影微调)
最后更新:2026-05-20(dev SPA 任务列表行 meta 加最近操作时间相对显示 + 新建弹框工作目录改下拉)
---
@ -23,6 +23,8 @@
### 2026-05-20
- **dev SPA 左侧任务列表行加「最近操作时间」**:用户要"显示最新操作时间"。`renderTaskList` 行 meta 区(badge / skill / N 条 / N tok / id-slice)在 id-slice 之前插一个 `<span class="muted" style="margin-left:auto;">`,文案用新加的 `fmtTimeAgo(iso)` 相对时间 helper:`<60s`→刚刚 / `<1h`N 分钟前 / 同日N 小时前 / 昨日昨天 HH:MM / 同年MM-DD HH:MM / 跨年YYYY-MM-DD,`title=` hover 出完整 `fmtTime` locale 。`margin-left:auto` id-slice 挪到时间 span(让两者一起靠右,中间 8px `.meta gap` 自然分隔)。字段用 `updated_at`(任务任何写操作 改名 / 新消息 / 状态切 都会更新,贴合"最新操作"语义),`/v1/tasks` payload 早已包含,后端零改。**没动**:左 pane 列表默认排序仍 `-created_at`(用户改排序顺序时另说);id-slice 保留(调试参考)。
- **dev SPA 新建任务弹框「工作目录」从 input + datalist 改 `<select>` 下拉**:用户要"做成下拉选择"。原 `<input list="folders-datalist">` autocomplete 改 `<select id="nt-wd-sel">`,选项 = `(留空 · 用任务名作目录)` + 既有目录(`name — N 个任务` / `空目录`) + `+ 新建目录…` sentinel(`__new__`)。选 `__new__` → 显示备用 `<input id="nt-wd-new">` 输入新目录名 + autofocus,提交时 `working_dir = sel === "__new__" ? nt-wd-new.value : sel`。hint 区改 `updateWdHint()` 三分支(新建 / 留空 / 复用),change + new-input + name-input 三事件触发。`<datalist id="folders-datalist">` 留在 modal 内但不再被它消费,**只供左 pane 顶部 `#filter-wd` 筛选 autocomplete**(datalist 按 id 引用,DOM 位置无关);`loadFolderSuggestions()` 同次拉取灌两边。**没动**:`/v1/folders` API、提交 body 形态(仍 `working_dir: string`,空串语义不变 → 后端 fallback 用任务名)、左 pane filter-wd 仍用 input + datalist(用户只点名"任务弹框")、DESIGN / RUN。**Tradeoff**:纯 select 实现最直接但会失"新名则新建",改两段式(select 含 `+ 新建…`,触发后展开 text input)保留所有原能力。
- **dev SPA 主页轻量美化(纯 CSS / HTML,不动 JS / 路由)**:用户要"简洁美化主页"。改四处:① header 从裸 "zcbot" 文字 → brand wrapper(24px 红渐变 "Z" logo + 标题字号 14→15 + letter-spacing + 顶栏 1px 极淡阴影),沿用登录页 brand 视觉但缩小;② 左 pane 三行 pane-head(任务标签/搜索/排序)用 `#pane-left .pane-head + .pane-head` 选择器把 filter / sort 子行换白底 + `--border-soft #ececec` 分隔,弱化为子层级,把两条 inline `border-top` 顺手去掉(与新 `border-bottom` 重叠会出双线);③ 顶栏 4 个语义按钮(完成/导出/废弃/删除)+ 选入弹框的复制/移动按钮从"常态彩边 + hover 加底色"改"常态中性 + hover 一次性上语义色(color + border + bg)",给 button 基础类加 transition 让色变平滑(沿用现有 `button.danger` 的同款 hover-only 范式);④ 圆角统一:button / input / textarea / select / floating-menu / .msg 4→6,三个 modal 卡片 6→8 + 阴影 `0 8px 24px → 0 12px 32px` 略深显悬浮感。**没动**:布局 / 交互逻辑 / 任何 JS / 后端 / DESIGN(纯视觉)/ RUN(无对外接口变化);dd-item 菜单的语义色保留(菜单内本来就靠色区分动作类型,不属于"顶栏中性"范畴)。
- **加 `config/models/glm.yaml`:智谱 GLM 5.1 接入(litellm zai provider + 国内站 bigmodel.cn)**:用户要加 GLM。litellm 1.83.14 内置 `zai` provider(PR #17307 早就 merge,我初次 grep 漏了 — 只搜了 zhipu/glm/doubao),`zai/glm-5.1` 自动路由到 z.ai 国际站(`api.z.ai`,env `ZAI_API_KEY`)。**用户用国内站 bigmodel.cn**(账号 / key 跟 z.ai 国际站不通用),YAML 走 `api_base: https://open.bigmodel.cn/api/paas/v4` 覆盖 litellm 默认(`core/llm.py:71-72` 已有 `if self.api_base: kwargs["api_base"]=...` 透传通道),env 命名 `ZHIPUAI_API_KEY` 跟国际站 `ZAI_API_KEY` 分开。family=`glm`,单 variant `pro`,context 200K / reliable 100K / max_out 8192,tool calling 标 good,run_python 开。**`thinking_mode: false`**:GLM 的 thinking 协议是 body `{"type":"enabled"}` 开关 +(可选)budget,与 OpenAI/DeepSeek 的 `reasoning_effort` int 等级不同;`core/llm.py:77-78` 只透传 `reasoning_effort`,要接 GLM thinking 得加 family 分支(`if family.startswith("glm"): kwargs["extra_body"]={"thinking":{"type":"enabled"}}`),不在加 YAML 范围,留 TODO。smoke:`ModelCapabilities.load('glm.pro', ...)` 正常 + `litellm.get_llm_provider('zai/glm-5.1')``(model=glm-5.1, provider=zai, default_base=https://api.z.ai/api/paas/v4)`,YAML override 生效后实际打 bigmodel.cn;`/v1/models` 扫描结果含 `glm.pro / 'GLM 5.1' / thinking=False`。**没动**:`core/llm.py`(避免半成品 thinking 分支)、DESIGN.md(只加模型档案,非架构变更)、`default_model`(仍 `deepseek_v4.flash`,GLM 是可选项,前端下拉里出现)。**已知待办**:① 接 GLM thinking 透传;② 豆包图像/视频生成(seedream/seedance,完全不同 API 形态,要单独管线)。
- **files SPA UX 翻面 + 拖拽上传 + 修 checkbox 全局 width bug**:沿用上条新加的两路由,但前端 UX 整套换。**原模型**(select-then-pick-dest):主区行带 checkbox + 顶栏全选三态 + 黄 bar(复制到 / 移动到 / 取消)→ 弹框选目标目录。**新模型**(at-dest-pull-sources):主区只读浏览,顶栏加 `[选入…]` 按钮 → 弹框内浏览任意目录 + 跨目录勾文件 / 子目录(`Set<rel>` 跨切换保留)+ 底部 `[复制到此处]` `[移动到此处]` 两按钮直接落到主区当前 `state.filesPath`。**理由**:用户切任务时主区自动跳 task working_dir,绝大多数操作是"把外面素材喂进当前 working_dir",destination-first 比 source-first 少一次心智切换,且主区干净。**附带**:① 主区 `<input type=checkbox class=row-cb>` 被全局 `input{ width:100%; }` 撑成全行宽 → 把 `.name`(`flex: 1; flex-basis: 0`)挤成 0 宽,行里只剩看不见的文字 + 居中的 checkbox(用户报"看不到文字"),根因不修永远埋雷,改 selector 排除 checkbox/radio/file。② 拖拽上传:`#pane-right` 监听 dragenter/over/leave/drop,有 `Files` 才响应(忽略文本拖拽),`#file-droparea` 红色虚线 overlay,落点 = `state.filesPath`,沿用 `/v1/files/upload`。**删了**:`state.selectedFiles` + `syncBulkBar` + `dirPicker` 模块 + 顶栏 selall + 黄 bar 整块 + 行 checkbox 渲染(按 CLAUDE.md 不留旧 UX)。**没动**:后端 `/v1/files/copy` `/v1/files/move`(同样的 `paths` + `dest_dir`)、DESIGN、RUN。

View File

@ -654,8 +654,12 @@
<h3>新建任务</h3>
<label for="nt-name">任务名(必填)</label>
<input id="nt-name" placeholder="例如 初稿大纲" />
<label for="nt-wd">工作目录(可选,留空 → 用任务名;已有则复用,新名则新建)</label>
<input id="nt-wd" list="folders-datalist" placeholder="选已有或新建,留空则用任务名" />
<label for="nt-wd-sel">工作目录(可选,留空 → 用任务名;已有则复用,新名则新建)</label>
<select id="nt-wd-sel">
<option value="">(留空 · 用任务名作目录)</option>
<option value="__new__">+ 新建目录…</option>
</select>
<input id="nt-wd-new" placeholder="新目录名" style="margin-top:6px;display:none;" />
<datalist id="folders-datalist"></datalist>
<div class="small muted" id="nt-wd-hint" style="margin-top:4px;min-height:1em;"></div>
<label for="nt-desc">描述(可选,任务长描述)</label>
@ -796,6 +800,28 @@ function fmtTime(iso) {
try { return new Date(iso).toLocaleString(); } catch (e) { return iso; }
}
// 相对时间(任务列表用):刚刚 / N 分钟前 / N 小时前 / 昨天 HH:MM / MM-DD / YYYY-MM-DD
function fmtTimeAgo(iso) {
if (!iso) return "";
let d;
try { d = new Date(iso); } catch (e) { return iso; }
if (isNaN(d.getTime())) return iso;
const now = new Date();
const diffSec = Math.floor((now - d) / 1000);
if (diffSec < 0) return d.toLocaleString(); // 时钟漂移兜底
if (diffSec < 60) return "刚刚";
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} 分钟前`;
if (diffSec < 86400 && now.getDate() === d.getDate()) return `${Math.floor(diffSec / 3600)} 小时前`;
const pad = (n) => String(n).padStart(2, "0");
const hhmm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
const yest = new Date(now); yest.setDate(now.getDate() - 1);
if (d.getFullYear() === yest.getFullYear() && d.getMonth() === yest.getMonth() && d.getDate() === yest.getDate()) {
return `昨天 ${hhmm}`;
}
if (d.getFullYear() === now.getFullYear()) return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hhmm}`;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function escapeHtml(s) {
return (s || "").replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
@ -1011,7 +1037,8 @@ function renderTaskList(tasks) {
${t.skill ? `<span class="muted">${escapeHtml(t.skill)}</span>` : ""}
<span>${t.n_messages || 0} 条</span>
<span>${t.tokens || 0} tok</span>
<span class="muted" style="margin-left:auto;font-family:monospace;">${t.task_id.slice(0, 8)}</span>
<span class="muted" style="margin-left:auto;" title="${escapeHtml(fmtTime(t.updated_at))}">${escapeHtml(fmtTimeAgo(t.updated_at))}</span>
<span class="muted" style="font-family:monospace;">${t.task_id.slice(0, 8)}</span>
</div>
</div>
<button class="dd-toggle task-menu" data-tid="${t.task_id}" title="任务操作"></button>
@ -2046,12 +2073,14 @@ async function uploadSelected() {
// ───── new task ─────
$("hd-new").onclick = async () => {
$("nt-name").value = ""; $("nt-wd").value = "";
$("nt-name").value = "";
$("nt-wd-new").value = ""; $("nt-wd-new").style.display = "none";
$("nt-desc").value = ""; $("nt-skill").value = "";
$("nt-err").textContent = "";
$("nt-wd-hint").textContent = "";
$("new-task-modal").classList.add("show");
await Promise.all([loadFolderSuggestions(), loadSkillOptions(), loadModels()]);
$("nt-wd-sel").value = ""; // 默认"留空·用任务名"(loadFolderSuggestions 重渲后保险再置一次)
populateModelSelect();
$("nt-name").focus();
};
@ -2069,7 +2098,8 @@ function populateModelSelect() {
$("nt-cancel").onclick = () => $("new-task-modal").classList.remove("show");
$("nt-go").onclick = async () => {
const name = $("nt-name").value.trim();
const working_dir = $("nt-wd").value.trim();
const sel = $("nt-wd-sel").value;
const working_dir = sel === "__new__" ? $("nt-wd-new").value.trim() : sel;
const desc = $("nt-desc").value.trim();
const skill = $("nt-skill").value;
const model_profile = $("nt-model").value;
@ -2087,18 +2117,29 @@ $("nt-go").onclick = async () => {
}
};
// 工作目录 autocomplete:打开 modal 时拉一次,输入时实时提示"复用 / 新建"
// 工作目录:打开 modal 时拉一次,同时灌 modal <select>(主) + 顶部筛选用的 datalist(副)
async function loadFolderSuggestions() {
let folders = [];
try {
const data = await api("GET", "/v1/folders");
folders = data.folders || [];
} catch (e) {
// 静默 — select 仍有默认两项
}
const dl = $("folders-datalist");
dl.innerHTML = (data.folders || []).map((f) => {
dl.innerHTML = folders.map((f) => {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
return `<option value="${escapeHtml(f.name)}" data-n="${f.n_tasks}" label="${escapeHtml(tag)}"></option>`;
}).join("");
} catch (e) {
// 静默 — datalist 留空不影响用户输入
const sel = $("nt-wd-sel");
const opts = ['<option value="">(留空 · 用任务名作目录)</option>'];
for (const f of folders) {
const tag = f.n_tasks ? `${f.n_tasks} 个任务` : `空目录`;
opts.push(`<option value="${escapeHtml(f.name)}" data-n="${f.n_tasks}">${escapeHtml(f.name)} — ${escapeHtml(tag)}</option>`);
}
opts.push('<option value="__new__">+ 新建目录…</option>');
sel.innerHTML = opts.join("");
sel.value = "";
}
// 智能体类型下拉:skill registry 服务器端静态,首次加载后缓存到 state.skills
@ -2122,28 +2163,35 @@ async function loadSkillOptions() {
sel.value = ""; // hd-new 已清空,这里幂等再保一次
}
$("nt-wd").addEventListener("input", () => {
const v = $("nt-wd").value.trim();
function updateWdHint() {
const sel = $("nt-wd-sel");
const v = sel.value;
const hint = $("nt-wd-hint");
if (!v) {
const newInp = $("nt-wd-new");
if (v === "__new__") {
newInp.style.display = "";
const nv = newInp.value.trim();
hint.innerHTML = nv
? `<span class="muted">→ 新建目录 ${escapeHtml(nv)}</span>`
: `<span class="muted">→ 输入新目录名</span>`;
} else if (v === "") {
newInp.style.display = "none";
const fallback = $("nt-name").value.trim();
hint.textContent = fallback ? `留空 → 用任务名「${fallback}」作目录` : "";
return;
}
const opt = $("folders-datalist").querySelector(`option[value="${CSS.escape(v)}"]`);
if (opt) {
const n = parseInt(opt.dataset.n) || 0;
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个任务`;
} else {
hint.innerHTML = `<span class="muted">→ 新建目录 ${escapeHtml(v)}</span>`;
newInp.style.display = "none";
const opt = sel.options[sel.selectedIndex];
const n = opt ? (parseInt(opt.dataset.n) || 0) : 0;
hint.innerHTML = `<span style="color:var(--accent);">→ 复用已有目录</span> · ${n} 个任务`;
}
}
$("nt-wd-sel").addEventListener("change", () => {
updateWdHint();
if ($("nt-wd-sel").value === "__new__") $("nt-wd-new").focus();
});
$("nt-wd-new").addEventListener("input", updateWdHint);
$("nt-name").addEventListener("input", () => {
// 任务名输入时,若工作目录为空,提示 fallback 文案动态更新
if (!$("nt-wd").value.trim()) {
const fallback = $("nt-name").value.trim();
$("nt-wd-hint").textContent = fallback ? `留空 → 用任务名「${fallback}」作目录` : "";
}
if ($("nt-wd-sel").value === "") updateWdHint();
});
// ───── boot ─────