From ee612adebde90fe294aee22867e246d94eb90aa1 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 07:38:10 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E7=9C=8B=E6=9D=BF=E4=B8=8E=E7=95=AA?= =?UTF-8?q?=E8=8C=84=E4=B8=80=E6=89=B9=EF=BC=9A=E8=BF=87=E5=8D=A1=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E3=80=81=E5=8D=A1=E7=89=87=E6=88=AA=E6=AD=A2=E3=80=81?= =?UTF-8?q?=E5=BD=93=E5=A4=A9=E6=98=8E=E7=BB=86=E3=80=81=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E5=91=A8=E6=8A=A5=E3=80=81=E5=85=A8=E5=A5=97=E5=9B=BE=E6=A0=87?= =?UTF-8?q?=EF=BC=9BAI=20=E8=B5=B7=E9=92=9F=E8=B5=B0=E9=97=A8=E7=A6=81?= =?UTF-8?q?=E3=80=81=E8=B5=B0=E5=AE=8C=E6=8F=90=E9=86=92=E5=9B=9E=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 看板这批: - 复习:过卡模式(全屏单卡,空格翻备注、1/2/3 打分、→ 跳过);行级「缓一天」走 review.schedule,难度与遍数保留;队列键 1/2/3 直评队首 - 卡片截止日期 dueDate:新建 @YYYY-MM-DD、卡面 ⏰ 徽章、编辑器日期框;工具加 dueDate 参数、系统提示带【截止】;Markdown 行尾 ddl 字段往返,注释里删掉即清空 - 热力图点格子展开当天专注明细(原始 session 列表,聚合仍在 desk.js) - 周报:新增 weekly.text op + 「复制周报」,与工具 report 同一份 weeklyText - 复盘:停手 2 秒自动落盘,换面板卸载前补存 - 设置:科目编辑器,最后一个不可删(对齐宿主「空列表不动旧值」) - 快捷键:/ 聚焦搜索、n 开新建 - 视觉:投入条统一绿族(暗色下品牌色解析成近白)、列头状态点、计时卡运行态描边、输入焦点环、打分键语义 hover;style.test 增补颜色守卫 - 图标:品牌番茄进 package.json 的 icon(插件管理页+设置侧栏),侧栏重画两枚(工作台=任务卡+计时徽章、单词打卡=词书打勾) - 诊断:mini 命中测试分档——盖的人住在宿主模态层(z≥1000)记 mini-under-modal(设计内让位),普通内容覆盖才是 mini-covered 番茄这批: - 工具 focus 起钟必须挂一张「进行中」的卡,不挂卡/不在 doing 直接拒绝并教完整流程;只限工具路径,手动起钟不受限 - AI 起钟记 by:'ai' + 会话 id,走完(含自动摆的休息)由 emitTimerEnd 往起钟会话发自动提醒;手动停钟永不提醒 - settings.notifySessionOnTimerEnd 开关(默认开),设置页有勾选;宿主侧 10 秒看门狗兜底结账 工具与杂项: - tools/shot-harness.mjs(真挂载视觉验证)与 compose-shots.py 等对照图脚本入库 - 删掉误入库的空文件;.gitignore 补 .v2c/ --- ).join( | 0 .gitignore | 3 + AGENTS.md | 15 ++ apply-probe.mjs | 73 ++++++- assets/icon.svg | 23 ++ client.js | 455 +++++++++++++++++++++++++++++++++++---- desk.js | 12 +- index.js | 133 ++++++++++-- markdown.js | 5 +- package.json | 2 + test/desk.test.js | 95 ++++++++ test/markdown.test.js | 17 ++ test/style.test.js | 2 + timer.js | 22 +- tools/compose-final.py | 51 +++++ tools/compose-gallery.py | 61 ++++++ tools/compose-shots.py | 69 ++++++ tools/shot-harness.mjs | 313 +++++++++++++++++++++++++++ 18 files changed, 1291 insertions(+), 60 deletions(-) delete mode 100644 ).join( create mode 100644 assets/icon.svg create mode 100644 tools/compose-final.py create mode 100644 tools/compose-gallery.py create mode 100644 tools/compose-shots.py create mode 100644 tools/shot-harness.mjs diff --git a/).join( b/).join( deleted file mode 100644 index e69de29..0000000 diff --git a/.gitignore b/.gitignore index 042ccd7..468b5fb 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ pack.log # 视觉验证的临时产物(harness + 截图),不进版本库 .shots/ + +# video2code 会话缓存,不进版本库 +.v2c/ diff --git a/AGENTS.md b/AGENTS.md index f985680..d5938b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,21 @@ 规则只写在 `bbdcView()` 一处(客户端是独立 bundle、只能 `require react`,两边各写一遍必散)。 另有 `durationAsOf` / `durationMightBeStale`:只在「本地落后且云端没兜住」时提醒, 判定今天用 `desk.js` 的 `dayKey`(本地时区),**不能用 `toISOString()`** —— UTC+8 的早上会认错天。 +10. **AI 起番茄钟走门禁,走完的提醒发回起钟的会话。** 这是用户点名的督促链路: + - **门禁**:工具 `focus` 起钟必须 `task=` 一张**「进行中」**的卡,不挂卡 / 卡不存在 / 卡不在 doing + 都直接拒绝,报错文案教完整流程(`add` 建卡 → `move` 到 `doing` → `focus` 挂卡)。目的是逼 AI 把它 + 安排的任务真的落到待办墙上,周报和近 30 天统计才对得上。**只限工具路径**——客户端 POST + `timer.start`(手动起钟)不受限制。 + - **会话标记**:AI 起钟时把 `exec.agent.id`(= 会话 id)存进 `state.timer`(`by:'ai'` + `sessionId`); + 专注走完自动摆的休息**继承**这两个字段(链条到休息结束为止),手动提前结束摆的休息也继承。 + - **提醒发送**:`withDesk` 结账时把走完的段收进 sink,`emitTimerEnd` 过滤(开关关了 / 不是 AI 链 / + 段走完超过 10 分钟才被结账的都跳过)后调钩子:`ctx.get('agents').get(sessionId).followup(消息)`。 + 这是官方 `dsh-goal-round-driver` 的同款姿势(app.asar 里有源码可对照);`followup` 空闲就开新一轮、 + 跑着就排下一轮。消息 `role:'user'`、`source.kind:'plugin'`,正文自带「这是系统自动消息,不是用户发言」。 + 宿主侧另有 10 秒看门狗:面板没开没人轮询 desk 时由它兜底结账。 + - **开关**:`settings.notifySessionOnTimerEnd`(默认开,`!== false` 即开,语义同 `injectPrompt`), + 设置页有勾选项。**手动停钟(客户端按钮 / 工具 `off=true`)永不提醒**;会话已关闭(agents 里没有) + 就静默放弃,不排队不补发。 ## 三个必须知道的坑 diff --git a/apply-probe.mjs b/apply-probe.mjs index 4b3c7c4..ede4c87 100644 --- a/apply-probe.mjs +++ b/apply-probe.mjs @@ -4,7 +4,7 @@ * 用法:node apply-probe.mjs (cwd = 本目录,含 index.js/desk.js/timer.js/client.js) */ import assert from 'node:assert/strict' -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -223,6 +223,77 @@ check('未知 op 带 error 文案', () => assert.ok(String(JSON.parse(p5.body).e const p6 = await post({ op: 'task.add' }) check('缺 title 返回 400', () => assert.equal(p6.code, 400)) +// ---- AI 起钟门禁 + 番茄钟走完的会话提醒(督促链路端到端)---- +// stub 一个 agents 服务:宿主提醒走 ctx.get('agents').get(sessionId).followup(消息) +const agentStub = { id: 'sess-A', followups: [], followup(msg) { this.followups.push(msg) } } +registered.services.agents = { get(id) { return id === 'sess-A' ? agentStub : undefined } } + +const blockedNoTask = await run('focus', { minutes: 25 }) +check('AI 起钟不挂卡被拒,且报错里教了完整流程', () => { + assert.equal(blockedNoTask.ok, false) + assert.match(blockedNoTask.message, /挂卡/) + assert.match(blockedNoTask.message, /action=add/) + assert.match(blockedNoTask.message, /status=doing/) +}) + +const gateCard = await run('add', { title: '门禁冒烟卡', subject: '英语' }) +check('门禁卡建好', () => assert.equal(gateCard.ok, true)) +const blockedTodo = await run('focus', { minutes: 25, task: '门禁冒烟卡' }) +check('AI 起钟挂待办卡被拒', () => { + assert.equal(blockedTodo.ok, false) + assert.match(blockedTodo.message, /进行中/) +}) + +await run('move', { title: '门禁冒烟卡', status: 'doing' }) +const aiFocus = await tool.execute({ action: 'focus', minutes: 25, task: '门禁冒烟卡' }, { agent: { id: 'sess-A' } }) +check('挂「进行中」的卡可以起钟', () => assert.equal(aiFocus.ok, true)) +check('起钟回执预告了自动提醒', () => assert.match(aiFocus.message, /自动往本会话发一条提醒/)) +check('计时器记下了起钟会话', () => { + const t = JSON.parse(readFileSync(join(PROBE_HOME, 'study-desk', 'desk.json'), 'utf8')).timer + assert.equal(t.by, 'ai') + assert.equal(t.sessionId, 'sess-A') +}) + +// 时间快进:把 endsAt 改到过去再 GET,宿主结账 → 提醒送进 stub 会话 +const deskPath = join(PROBE_HOME, 'study-desk', 'desk.json') +const rawDesk = JSON.parse(readFileSync(deskPath, 'utf8')) +rawDesk.timer.endsAt = Date.now() - 1000 +rawDesk.timer.startedAt = rawDesk.timer.endsAt - 25 * 60000 +writeFileSync(deskPath, JSON.stringify(rawDesk)) +const nudgeRes = fakeRes() +await route.handler({ method: 'GET', url: '/api/dsh-study-desk?op=state', headers: {}, on() {} }, nudgeRes) +check('专注走完 → 提醒以 user 消息送进起钟会话', () => { + assert.equal(agentStub.followups.length, 1) + const msg = agentStub.followups[0] + assert.equal(msg.role, 'user') + assert.match(msg.content[0].text, /番茄钟·自动提醒/) + assert.match(msg.content[0].text, /25 分钟/) + assert.equal(msg.source.kind, 'plugin') +}) +check('自动摆的休息继承会话标记(链条不断)', () => { + const t = JSON.parse(nudgeRes.body).state.timer + assert.equal(t.kind, 'break') + assert.equal(t.sessionId, 'sess-A') +}) + +// 关掉开关:休息走完不再提醒 +await post({ op: 'settings.update', patch: { notifySessionOnTimerEnd: false } }) +const rawDesk2 = JSON.parse(readFileSync(deskPath, 'utf8')) +rawDesk2.timer.endsAt = Date.now() - 1000 +writeFileSync(deskPath, JSON.stringify(rawDesk2)) +await route.handler({ method: 'GET', url: '/api/dsh-study-desk?op=state', headers: {}, on() {} }, fakeRes()) +check('开关关闭后不再提醒', () => assert.equal(agentStub.followups.length, 1)) + +// POST timer.start(客户端手动路径)不受门禁限制,也不带会话标记 +const pUser = await post({ op: 'timer.start', minutes: 5, name: '手动起钟' }) +check('手动起钟不需要挂卡', () => { + const body = JSON.parse(pUser.body) + assert.equal(body.ok, true) + assert.equal(body.state.timer.by, null) +}) +await post({ op: 'timer.reset' }) +await run('remove', { title: '门禁冒烟卡' }) + // ---- 复习 / 复盘 / Markdown const p7 = await post({ op: 'review.grade', title: 'POST 加的卡', grade: 'easy' }) check('POST review.grade 200', () => assert.equal(p7.code, 200)) diff --git a/assets/icon.svg b/assets/icon.svg new file mode 100644 index 0000000..9b66625 --- /dev/null +++ b/assets/icon.svg @@ -0,0 +1,23 @@ + + dsh-study-desk · 学习工作台 + + + + + + + + + + + + + + + + diff --git a/client.js b/client.js index db5ea91..24bd9f0 100644 --- a/client.js +++ b/client.js @@ -135,6 +135,22 @@ window.__ModuleLoader__.load({ return `${d} 天后` } + /** 快捷键只在「没在打字」时生效:输入框、下拉、可编辑元素里的按键是内容,不是指令。 */ + function isTypingTarget(e) { + const t = e && e.target + if (!t || !t.tagName) return false + const tag = String(t.tagName).toLowerCase() + return tag === 'input' || tag === 'textarea' || tag === 'select' || t.isContentEditable === true + } + + /** 时:分。当天明细里每一段专注的起点。 */ + function hhmm(ts) { + const d = new Date(Number(ts) || 0) + if (!Number.isFinite(d.getTime())) return '—' + const p = (n) => String(n).padStart(2, '0') + return `${p(d.getHours())}:${p(d.getMinutes())}` + } + // ----------------------------------------------------------------------- // 全局 store:宿主是唯一真相源,这里只缓存最近一次快照 // ----------------------------------------------------------------------- @@ -430,7 +446,18 @@ window.__ModuleLoader__.load({ 'aria-hidden': true, } const paths = { - board: ['M3 4.5h14v11H3z', 'M3 8.5h14', 'M7.5 8.5v7'], + board: ['M3 4.5h14v11H3z', 'M3 8.5h14', 'M7.6 8.5v7', 'M12.3 8.5v7', { d: 'M13.4 10.75h2.5v2.5h-2.5z', fill: true }], + // 工作台侧栏入口:任务卡 + 计时徽章(品牌款是番茄,侧栏这枚讲「卡上的时间」)。 + // 卡的右下角在徽章处断开口,避免两条描边打架。 + deskcard: [ + 'M13 3.5H6A1.5 1.5 0 0 0 4.5 5v10A1.5 1.5 0 0 0 6 16.5h4.9', + 'M14.5 9.2V5A1.5 1.5 0 0 0 13 3.5', + 'M7.5 7.5h4', + 'M7.5 10.5h3', + 'M18.3 13.5a4.3 4.3 0 1 1-8.6 0 4.3 4.3 0 0 1 8.6 0z', + 'M14 13.3v-2.1', + 'M14 13.5l1.7 1.2', + ], clock: ['M10 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13z', 'M10 6.5v4l2.5 1.5'], play: ['M6.5 4.8v10.4l8-5.2z'], pause: ['M7 4.8v10.4', 'M13 4.8v10.4'], @@ -448,10 +475,20 @@ window.__ModuleLoader__.load({ down: ['M10 3.5v9', 'M6.2 8.8 10 12.6l3.8-3.8', 'M4 16.5h12'], up: ['M10 12.5v-9', 'M6.2 7.2 10 3.4l3.8 3.8', 'M4 16.5h12'], tag: ['M10.6 3.5H16v5.4l-8 8-5.4-5.4z', 'M13.1 6.4h.01'], + // 词书打卡:本子 + 书脊 + 封面上的勾(原先是通用标签形,认不出「打卡」) + book: ['M5 3.5h10.5a1 1 0 0 1 1 1v11a1 1 0 0 1-1 1H5A1.5 1.5 0 0 1 3.5 15V5A1.5 1.5 0 0 1 5 3.5z', 'M6.5 3.5v13', 'M9.2 10.3l1.7 1.7 3-3.6'], search: ['M8.8 3.4a5.4 5.4 0 1 0 0 10.8 5.4 5.4 0 0 0 0-10.8z', 'M12.8 12.8l3.7 3.7'], } const p = paths[name] || paths.board - return h('svg', common, p.map((d, i) => h('path', { key: i, d }))) + // 路径项可以是字符串(描边)或 { d, fill: true }(填 currentColor,如看板里亮着的那格) + return h('svg', common, p.map((entry, i) => { + const item = typeof entry === 'string' ? { d: entry } : entry + return h('path', { + key: i, d: item.d, + fill: item.fill ? 'currentColor' : 'none', + stroke: item.fill ? 'none' : 'currentColor', + }) + })) } function Progress({ value, max, tone }) { @@ -597,7 +634,7 @@ window.__ModuleLoader__.load({ setNameDraft('') } - return h('section', { className: 'sd-timer-card' }, [ + return h('section', { className: 'sd-timer-card' + (timer && timer.running ? ' running' : '') }, [ !timer ? h('div', { className: 'sd-timer-name-row', key: 'nr' }, [ h('input', { key: 'n', @@ -699,6 +736,8 @@ window.__ModuleLoader__.load({ const settings = s.state.settings || {} const weeksCount = Number(settings.heatmapWeeks) || 26 const [range, setRange] = React.useState(weeksCount) + // 点了哪个格子(dayKey):展开那天的专注明细。原始 sessions 列表,不是聚合 —— 汇总仍归 desk.js。 + const [picked, setPicked] = React.useState(null) const byDay = React.useMemo(() => { const map = new Map() @@ -755,6 +794,12 @@ window.__ModuleLoader__.load({ const template = `22px repeat(${grid.cols.length}, 13px)` + const pickedRows = picked + ? s.state.sessions + .filter((x) => x.kind === 'focus' && dayKey(x.at) === picked) + .sort((a, b) => a.at - b.at) + : [] + return h('section', { className: 'sd-card-block' }, [ h('div', { className: 'sd-block-head', key: 'h' }, [ h('h2', { className: 'sd-block-title', key: 't' }, '学习热力图'), @@ -782,8 +827,26 @@ window.__ModuleLoader__.load({ className: 'sd-cell' + (d ? ' lv' + d.level + (d.key === dayKey(now) ? ' today' : '') : ' empty'), style: { gridColumn: wi + 2, gridRow: di + 2 }, title: d ? `${d.key} · 专注 ${d.minutes} 分钟${d.count ? ` · ${d.count} 段` : ''}` : '', + onClick: d ? () => setPicked((cur) => (cur === d.key ? null : d.key)) : undefined, }))), ])), + picked + ? h('div', { className: 'sd-heat-day-detail', key: 'detail' }, [ + h('div', { className: 'sd-heat-day-head', key: 'h' }, [ + h('b', { key: 'd' }, picked + (picked === dayKey(now) ? '(今天)' : '')), + h('span', { className: 'sd-block-sub', key: 's' }, pickedRows.length + ? `${pickedRows.length} 段 · 共 ${fmtMinutes(pickedRows.reduce((m, x) => m + x.minutes, 0))}` + : '这天没有专注记录'), + h('button', { className: 'sd-plan-toggle', key: 'x', onClick: () => setPicked(null) }, '收起'), + ]), + ...pickedRows.map((x) => h('div', { className: 'sd-heat-day-row', key: x.id }, [ + h('span', { className: 'sd-heat-day-time', key: 't' }, hhmm(x.at)), + h('span', { className: 'sd-heat-day-name', key: 'n', title: x.title || x.subject || '' }, + x.title || x.subject || '未命名'), + h('span', { className: 'sd-heat-day-min', key: 'm' }, fmtShort(x.minutes)), + ])), + ]) + : null, h('div', { className: 'sd-heat-foot', key: 'f' }, [ h('span', { className: 'sd-heat-note', key: 'n' }, `点亮条件:当天实际复习满 1 分钟;越亮 = 越接近每日 ${fmtMinutes(goal)} 的目标`), @@ -1093,6 +1156,7 @@ window.__ModuleLoader__.load({ note: task.note || '', subject: task.subject || '', estimateMin: task.estimateMin || 0, + dueDate: task.dueDate || '', }) setEditing(true) } @@ -1105,6 +1169,7 @@ window.__ModuleLoader__.load({ if (draft.subject !== (task.subject || '')) patch.subject = draft.subject const est = Math.max(0, Math.floor(Number(draft.estimateMin) || 0)) if (est !== (task.estimateMin || 0)) patch.estimateMin = est + if ((draft.dueDate || '') !== (task.dueDate || '')) patch.dueDate = draft.dueDate || null setEditing(false) setDraft(null) if (Object.keys(patch).length) onUpdate && onUpdate(task.id, patch) @@ -1128,6 +1193,7 @@ window.__ModuleLoader__.load({ field('title', '标题', { autoFocus: true }), field('subject', '科目'), field('estimateMin', '预计分钟', { type: 'number', min: 0 }), + field('dueDate', '截止', { type: 'date' }), h('label', { className: 'sd-edit-row', key: 'note' }, [ h('span', { className: 'sd-edit-label', key: 'l' }, '备注'), h('textarea', { @@ -1175,6 +1241,10 @@ window.__ModuleLoader__.load({ : null, h('div', { className: 'sd-card-foot', key: 'foot' }, [ task.subject ? h('span', { className: 'sd-tag', key: 's' }, task.subject) : null, + task.dueDate ? h('span', { + className: 'sd-due' + (daysUntil(task.dueDate, Date.now()) <= 0 ? ' late' : ''), + key: 'dd', title: `截止 ${task.dueDate}`, + }, `⏰ ${dueLabel(task.dueDate)}`) : null, spent ? h('span', { className: 'sd-spent', key: 'm', title: '累计投入' }, fmtShort(spent)) : null, task.estimateMin && !spent ? h('span', { className: 'sd-est', key: 'e', title: '预计' }, '~' + fmtShort(task.estimateMin)) : null, scheduled @@ -1217,24 +1287,32 @@ window.__ModuleLoader__.load({ ]) } - function Composer({ status, subjects, onAdd }) { + function Composer({ status, subjects, onAdd, openSignal }) { const [open, setOpen] = React.useState(false) const [text, setText] = React.useState('') const [subject, setSubject] = React.useState('') const ref = React.useRef(null) React.useEffect(() => { if (open && ref.current) ref.current.focus() }, [open]) + // 快捷键 n 叫它开:openSignal 从 0 起跳,只在变大时拉开(收起由自己的 Escape/取消管) + React.useEffect(() => { if (openSignal) setOpen(true) }, [openSignal]) const commit = () => { const raw = text.trim() if (!raw) { setOpen(false); return } - // Obsidian 肌肉记忆:正文里写 #标签 就直接落成标签 + // Obsidian 肌肉记忆:正文里写 #标签 就直接落成标签;@YYYY-MM-DD 设截止日期 const tags = [] + let dueDate = '' const title = raw.replace(/(?:^|\s)#([^\s#,,、]{1,24})/g, (m, tag) => { tags.push(tag); return '' }) + .replace(/(?:^|\s)@(\d{4}-\d{2}-\d{2})(?=\s|$)/g, (m, d) => { dueDate = d; return '' }) .replace(/\s+/g, ' ') .trim() - if (!title) { onAdd({ title: tags.join(' · '), subject, status }); setText(''); return } - onAdd({ title, subject, status, tags }) + if (!title) { + onAdd({ title: tags.join(' · ') || (dueDate ? '截止 ' + dueDate : ''), subject, status, dueDate: dueDate || undefined }) + setText('') + return + } + onAdd({ title, subject, status, tags, dueDate: dueDate || undefined }) setText('') if (ref.current) ref.current.focus() } @@ -1273,7 +1351,7 @@ window.__ModuleLoader__.load({ ]) } - function Column({ meta, tasks, total, minutes, subjects, hover, setHover, confirmId, setConfirmId, dragging, setDragging, onMove, onDelete, onUpdate, onAdd, onToggleReview, onPickTag, activeTags, filtered }) { + function Column({ meta, tasks, total, minutes, subjects, hover, setHover, confirmId, setConfirmId, dragging, setDragging, onMove, onDelete, onUpdate, onAdd, onToggleReview, onPickTag, activeTags, filtered, openSignal }) { return h('section', { className: 'sd-col' + (hover === meta.id ? ' hover' : ''), onDragOver: (e) => { e.preventDefault(); setHover(meta.id) }, @@ -1287,6 +1365,7 @@ window.__ModuleLoader__.load({ }, }, [ h('div', { className: 'sd-col-head', key: 'h' }, [ + h('span', { className: 'sd-col-dot sd-col-dot-' + meta.id, key: 'd' }), h('span', { className: 'sd-col-latin', key: 'l' }, meta.latin), h('span', { className: 'sd-col-label', key: 'n' }, meta.label), h('span', { className: 'sd-col-count', key: 'c' }, filtered ? `${tasks.length}/${total}` : String(tasks.length)), @@ -1311,7 +1390,10 @@ window.__ModuleLoader__.load({ activeTags, })), tasks.length ? null : h('div', { className: 'sd-col-empty', key: 'e' }, filtered ? '这一列没有符合过滤条件的卡' : (meta.id === 'done' ? '还没有完成的卡' : '空的')), - h(Composer, { key: 'composer', status: meta.id, subjects, onAdd }), + meta.id === 'done' && !filtered && total > tasks.length + ? h('div', { className: 'sd-col-more', key: 'more' }, `只摆最近 30 张 · 共 ${total} 张`) + : null, + h(Composer, { key: 'composer', status: meta.id, subjects, onAdd, openSignal }), ]), ]) } @@ -1325,9 +1407,30 @@ window.__ModuleLoader__.load({ const [tags, setTags] = React.useState([]) const [dueOnly, setDueOnly] = React.useState(false) const [q, setQ] = React.useState('') + // 快捷键 n 的信号:从 0 起跳,只发给待办列的 Composer + const [composeTick, setComposeTick] = React.useState(0) const subjects = (s.state.settings && s.state.settings.subjects) || [] const allTags = s.tags || [] + // 键盘流:/ 聚焦搜索,n 打开待办列的新建框。 + // 打字的时候这些是字符不是指令,isTypingTarget 把输入中的情况全挡掉。 + React.useEffect(() => { + const onKey = (e) => { + if (e.metaKey || e.ctrlKey || e.altKey) return + if (isTypingTarget(e)) return + if (document.querySelector('.sd-focus-overlay')) return // 过卡模式开着时,键盘是它的 + if (e.key === '/') { + const el = document.querySelector('.sd-search-input') + if (el) { e.preventDefault(); el.focus() } + } else if (e.key === 'n' || e.key === 'N') { + e.preventDefault() + setComposeTick((v) => v + 1) + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + const move = (id, status, beforeId) => store.apply('task.move', { id, status, beforeId: beforeId || undefined }) const del = (id) => store.apply('task.delete', { id }) const update = (id, patch) => store.apply('task.update', { id, patch }) @@ -1413,6 +1516,7 @@ window.__ModuleLoader__.load({ onToggleReview: toggleReview, onPickTag: pickTag, activeTags: tags, + openSignal: meta.id === 'todo' ? composeTick : 0, }) })), ]) @@ -1676,6 +1780,81 @@ window.__ModuleLoader__.load({ // 今日复习队列 // ----------------------------------------------------------------------- + // ----------------------------------------------------------------------- + // 过卡模式:全屏一次一张。备注默认藏着(回背类卡先自己回想),空格/点击翻开。 + // 键盘监听挂在浮层元素上并 stopPropagation —— Board 的 / 与 n、队列的 1/2/3 都在 + // window 上,事件从浮层冒到 window 之前就会被拦下,不会两头都打分。 + // ----------------------------------------------------------------------- + + function FocusReview({ session, setSession, onClose, onGrade }) { + const cur = session.items[session.i] || null + const ref = React.useRef(null) + React.useEffect(() => { if (ref.current && ref.current.focus) ref.current.focus() }, []) + + const advance = () => setSession((f) => ({ ...f, i: f.i + 1, revealed: false })) + const reveal = () => setSession((f) => ({ ...f, revealed: true })) + const grade = (g) => { if (cur) onGrade(cur.id, g); advance() } + + const onKeyDown = (e) => { + e.stopPropagation() + if (e.key === 'Escape') { e.preventDefault(); onClose(); return } + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault() + if (!session.revealed) reveal() + return + } + if (e.key === 'ArrowRight') { e.preventDefault(); advance(); return } + const g = { 1: 'again', 2: 'good', 3: 'easy' }[e.key] + if (g) { e.preventDefault(); grade(g) } + } + + const done = !cur + return toTopLayer(h('div', { + className: 'sd-focus-overlay', ref, tabIndex: 0, onKeyDown, + role: 'dialog', 'aria-label': '复习过卡模式', + }, [ + h('div', { className: 'sd-focus-card', key: 'c' }, done + ? [ + h('div', { className: 'sd-focus-title', key: 't' }, '这一批过完了'), + h('div', { className: 'sd-focus-meta', key: 'm' }, `共 ${session.items.length} 张。打分记录都进了排期,明天见。`), + h('div', { className: 'sd-focus-acts', key: 'a' }, [ + h('button', { key: 'ok', className: 'sd-textbtn primary', onClick: onClose }, '完成'), + h('button', { key: 'more', className: 'sd-textbtn', onClick: () => setSession({ items: session.items, i: 0, revealed: false }) }, '再过一遍'), + ]), + ] + : [ + h('div', { className: 'sd-focus-progress', key: 'p' }, + `${session.i + 1} / ${session.items.length} · 空格 显示备注 · → 跳过 · Esc 退出`), + h('div', { className: 'sd-focus-title', key: 't' }, cur.title), + h('div', { className: 'sd-focus-meta', key: 'm' }, [ + cur.subject ? h('span', { className: 'sd-tag', key: 's' }, cur.subject) : null, + h('span', { className: 'sd-due', key: 'd' }, dueLabel(cur.review.due)), + h('span', { className: 'sd-review-cap', key: 'r' }, + `第 ${cur.review.reps + 1} 遍 · 上次间隔 ${cur.review.intervalDays} 天 · 难度 ${cur.review.ease}`), + cur.curve ? h('span', { className: 'sd-review-curve', key: 'cv' }, [ + h(MemoryCurve, { key: 'g', curve: cur.curve, width: 66, height: 20 }), + h('span', { className: 'sd-review-mem', key: 'mem' }, `记忆 ${cur.curve.todayRetention}%`), + ]) : null, + ]), + cur.note + ? (session.revealed + ? h('div', { className: 'sd-focus-note', key: 'n' }, cur.note) + : h('button', { className: 'sd-textbtn', key: 'n', onClick: reveal }, '显示备注(空格)')) + : null, + h('div', { className: 'sd-focus-acts', key: 'a' }, [ + ...GRADES.map((g) => h('button', { + key: g, className: 'sd-grade lg ' + g, + title: g === 'again' ? '忘了:间隔清零,今天之内再来一遍(快捷键 1)' + : g === 'easy' ? '很简单:间隔拉长一点(快捷键 3)' + : '记得:按难度推进间隔(快捷键 2)', + onClick: () => grade(g), + }, GRADE_LABEL[g])), + h('button', { key: 'skip', className: 'sd-textbtn', title: '先放着,等下再说(快捷键 →)', onClick: advance }, '跳过 →'), + ]), + ]), + ])) + } + function ReviewQueue() { const s = useStore() const st = s.review || {} @@ -1685,9 +1864,35 @@ window.__ModuleLoader__.load({ setBusy(id) Promise.resolve(store.apply('review.grade', { id, grade: g })).finally(() => setBusy(null)) } + // 缓一天:排期推到明天,难度/遍数/间隔原样保留(review.schedule 本来就认任意 due) + const snooze = (id) => { + setBusy(id) + Promise.resolve(store.apply('review.schedule', { id, due: dayKey(addDays(Date.now(), 1)) })) + .finally(() => setBusy(null)) + } const limit = Number(s.state.settings.reviewQueueSize) || 10 const shown = queue.slice(0, limit) + // 1/2/3 给队首那张打分:队列顺序就是 desk.js 排好的「最该先过」。 + // queue 的引用只在快照到达时才换,所以这条监听不会跟着 500ms 的钟摆重挂。 + React.useEffect(() => { + const onKey = (e) => { + if (e.metaKey || e.ctrlKey || e.altKey) return + if (isTypingTarget(e)) return + if (document.querySelector('.sd-focus-overlay')) return // 过卡模式开着时,按键归它自己管 + const g = { 1: 'again', 2: 'good', 3: 'easy' }[e.key] + if (!g || busy) return + const first = queue.slice(0, limit)[0] + if (first) grade(first.id, g) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [queue, limit, busy]) + + // 过卡模式:一次一张全屏过,session 在进入那一刻定住队列(打分后快照会变,不能跟着漂) + const [focus, setFocus] = React.useState(null) + const startFocus = () => setFocus({ items: queue.slice(), i: 0, revealed: false }) + return h('section', { className: 'sd-card-block sd-review' }, [ h('div', { className: 'sd-block-head', key: 'h' }, [ h('h2', { className: 'sd-block-title', key: 't' }, [ @@ -1695,8 +1900,17 @@ window.__ModuleLoader__.load({ h('span', { key: 'x' }, ' 今日复习'), ]), h('span', { className: 'sd-block-sub', key: 's' }, st.scheduled - ? `排期 ${st.scheduled} 张 · 待过 ${st.due}(逾期 ${st.overdue})· 今天已打分 ${st.doneToday} 次 · 本周 ${st.weekDone} 次` + ? `排期 ${st.scheduled} 张 · 待过 ${st.due}(逾期 ${st.overdue})· 今天已打分 ${st.doneToday} 次 · 本周 ${st.weekDone} 次${shown.length ? ' · 按 1/2/3 过第一张' : ''}` : '还没有卡进入复习循环'), + shown.length + ? h('div', { className: 'sd-row sd-row-end', key: 'b' }, [ + h('button', { + className: 'sd-pill sm', key: 'f', + title: '一张一张过大卡:1/2/3 打分,空格显示备注,→ 跳过,Esc 退出', + onClick: startFocus, + }, '过卡模式'), + ]) + : null, ]), h(PlanStrip, { key: 'p' }), shown.length @@ -1717,15 +1931,22 @@ window.__ModuleLoader__.load({ h('span', { className: 'sd-review-cap', key: 'r' }, `第 ${t.review.reps + 1} 遍 · 上次间隔 ${t.review.intervalDays} 天 · 难度 ${t.review.ease}`), ]), ]), - h('div', { className: 'sd-review-acts', key: 'c' }, GRADES.map((g) => h('button', { - key: g, - className: 'sd-grade ' + g, - disabled: busy === t.id, - title: g === 'again' ? '忘了:间隔清零,今天之内再来一遍' - : g === 'easy' ? '很简单:间隔拉长一点' - : '记得:按难度推进间隔', - onClick: () => grade(t.id, g), - }, GRADE_LABEL[g]))), + h('div', { className: 'sd-review-acts', key: 'c' }, [ + ...GRADES.map((g) => h('button', { + key: g, + className: 'sd-grade ' + g, + disabled: busy === t.id, + title: g === 'again' ? '忘了:间隔清零,今天之内再来一遍' + : g === 'easy' ? '很简单:间隔拉长一点' + : '记得:按难度推进间隔', + onClick: () => grade(t.id, g), + }, GRADE_LABEL[g])), + h('button', { + key: 'snooze', className: 'sd-textbtn sm', disabled: busy === t.id, + title: '今天不想过它:排期改到明天,难度与间隔原样保留', + onClick: () => snooze(t.id), + }, '缓一天'), + ]), ]))) : h('div', { className: 'sd-empty-block', key: 'e' }, st.scheduled ? '今天该复习的都过完了。想加练就把更多卡拉进复习循环(卡片右下角的 ↻)。' @@ -1735,6 +1956,10 @@ window.__ModuleLoader__.load({ queue.length > shown.length ? h('div', { className: 'sd-review-more', key: 'm' }, `队列里还有 ${queue.length - shown.length} 张没摆出来(每次摆几张在设置页里调)`) : null, + focus ? h(FocusReview, { + key: 'focus', session: focus, setSession: setFocus, + onClose: () => setFocus(null), onGrade: grade, + }) : null, ]) } @@ -1748,6 +1973,23 @@ window.__ModuleLoader__.load({ const goal = (s.weekly && s.weekly.goalWeekly) || 0 const max = Math.max(goal, ...weeks.map((w) => w.minutes), 60) const cur = weeks[weeks.length - 1] + // 复制周报:文本由宿主的 weeklyText 生成(和模型看到的同一份),这里只管进剪贴板 + const [copied, setCopied] = React.useState('') + const copy = async () => { + try { + const res = await fetch(API, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ op: 'weekly.text' }), + }) + const json = await res.json() + if (!json || !json.text) throw new Error((json && json.error) || '没拿到周报文本') + await navigator.clipboard.writeText(json.text) + setCopied('已复制,去 Obsidian 粘贴即可') + } catch (error) { + setCopied('复制失败:' + String((error && error.message) || error)) + } + setTimeout(() => setCopied(''), 3000) + } return h('section', { className: 'sd-card-block' }, [ h('div', { className: 'sd-block-head', key: 'h' }, [ @@ -1755,6 +1997,10 @@ window.__ModuleLoader__.load({ h('span', { className: 'sd-block-sub', key: 's' }, cur ? `本周 ${fmtMinutes(cur.minutes)}${goal ? ` / 目标 ${fmtMinutes(goal)}` : ''}${cur.deltaPct === null ? '' : ` · 比上周${cur.deltaPct >= 0 ? '+' : ''}${cur.deltaPct}%`}` : '还没有记录'), + weeks.length ? h('div', { className: 'sd-row sd-row-end', key: 'c' }, [ + copied ? h('span', { className: 'sd-block-sub', key: 'n' }, copied) : null, + h('button', { key: 'b', className: 'sd-textbtn sm', onClick: copy }, '复制周报'), + ]) : null, ]), weeks.length ? h('div', { className: 'sd-weeks', key: 'w' }, weeks.map((w) => { @@ -1796,6 +2042,25 @@ window.__ModuleLoader__.load({ React.useEffect(() => { setDraft(saved) }, [today]) const save = () => store.apply('journal.set', { text: draft, date: today }) + + // 草稿曾经只活在组件状态里:切去单词打卡面板再回来就没了。现在停手 2 秒自动落盘, + // 卸载(换面板)那一刻再把没存上的部分补存一次;手动保存与撤销按钮照旧。 + const latest = React.useRef(null) + latest.current = { draft, saved, today } + React.useEffect(() => { + if (!dirty) return + const id = setTimeout(() => { + const cur = latest.current + store.apply('journal.set', { text: cur.draft, date: cur.today }) + }, 2000) + return () => clearTimeout(id) + }, [draft, saved]) + React.useEffect(() => () => { + const cur = latest.current + if (cur && cur.draft !== cur.saved) { + store.apply('journal.set', { text: cur.draft, date: cur.today }) + } + }, []) const recent = (s.journalLog || []).filter((x) => x.date !== today).slice(0, 6) return h('section', { className: 'sd-card-block sd-journal' }, [ @@ -1823,7 +2088,7 @@ window.__ModuleLoader__.load({ className: 'sd-textbtn primary', disabled: !dirty, onClick: save, }, dirty ? '保存' : '已保存'), dirty ? h('button', { className: 'sd-textbtn', onClick: () => setDraft(saved) }, '撤销') : null, - h('span', { className: 'sd-block-sub', key: 'h' }, 'Ctrl+回车保存'), + h('span', { className: 'sd-block-sub', key: 'h' }, '停手 2 秒自动存 · Ctrl+回车立即存'), ]), recent.length ? h('div', { className: 'sd-journal-list', key: 'l' }, recent.map((x) => h('div', { className: 'sd-journal-item', key: x.date }, [ @@ -1853,7 +2118,7 @@ window.__ModuleLoader__.load({ h(ReviewQueue, { key: 'review' }), h('div', { className: 'sd-section-title', key: 'st' }, [ h('span', { key: 'a' }, '待办墙'), - h('span', { className: 'sd-section-hint', key: 'b' }, '拖动卡片换列 · 输入 #标签 直接打标 · ↻ 把卡拉进复习循环'), + h('span', { className: 'sd-section-hint', key: 'b' }, '拖动卡片换列 · #标签 直接打标 · @YYYY-MM-DD 设截止 · ↻ 拉进复习循环 · / 搜索 · n 新建'), ]), h(Board, { key: 'board' }), h('div', { className: 'sd-two', key: 'two' }, [ @@ -1923,6 +2188,10 @@ window.__ModuleLoader__.load({ // 悬浮卡片被谁盖住,在浏览器里根本看不见:拿命中测试结果回传宿主写盘。 // reportDiag 按 kind+where+message 去重,所以反复探也不会把诊断文件灌爆。 + // 被盖分两档:盖的人住在宿主模态层(z≥1000:模态框 1000、菜单/气泡 1100)里, + // 是迷你条「压在模态下」这条设计取舍的必然结果,不算事故 —— 单独记 mini-under-modal; + // 普通内容盖上来才是真回归,继续记 mini-covered。2026-11-05 那次误报就是设置页 + // (z≥1000)的账号卡片盖住了它,翻诊断时差点当 bug 修。 function probeOverlayHit(el) { if (!el || typeof document.elementFromPoint !== 'function') return const rect = el.getBoundingClientRect() @@ -1932,13 +2201,24 @@ window.__ModuleLoader__.load({ const top = document.elementFromPoint(cx, cy) if (!top || top === el || el.contains(top)) return const chain = [] - for (let node = top; node && chain.length < 3; node = node.parentElement) { - const cls = typeof node.className === 'string' && node.className.trim() - ? '.' + node.className.trim().split(/\s+/).slice(0, 2).join('.') - : '' - chain.push(node.tagName.toLowerCase() + cls) + let modalZ = 0 + for (let node = top, i = 0; node && node !== document.body; node = node.parentElement, i += 1) { + if (i < 3) { + const cls = typeof node.className === 'string' && node.className.trim() + ? '.' + node.className.trim().split(/\s+/).slice(0, 2).join('.') + : '' + chain.push(node.tagName.toLowerCase() + cls) + } + if (!modalZ) { + const z = Number(window.getComputedStyle(node).zIndex) + if (Number.isFinite(z) && z >= 1000) modalZ = z + } } - reportDiag({ kind: 'mini-covered', where: 'mini.命中测试', message: `中心 ${cx},${cy} 命中的是 ${chain.join(' < ')}` }) + reportDiag({ + kind: modalZ ? 'mini-under-modal' : 'mini-covered', + where: 'mini.命中测试', + message: `中心 ${cx},${cy} 命中的是 ${chain.join(' < ')}` + (modalZ ? `(宿主模态层 z=${modalZ},设计内让位)` : ''), + }) } function MiniBar() { @@ -2204,6 +2484,46 @@ window.__ModuleLoader__.load({ ]) } + /** + * 科目编辑:settings.subjects 是 [{id,label}],每次改动整份随 settings.update 提交。 + * 宿主侧(desk.js updateSettings)对 subjects 的规矩是「空列表不动旧值」, + * 所以 UI 上最后一个科目不许删 —— 不然她以为删掉了,desk.json 里还在。 + */ + function SubjectsField({ value, onCommit }) { + const list = Array.isArray(value) ? value : [] + const [draft, setDraft] = React.useState('') + const add = () => { + const label = draft.trim().slice(0, 24) + if (!label || list.some((x) => x.label === label)) { setDraft(''); return } + onCommit([...list, { id: 's' + Date.now().toString(36), label }]) + setDraft('') + } + const remove = (id) => onCommit(list.filter((x) => x.id !== id)) + return h('div', { className: 'sd-field' }, [ + h('span', { className: 'sd-field-label', key: 'l' }, '科目'), + h('div', { className: 'sd-row', key: 'r' }, [ + ...list.map((x) => h('span', { key: x.id, className: 'sd-subj-chip' }, [ + h('span', { key: 'n' }, x.label), + h('button', { + key: 'x', className: 'sd-subj-x', disabled: list.length <= 1, + title: list.length <= 1 + ? '至少留一个科目' + : '从科目列表移除(已写在卡上的科目名不动,只是下拉和过滤里不再出现)', + onClick: () => remove(x.id), + }, '×'), + ])), + h('input', { + key: 'i', className: 'sd-input', type: 'text', value: draft, + placeholder: '加一个科目,回车确认', + onChange: (e) => setDraft(e.target.value), + onKeyDown: (e) => { if (e.key === 'Enter') { e.preventDefault(); add() } }, + }), + h('button', { key: 'a', className: 'sd-textbtn', disabled: !draft.trim(), onClick: add }, '添加'), + ]), + h('span', { className: 'sd-field-hint', key: 'h' }, '科目用在卡片、过滤栏和近 30 天统计里;重名或空的会被忽略'), + ]) + } + function Settings() { const s = useStore() usePoll(60000) @@ -2223,6 +2543,7 @@ window.__ModuleLoader__.load({ hint: '格式:名称 2026-10-15~2026-10-24;日期拿不准就在行尾加「约」。坏行会被忽略,不会报错。', value: st.milestonesText || '', onCommit: (v) => patch({ milestonesText: v }), }), + h(SubjectsField, { key: 'subjects', value: st.subjects, onCommit: (list) => patch({ subjects: list }) }), h('div', { className: 'sd-settings-grid', key: 'g' }, [ h(NumField, { key: 'goal', label: '每日目标(分钟)', value: st.dailyGoalMin || 180, min: 10, max: 1440, onCommit: (v) => patch({ dailyGoalMin: v }) }), h(NumField, { key: 'focus', label: '专注时长(分钟)', value: st.focusMin || 25, min: 1, max: 240, onCommit: (v) => patch({ focusMin: v }) }), @@ -2240,6 +2561,14 @@ window.__ModuleLoader__.load({ h('span', { key: 'l' }, '每轮对话把「今天该做什么、该复习什么、昨天卡在哪」告诉模型'), h('span', { className: 'sd-field-hint', key: 'h' }, '关掉能省一点上下文'), ]), + h('label', { className: 'sd-field sd-field-inline', key: 'nfy' }, [ + h('input', { + key: 'c', type: 'checkbox', checked: st.notifySessionOnTimerEnd !== false, + onChange: (e) => patch({ notifySessionOnTimerEnd: e.target.checked }), + }), + h('span', { key: 'l' }, 'AI 起的番茄钟走完后,往开钟的那个会话发一条自动提醒(督促用)'), + h('span', { className: 'sd-field-hint', key: 'h' }, '手动起的钟不提醒;会话已关闭时不补发'), + ]), h('div', { className: 'sd-settings-file', key: 'f' }, `数据文件:${s.deskFile || '—'}`), h('div', { className: 'sd-settings-file', key: 'f2' }, `Markdown:${s.markdownFile || '(还没导出过)'} —— 把它软链进 Obsidian vault 就能当笔记改,改完在工作台点「从 Markdown 读回」`), @@ -2269,7 +2598,7 @@ window.__ModuleLoader__.load({ '.sd-head-main{display:flex;flex-direction:column;gap:4px}', '.sd-title{margin:0;font-size:var(--sd-fs-num);font-weight:600;letter-spacing:.01em;font-family:var(--dsw-font-family)}', '.sd-subtitle{font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-tertiary);letter-spacing:.02em}', - '.sd-count{display:flex;align-items:baseline;gap:4px;padding:6px 14px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:var(--dsw-alias-bg-layer-1)}', + '.sd-count{display:flex;align-items:baseline;gap:4px;padding:6px 14px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 22%,var(--dsw-alias-border-l1));border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 6%,var(--dsw-alias-bg-layer-1))}', '.sd-count-num{font-size:var(--sd-fs-num);font-weight:600;line-height:1;font-variant-numeric:tabular-nums;font-family:var(--dsw-font-family-brand);color:var(--dsw-alias-brand-primary)}', '.sd-count-cap{font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-secondary)}', '.sd-count-label{font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-tertiary);margin-left:6px;letter-spacing:.04em}', @@ -2294,7 +2623,9 @@ window.__ModuleLoader__.load({ // 今日进度条 = 今天已投入的分钟数,跟热力图/周报同一个绿;计时器那条继续走品牌色(语义不同) '.sd-bar-study .sd-bar-fill{background:var(--dsw-static-green-500)}', // 计时卡 - '.sd-timer-card{border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius);background:var(--dsw-alias-bg-layer-1);padding:16px 18px;display:flex;flex-direction:column;gap:12px;box-shadow:0 1px 2px rgba(0,0,0,.05)}', + '.sd-timer-card{border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius);background:var(--dsw-alias-bg-layer-1);padding:16px 18px;display:flex;flex-direction:column;gap:12px;box-shadow:0 1px 2px rgba(0,0,0,.05);transition:border-color .2s ease}', + // 走钟时卡沿淡淡描一圈品牌色:余光扫一眼就知道钟在跑,不用去读数字 + '.sd-timer-card.running{border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 32%,var(--dsw-alias-border-l1))}', '.sd-timer-name-row{display:flex;align-items:center;gap:7px;flex-wrap:wrap}', '.sd-timer-name-row .sd-input.wide{flex:1;min-width:12em}', '.sd-name-empty::placeholder{color:var(--dsw-alias-label-dimmed)}', @@ -2309,15 +2640,18 @@ window.__ModuleLoader__.load({ '.sd-timer-task{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40ch}', '.sd-timer-sub{color:var(--dsw-alias-label-tertiary)}', '.sd-timer-bar{margin-top:2px}', + '.sd-timer-bar .sd-bar,.sd-today-bar .sd-bar{height:6px}', '.sd-timer-side{display:flex;align-items:center;gap:6px;flex:none}', '.sd-timer-foot{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:10px;flex-wrap:wrap}', // 通用控件 '.sd-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}', '.sd-row-end{margin-left:auto}', '.sd-mini-cap{font-size:var(--sd-fs-micro);letter-spacing:.06em;color:var(--dsw-alias-label-tertiary)}', - '.sd-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);cursor:pointer;flex:none;transition:border-color .12s ease,color .12s ease,background .12s ease}', + '.sd-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);cursor:pointer;flex:none;transition:border-color .12s ease,color .12s ease,background .12s ease,transform .12s ease}', '.sd-iconbtn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}', + '.sd-iconbtn:active{transform:scale(.94)}', '.sd-iconbtn-lg{width:42px;height:42px;border-radius:12px;color:var(--dsw-alias-brand-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 35%,transparent)}', + '.sd-iconbtn-lg:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 10%,transparent)}', '.sd-iconbtn.sm{width:22px;height:22px;border-radius:6px}', '.sd-textbtn{border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);font-size:var(--sd-fs-cap);padding:3px 10px;border-radius:7px;cursor:pointer;transition:color .12s ease,border-color .12s ease}', '.sd-textbtn:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}', @@ -2328,9 +2662,10 @@ window.__ModuleLoader__.load({ '.sd-pill.sm{font-size:var(--sd-fs-micro);padding:2px 8px}', '.sd-select{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:7px;padding:3px 8px;font-size:var(--sd-fs-cap);max-width:200px;outline:none}', '.sd-select:disabled{opacity:.55}', + '.sd-select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}', '.sd-select.sm{font-size:var(--sd-fs-cap)}', '.sd-input{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:7px;padding:4px 9px;font-size:var(--sd-fs-body);width:88px;outline:none}', - '.sd-input:focus{border-color:var(--dsw-alias-brand-primary)}', + '.sd-input:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent)}', '.sd-input.wide{width:100%;min-width:0}', // 分区标题:层级靠字重和留白建立,不靠画线 '.sd-section-title{display:flex;align-items:baseline;gap:10px;font-size:var(--sd-fs-cap);font-weight:600;letter-spacing:.16em;color:var(--dsw-alias-label-tertiary)}', @@ -2338,7 +2673,8 @@ window.__ModuleLoader__.load({ // 看板 '.sd-boardwrap{display:flex;flex-direction:column;gap:10px}', '.sd-filters{display:flex;align-items:center;gap:8px;flex-wrap:wrap}', - '.sd-search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px 10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-tertiary)}', + '.sd-search{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:3px 10px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-tertiary);transition:border-color .12s ease,box-shadow .12s ease}', + '.sd-search:focus-within{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent)}', '.sd-search-input{border:none;outline:none;background:transparent;color:var(--dsw-alias-label-primary);font-size:var(--sd-fs-cap);width:170px}', '.sd-tagrow{display:inline-flex;align-items:center;gap:5px;flex-wrap:wrap}', '.sd-tagchip{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:var(--sd-fs-micro);padding:1px 8px;border-radius:999px;cursor:pointer;transition:color .12s ease,border-color .12s ease,background .12s ease}', @@ -2359,14 +2695,20 @@ window.__ModuleLoader__.load({ '.sd-col{border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius);background:var(--dsw-alias-bg-layer-1);padding:12px;display:flex;flex-direction:column;gap:var(--sd-gap-row);min-height:120px;transition:border-color .15s ease,background .15s ease}', '.sd-col.hover{border-color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 5%,var(--dsw-alias-bg-layer-1))}', '.sd-col-head{display:flex;align-items:center;gap:7px;padding:2px 4px 4px}', + // 列头状态点:待办=琥珀(欠着的债,与到期预报同族)、进行中=蓝(分类第一色)、完成=绿(已投入) + '.sd-col-dot{width:7px;height:7px;border-radius:50%;flex:none}', + '.sd-col-dot-todo{background:var(--dsw-static-amber-500)}', + '.sd-col-dot-doing{background:var(--dsw-static-deepseek-500)}', + '.sd-col-dot-done{background:var(--dsw-static-green-500)}', + '.sd-col-more{font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-dimmed);text-align:center}', '.sd-col-latin{font-size:var(--sd-fs-tick);letter-spacing:.16em;color:var(--dsw-alias-label-dimmed)}', '.sd-col-label{font-size:var(--sd-fs-body);font-weight:600;color:var(--dsw-alias-label-secondary)}', '.sd-col-count{margin-left:auto;font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;background:var(--dsw-alias-bg-layer-2);border-radius:999px;padding:1px 8px}', '.sd-col-body{display:flex;flex-direction:column;gap:8px}', '.sd-col-empty{font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-dimmed);padding:10px 6px;text-align:center}', // 卡片 - '.sd-card{border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius-sm);background:var(--dsw-alias-bg-base);padding:10px 12px;display:flex;flex-direction:column;gap:6px;cursor:grab;transition:border-color .12s ease,box-shadow .12s ease,opacity .12s ease}', - '.sd-card:hover{border-color:var(--dsw-alias-border-l3);box-shadow:0 1px 4px rgba(0,0,0,.07)}', + '.sd-card{border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius-sm);background:var(--dsw-alias-bg-base);padding:10px 12px;display:flex;flex-direction:column;gap:6px;cursor:grab;transition:border-color .12s ease,box-shadow .12s ease,opacity .12s ease,transform .12s ease}', + '.sd-card:hover{border-color:var(--dsw-alias-border-l3);box-shadow:0 1px 4px rgba(0,0,0,.07);transform:translateY(-1px)}', '.sd-card.dragging{opacity:.45}', '.sd-card.done{opacity:.68}', '.sd-card.done .sd-card-title{text-decoration:line-through;color:var(--dsw-alias-label-tertiary)}', @@ -2386,7 +2728,7 @@ window.__ModuleLoader__.load({ '.sd-mini:hover{box-shadow:0 10px 30px rgba(0,0,0,.34)}', '.sd-mini-fab:hover{box-shadow:0 10px 30px rgba(0,0,0,.34)}', // 尊重系统的「减少动态效果」 - '@media (prefers-reduced-motion:reduce){.sd-mini,.sd-mini-fab,.sd-card-acts,.sd-act{transition:none}}', + '@media (prefers-reduced-motion:reduce){.sd-mini,.sd-mini-fab,.sd-card-acts,.sd-act,.sd-card,.sd-iconbtn{transition:none}.sd-card:hover{transform:none}.sd-iconbtn:active{transform:none}}', // 键盘可见焦点 '.sd-iconbtn:focus-visible,.sd-act:focus-visible,.sd-pill:focus-visible,.sd-textbtn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}', '.sd-act{display:inline-flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;padding:2px 5px;border-radius:5px;font-size:var(--sd-fs-micro);transition:color .12s ease,background .12s ease}', @@ -2398,11 +2740,11 @@ window.__ModuleLoader__.load({ '.sd-edit-row{display:flex;align-items:center;gap:8px}', '.sd-edit-label{flex:none;width:4.6em;font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-tertiary)}', '.sd-edit-input{flex:1;min-width:0;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:var(--sd-fs-cap);line-height:1.5;padding:4px 7px;outline:none;font-family:inherit;resize:vertical}', - '.sd-edit-input:focus{border-color:var(--dsw-alias-brand-primary)}', + '.sd-edit-input:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent)}', '.sd-act.danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}', // 新建 '.sd-add{display:flex;align-items:center;justify-content:center;gap:6px;width:100%;border:1px dashed var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-tertiary);font-size:var(--sd-fs-cap);padding:7px;border-radius:9px;cursor:pointer;transition:color .12s ease,border-color .12s ease}', - '.sd-add:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3)}', + '.sd-add:hover{color:var(--dsw-alias-brand-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,var(--dsw-alias-border-l1))}', '.sd-composer{display:flex;flex-direction:column;gap:7px;border:1px solid var(--dsw-alias-brand-primary);border-radius:10px;padding:8px;background:var(--dsw-alias-bg-base)}', '.sd-composer-input{width:100%;resize:vertical;border:none;outline:none;background:transparent;color:var(--dsw-alias-label-primary);font-size:var(--sd-fs-body);line-height:1.55;font-family:inherit}', '.sd-composer-foot{display:flex;align-items:center;gap:8px}', @@ -2431,6 +2773,15 @@ window.__ModuleLoader__.load({ '.sd-cell.lv3{background:color-mix(in srgb,var(--dsw-static-green-500) 74%,var(--dsw-alias-bg-layer-3))}', '.sd-cell.lv4{background:var(--dsw-static-green-500)}', '.sd-cell.today{outline:1px solid var(--dsw-alias-label-primary);outline-offset:1px}', + '.sd-cell:hover{box-shadow:inset 0 0 0 1px var(--dsw-alias-label-tertiary)}', + '.sd-cell:not(.empty){cursor:pointer}', + // 点击某天格子展开的当天明细:原始 session 列表(不是聚合) + '.sd-heat-day-detail{border-top:1px solid var(--dsw-alias-border-l1);padding-top:8px;display:flex;flex-direction:column;gap:5px}', + '.sd-heat-day-head{display:flex;align-items:center;gap:8px;font-size:var(--sd-fs-cap)}', + '.sd-heat-day-row{display:grid;grid-template-columns:3.2em minmax(0,1fr) auto;gap:8px;align-items:center;font-size:var(--sd-fs-cap)}', + '.sd-heat-day-time{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}', + '.sd-heat-day-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary)}', + '.sd-heat-day-min{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums}', '.sd-cell.legend{display:inline-block;vertical-align:middle;margin:0 2px}', '.sd-heat-foot{display:flex;align-items:center;gap:14px;flex-wrap:wrap;font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-tertiary)}', '.sd-legend{display:inline-flex;align-items:center;gap:3px;margin-left:auto}', @@ -2462,13 +2813,16 @@ window.__ModuleLoader__.load({ '.sd-recent-row{display:grid;grid-template-columns:minmax(0,3.6em) minmax(0,1fr) auto;align-items:center;gap:9px;font-size:var(--sd-fs-cap)}', '.sd-recent-date{color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}', '.sd-recent-bar{height:5px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);overflow:hidden}', - '.sd-recent-bar i{display:block;height:100%;border-radius:999px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 70%,transparent)}', + '.sd-recent-bar i{display:block;height:100%;border-radius:999px;background:color-mix(in srgb,var(--dsw-static-green-500) 78%,transparent)}', '.sd-recent-num{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;white-space:nowrap}', // 科目分布 '.sd-subj-list{display:flex;flex-direction:column;gap:7px}', '.sd-subj{display:grid;grid-template-columns:minmax(0,7.5em) minmax(0,1fr) auto;align-items:center;gap:9px;font-size:var(--sd-fs-cap)}', '.sd-subj-label{color:var(--dsw-alias-label-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.sd-subj-bar{height:5px;border-radius:999px;background:var(--dsw-alias-bg-layer-3);overflow:hidden}', + // 科目投入条跟热力图/周报同族(绿=已投入)。此前继承 .sd-bar-fill 的品牌色, + // 暗色主题下解析成近白,是整页最刺眼的一根条。 + '.sd-subj-bar .sd-bar-fill{background:var(--dsw-static-green-500)}', '.sd-subj-min{font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary)}', '.sd-empty-block{font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-dimmed);line-height:1.6}', // 番茄分布饼图 @@ -2495,11 +2849,25 @@ window.__ModuleLoader__.load({ '.sd-review-acts{display:flex;align-items:center;gap:6px;flex:none;margin-left:auto}', '.sd-grade{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);font-size:var(--sd-fs-cap);padding:3px 11px;border-radius:8px;cursor:pointer;transition:color .12s ease,border-color .12s ease,background .12s ease}', '.sd-grade:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}', + // 打分键 hover 按各自的语义色淡染:指尖悬在哪一档,颜色先应一声 + '.sd-grade.again:hover{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}', + '.sd-grade.good:hover{color:var(--dsw-alias-brand-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 10%,transparent)}', + '.sd-grade.easy:hover{color:var(--dsw-alias-state-success-primary);border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 45%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 10%,transparent)}', '.sd-grade:disabled{opacity:.5;cursor:default}', '.sd-grade.again{color:var(--dsw-alias-state-error-primary)}', '.sd-grade.good{color:var(--dsw-alias-brand-primary);border-color:color-mix(in srgb,var(--dsw-alias-brand-primary) 35%,transparent)}', '.sd-grade.easy{color:var(--dsw-alias-state-success-primary)}', '.sd-review-more{font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-dimmed)}', + // 过卡模式:全屏浮层。z-index 压在宿主模态(1000)之下;Windows 顶部那条 drag 带 + // 会吞键盘焦点区的点击 —— 浮层必须显式 no-drag(坑 3 同款)。 + '.sd-focus-overlay{position:fixed;inset:0;z-index:920;background:var(--dsw-alias-bg-base);-webkit-app-region:no-drag;display:flex;align-items:center;justify-content:center;padding:36px;outline:none}', + '.sd-focus-card{width:min(720px,100%);max-height:100%;overflow-y:auto;display:flex;flex-direction:column;gap:14px;padding:26px 28px;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius);background:var(--dsw-alias-bg-layer-1);box-shadow:0 10px 40px rgba(0,0,0,.18)}', + '.sd-focus-title{font-size:var(--sd-fs-hero);font-weight:600;line-height:1.4;word-break:break-word}', + '.sd-focus-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-secondary)}', + '.sd-focus-note{font-size:var(--sd-fs-body);line-height:1.75;color:var(--dsw-alias-label-secondary);white-space:pre-wrap;word-break:break-word;border-left:2px solid var(--dsw-alias-border-l2);padding-left:12px}', + '.sd-focus-acts{display:flex;align-items:center;gap:8px;margin-top:auto;flex-wrap:wrap}', + '.sd-focus-progress{font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-dimmed);font-variant-numeric:tabular-nums}', + '.sd-grade.lg{font-size:var(--sd-fs-sub);padding:7px 20px;border-radius:10px}', // 遗忘曲线:颜色跟着「今天还剩多少记忆」走,绿=还稳、蓝=该看了、红=快塌了 '.sd-curve{flex:none;overflow:visible}', '.sd-curve-line{fill:none;stroke:currentColor;stroke-width:1.4;stroke-linejoin:round;stroke-linecap:round}', @@ -2546,7 +2914,7 @@ window.__ModuleLoader__.load({ '.sd-week-label{font-size:var(--sd-fs-tick);color:var(--dsw-alias-label-dimmed);font-variant-numeric:tabular-nums}', // 每日复盘 '.sd-journal-input{width:100%;resize:vertical;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-size:var(--sd-fs-body);line-height:1.6;padding:8px 10px;outline:none;font-family:inherit}', - '.sd-journal-input:focus{border-color:var(--dsw-alias-brand-primary)}', + '.sd-journal-input:focus{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 14%,transparent)}', '.sd-journal-list{display:flex;flex-direction:column;gap:6px;border-top:1px solid var(--dsw-alias-border-l1);padding-top:8px}', '.sd-journal-item{display:grid;grid-template-columns:3.4em 1fr;gap:8px;font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-secondary)}', '.sd-journal-date{color:var(--dsw-alias-label-dimmed);font-variant-numeric:tabular-nums}', @@ -2581,6 +2949,11 @@ window.__ModuleLoader__.load({ '.sd-field-label{font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-secondary)}', '.sd-field-hint{font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-tertiary);line-height:1.5}', '.sd-settings-file{font-size:var(--sd-fs-micro);color:var(--dsw-alias-label-dimmed);word-break:break-all}', + // 设置页的科目 chip:带一颗删除叉,最后一个不许删(宿主对空列表不动旧值) + '.sd-subj-chip{display:inline-flex;align-items:center;gap:3px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:var(--sd-fs-micro);padding:2px 4px 2px 9px;border-radius:999px}', + '.sd-subj-x{border:none;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:var(--sd-fs-cap);line-height:1;padding:0 5px;border-radius:50%;transition:color .12s ease,background .12s ease}', + '.sd-subj-x:hover{color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-interactive-bg-hover-danger)}', + '.sd-subj-x:disabled{opacity:.4;cursor:default}', '@media (max-width:900px){.sd-board{grid-template-columns:minmax(0,1fr)}.sd-two{grid-template-columns:minmax(0,1fr)}}', ].join('') @@ -2688,7 +3061,7 @@ window.__ModuleLoader__.load({ // 左侧栏图标:id 与 main 的 key 同名,侧栏点它就会 dispatch 到 main 的同一格 register('sidebar.panellist', { name: 'sidebar.panellist', id: PANEL_ID, order: 40, label: '学习工作台' }, - (props) => h(Icon, { name: 'board', size: (props && props.size) || 18 }), + (props) => h(Icon, { name: 'deskcard', size: (props && props.size) || 18 }), 'sidebar.panellist') // 整页工作台 @@ -2714,7 +3087,7 @@ window.__ModuleLoader__.load({ // layout.selectPanel: main panel "…" is not registered register('sidebar.panellist', { name: 'sidebar.panellist', id: BBCDC_PANEL_ID, order: 41, label: '单词打卡' }, - (props) => h(Icon, { name: 'tag', size: (props && props.size) || 18 }), + (props) => h(Icon, { name: 'book', size: (props && props.size) || 18 }), 'sidebar.panellist.bbdc') register('main', diff --git a/desk.js b/desk.js index fdff403..95a333d 100644 --- a/desk.js +++ b/desk.js @@ -254,6 +254,7 @@ export function normalize(raw) { subject: String(t.subject || ''), note: String(t.note || ''), estimateMin: Math.max(0, Math.floor(Number(t.estimateMin) || 0)), + dueDate: /^\d{4}-\d{2}-\d{2}$/.test(String(t.dueDate || '')) ? String(t.dueDate) : null, createdAt: Number(t.createdAt) || Date.now(), updatedAt: Number(t.updatedAt) || Number(t.createdAt) || Date.now(), doneAt: Number(t.doneAt) || null, @@ -391,6 +392,7 @@ export function addTask(state, input, now = Date.now()) { subject: String((input && input.subject) || ''), note: String((input && input.note) || ''), estimateMin: Math.max(0, Math.floor(Number(input && input.estimateMin) || 0)), + dueDate: /^\d{4}-\d{2}-\d{2}$/.test(String((input && input.dueDate) || '')) ? String(input.dueDate) : null, createdAt: now, updatedAt: now, doneAt: status === 'done' ? now : null, @@ -411,6 +413,11 @@ export function updateTask(state, ref, patch, now = Date.now()) { if (typeof p.note === 'string') task.note = p.note if (typeof p.subject === 'string') task.subject = p.subject if (p.estimateMin !== undefined) task.estimateMin = Math.max(0, Math.floor(Number(p.estimateMin) || 0)) + if (p.dueDate !== undefined) { + // 卡片自己的硬截止(区别于复习排期 review.due):只认 YYYY-MM-DD,别的形状一律清空 + const v = String(p.dueDate || '').trim() + task.dueDate = /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : null + } if (p.pinned !== undefined) task.pinned = !!p.pinned if (p.tags !== undefined) task.tags = normalizeTags(p.tags) if (p.review === true && !task.review.due) task.review = { ...emptyReview(), due: dayKey(now) } @@ -491,6 +498,7 @@ export function updateSettings(state, patch) { } } if (p.injectPrompt !== undefined) next.injectPrompt = !!p.injectPrompt + if (p.notifySessionOnTimerEnd !== undefined) next.notifySessionOnTimerEnd = !!p.notifySessionOnTimerEnd for (const k of ['goalLabel', 'goalNote']) { if (p[k] !== undefined) next[k] = String(p[k]).trim().slice(0, 40) } @@ -1059,12 +1067,12 @@ export function summary(state, now = Date.now()) { today: state.journal[todayKey] || '', yesterday: state.journal[dayKey(addDays(now, -1))] || '', }, - doing: cols.doing.map((t) => ({ id: t.id, title: t.title, subject: t.subject, minutes: byTask.get(t.id) || 0 })), + doing: cols.doing.map((t) => ({ id: t.id, title: t.title, subject: t.subject, dueDate: t.dueDate || null, minutes: byTask.get(t.id) || 0 })), todo: cols.todo .slice() .sort((a, b) => (b.pinned - a.pinned) || (a.order - b.order)) .slice(0, 12) - .map((t) => ({ id: t.id, title: t.title, subject: t.subject, pinned: t.pinned, estimateMin: t.estimateMin })), + .map((t) => ({ id: t.id, title: t.title, subject: t.subject, pinned: t.pinned, dueDate: t.dueDate || null, estimateMin: t.estimateMin })), doneToday: doneToday.map((t) => ({ id: t.id, title: t.title })).slice(0, 12), week: heatmap(state, 1, now).weeks[0].days.filter(Boolean).map((d) => ({ date: d.key, minutes: d.minutes })), } diff --git a/index.js b/index.js index 885ebc1..e33eea3 100644 --- a/index.js +++ b/index.js @@ -17,6 +17,7 @@ * 数据落在 /study-desk/desk.json,路径不写死在 patch 里(写死别人装完起不来)。 */ +import { randomUUID } from 'node:crypto' import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -192,6 +193,44 @@ function serial(fn) { return run } +// --------------------------------------------------------------------------- +// 番茄钟走完 → 给起钟的那个 DSH 会话发自动提醒(督促用)。 +// 钩子在 apply() 里挂上(需要 ctx 去拿 agents 服务);链路: +// 起钟(工具,exec.agent.id 存进 state.timer.sessionId) +// → 任意一侧读写 desk 时 reconcileTimer 结账,把走完的段收集进 sink +// → emitTimerEnd 过滤开关/手动/过期 → 钩子用 agents.get(sessionId).followup(消息) +// 唤醒那个会话(官方 goal-round-driver 同款姿势;空闲就开新一轮,跑着就排下一轮)。 +// 手动停钟(客户端按钮 / 工具 off=true)不提醒;段走完超过 10 分钟才被结账的也不提醒。 +// --------------------------------------------------------------------------- + +let timerEndHook = null + +function timerEndText(info) { + if (info.kind === 'focus') { + return `【番茄钟·自动提醒】「${info.title || '未命名'}」这轮 ${info.minutes} 分钟专注计时走完了。` + + '这是 study_desk 的系统自动消息,不是用户发言:请用一两句话跟用户确认这轮做了什么,然后提醒休息;' + + '不要自动开始下一轮专注,等用户发话。' + } + return `【番茄钟·自动提醒】${info.minutes} 分钟的休息计时结束了。` + + '这是 study_desk 的系统自动消息:提醒用户回到学习;用户要继续时用 study_desk 的 focus 起下一轮' + + '(task 必须挂一张「进行中」的卡),不要未经用户确认自动连开。' +} + +function emitTimerEnd(state, endedSegments) { + if (!Array.isArray(endedSegments) || !endedSegments.length) return + if (state.settings && state.settings.notifySessionOnTimerEnd === false) return + if (typeof timerEndHook !== 'function') return + for (const info of endedSegments) { + // AI 起的才提醒;拖了太久才被结账的段(人早就不在场)也跳过 + if (!info.sessionId || (Date.now() - info.endedAt) > 10 * 60000) continue + try { + timerEndHook(info) + } catch (error) { + console.error('[dsh-study-desk] 番茄钟提醒发送失败:', error && error.message) + } + } +} + /** * 在串行临界区里读状态 → 顺手结掉走完的计时器 → 跑 mutator → 需要时落盘。 * mutator 为 null 时是纯读,但计时器结账导致的改动仍会写回。 @@ -199,11 +238,13 @@ function serial(fn) { function withDesk(mutator) { return serial(() => { const state = readDesk() - const reconciled = reconcileTimer(state) + const endedSegments = [] + const reconciled = reconcileTimer(state, Date.now(), endedSegments) let result if (mutator) result = mutator(state) if (mutator || reconciled) { const saved = writeDesk(state) + emitTimerEnd(saved, endedSegments) return { state: saved, result } } return { state, result } @@ -356,6 +397,12 @@ async function handleApi(req, res) { sendJson(res, 200, snapshot(state)) return } + if (op === 'weekly.text') { + // 周报的纯文本(与 study_desk 工具 report 同一份 weeklyText,不另写一套格式) + const { state } = await withDesk(null) + sendJson(res, 200, { ok: true, text: weeklyText(state, Math.max(1, Math.round(Number(payload.weeks) || 8))) }) + return + } if (op === 'diag.report') { // 客户端诊断:不碰 desk.json,只追加到 client-diag.json const report = writeDiag(payload) @@ -405,12 +452,12 @@ function boardText(state) { if (ms) lines.push(`- 最近节点:${ms.label} ${ms.start}${ms.end !== ms.start ? '~' + ms.end : ''}(${ms.text}${ms.approx ? ',日期按往年惯例估' : ''})`) lines.push(`- 今日专注 ${fmtMinutes(s.today.minutes)} / 目标 ${fmtMinutes(s.today.goal)},连续打卡 ${s.streak} 天`) if (s.doing.length) { - lines.push('- 进行中 ' + s.counts.doing + ':' + s.doing.map((t) => `${t.title}${t.minutes ? `(已投入 ${fmtMinutes(t.minutes)})` : ''}`).join(' | ')) + lines.push('- 进行中 ' + s.counts.doing + ':' + s.doing.map((t) => `${t.title}${t.dueDate ? `【${t.dueDate} 截止】` : ''}${t.minutes ? `(已投入 ${fmtMinutes(t.minutes)})` : ''}`).join(' | ')) } else { lines.push('- 进行中 0:现在没有正在做的卡') } if (s.todo.length) { - lines.push('- 待办 ' + s.counts.todo + ':' + s.todo.slice(0, 8).map((t) => t.pinned ? '★' + t.title : t.title).join(' | ')) + lines.push('- 待办 ' + s.counts.todo + ':' + s.todo.slice(0, 8).map((t) => (t.pinned ? '★' + t.title : t.title) + (t.dueDate ? `【${t.dueDate} 截止】` : '')).join(' | ')) } else { lines.push('- 待办 0') } @@ -543,7 +590,10 @@ function makeTool() { '学习工作台:读写用户的待办墙(待办/进行中/已完成三列)、记录专注时长、查统计与热力图、起停番茄钟、' + '管间隔重复复习队列、写每日复盘、出周报。用户说「今天要做什么」「把 X 加到进行中」「这件做完了」' + '"我今天学了多久""开始 25 分钟专注""今天该复习什么""这个我忘了/记住了""帮我记一下今天的复盘""最近几周学得怎么样"' - + '这类话时用它,而不是自己另开一份待办清单。倒计时的目标与节点由用户在设置页自己填(考试、答辩、投稿都可以)。action 取值:' + + '这类话时用它,而不是自己另开一份待办清单。倒计时的目标与节点由用户在设置页自己填(考试、答辩、投稿都可以)。' + + 'AI 起番茄钟必须走完整流程:action=add 建卡(或挑已有卡)→ action=move 移到 status=doing → action=focus 且 task=那张卡;' + + '不挂卡或卡不在「进行中」会被拒绝。番茄钟走完/休息结束时本工具会自动往起钟的会话发一条【番茄钟·自动提醒】(设置页可关)。' + + 'action 取值:' + 'board=看当前看板全貌;add=新建卡;move=卡片换列/排序;update=改卡片字段;remove=删卡;' + 'focus=起停番茄钟(off=true 停;minutes 控制时长;title 给这段命名,饼图按名字聚合);stats=查最近 N 天统计;' + 'review=看今日复习队列(带 id/title + grade 时改为打分);journal=看最近复盘(带 text 时写入);' @@ -557,11 +607,12 @@ function makeTool() { note: { type: 'string', description: '备注' }, tags: { type: 'string', description: '卡片标签,逗号分隔(如「名词解释, 错题」),add/update 用' }, estimateMin: { type: 'number', description: '预计要花多少分钟' }, + dueDate: { type: 'string', description: '截止日期 YYYY-MM-DD(add/update 用;卡片自己的硬截止,区别于复习排期的 due)' }, pinned: { type: 'boolean', description: '是否置顶(add/update)' }, review: { type: 'boolean', description: 'add 时给 true = 直接把这张卡拉进间隔重复复习循环' }, beforeId: { type: 'string', description: 'move 时插到这张卡之前;省略则排到该列末尾' }, minutes: { type: 'number', description: 'focus 的时长(分钟);stats 时表示看最近多少天(默认 30)' }, - task: { type: 'string', description: 'focus 时把专注挂到哪张卡上(id 或标题)' }, + task: { type: 'string', description: 'focus 时把专注挂到哪张卡上(id 或标题);AI 起钟必填,且必须是「进行中」的卡——没有就先 add 建卡、move 到 doing' }, off: { type: 'boolean', description: 'focus 且 off=true 时停止番茄钟' }, grade: { type: 'string', enum: GRADES, description: 'review 的打分:again 忘了 / good 记得 / easy 很简单' }, due: { type: 'string', description: '排期日期 YYYY-MM-DD(review 排期用;省略=今天)' }, @@ -576,7 +627,7 @@ function makeTool() { schema: { type: 'json' }, render: (_args, value) => [{ type: 'text', text: String((value && value.message) || '') }], }, - async execute(args) { + async execute(args, exec) { const action = String(args.action || 'board') if (!STUDY_DESK_ACTIONS.includes(action)) return { ok: false, message: '未知 action:' + action } try { @@ -595,6 +646,7 @@ function makeTool() { note: args.note, status: args.status || 'todo', estimateMin: args.estimateMin, + dueDate: args.dueDate, pinned: args.pinned, tags: args.tags, review: args.review, @@ -619,7 +671,7 @@ function makeTool() { const ref = args.id || args.title if (!ref) return { ok: false, message: 'update 需要 id 或 title' } const patch = {} - for (const k of ['title', 'note', 'subject', 'estimateMin', 'pinned', 'status', 'tags']) { + for (const k of ['title', 'note', 'subject', 'estimateMin', 'dueDate', 'pinned', 'status', 'tags']) { if (args[k] !== undefined) patch[k] = args[k] } if (args.review !== undefined) patch.review = !!args.review @@ -637,17 +689,42 @@ function makeTool() { const { state } = await withDesk((s) => resetTimer(s)) return { ok: true, message: `番茄钟已停。今天已专注 ${fmtMinutes(todayMinutes(state))}。` } } - const taskRef = args.task const named = String(args.title || '').trim().slice(0, 24) - const { state, result } = await withDesk((s) => startTimer(s, { - minutes: args.minutes, - taskId: taskRef, - name: named || undefined, - })) + // exec.agent.id 就是会话 id:存进计时器,走完时自动提醒发回这个会话 + const agentId = exec && exec.agent && exec.agent.id ? String(exec.agent.id) : null + const taskRef = String(args.task || '').trim() + const { state, result } = await withDesk((s) => { + // AI 起钟的门禁:必须挂一张「进行中」的卡。刻意让 AI 走完 + // 添加待办 → 移到进行中 → 挂卡起钟 这段流程,AI 安排的任务才会 + // 真的落在待办墙上,周报和近 30 天统计才对得上。 + if (!taskRef) { + throw new Error('AI 起番茄钟必须挂卡。流程:先 action=add 新建卡(或用 board 挑一张已有卡),' + + '再 action=move 把它移到 status=doing,最后 action=focus 且 task=<那张卡的 id 或标题>。') + } + const task = s.tasks.find((t) => t.id === taskRef + || t.title === taskRef + || t.title.toLowerCase() === taskRef.toLowerCase()) + if (!task) { + throw new Error(`找不到卡「${taskRef}」。先 action=add 建卡,再用 action=move 移到 status=doing,然后 focus 挂它。`) + } + if (task.status !== 'doing') { + const where = task.status === 'todo' ? '待办' : '已完成' + throw new Error(`「${task.title}」现在是「${where}」,不在「进行中」。先 action=move 把它移到 status=doing,再 focus 挂它。`) + } + return startTimer(s, { + minutes: args.minutes, + taskId: task.id, + name: named || undefined, + by: 'ai', + sessionId: agentId, + }) + }) const s = summary(state) return { ok: true, - message: `开始 ${result.minutes} 分钟${result.kind === 'focus' ? '专注' : '休息'}${result.title ? `(${result.title})` : ''}。今天已专注 ${fmtMinutes(s.today.minutes)} / 目标 ${fmtMinutes(s.today.goal)}。`, + message: `开始 ${result.minutes} 分钟${result.kind === 'focus' ? '专注' : '休息'}${result.title ? `(${result.title})` : ''}。` + + `今天已专注 ${fmtMinutes(s.today.minutes)} / 目标 ${fmtMinutes(s.today.goal)}。` + + (agentId ? '走完时会自动往本会话发一条提醒。' : ''), } } if (action === 'review') { @@ -750,6 +827,34 @@ export function apply(ctx, config) { state: () => withDesk(null), } + // 番茄钟提醒的发送端:agents 是宿主的会话服务(官方 goal-round-driver 同款用法)。 + // 会话已关闭 / 宿主重启后 agents 里没有它 → 静默放弃,不排队不补发。 + timerEndHook = (info) => { + const agents = ctx.get('agents') + const agent = agents && typeof agents.get === 'function' ? agents.get(info.sessionId) : null + if (!agent || typeof agent.followup !== 'function') return + agent.followup({ + id: randomUUID(), + role: 'user', + content: [{ type: 'text', text: timerEndText(info) }], + source: { kind: 'plugin', plugin: name, event: 'timer-end', at: Date.now() }, + }) + } + + // 看门狗:面板没开时没人轮询 desk,提醒就得靠它兜底。 + // 每 10 秒看一眼;只有真有钟在跑且已到点才进串行区结账(顺带触发提醒),平时零写入。 + ctx.effect(() => { + const id = setInterval(() => { + try { + const st = readDesk() + if (st.timer && st.timer.running && st.timer.endsAt <= Date.now()) { + withDesk(null).catch(() => {}) + } + } catch { /* 下一轮再看 */ } + }, 10000) + return () => clearInterval(id) + }, 'dsh-study-desk: timer watchdog') + // 给别的插件留一个入口(本插件自己不用,纯便利):注册服务要用 ctx.provide, // ctx.set 只能覆盖已经 provide 过的服务,直接 set 会抛 // Error: cannot set property "studyDesk" without provide diff --git a/markdown.js b/markdown.js index 34098bd..74c3931 100644 --- a/markdown.js +++ b/markdown.js @@ -135,6 +135,7 @@ export function toMarkdown(state, now = Date.now(), extra = {}) { ['est', t.estimateMin || null], ['spent', byTask.get ? byTask.get(t.id) : null], ['tags', (t.tags || []).join('|') || null], + ['ddl', t.dueDate], ['due', t.review && t.review.due], ['interval', t.review && t.review.due ? t.review.intervalDays : null], ['ease', t.review && t.review.due ? t.review.ease : null], @@ -233,6 +234,7 @@ export function fromMarkdown(text) { status: meta.done ? 'done' : status, note: '', estimateMin: Number(meta.est) || 0, + dueDate: /^\d{4}-\d{2}-\d{2}$/.test(String(meta.ddl || '')) ? String(meta.ddl) : null, tags: normalizeTags((meta.tags || '').split('|')), review: meta.due ? { @@ -270,7 +272,7 @@ export function mergeMarkdown(state, parsed) { for (const card of parsed.cards || []) { const existing = card.id ? state.tasks.find((t) => t.id === card.id) : null if (existing) { - const changed = ['title', 'subject', 'note', 'estimateMin', 'tags', 'review'].some((k) => { + const changed = ['title', 'subject', 'note', 'estimateMin', 'dueDate', 'tags', 'review'].some((k) => { const a = JSON.stringify(existing[k]) const b = JSON.stringify(card[k]) if (a === b) return false @@ -295,6 +297,7 @@ export function mergeMarkdown(state, parsed) { subject: card.subject, note: card.note, estimateMin: card.estimateMin, + dueDate: card.dueDate || null, createdAt: Date.now(), updatedAt: Date.now(), doneAt: card.status === 'done' ? (card.doneAt || Date.now()) : null, diff --git a/package.json b/package.json index cba572c..586a33e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "dsh-study-desk", "version": "0.7.0", + "icon": "./assets/icon.svg", "description": "学习工作台 DSH 插件:Notion 式待办墙与标签过滤 + 间隔重复复习队列(简化 SM-2,带记忆曲线、未来 7 天到期预报与今日建议)+ 按实际复习时长点亮的热力图与周报 + 每日复盘 + Markdown 导出读回 + 跨面板存活的番茄钟 + 独立的不背单词打卡面板(只读本机 App 数据)。只消费主题 token,融入现有主题而不改主题。", "type": "module", "main": "index.js", @@ -64,6 +65,7 @@ "bbdc.js", "markdown.js", "timer.js", + "assets/icon.svg", "test/", "tools/client-loader.mjs", "README.md", diff --git a/test/desk.test.js b/test/desk.test.js index 123500d..ef07fa5 100644 --- a/test/desk.test.js +++ b/test/desk.test.js @@ -28,6 +28,7 @@ import { summary, todayMinutes, updateSettings, + updateTask, weeklyReport, } from '../desk.js' import { completeTimer, makeTimer, pauseTimer, reconcileTimer, remainingMs, resetTimer, startTimer } from '../timer.js' @@ -563,3 +564,97 @@ test('「结束这一段」按真实时长记账,「放弃」不记账', () => resetTimer(dropped) assert.equal(dropped.sessions.length, 0, '放弃不该记账') }) + +test('截止日期:addTask/updateTask 只认 YYYY-MM-DD,别的形状一律清空', () => { + const s = normalize(null) + const a = addTask(s, { title: '交开题报告', dueDate: '2026-12-01' }, T0) + assert.equal(a.dueDate, '2026-12-01') + updateTask(s, a.id, { dueDate: '2026-12-15' }, T0) + assert.equal(a.dueDate, '2026-12-15', '合法改期能改上') + updateTask(s, a.id, { dueDate: '下周五' }, T0) + assert.equal(a.dueDate, null, '非法日期清空,而不是保留旧值') + const b = addTask(s, { title: '没有截止', dueDate: '明天' }, T0) + assert.equal(b.dueDate, null) + updateTask(s, b.id, { dueDate: '' }, T0) + assert.equal(b.dueDate, null, '空串就是清除') +}) + +test('normalize 认得 dueDate:合法保留、非法丢掉,旧文件没这个字段也不炸', () => { + const s = normalize({ tasks: [ + { id: 'a', title: '有截止', dueDate: '2026-11-20' }, + { id: 'b', title: '坏截止', dueDate: '12月20日' }, + { id: 'c', title: '没字段' }, + ] }) + assert.equal(s.tasks[0].dueDate, '2026-11-20') + assert.equal(s.tasks[1].dueDate, null) + assert.equal(s.tasks[2].dueDate, null) +}) + +test('summary 的 doing/todo 带上 dueDate(系统提示要拿它提醒她)', () => { + const s = normalize(null) + const a = addTask(s, { title: '写综述', dueDate: '2026-10-20' }, T0) + assert.equal(summary(s, T0).todo[0].dueDate, '2026-10-20') + updateTask(s, a.id, { status: 'doing' }, T0) + assert.equal(summary(s, T0).doing[0].dueDate, '2026-10-20', '挪到进行中之后仍然带着截止') +}) + +// --------------------------------------------------------------------------- +// AI 起钟的会话标记与会话提醒素材(番茄钟督促链路) +// --------------------------------------------------------------------------- + +test('AI 起的钟带会话标记,自动摆的休息继承标记,到点收走提醒素材', () => { + const s = normalize(null) + updateSettings(s, { focusMin: 25, shortBreakMin: 5 }) + startTimer(s, { kind: 'focus', by: 'ai', sessionId: 'sess-A' }, T0) + assert.equal(s.timer.by, 'ai') + assert.equal(s.timer.sessionId, 'sess-A') + + const ended = [] + assert.equal(reconcileTimer(s, T0 + 25 * MIN, ended), true) + assert.equal(ended.length, 1) + assert.equal(ended[0].kind, 'focus') + assert.equal(ended[0].sessionId, 'sess-A') + assert.equal(ended[0].endedAt, T0 + 25 * MIN) + + // 休息继承链条:休息走完时提醒还发给同一个会话 + assert.equal(s.timer.kind, 'break') + assert.equal(s.timer.sessionId, 'sess-A') + ended.length = 0 + assert.equal(reconcileTimer(s, T0 + 30 * MIN, ended), true) + assert.equal(ended.length, 1) + assert.equal(ended[0].kind, 'break') + assert.equal(ended[0].sessionId, 'sess-A') +}) + +test('手动起的钟没有会话标记,不产生会话提醒素材', () => { + const s = normalize(null) + updateSettings(s, { focusMin: 25, shortBreakMin: 5 }) + startTimer(s, { kind: 'focus', minutes: 25 }, T0) + assert.equal(s.timer.by, null) + assert.equal(s.timer.sessionId, null) + const ended = [] + reconcileTimer(s, T0 + 25 * MIN, ended) + assert.equal(ended.length, 1) + assert.equal(ended[0].sessionId, null) +}) + +test('手动提前结束后,AI 链条的休息仍然继承会话标记', () => { + const s = normalize(null) + updateSettings(s, { focusMin: 25, shortBreakMin: 5 }) + startTimer(s, { kind: 'focus', by: 'ai', sessionId: 'sess-B' }, T0) + pauseTimer(s, T0 + 10 * MIN) + completeTimer(s, T0 + 20 * MIN) + assert.equal(s.timer.kind, 'break') + assert.equal(s.timer.sessionId, 'sess-B') +}) + +test('updateSettings 能开关 notifySessionOnTimerEnd,不传不动它', () => { + const s = normalize(null) + assert.equal(s.settings.notifySessionOnTimerEnd, undefined, '默认没写进文件(undefined = 开)') + updateSettings(s, { notifySessionOnTimerEnd: false }) + assert.equal(s.settings.notifySessionOnTimerEnd, false) + updateSettings(s, { notifySessionOnTimerEnd: true }) + assert.equal(s.settings.notifySessionOnTimerEnd, true) + updateSettings(s, { focusMin: 30 }) + assert.equal(s.settings.notifySessionOnTimerEnd, true, '别的设置项不该动它') +}) diff --git a/test/markdown.test.js b/test/markdown.test.js index 426eefa..fda9d2c 100644 --- a/test/markdown.test.js +++ b/test/markdown.test.js @@ -26,6 +26,7 @@ function seeded() { addTask(s, { title: '英语阅读 2018 Text1', subject: '英语', review: true }, T0) const done = addTask(s, { title: '马原原理背诵', subject: '政治', status: 'done' }, T0) updateTask(s, a.id, { note: '重点:议程设置\n多看两页' }, T0) + updateTask(s, a.id, { dueDate: '2026-11-01' }, T0) scheduleTask(s, a.id, '2026-10-06', T0) gradeTask(s, done.id, 'good', T0) logSession(s, { minutes: 50, at: T0, taskId: a.id }) @@ -52,6 +53,7 @@ test('导出再读回:卡片、标签、笔记、复习排期与复盘都对 assert.equal(a.subject, '631 公共管理') assert.equal(a.status, 'todo') assert.equal(a.estimateMin, 45) + assert.equal(a.dueDate, '2026-11-01', 'ddl 机器字段应该往返保留') assert.deepEqual(a.tags, ['名词解释', '错题']) assert.equal(a.note, '重点:议程设置\n多看两页') assert.equal(a.review.due, '2026-10-06') @@ -128,4 +130,19 @@ test('导出的 Markdown 里不含本机路径,注释能被 Obsidian 阅读模 assert.ok(!/[A-Za-z]:[\\/]/.test(md), '不该出现 Windows 绝对路径') assert.match(md, /', '', + ].join('\n') + mergeMarkdown(s, fromMarkdown(md)) + assert.equal(s.tasks[0].dueDate, '2026-11-30') + const again = mergeMarkdown(s, fromMarkdown(md)) + assert.deepEqual(again, { added: 0, updated: 0 }, '原样再读一遍不该算改动') + mergeMarkdown(s, fromMarkdown(md.replace(' ddl:2026-11-30', ''))) + assert.equal(s.tasks[0].dueDate, null, 'ddl 没了 = 她删掉了截止,读回时清空') }) diff --git a/test/style.test.js b/test/style.test.js index d5cf0f3..1a8b296 100644 --- a/test/style.test.js +++ b/test/style.test.js @@ -43,6 +43,8 @@ test('投入类图表走绿色阶、到期预报走暖色,不再用会掉成 } assert.match(rule('.sd-week.current .sd-week-fill'), /--dsw-static-green-500/, '周报当前周该跟热力图同族') assert.match(rule('.sd-bar-study .sd-bar-fill'), /--dsw-static-green-500/, '今日进度条该跟热力图同族') + assert.match(rule('.sd-subj-bar .sd-bar-fill'), /--dsw-static-green-500/, '近30天/周报的科目投入条该跟热力图同族(brand 在暗色主题解析成近白)') + assert.match(rule('.sd-recent-bar i'), /--dsw-static-green-500/, '单词打卡近 7 天的复习条也是投入语义,走绿族') assert.match(rule('.sd-fbar-fill'), /--dsw-static-amber-500/, '到期预报柱体该走暖色') assert.match(rule('.sd-fbar.today .sd-fbar-fill'), /--dsw-static-red-500/, '今天那一根该转红') // 计时器那条她没让改,别顺手改齐 diff --git a/timer.js b/timer.js index 454acfe..35d1c9a 100644 --- a/timer.js +++ b/timer.js @@ -67,6 +67,10 @@ export function makeTimer(state, input, now = Date.now()) { // 番茄ToDo 式命名:session.title 是这个番茄的「户口」,饼图按它聚合——起名发生在起钟这一刻。 // 优先级:手动写的名字 > 挂的卡片标题 > 兜底桶名(账不丢)。 const custom = String((input && (input.name || input.title)) || '').trim().slice(0, 24) + // AI 起的钟记下起钟会话:走完时宿主要往那个会话发自动提醒(督促用)。 + // by 不是 'ai' 就一律当手动,不存会话,也不提醒——「只有大模型调整番茄钟才用」。 + const by = input && input.by === 'ai' ? 'ai' : null + const sessionId = by === 'ai' ? (String((input && input.sessionId) || '') || null) : null const span = minutes * MINUTE return { kind, @@ -75,6 +79,8 @@ export function makeTimer(state, input, now = Date.now()) { taskId: task ? task.id : null, title: custom || (task ? task.title : (kind === 'focus' ? '专注(未命名)' : '')), subject: task ? task.subject : String((input && input.subject) || ''), + by, + sessionId, startedAt: now, endsAt: now + span, running: true, @@ -130,8 +136,11 @@ function pushFocus(state, timer, minutes, at) { * 把已经走完的计时器结账。返回 state 是否被改动。 * 专注走完 → 记一条 session,并按节奏自动摆上休息; * 若人已经离开很久(连休息也过去了),就不再摆一个已经过期的计时器。 + * + * endedSink(可选)收走「哪一段走完了」的素材:宿主拿它给起钟会话发自动提醒。 + * 传数组进来,走完一段就 push 一条 {kind,title,subject,minutes,by,sessionId,endedAt}。 */ -export function reconcileTimer(state, now = Date.now()) { +export function reconcileTimer(state, now = Date.now(), endedSink = null) { const t = state.timer if (!t || !t.running || !(t.endsAt <= now)) return false if (t.kind === 'focus') { @@ -147,6 +156,9 @@ export function reconcileTimer(state, now = Date.now()) { taskId: null, title: '', subject: '', + // 休息继承起钟会话:这条链是 AI 起的,休息走完也要提醒它收尾 + by: t.by, + sessionId: t.sessionId, startedAt: t.endsAt, endsAt, running: true, @@ -154,8 +166,14 @@ export function reconcileTimer(state, now = Date.now()) { spentMs: 0, } : null + if (endedSink) { + endedSink.push({ kind: 'focus', title: t.title, subject: t.subject, minutes: t.minutes, by: t.by, sessionId: t.sessionId, endedAt: t.endsAt }) + } } else { state.timer = null + if (endedSink) { + endedSink.push({ kind: 'break', minutes: t.minutes, by: t.by, sessionId: t.sessionId, endedAt: t.endsAt }) + } } return true } @@ -176,6 +194,8 @@ export function completeTimer(state, now = Date.now()) { taskId: null, title: '', subject: '', + by: t.by, + sessionId: t.sessionId, startedAt: now, endsAt: now + minutes * MINUTE, running: true, diff --git a/tools/compose-final.py b/tools/compose-final.py new file mode 100644 index 0000000..3f038f7 --- /dev/null +++ b/tools/compose-final.py @@ -0,0 +1,51 @@ +# 最终确认图:旧色 vs 新色,浅底/深底各一列。 +import os +from PIL import Image, ImageDraw, ImageFont + +S = os.path.join(os.path.dirname(__file__), '..', '.shots') +p = lambda n: os.path.join(S, n) +try: + F = ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 19) + FS = ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 14) +except Exception: + F = FS = ImageFont.load_default() +BG, FG = (24, 26, 30), (234, 236, 240) +# 裁切框来自 harness 的 diag(两种主题同一套布局) +BOX = {'heat': (236, 1206, 646, 1446), 'bars': (258, 640, 1216, 782)} +SCALE = {'heat': 2, 'bars': 1} + + +def sheet(kind, title, old_src, new_src): + sc = SCALE[kind] + rows = [] + for label, src in [('旧:品牌色(主题里掉成灰阶)', old_src), ('新:' + {'heat': '绿色阶', 'bars': '暖色(今天转红)'}[kind], new_src)]: + cells = [] + for dk, tag in [(0, '浅底'), (1, '深底')]: + im = Image.open(p(f'{src}-{dk}.png')).convert('RGB').crop(BOX[kind]) + im = im.resize((im.width * sc, im.height * sc), Image.LANCZOS) + bar = Image.new('RGB', (im.width, im.height + 22), BG) + ImageDraw.Draw(bar).text((8, 3), tag, font=FS, fill=FG) + bar.paste(im, (0, 22)) + cells.append(bar) + roww = sum(c.width for c in cells) + 16 + row = Image.new('RGB', (roww, max(c.height for c in cells)), BG) + x = 0 + for c in cells: + row.paste(c, (x, 0)) + x += c.width + 16 + rows.append((label, row)) + lw = 330 + out = Image.new('RGB', (lw + max(r.width for _, r in rows) + 32, 56 + sum(r.height + 30 for _, r in rows)), BG) + d = ImageDraw.Draw(out) + d.text((16, 14), title, font=F, fill=FG) + y = 56 + for label, row in rows: + d.text((16, y + 6), label, font=FS, fill=FG) + out.paste(row, (lw, y)) + y += row.height + 30 + out.save(p(f'final-{kind}.png')) + print('final-%s.png' % kind, out.size) + + +sheet('heat', '学习热力图 · 改色前后', 'g-cur', 'final') +sheet('bars', '今日复习 · 未来 7 天柱状图 改色前后', 'g-cur', 'final') diff --git a/tools/compose-gallery.py b/tools/compose-gallery.py new file mode 100644 index 0000000..c9ae397 --- /dev/null +++ b/tools/compose-gallery.py @@ -0,0 +1,61 @@ +# 把三套候选色 + 现在这版拼成两张可选对照表(热力图、复习柱状图),深浅底各一列。 +import os +from PIL import Image, ImageDraw, ImageFont + +S = os.path.join(os.path.dirname(__file__), '..', '.shots') +p = lambda n: os.path.join(S, n) +try: + F = ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 20) + FS = ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 15) +except Exception: + F = FS = ImageFont.load_default() + +BG = (24, 26, 30) +FG = (234, 236, 240) +VARIANTS = [('cur', '现在这版(品牌色,深色主题下接近灰白)'), ('green', 'A 绿色阶梯'), ('warm', 'B 暖色番茄(琥珀→红)'), ('teal', 'C 青色')] +# rect 来自 harness 的 diag:heat=281,1246,230,131 forecast=281,672,913,71(两种主题同一套布局) +BOX = {'heat': (236, 1210, 640, 1442), 'bars': (258, 640, 1216, 780)} + + +def sheet(kind, title): + scale = 2 if kind == 'heat' else 1 + rows = [] + for key, label in VARIANTS: + ims = [] + for dk in (0, 1): + im = Image.open(p(f'g-{key}-{dk}.png')).convert('RGB').crop(BOX[kind]) + im = im.resize((im.width * scale, im.height * scale), Image.LANCZOS) + ims.append(im) + h = min(i.height for i in ims) + cells = [] + for i, tag in zip(ims, ['浅底', '深底']): + bar = Image.new('RGB', (i.width, i.height + 24), BG) + d = ImageDraw.Draw(bar) + d.text((8, 3), tag, font=FS, fill=FG) + bar.paste(i, (0, 24)) + cells.append(bar.crop((0, 0, i.width, h + 24))) + roww = sum(c.width for c in cells) + 16 + row = Image.new('RGB', (roww, max(c.height for c in cells)), BG) + x = 0 + for c in cells: + row.paste(c, (x, 0)) + x += c.width + 16 + rows.append((label, row)) + + labelw = 460 + W = labelw + max(r.width for _, r in rows) + 32 + H = 60 + sum(28 + r.height for _, r in rows) + 16 * len(rows) + out = Image.new('RGB', (W, H), BG) + d = ImageDraw.Draw(out) + d.text((16, 16), title, font=F, fill=FG) + y = 60 + for label, row in rows: + d.text((16, y + row.height // 2 - 10), label, font=FS, fill=FG) + out.paste(row, (labelw, y)) + y += row.height + 16 + 12 + out.save(p(f'gallery-{kind}.png')) + print('gallery-%s.png' % kind, out.size) + + +sheet('heat', '学习热力图:四套候选色') +sheet('bars', '今日复习卡 · 未来 7 天柱状图:四套候选色') diff --git a/tools/compose-shots.py b/tools/compose-shots.py new file mode 100644 index 0000000..5b549eb --- /dev/null +++ b/tools/compose-shots.py @@ -0,0 +1,69 @@ +# 把 .shots 里的截图拼成两张对照图:整页 2x2、饼图放大对照。 +# 只在本地生成(.shots 已 gitignore),不上传任何东西。 +import os +from PIL import Image, ImageDraw, ImageFont + +S = os.path.join(os.path.dirname(__file__), '..', '.shots') +p = lambda n: os.path.join(S, n) + +try: + FONT = ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 22) + FONTS = ImageFont.truetype('C:/Windows/Fonts/msyh.ttc', 17) +except Exception: + FONT = FONTS = ImageFont.load_default() + +BG = (24, 26, 30) +FG = (232, 234, 238) + + +def labeled(img, text, font): + bar = font.size + 16 + out = Image.new('RGB', (img.width, img.height + bar), BG) + out.paste(img, (0, bar)) + d = ImageDraw.Draw(out) + d.text((12, 6), text, font=font, fill=FG) + return out + + +def grid(items, cols, w, gap=20, labels_above=True): + cells = [] + for img, text in items: + scale = w / img.width + im = img.resize((w, int(img.height * scale)), Image.LANCZOS) + cells.append(labeled(im, text, FONTS) if labels_above else im) + rows = [cells[i:i + cols] for i in range(0, len(cells), cols)] + rh = [max(c.height for c in r) for r in rows] + W = sum(c.width for c in rows[0]) + gap * (cols + 1) + H = sum(rh) + gap * (len(rows) + 1) + out = Image.new('RGB', (W, H), BG) + y = gap + for r, h in zip(rows, rh): + x = gap + for c in r: + out.paste(c, (x, y)) + x += c.width + gap + y += h + gap + return out + + +# ---- 1. 整页对照 +pairs = [('old-light.png', '改前 · 浅色'), ('new-light.png', '改后 · 浅色'), + ('old-dark.png', '改前 · 深色'), ('new-dark.png', '改后 · 深色')] +imgs = [(Image.open(p(n)).convert('RGB'), t) for n, t in pairs] +grid(imgs, 2, 600).save(p('compare.png')) + +# ---- 2. 饼图放大对照(rect 从 harness 的 diag 里读到) +RECTS = {'old': (862, 1044, 335, 112), 'new': (861, 1082, 333, 117)} +PAD = 14 +crops = [] +for v, label in [('old', '改前 · 灰阶语义色(5 色里有 3 个其实是灰的)'), ('new', '改后 · 宿主色板分色')]: + x, y, w, h = RECTS[v] + box = (max(0, x - PAD), max(0, y - PAD), x + w + PAD, y + h + PAD) + im = Image.open(p(f'{v}-light.png')).convert('RGB').crop(box) + im = im.resize((im.width * 3, im.height * 3), Image.LANCZOS) + crops.append((im, label)) +grid(crops, 1, 1000, labels_above=True).save(p('pie-compare.png')) + +for n in ['compare.png', 'pie-compare.png']: + im = Image.open(p(n)) + print(n, im.size, os.path.getsize(p(n)) // 1024, 'KB') diff --git a/tools/shot-harness.mjs b/tools/shot-harness.mjs new file mode 100644 index 0000000..9692b04 --- /dev/null +++ b/tools/shot-harness.mjs @@ -0,0 +1,313 @@ +/** + * 视觉验证用的「真挂载」外壳生成器。 + * + * 为什么要它:apply-probe 只跑到「factory 返回了 Boundary」,看不到布局;而界面改动的坑 + * (中心列 overflow:hidden、层叠上下文、字号阶梯没挂到根上)只有真渲染才暴露得出来。 + * 这里按 AGENTS.md 记载的做法:真 React 18 UMD + 复刻宿主外壳 + 宿主自己的 --dsw-* token, + * 然后把 client.js 当真插件加载进来、把槽位挂出来。 + * + * 数据用她本机真实的 desk.json(拷到临时 DSH_HOME,绝不写回原文件),截图才是她会看到的那一屏。 + * + * 用法:node tools/shot-harness.mjs → 产物在 .shots/(已 gitignore) + */ +import { execFileSync } from 'node:child_process' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))) +const ASAR = 'D:/Program Files/DeepSeek Harness/resources/app.asar' +const REACT_DIR = 'D:/chatbox/node_modules' +const REAL_HOME = 'D:/DSH/.dsh' +const OUT = join(ROOT, '.shots') + +mkdirSync(OUT, { recursive: true }) + +// ---------------------------------------------------------------- 1. 真快照 +// 宿主半边的 snapshot() 没导出,所以照 apply-probe 的办法起个 stub ctx,走真路由取快照。 +// DSH_HOME 先指到临时目录并拷进她的 desk.json —— 读真实数据,但不碰真实文件。 +const HOME = mkdtempSync(join(tmpdir(), 'desk-shot-')) +mkdirSync(join(HOME, 'study-desk'), { recursive: true }) +for (const f of ['desk.json', 'bbdc.json']) { + try { cpSync(join(REAL_HOME, 'study-desk', f), join(HOME, 'study-desk', f)) } catch { /* 没有就算了 */ } +} +process.env.DSH_HOME = HOME + +const routes = [] +const ctx = { + tools: { register: () => () => {} }, + webServer: { register: (r) => { routes.push(r); return () => {} } }, + effect: (f) => { f(); return () => {} }, + on: () => () => {}, + set: (k, v) => { services[k] = v }, + provide: (k, v) => { services[k] = v; return () => {} }, + get: (k) => services[k], + log: () => {}, + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + dirs: { home: HOME }, +} +const services = {} +const host = await import('../index.js') +host.apply(ctx, {}) + +function fakeRes() { + const res = { code: 0, chunks: [] } + res.setHeader = () => {} + res.writeHead = (code) => { res.code = code } + res.write = (s) => { res.chunks.push(s); return true } + res.end = (s) => { if (s !== undefined) res.chunks.push(s); res.body = res.chunks.join('') } + return res +} +const route = routes.find((x) => x.path === '/api/dsh-study-desk') +if (!route) throw new Error(`没注册路由,拿到的是 ${JSON.stringify(routes.map((r) => r && r.path))}`) +const res = fakeRes() +await route.handler({ method: 'GET', url: '/api/dsh-study-desk', headers: {}, on() {} }, res) +const snapshot = JSON.parse(res.body) + +// 顺手验 POST 通路的 weekly.text op(「复制周报」按钮用的纯文本,格式与工具 report 同一份) +const resW = fakeRes() +await route.handler({ + headers: {}, + on(ev, cb) { + if (ev === 'data') cb(Buffer.from(JSON.stringify({ op: 'weekly.text' }))) + if (ev === 'end') cb() + }, +}, resW) +let weeklyText = '' +try { weeklyText = JSON.parse(resW.body).text || '' } catch { /* op 挂了就留空,下面会报 */ } +console.log('[weekly.text] ok:', weeklyText.includes('周(每周从周一算起)'), '| head:', (weeklyText.split('\n')[0] || 'n/a').slice(0, 40)) +rmSync(HOME, { recursive: true, force: true }) +// 快照里带着临时 home 的绝对路径,会原样印进页脚 —— 截图是要给人看的,换成占位写法。 +for (const key of ['deskFile', 'markdownFile']) { + if (snapshot[key]) snapshot[key] = `/study-desk/${String(snapshot[key]).split(/[\\/]/).pop()}` +} +console.log('[snapshot] keys:', Object.keys(snapshot).join(',')) +console.log('[snapshot] focusPie today slices:', (snapshot.focusPie?.today || []).length, + '| month:', (snapshot.focusPie?.month || []).length, + '| board:', JSON.stringify(Object.fromEntries(Object.entries(snapshot).filter(([k]) => /task|board|column|state/.test(k)).map(([k, v]) => [k, Array.isArray(v) ? v.length : typeof v])))) + +// ---------------------------------------------------------------- 2. 宿主 token +function extractTokens() { + const asar = readFileSync(ASAR, 'latin1') + const blocks = [] + const re = /(body(?:\[data-ds-dark-theme\])?)\{(--dsw-[^}]*)\}/g + let m + while((m = re.exec(asar))) blocks.push({ sel: m[1], first: m[2].split(';')[0].split(':')[0], css: m[0] }) + // 同一个 selector + 同一组变量的重复定义里挑最完整的那份(asar 里有 minified / 非 minified 两份拷贝) + const best = new Map() + for (const b of blocks) { + const key = b.sel + '|' + b.first.slice(0, 22) + if (!best.has(key) || best.get(key).length < b.css.length) best.set(key, b.css) + } + const dark = [...best.values()].filter((c) => c.startsWith('body[data-ds-dark-theme]')).join('\n') + const light = [...best.values()].filter((c) => !c.startsWith('body[data-ds-dark-theme]')).join('\n') + return { light, dark } +} +const tokens = extractTokens() +console.log('[tokens] light', tokens.light.length, 'B | dark', tokens.dark.length, 'B') + +// ---------------------------------------------------------------- 3. 两版 client.js +const clientNew = readFileSync(join(ROOT, 'client.js'), 'utf8') +const clientOld = execFileSync('git', ['show', 'HEAD:client.js'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) +writeFileSync(join(OUT, 'client.new.js'), clientNew) +writeFileSync(join(OUT, 'client.old.js'), clientOld) +cpSync(join(REACT_DIR, 'react/umd/react.production.min.js'), join(OUT, 'react.js')) +cpSync(join(REACT_DIR, 'react-dom/umd/react-dom.production.min.js'), join(OUT, 'react-dom.js')) + +// ---------------------------------------------------------------- 4. 外壳 +// 热力图 + 「今日复习」卡里那排柱状图的三套候选色,全部由宿主 token 派生。 +const G = 'var(--dsw-static-green-500)' +const A4 = 'var(--dsw-static-amber-400)' +const A5 = 'var(--dsw-static-amber-500)' +const R5 = 'var(--dsw-static-red-500)' +const TEAL = 'color-mix(in srgb,var(--dsw-static-blue-450) 55%,var(--dsw-static-green-400))' +const BG3 = 'var(--dsw-alias-bg-layer-3)' +const ramp = (c) => [ + `.sd-cell.lv1{background:color-mix(in srgb,${c} 24%,${BG3})}`, + `.sd-cell.lv2{background:color-mix(in srgb,${c} 48%,${BG3})}`, + `.sd-cell.lv3{background:color-mix(in srgb,${c} 74%,${BG3})}`, + `.sd-cell.lv4{background:${c}}`, +].join('') +const HEAT_VARIANTS = { + green: ramp(G) + `.sd-fbar-fill{background:color-mix(in srgb,${G} 46%,transparent)}.sd-fbar.today .sd-fbar-fill{background:${G}}`, + teal: ramp(TEAL) + `.sd-fbar-fill{background:color-mix(in srgb,${TEAL} 46%,transparent)}.sd-fbar.today .sd-fbar-fill{background:${TEAL}}`, + // 暖色不是等比拉饱和:低档用琥珀、高档压到红,才看得出「越久越热」而不是「越来越橙」 + warm: [ + `.sd-cell.lv1{background:color-mix(in srgb,${A4} 34%,${BG3})}`, + `.sd-cell.lv2{background:${A5}}`, + `.sd-cell.lv3{background:color-mix(in srgb,${A5} 55%,${R5})}`, + `.sd-cell.lv4{background:${R5}}`, + `.sd-fbar-fill{background:color-mix(in srgb,${A5} 52%,transparent)}`, + `.sd-fbar.today .sd-fbar-fill{background:${R5}}`, + ].join(''), +} + +const boot = String.raw` +const Q = new URLSearchParams(location.search) +const DARK = Q.get('dark') === '1' +if (DARK) document.body.setAttribute('data-ds-dark-theme', '') +// ?heat=green|warm|teal 给热力图与复习柱状图套一层候选配色(同优先级、写在后面就能覆盖)。 +// 色值一律走宿主 --dsw-static-*,和插件的「绝不写死颜色」规矩一致。 +const HEAT = ${JSON.stringify(HEAT_VARIANTS)} +if (Q.get('heat') && HEAT[Q.get('heat')]) { + const st = document.createElement('style') + st.textContent = HEAT[Q.get('heat')] + document.head.appendChild(st) +} +window.__ModuleLoader__ = { load: (def) => { window.__def = def } } +const API = '/api/dsh-study-desk' +window.__calls = [] +window.addEventListener('error', (e) => { window.__bootError = (window.__bootError || '') + ' ' + e.message }) +window.fetch = async (url, opts) => { + const body = opts && opts.body ? JSON.parse(opts.body) : {} + window.__calls.push(body.op || 'GET') + // 行为断言有时要看完整 payload(比如 task.add 里的 dueDate),跟 op 名分开记 + ;(window.__callBodies = window.__callBodies || []).push(body) + if (body.op === 'diag.report') return { ok: true, status: 200, json: async () => ({ ok: true }) } + // 让假宿主稍微带点状态:settings.update 真把 patch 合进快照, + // 这样才能验「输入 90 → 秒表显示 90:00」这条链,而不是只验渲染出来了个输入框。 + if (body.op === 'settings.update' && body.patch) Object.assign(window.__SNAPSHOT__.state.settings, body.patch) + return { ok: true, status: 200, json: async () => window.__SNAPSHOT__ } +} +` + +const mount = String.raw` +const require = (name) => (name === 'react' ? window.React : name === 'react-dom' ? window.ReactDOM : {}) +const mod = window.__def.factory(require) +const slots = [] +// client.js 只走 slots.inject(slot, () => slots.register(meta, factory)), +// 而 meta.name 就是槽位名,所以登记处按 meta.name 收即可。 +const slotsSvc = { + inject: (slot, fn) => { fn(); return () => {} }, + register: (meta, factory) => { slots.push({ meta, factory }); return () => {} }, +} +const ctx = { get: (k) => (k === 'slots' ? slotsSvc : undefined), effect: (f) => { f(); return () => {} }, provide: () => () => {}, log: () => {} } +mod.apply(ctx) + +const hostOf = (meta) => { + if (meta.name === 'main') return meta.key === 'study-desk' ? document.getElementById('main') : document.getElementById('main2') + if (meta.name === 'shell.overlay') return document.getElementById('overlay') + if (meta.name === 'sidebar.panellist') return document.getElementById('nav') + if (meta.name === 'settings.section') return document.getElementById('settings') + return null +} +// 先让 apply() 里的 store.refresh() 把快照取回来,再挂载:一挂载就有数据, +// 不用等第二次并发重渲染(虚拟时间未必泵得动 MessageChannel)。 +setTimeout(() => { + for (const s of slots) { + const host = hostOf(s.meta) + if (!host) continue + // 无头 --dump-dom/--screenshot 靠 --virtual-time-budget 推进时间,而 React 18 的并发 root + // 是 MessageChannel 调度的,虚拟时间不一定泵得动 —— flushSync 强制同步提交。 + ReactDOM.flushSync(() => ReactDOM.createRoot(host).render(s.factory())) + } + window.__slotCount = slots.length + // ?range=month:切到「近 30 天」,她那个月饼才有 5 段,颜色分不分得开才看得出来。 + // React 的 onClick 会在 dispatch 里同步跑,外面套 flushSync 才能让这次状态更新立刻提交。 + if (Q.get('range') === 'month') { + const pill = document.querySelectorAll('.sd-range-tabs button')[1] + if (pill) ReactDOM.flushSync(() => pill.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + } + // ?setmin=90:往「自定义」那个框里填值并回车提交,验「输入 → settings → 秒表」这条链 + const setMin = Q.get('setmin') + if (setMin) { + const el = document.querySelector('.sd-timer-card input[type=number]') + if (el) { + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set + setter.call(el, setMin) + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + } + } + setTimeout(() => { + const diag = document.getElementById('diag') + const rectOf = (sel) => { const el = document.querySelector(sel); if (!el) return 'none'; const r = el.getBoundingClientRect(); return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)].join(',') } + const page = document.querySelector('.sd-page') + const err = document.querySelector('.sd-error') + const pie = (window.__SNAPSHOT__.focusPie || {}) + diag.textContent = [ + 'react=' + window.React.version, + 'slots=' + slots.length, + 'mounted=' + Boolean(page), + 'pageH=' + Math.round(page ? page.getBoundingClientRect().height : 0), + 'mainKids=' + document.getElementById('main').childElementCount, + 'pieMonth=' + ((pie.month || []).length), + 'cards=' + document.querySelectorAll('.sd-card').length, + 'heatCells=' + document.querySelectorAll('.sd-cell').length, + 'slices=' + document.querySelectorAll('.sd-pie path').length, + 'timerNumFields=' + document.querySelectorAll('.sd-timer-card input[type=number]').length, + 'focusMinNow=' + window.__SNAPSHOT__.state.settings.focusMin, + 'idleClock=' + ((document.querySelector('.sd-timer-clock') || {}).textContent || 'none'), + 'playTitle=' + ((document.querySelector('.sd-timer-card .sd-iconbtn-lg') || {}).title || 'none'), + 'pieRect=' + rectOf('.sd-pie-wrap'), + 'heatRect=' + rectOf('.sd-heat'), + 'forecastRect=' + rectOf('.sd-forecast'), + 'mini=' + document.querySelectorAll('.sd-mini').length, + 'calls=' + window.__calls.join(','), + 'errText=' + (err ? String(err.textContent).slice(0, 160) : 'none'), + 'bootErr=' + (window.__bootError || 'none'), + ].join(' | ') + window.__mounted = true + }, 260) +}, 300) +` + +const shell = ` +html,body{margin:0;height:100%;font-family:var(--dsw-font-family);} +.frame{display:grid;grid-template-columns:236px minmax(0,1fr);height:100vh;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)} +.side{background:var(--dsw-specific-sidebar-fill,var(--dsw-alias-bg-layer-1));border-right:1px solid var(--dsw-alias-border-l1);padding:14px 10px;display:flex;flex-direction:column;gap:10px;overflow:hidden} +.centerCol{display:flex;flex-direction:column;overflow:hidden;position:relative;background:var(--dsw-alias-bg-layer-1)} +.overlayLayer{position:absolute;inset:0;z-index:20;pointer-events:none} +.overlayLayer>*{pointer-events:auto} +.navbtn{font-size:13px;color:var(--dsw-alias-label-secondary);padding:6px 8px;border-radius:8px;background:var(--dsw-alias-bg-layer-2)} +#main2,#settings{display:none} +` + +const html = ` +study-desk harness + + + +
+
+
+
+
+
+

+
+
+
+
+
+`
+
+writeFileSync(join(OUT, 'panel-new.html'), html.replace('__CLIENT__', 'new'))
+writeFileSync(join(OUT, 'panel-old.html'), html.replace('__CLIENT__', 'old'))
+
+// ---------------------------------------------------------------- 5. 色板对照页
+// 她那个月饼只有 5 段,看不出 8 色好不好分;这一页把 8 个 tone 都铺出来,
+// 深浅底各一行,并写上每个色值出自哪个 token(方便核对「没有写死颜色」这条)。
+const { PIE_TONES } = (await import('./client-loader.mjs')).loadClient()
+const toneLabel = (t) => t
+  .replace(/var\(--dsw-static-([\w-]+)\)/g, '$1')
+  .replace(/color-mix\(in srgb,var\(--dsw-static-([\w-]+)\) (\d+)%,var\(--dsw-static-([\w-]+)\)\)/g, '$1 + $3 @$2%')
+const palettePage = (dark) => `
+
+
+

饼图分类色板 · 8 色 · 全部取自宿主 --dsw-static-*

+
${PIE_TONES.map((t, i) => `
${i + 1}
`).join('')}
+
${PIE_TONES.map((t, i) => `${i + 1} ${toneLabel(t)}`).join('
')}
+` +writeFileSync(join(OUT, 'palette-light.html'), palettePage(false)) +writeFileSync(join(OUT, 'palette-dark.html'), palettePage(true)) +console.log('[done] .shots/panel-{new,old}.html .shots/palette-{light,dark}.html (' + PIE_TONES.length + ' tones)') From 649d6f96a4ea6da673775ebe576cee3f1101ce43 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 5 Nov 2026 08:23:34 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=AF=8F=E6=97=A5=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E8=A1=A8=EF=BC=9AHH:MM~HH:MM=20=E5=81=9A=E4=BB=80=E4=B9=88?= =?UTF-8?q?=EF=BC=8C=E5=8F=AF=E6=82=AC=E6=B5=AE=E5=9C=A8=E6=A1=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - desk.js:state.dailyPlan(enabled/float/items)在 normalizeDailyPlan 一处校验——坏行忽略、起终写反丢弃、按开始时刻排序、超 48 条截断;dailyPlanNow 单点算「现在在哪个区间 / 下一场」,系统提示注入与快照 dayPlan 都从它取 - index.js:快照带 dayPlan;boardText 注入「现在按计划该做什么 / 下一场」,模型才能对着计划督促;新增 dailyPlan.update op(客户端开关与编辑器共用) - client.js:主面板新分区(当前时段高亮 + 剩余分钟,已过变暗);设置页编辑器(一行一条的 textarea,起<终预检,真正的规矩在宿主一处)与两个独立开关(启用 / 悬浮);第二个 shell.overlay 悬浮件 PlanDock——坑位规矩照抄迷你条:portal 到 body、no-drag、pointer-events:auto、z-index:900、clampMiniPos 顶部避开标题栏拖拽带,默认位(bottom:64px)叠在迷你条上方互不打架,落点单独存 plan-pos,可收成小圆点、双击回默认位;当前时段高亮走 state-business 语义色,与「当前节点」chip 同款 - 测试:73 个单测(新增计划表归一化 / 区间判定 / 单独开关 / 悬浮件坑位守卫:no-drag、pointer-events、z-index、默认位不重叠、钳制不许另起炉灶);探针改 7 槽位断言并补 dailyPlan.update 校验与快照 dayPlan 形状;shot-harness 支持 ?plan=1 伪造开启计划表的快照、?planopen / ?planfloatoff 验展开与「设置页关悬浮→件消失」整链,并把宿主 effect 的清理函数跑掉(番茄督促带来的 10s 看门狗 setInterval 不清进程退不出去) --- AGENTS.md | 20 ++- apply-probe.mjs | 40 ++++- client.js | 345 +++++++++++++++++++++++++++++++++++++++-- desk.js | 78 ++++++++++ index.js | 16 ++ test/desk.test.js | 63 ++++++++ test/mini-dock.test.js | 35 +++++ tools/shot-harness.mjs | 51 +++++- 8 files changed, 623 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d5938b3..0209be1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,9 @@ - **待办墙**:Notion 式三列(待办 / 进行中 / 已完成),卡片带科目、预计时长、累计投入,可拖拽换列; - **学习热力图**:点亮条件是**当天实际复习了多少分钟**,不是「当天有没有消耗 token / 有没有开过会话」; - **番茄钟**:专注 / 短休 / 长休自动轮换,换面板、刷新、关掉浏览器再打开都按绝对截止时间恢复; + AI 起钟走门禁(必须挂「进行中」的卡),走完自动提醒开钟的那个会话(可开关); +- **每日计划表**:`HH:MM~HH:MM 事情` 的每日固定模板,开了之后主面板显示、模型从系统提示里 + 知道「现在该做什么」,还可以像迷你条一样悬浮在桌面上(功能与悬浮各自独立开关); - **常驻迷你条**:右下角小胶囊,在对话页也能看见剩余时间。 它**不属于 DSH 官方**,也不依赖任何第三方包(运行时依赖为零)。宿主半边只额外用到 DSH 自己的 @@ -25,14 +28,14 @@ | `markdown.js` | Markdown 导出 / 读回(纯函数):`toMarkdown` 生成 Obsidian 友好的 `desk.md`,`fromMarkdown` + `mergeMarkdown` 按 id 合并读回。机器字段放在行尾 `` 注释里。 | | `bbdc.js` | **不背单词打卡桥**(纯数据层,零依赖):从腾讯 Androws 虚拟磁盘里的 App 数据读打卡/时长/词书,再用 App WebView 缓存里挖到的 `sid` 打 `learnywhere.cn` 的统计接口补累计词数。**只读**,不回写 App。`bbdcView()` 把采集结果裁成面板要看的紧凑形状(挡住 sid 摘要与临时目录);`readBbdc()` 带 10 分钟缓存 + 并发去重。 | | `timer.js` | 番茄钟状态机:`start/pause/resume/reset/complete/reconcile`。真相是绝对时间戳,不用「还剩多少毫秒」。 | -| `index.js` | **宿主半边**:`study_desk` 工具(11 个 action)+ `/api/dsh-study-desk` 路由 + 往 system prompt 注入今日摘要。派生数据(复习队列(每张带 `curve`)/统计/`forecast` 七日预报/`plan` 今日建议/周报/标签/单词打卡)一律由 `desk.js` / `bbdc.js` 算好再随快照发出。 | -| `client.js` | **客户端半边**:整页看板(复习队列 / 过滤栏 / 热力图 / 周报 / 复盘)+ 独立的「单词打卡」面板 + 设置页 + 迷你条,注册 6 个槽位(2 个侧栏入口 + 2 个 main + 1 个迷你条 + 1 个设置节)。 | +| `index.js` | **宿主半边**:`study_desk` 工具(11 个 action)+ `/api/dsh-study-desk` 路由 + 往 system prompt 注入今日摘要(含每日计划表的「现在该做什么」)。派生数据(复习队列(每张带 `curve`)/统计/`forecast` 七日预报/`plan` 今日建议/`dayPlan` 计划表当前时段/周报/标签/单词打卡)一律由 `desk.js` / `bbdc.js` 算好再随快照发出。 | +| `client.js` | **客户端半边**:整页看板(复习队列 / 过滤栏 / 热力图 / 周报 / 复盘 / 每日计划表)+ 独立的「单词打卡」面板 + 设置页 + 迷你条与计划表两个悬浮件,注册 7 个槽位(2 个侧栏入口 + 2 个 main + 2 个 shell.overlay + 1 个设置节)。 | | `cordis.patch.yml` | 让宿主把本插件 insert 进 loader 树。 | | `package.json` | `dsh.bundle.patch` 指向上面那个 patch;`dsh.client` 声明客户端半边与注入的运行时包。 | -| `test/desk.test.js` | 30 个单测,`node --test`。 | +| `test/desk.test.js` | 数据层主套件(含番茄钟状态机、AI 起钟会话标记、每日计划表归一化与区间判定),`node --test`。 | | `test/markdown.test.js` | 4 个单测:导出→读回往返、手写 Markdown 读回、id 命中即更新。 | | `test/bbdc.test.js` | 13 个单测:prefs 解析、按月求和(累计 293 词就是 48 个月的 `learnNum` 相加)、`sid` 编码变体、时长两边取新、本地刷盘滞后标记等。 | -| `test/mini-dock.test.js` | 5 个单测:迷你条松手不贴边、仍会被收回视口、吸附实现已移除、声明了 `no-drag`、不许停进宿主标题栏那条拖拽带。 | +| `test/mini-dock.test.js` | 8 个单测:迷你条松手不贴边、仍会被收回视口、吸附实现已移除、声明了 `no-drag`、不许停进宿主标题栏那条拖拽带,以及计划表悬浮件的同款坑位守卫(no-drag / pointer-events / z-index / 默认位不重叠 / 钳制函数不许另起炉灶)。 | | `test/style.test.js` | 6 个单测(视觉层守卫):CSS 里 hex 必须为 0、饼图 8 色不许退回语义色、不许有零点几像素字号、标题不用衬线、字阶挂在各根上、图表配色归属。 | | `tools/client-loader.mjs` | 把 `client.js` 这个 loader 格式的 bundle 在纯 Node 里载进来、取 `exports.__test`。两个测试文件共用一份,免得各写一套桩。放在 `tools/` 是因为 `node --test` 会把 `test/` 下每个文件都当测试跑。 | | `apply-probe.mjs` | 离线冒烟:用 stub ctx 把两侧 `apply()` 真跑一遍,不启动 DSH 就能抓加载期错误。 | @@ -62,6 +65,9 @@ 或 Windows 用户目录那种绝对路径(`C:` 下 Users 里带真名的那种)—— CI 会 grep 直接红。 7. **复习排期、队列排序、周报、标签聚合只在 `desk.js` 一处算。** 宿主把 `review / due / tags / weekly / journal` 随快照发出,`client.js` 只画不算。浏览器里再实现一遍 SM-2 迟早和宿主对不上。 + 每日计划表的「现在在哪个区间」(`dailyPlanNow`)同理只在 desk.js:注入 system prompt 与快照 + `dayPlan` 都从它取。客户端那份 `pmin()` 只做钟表换算(当前分钟落在哪个区间、剩几分钟), + 和画倒计时是一档的事,不是领域算法——别把排序、校验再写一遍进浏览器。 8. **Markdown 读回是「合并」不是「覆盖」**:`desk.md` 里没出现的卡不动;靠行尾 `` 对回原卡, 注释被删就当新卡。导出的 Markdown 里不许出现本机绝对路径。 9. **不背单词那边只读,且凭据不出门。** `bbdc.js` 只读 App 数据与官方 H5 的公开统计接口, @@ -199,8 +205,8 @@ ctx.set('studyDesk', { ... }) // 抛 Error: cannot set property "studyDesk ## 测试 ```bash -node --test # 58 个单测:数据层 + 番茄钟状态机 + 复习排期 + 遗忘曲线/预报/建议 + Markdown 往返 + 不背单词 prefs 解析与时长取新 + 迷你条落点与拖拽带 + 视觉层守卫(hex / 字阶 / 配色归属) -node apply-probe.mjs # 离线冒烟:stub ctx 跑两侧 apply(),检查工具 / 路由 / 槽位注册 / 新 op / 快照新字段 +node --test # 73 个单测:数据层 + 番茄钟状态机 + AI 起钟会话标记 + 复习排期 + 遗忘曲线/预报/建议 + Markdown 往返 + 不背单词 prefs 解析与时长取新 + 迷你条/计划表悬浮件落点与拖拽带 + 每日计划表 + 视觉层守卫(hex / 字阶 / 配色归属) +node apply-probe.mjs # 离线冒烟:stub ctx 跑两侧 apply(),检查工具 / 路由 / 槽位注册 / 新 op / 快照新字段 / AI 起钟门禁与会话提醒 / 每日计划表校验 node install.mjs --check # 体检:本目录与某个 profile 的接线是否完整 ``` @@ -214,7 +220,7 @@ node install.mjs --check # 体检:本目录与某个 profile 的接线是否 拿一份 React 18 的 UMD 构建(本机任意 `node_modules/react{,-dom}/umd/*.production.min.js`), 写一个 HTML 复刻宿主外壳(`frame > centerCol(flex, overflow:hidden) + overlayLayer(z-index:20, pointer-events:none)`), 塞进宿主的 `body{--dsw-alias-*}` 主题 token,替换 `window.fetch` 成一个小假宿主, -然后用 `def.factory(require)` 拿到模块、`ReactDOM.createRoot` 把四个槽位挂出来, +然后用 `def.factory(require)` 拿到模块、`ReactDOM.createRoot` 把七个槽位挂出来, 再用真 `MouseEvent('click')` 点一遍按钮、断言 POST 出去的 op。 暗色只要给 `` 加 `data-ds-dark-theme`(注意:暗色标记在 **body** 上,不是 html)。 无头 Edge 出图与结果:`msedge --headless=new --dump-dom`(把断言写进一个屏外 `
`)+ `--screenshot`。
diff --git a/apply-probe.mjs b/apply-probe.mjs
index ede4c87..fef36c7 100644
--- a/apply-probe.mjs
+++ b/apply-probe.mjs
@@ -294,6 +294,30 @@ check('手动起钟不需要挂卡', () => {
 await post({ op: 'timer.reset' })
 await run('remove', { title: '门禁冒烟卡' })
 
+// ---- 每日计划表:数据校验在 desk.js 一处,快照带宿主算好的 dayPlan
+const dp1 = await post({ op: 'dailyPlan.update', patch: {
+  enabled: true,
+  items: [
+    '这行不是对象,会被丢掉',
+    { start: '08:00', end: '09:30', label: '背单词', subject: '英语' },
+    { start: '09:30', end: '08:00', label: '起终写反的', subject: '' },
+    { start: '25:00', end: '26:00', label: '非法时刻的' },
+  ],
+} })
+check('POST dailyPlan.update 200', () => assert.equal(dp1.code, 200))
+check('计划表坏行被丢、好行保留', () => {
+  const dp = JSON.parse(dp1.body).state.dailyPlan
+  assert.equal(dp.enabled, true)
+  assert.equal(dp.items.length, 1)
+  assert.equal(dp.items[0].label, '背单词')
+})
+check('快照带 dayPlan(current/next 形状)', () => {
+  const j = JSON.parse(dp1.body)
+  assert.ok(Array.isArray(j.dayPlan.items) && j.dayPlan.items.length === 1)
+  assert.ok(j.dayPlan.current === null || j.dayPlan.current.label === '背单词')
+})
+await post({ op: 'dailyPlan.update', patch: { enabled: false } })
+
 // ---- 复习 / 复盘 / Markdown
 const p7 = await post({ op: 'review.grade', title: 'POST 加的卡', grade: 'easy' })
 check('POST review.grade 200', () => assert.equal(p7.code, 200))
@@ -413,6 +437,7 @@ globalThis.fetch = async () => ({
       journal: {},
       timer: null,
       settings: { focusMin: 25, shortBreakMin: 5, longBreakMin: 15, roundsBeforeLong: 4, dailyGoalMin: 180, streakMin: 10, injectPrompt: true, subjects: [], heatmapWeeks: 26, reviewQueueSize: 10 },
+      dailyPlan: { enabled: true, float: true, items: [{ id: 'a', start: '08:00', end: '09:30', label: '背单词', subject: '英语' }] },
       createdAt: 0,
       updatedAt: 0,
     },
@@ -421,6 +446,7 @@ globalThis.fetch = async () => ({
     streak: 0,
     focusRoundsToday: 0,
     review: { scheduled: 0, due: 0, dueToday: 0, overdue: 0, doneToday: 0, weekDone: 0 },
+    dayPlan: { current: null, next: null, items: [{ id: 'a', start: '08:00', end: '09:30', label: '背单词', subject: '英语' }] },
     due: [],
     tags: [],
     weekly: { weeks: [], goalWeekly: 1260 },
@@ -471,12 +497,12 @@ const clientCtx = {
 
 check('client apply() 不抛', () => clientMod.apply(clientCtx))
 // 两个面板:学习工作台 + 单词打卡,各自一对 sidebar.panellist / main
-check('注册了 6 个槽位(两个面板 + 迷你条 + 设置页)', () => {
+check('注册了 7 个槽位(两个面板 + 两个悬浮件 + 设置页)', () => {
   const names = clientRegistered.slots.map((s) => s.slotName).sort()
-  assert.deepEqual(names, ['main', 'main', 'settings.section', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist'])
+  assert.deepEqual(names, ['main', 'main', 'settings.section', 'shell.overlay', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist'])
 })
 check('inject 的槽位名与注册的一致', () => {
-  assert.deepEqual(clientRegistered.injects.slice().sort(), ['main', 'main', 'settings.section', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist'])
+  assert.deepEqual(clientRegistered.injects.slice().sort(), ['main', 'main', 'settings.section', 'shell.overlay', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist'])
 })
 check('两个侧栏面板的 id 与两个 main 的 key 逐一对齐', () => {
   const ids = clientRegistered.slots.filter((x) => x.slotName === 'sidebar.panellist').map((x) => x.id).sort()
@@ -514,9 +540,13 @@ check('每个槽位的 factory 都用 Boundary 包了一层', () => {
     assert.equal(node.__el.name, 'Boundary', `${s.slotName} 没被 Boundary 包住,实际是 ${node.__el && node.__el.name}`)
   }
 })
-check('Boundary 的 where 标签能区分两个面板', () => {
+check('Boundary 的 where 标签能区分两个面板与两个悬浮件', () => {
   const wheres = clientRegistered.slots.map((s) => s.factory({ size: 18 }).props.where).sort()
-  assert.deepEqual(wheres, ['main', 'main.bbdc', 'settings.section', 'shell.overlay', 'sidebar.panellist', 'sidebar.panellist.bbdc'])
+  assert.deepEqual(wheres, ['main', 'main.bbdc', 'settings.section', 'shell.overlay', 'shell.overlay.plandock', 'sidebar.panellist', 'sidebar.panellist.bbdc'])
+})
+check('两个 shell.overlay 的 id 不冲突(宿主按 id 挂槽位)', () => {
+  const ids = clientRegistered.slots.filter((x) => x.slotName === 'shell.overlay').map((x) => x.id).sort()
+  assert.deepEqual(ids, ['study-desk-mini', 'study-desk-plan'])
 })
 check('注入了一份 style', () => assert.ok(clientRegistered.effects.length > 0))
 
diff --git a/client.js b/client.js
index 24bd9f0..3b224a5 100644
--- a/client.js
+++ b/client.js
@@ -12,7 +12,7 @@
  * 挂载点:
  *   sidebar.panellist  → 左侧栏图标(id 与 main 的 key 同名,侧栏自动 dispatch)
  *   main (keyed)       → 整页工作台
- *   shell.overlay      → 右下角常驻迷你计时条(跨面板存活)
+ *   shell.overlay      → 右下角常驻迷你计时条 + 每日计划表悬浮件(跨面板存活,各一个槽位)
  *   settings.section   → 「学习工作台」设置页
  */
 
@@ -48,6 +48,8 @@ window.__ModuleLoader__.load({
     // 单词打卡是第二个独立面板:侧栏一项 + main 一格,id 与 main 的 key 必须同名
     const BBCDC_PANEL_ID = 'study-desk-bbdc'
     const MINI_POS_KEY = 'dsh-study-desk:mini-pos'
+    // 每日计划表悬浮件的落点:和迷你条一样是纯本地偏好,不占 desk.json
+    const PLAN_POS_KEY = 'dsh-study-desk:plan-pos'
 
     const STATUS_META = [
       { id: 'todo', label: '待办', latin: 'TODO' },
@@ -158,7 +160,7 @@ window.__ModuleLoader__.load({
     const store = {
       ready: false,
       error: null,
-      state: { tasks: [], sessions: [], reviews: [], journal: {}, timer: null, settings: {} },
+      state: { tasks: [], sessions: [], reviews: [], journal: {}, timer: null, settings: {}, dailyPlan: { enabled: false, float: false, items: [] } },
       minutes: {},
       todayMinutes: 0,
       streak: 0,
@@ -169,6 +171,8 @@ window.__ModuleLoader__.load({
       forecast: { days: [], later: 0, total: 0, max: 0 },
       focusPie: null,
       plan: { items: [], due: 0, cap: 10, advice: null },
+      // 每日计划表:宿主算好「现在/下一场」,这里只缓存
+      dayPlan: { current: null, next: null, items: [] },
       tags: [],
       weekly: { weeks: [], goalWeekly: 0 },
       journalLog: [],
@@ -206,6 +210,7 @@ window.__ModuleLoader__.load({
         this.forecast = json.forecast || this.forecast
         this.focusPie = json.focusPie || this.focusPie
         this.plan = json.plan || this.plan
+        this.dayPlan = json.dayPlan || this.dayPlan
         this.tags = json.tags || []
         this.weekly = json.weekly || this.weekly
         this.journalLog = json.journal || []
@@ -459,6 +464,8 @@ window.__ModuleLoader__.load({
           'M14 13.5l1.7 1.2',
         ],
         clock: ['M10 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13z', 'M10 6.5v4l2.5 1.5'],
+        // 每日计划表:日历框 + 两个吊耳 + 指针(区别于纯时钟,讲「时间段」)
+        plancal: ['M3.5 6h13v11h-13z', 'M3.5 9h13', 'M7 4v2.5', 'M13 4v2.5', 'M10 11v2.5', 'M10 13.5l1.8 1.3'],
         play: ['M6.5 4.8v10.4l8-5.2z'],
         pause: ['M7 4.8v10.4', 'M13 4.8v10.4'],
         stop: ['M6 6h8v8H6z'],
@@ -2115,6 +2122,7 @@ window.__ModuleLoader__.load({
             h(Head, { key: 'head', now }),
             h(TimerPanel, { key: 'timer', now }),
             s.error ? h('div', { className: 'sd-error', key: 'err' }, '工作台数据读取失败:' + s.error) : null,
+            h(DayPlanSection, { key: 'dayplan', now }),
             h(ReviewQueue, { key: 'review' }),
             h('div', { className: 'sd-section-title', key: 'st' }, [
               h('span', { key: 'a' }, '待办墙'),
@@ -2136,6 +2144,47 @@ window.__ModuleLoader__.load({
       ])
     }
 
+    // -----------------------------------------------------------------------
+    // 每日计划表(主面板分区):哪个时间段做什么事。
+    // 区间数据与排序全部由宿主发来(desk.js 的 dailyPlanNow 单点算),
+    // 这里只做钟表运算——当前分钟落在哪个区间里,跟画倒计时是一档的事。
+    // -----------------------------------------------------------------------
+
+    /** 'HH:MM' → 当天分钟数。纯钟表换算,客户端/宿主各有一份(宿主的带校验)。 */
+    function pmin(hhmm) {
+      const m = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || '').trim())
+      if (!m) return NaN
+      return Number(m[1]) * 60 + Number(m[2])
+    }
+
+    function DayPlanSection({ now }) {
+      const s = useStore()
+      const dp = (s.state && s.state.dailyPlan) || null
+      if (!dp || dp.enabled !== true || !Array.isArray(dp.items) || !dp.items.length) return null
+      const d = new Date(now)
+      const mins = d.getHours() * 60 + d.getMinutes()
+      const rows = dp.items.map((it) => {
+        const from = pmin(it.start)
+        const to = pmin(it.end)
+        const state = mins >= from && mins < to ? 'now' : mins >= to ? 'past' : 'todo'
+        return { it, state, left: state === 'now' ? Math.max(1, to - mins) : 0 }
+      })
+      return h('div', { key: 'wrap' }, [
+        h('div', { className: 'sd-section-title' }, [
+          h('span', { key: 'a' }, '每日计划表'),
+          h('span', { className: 'sd-section-hint', key: 'b' }, '时间段在设置页编辑;开着的时候模型也会从系统提示里知道现在该做什么'),
+        ]),
+        h('div', { className: 'sd-plan-card' }, rows.map(({ it, state, left }) =>
+          h('div', { className: 'sd-plan-row ' + state, key: it.id }, [
+            h('span', { className: 'sd-plan-time', key: 't' }, `${it.start}~${it.end}`),
+            h('span', { className: 'sd-plan-label', key: 'l', title: it.label }, it.label),
+            it.subject ? h('span', { className: 'sd-plan-subject', key: 's' }, it.subject) : null,
+            state === 'now' ? h('span', { className: 'sd-plan-flag', key: 'x' }, `剩 ${left} 分钟`) : null,
+            state === 'past' ? h('span', { className: 'sd-plan-flag past', key: 'x' }, '已过') : null,
+          ]))),
+      ])
+    }
+
     // -----------------------------------------------------------------------
     // 常驻迷你计时条(shell.overlay)
     // -----------------------------------------------------------------------
@@ -2189,10 +2238,11 @@ window.__ModuleLoader__.load({
     // 悬浮卡片被谁盖住,在浏览器里根本看不见:拿命中测试结果回传宿主写盘。
     // reportDiag 按 kind+where+message 去重,所以反复探也不会把诊断文件灌爆。
     // 被盖分两档:盖的人住在宿主模态层(z≥1000:模态框 1000、菜单/气泡 1100)里,
-    // 是迷你条「压在模态下」这条设计取舍的必然结果,不算事故 —— 单独记 mini-under-modal;
-    // 普通内容盖上来才是真回归,继续记 mini-covered。2026-11-05 那次误报就是设置页
+    // 是悬浮件「压在模态下」这条设计取舍的必然结果,不算事故 —— 单独记 -under-modal;
+    // 普通内容盖上来才是真回归,继续记 -covered。2026-11-05 那次误报就是设置页
     // (z≥1000)的账号卡片盖住了它,翻诊断时差点当 bug 修。
-    function probeOverlayHit(el) {
+    // tag 区分两个悬浮件(mini=迷你条,plandock=每日计划表),诊断里能分开看。
+    function probeOverlayHit(el, tag = 'mini') {
       if (!el || typeof document.elementFromPoint !== 'function') return
       const rect = el.getBoundingClientRect()
       if (!rect.width || !rect.height) return
@@ -2215,8 +2265,8 @@ window.__ModuleLoader__.load({
         }
       }
       reportDiag({
-        kind: modalZ ? 'mini-under-modal' : 'mini-covered',
-        where: 'mini.命中测试',
+        kind: modalZ ? `${tag}-under-modal` : `${tag}-covered`,
+        where: `${tag}.命中测试`,
         message: `中心 ${cx},${cy} 命中的是 ${chain.join(' < ')}` + (modalZ ? `(宿主模态层 z=${modalZ},设计内让位)` : ''),
       })
     }
@@ -2313,8 +2363,8 @@ window.__ModuleLoader__.load({
       }, [])
 
       React.useEffect(() => {
-        probeOverlayHit(rootRef.current)
-        const id = setInterval(() => probeOverlayHit(rootRef.current), 5000)
+        probeOverlayHit(rootRef.current, 'mini')
+        const id = setInterval(() => probeOverlayHit(rootRef.current, 'mini'), 5000)
         return () => clearInterval(id)
       }, [])
 
@@ -2424,6 +2474,182 @@ window.__ModuleLoader__.load({
       ]))
     }
 
+    // -----------------------------------------------------------------------
+    // 每日计划表悬浮件(shell.overlay 第二件)
+    // 与迷你条同一套坑位规矩:portal 到 body、pointer-events:auto、z-index:900、
+    // no-drag、clampMiniPos 顶部避开标题栏拖拽带、落点存 localStorage。
+    // 开关:dailyPlan.enabled 开整个功能,dailyPlan.float 单独管这件悬浮。
+    // -----------------------------------------------------------------------
+
+    function readPlanPos() {
+      try {
+        const raw = window.localStorage.getItem(PLAN_POS_KEY)
+        if (!raw) return null
+        const p = JSON.parse(raw)
+        if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) return { x: p.x, y: p.y }
+      } catch (error) { /* 读不到就当没挪过 */ }
+      return null
+    }
+
+    function writePlanPos(p) {
+      try {
+        if (!p) window.localStorage.removeItem(PLAN_POS_KEY)
+        else window.localStorage.setItem(PLAN_POS_KEY, JSON.stringify({ x: Math.round(p.x), y: Math.round(p.y) }))
+      } catch (error) { /* 写不进去就这一次不记 */ }
+    }
+
+    function PlanDock() {
+      const s = useStore()
+      const now = useTick(1000)
+      usePoll(60000)
+      const [open, setOpen] = React.useState(false)
+      const [hidden, setHidden] = React.useState(false)
+      const [pos, setPos] = React.useState(readPlanPos)
+      const dragRef = React.useRef(null)
+      const rootRef = React.useRef(null)
+      const justDraggedRef = React.useRef(false)
+      // 钩子全部走完再决定渲不渲染:开关翻动的瞬间不能少调钩子
+      const dp = (s.state && s.state.dailyPlan) || null
+      const live = Boolean(dp && dp.enabled === true && dp.float === true
+        && Array.isArray(dp.items) && dp.items.length > 0)
+
+      React.useEffect(() => {
+        setPos((current) => {
+          if (!current) return current
+          const el = rootRef.current
+          if (!el) return current
+          const rect = el.getBoundingClientRect()
+          return clampMiniPos(current, rect.width, rect.height)
+        })
+      }, [hidden, open])
+
+      React.useEffect(() => {
+        function onResize() {
+          setPos((current) => {
+            if (!current) return current
+            const el = rootRef.current
+            if (!el) return current
+            const rect = el.getBoundingClientRect()
+            return clampMiniPos(current, rect.width, rect.height)
+          })
+        }
+        window.addEventListener('resize', onResize)
+        return () => window.removeEventListener('resize', onResize)
+      }, [])
+
+      React.useEffect(() => {
+        probeOverlayHit(rootRef.current, 'plandock')
+        const id = setInterval(() => probeOverlayHit(rootRef.current, 'plandock'), 5000)
+        return () => clearInterval(id)
+      }, [])
+
+      if (!live) return null
+
+      const d = new Date(now)
+      const mins = d.getHours() * 60 + d.getMinutes()
+      const current = dp.items.find((it) => mins >= pmin(it.start) && mins < pmin(it.end)) || null
+      const next = dp.items.find((it) => pmin(it.start) > mins) || null
+      const leftMin = current ? Math.max(1, pmin(current.end) - mins) : 0
+
+      // 拖动手势与迷你条同一套:非按钮处按下起拖、挪 4px 内不算拖、双击回默认位
+      const dragHandlers = {
+        onPointerDown: (event) => {
+          if (event.button !== 0) return
+          if (event.target && event.target.closest && event.target.closest('button')) return
+          const el = event.currentTarget
+          const rect = el.getBoundingClientRect()
+          dragRef.current = {
+            dx: event.clientX - rect.left,
+            dy: event.clientY - rect.top,
+            w: rect.width,
+            h: rect.height,
+            sx: event.clientX,
+            sy: event.clientY,
+            moved: false,
+          }
+          try { el.setPointerCapture(event.pointerId) } catch (error) { /* 拿不到捕获也照样能拖 */ }
+        },
+        onPointerMove: (event) => {
+          const dr = dragRef.current
+          if (!dr) return
+          if (event.buttons === 0) { dragRef.current = null; return }
+          if (!dr.moved && Math.abs(event.clientX - dr.sx) + Math.abs(event.clientY - dr.sy) < 4) return
+          dr.moved = true
+          setPos(clampMiniPos({ x: event.clientX - dr.dx, y: event.clientY - dr.dy }, dr.w, dr.h))
+        },
+        onPointerUp: (event) => {
+          const dr = dragRef.current
+          dragRef.current = null
+          if (!dr) return
+          try { event.currentTarget.releasePointerCapture(event.pointerId) } catch (error) { /* 同上 */ }
+          if (dr.moved) {
+            justDraggedRef.current = true
+            setTimeout(() => { justDraggedRef.current = false }, 0)
+            const nextPos = clampMiniPos({ x: event.clientX - dr.dx, y: event.clientY - dr.dy }, dr.w, dr.h)
+            setPos(nextPos)
+            writePlanPos(nextPos)
+          }
+        },
+        onPointerCancel: () => { dragRef.current = null },
+        onLostPointerCapture: () => { dragRef.current = null },
+        onDoubleClick: (event) => {
+          if (event.target && event.target.closest && event.target.closest('button')) return
+          setPos(null); writePlanPos(null)
+        },
+      }
+      const posStyle = pos ? { left: pos.x + 'px', top: pos.y + 'px', right: 'auto', bottom: 'auto' } : null
+
+      if (hidden) {
+        return toTopLayer(h('button', Object.assign({
+          ref: rootRef,
+          className: 'sd-plandock-fab',
+          title: '展开每日计划(可拖动,双击回右侧)',
+          onClick: () => { if (!justDraggedRef.current) setHidden(false) },
+          style: posStyle,
+        }, dragHandlers), h(Icon, { name: 'plancal', size: 14 })))
+      }
+
+      const rows = dp.items.map((it) => {
+        const from = pmin(it.start)
+        const to = pmin(it.end)
+        const state = mins >= from && mins < to ? 'now' : mins >= to ? 'past' : 'todo'
+        return { it, state, left: state === 'now' ? Math.max(1, to - mins) : 0 }
+      })
+
+      return toTopLayer(h('div', Object.assign({
+        ref: rootRef,
+        className: 'sd-plandock' + (open ? ' open' : ''),
+        style: posStyle,
+      }, dragHandlers), [
+        h('div', { className: 'sd-plandock-main', key: 'm' }, [
+          h('span', { key: 'g', className: 'sd-mini-grip', title: '按住这里拖动(双击回右侧)' }),
+          h('span', { key: 'i', className: 'sd-plandock-icon' }, h(Icon, { name: 'plancal', size: 14 })),
+          h('span', { key: 'c', className: 'sd-plandock-now' },
+            current
+              ? `${current.label} · 剩 ${leftMin} 分钟`
+              : next
+                ? `下场 ${next.start} ${next.label}`
+                : '今日无安排'),
+          h('button', {
+            key: 'x',
+            className: 'sd-iconbtn sm',
+            title: open ? '收起' : '展开全天',
+            onClick: () => setOpen((v) => !v),
+          }, h(Icon, { name: open ? 'collapse' : 'expand', size: 14 })),
+        ]),
+        open ? h('div', { className: 'sd-plandock-panel', key: 'p' }, [
+          ...rows.map(({ it, state, left }) => h('div', { className: 'sd-plan-row ' + state, key: it.id }, [
+            h('span', { className: 'sd-plan-time', key: 't' }, `${it.start}~${it.end}`),
+            h('span', { className: 'sd-plan-label', key: 'l', title: it.label }, it.label),
+            state === 'now' ? h('span', { className: 'sd-plan-flag', key: 'x' }, `剩 ${left} 分`) : null,
+          ])),
+          h('div', { className: 'sd-mini-row sd-mini-actions', key: 'a' }, [
+            h('button', { key: 'hide', className: 'sd-textbtn', onClick: () => { setOpen(false); setHidden(true) } }, '收起成小圆点'),
+          ]),
+        ]) : null,
+      ]))
+    }
+
     // -----------------------------------------------------------------------
     // 设置页
     // -----------------------------------------------------------------------
@@ -2524,6 +2750,69 @@ window.__ModuleLoader__.load({
       ])
     }
 
+    /**
+     * 每日计划表编辑器:一行一个时间段 `HH:MM~HH:MM 事情 【科目】`。
+     * 解析只做形状预检(起 < 终),真正落库的规矩在 desk.js normalizeDailyPlan 一处
+     * ——坏行被忽略,草稿文本不会被洗掉(洗掉会把她正在打的字吞了)。
+     */
+    function planItemsToText(items) {
+      return (items || []).map((it) =>
+        `${it.start}~${it.end} ${it.label}${it.subject ? ` 【${it.subject}】` : ''}`).join('\n')
+    }
+
+    function planItemsFromText(text) {
+      const out = []
+      for (const line of String(text || '').replace(/\r\n/g, '\n').split('\n')) {
+        const m = /^\s*(\d{1,2}):\s*(\d{2})\s*~\s*(\d{1,2}):\s*(\d{2})\s+(.+?)\s*(?:【(.+?)】)?\s*$/.exec(line)
+        if (!m) continue
+        const start = `${m[1].padStart(2, '0')}:${m[2]}`
+        const end = `${m[3].padStart(2, '0')}:${m[4]}`
+        if (pmin(start) >= pmin(end)) continue
+        out.push({ start, end, label: m[5].trim(), subject: (m[6] || '').trim() })
+      }
+      return out
+    }
+
+    function DayPlanField({ value, onCommit }) {
+      const dp = value || { enabled: false, float: false, items: [] }
+      const text = planItemsToText(dp.items)
+      const [draft, setDraft] = React.useState(text)
+      React.useEffect(() => setDraft(text), [text])
+      const dirty = draft !== text
+      const patch = (p) => onCommit({ patch: p })
+      const commit = () => { if (dirty) patch({ items: planItemsFromText(draft) }) }
+      return h('div', { className: 'sd-field', key: 'dp' }, [
+        h('span', { className: 'sd-field-label' }, '每日计划表(哪个时间段做什么)'),
+        h('div', { className: 'sd-row', key: 'sw' }, [
+          h('label', { className: 'sd-field sd-field-inline', key: 'en' }, [
+            h('input', {
+              type: 'checkbox', checked: dp.enabled === true,
+              onChange: (e) => patch({ enabled: e.target.checked }),
+            }),
+            h('span', {}, '启用'),
+            h('span', { className: 'sd-field-hint' }, '开着时主面板会显示,模型也从系统提示里知道现在该做什么'),
+          ]),
+          h('label', { className: 'sd-field sd-field-inline', key: 'fl' }, [
+            h('input', {
+              type: 'checkbox', checked: dp.float === true,
+              onChange: (e) => patch({ float: e.target.checked }),
+            }),
+            h('span', {}, '悬浮在桌面上'),
+            h('span', { className: 'sd-field-hint' }, '和右下角迷你条一样可拖动、可收成小圆点'),
+          ]),
+        ]),
+        h('textarea', {
+          key: 't', className: 'sd-journal-input', rows: 6, value: draft,
+          placeholder: '08:00~09:30 背单词 【英语】\n14:00~16:00 864 论述练习\n16:00~16:20 休息',
+          onChange: (e) => setDraft(e.target.value),
+        }),
+        h('div', { className: 'sd-row', key: 'r' }, [
+          h('button', { className: 'sd-textbtn', disabled: !dirty, onClick: commit }, dirty ? '保存时间段' : '已保存'),
+          h('span', { className: 'sd-field-hint' }, '一行一条:HH:MM~HH:MM 事情,行尾可加【科目】;坏行会被忽略。这是每天的固定模板,不按日期存'),
+        ]),
+      ])
+    }
+
     function Settings() {
       const s = useStore()
       usePoll(60000)
@@ -2544,6 +2833,10 @@ window.__ModuleLoader__.load({
           value: st.milestonesText || '', onCommit: (v) => patch({ milestonesText: v }),
         }),
         h(SubjectsField, { key: 'subjects', value: st.subjects, onCommit: (list) => patch({ subjects: list }) }),
+        h(DayPlanField, {
+          key: 'dayplan', value: s.state.dailyPlan,
+          onCommit: (p) => store.apply('dailyPlan.update', p),
+        }),
         h('div', { className: 'sd-settings-grid', key: 'g' }, [
           h(NumField, { key: 'goal', label: '每日目标(分钟)', value: st.dailyGoalMin || 180, min: 10, max: 1440, onCommit: (v) => patch({ dailyGoalMin: v }) }),
           h(NumField, { key: 'focus', label: '专注时长(分钟)', value: st.focusMin || 25, min: 1, max: 240, onCommit: (v) => patch({ focusMin: v }) }),
@@ -2584,7 +2877,7 @@ window.__ModuleLoader__.load({
       // 挂在四个根上:整页面板、设置节、迷你条、收起态的小圆点 —— 迷你条是 portal 到 body 的,
       // 不在 .sd-page 里面,所以必须单独列出来,否则它取不到阶梯值。
       // SVG 内部(饼图圆心那两行)的 font-size 是 viewBox 用户单位,不是设备像素,刻意不走这套阶梯。
-      '.sd-page,.sd-settings,.sd-mini,.sd-mini-fab,.sd-error{--sd-fs-tick:10px;--sd-fs-micro:11px;--sd-fs-cap:12px;--sd-fs-body:13px;--sd-fs-sub:15px;--sd-fs-mid:20px;--sd-fs-num:26px;--sd-fs-hero:32px;--sd-gap-block:16px;--sd-gap-card:12px;--sd-gap-row:8px;--sd-radius:16px;--sd-radius-sm:11px}',
+      '.sd-page,.sd-settings,.sd-mini,.sd-mini-fab,.sd-plandock,.sd-plandock-fab,.sd-error{--sd-fs-tick:10px;--sd-fs-micro:11px;--sd-fs-cap:12px;--sd-fs-body:13px;--sd-fs-sub:15px;--sd-fs-mid:20px;--sd-fs-num:26px;--sd-fs-hero:32px;--sd-gap-block:16px;--sd-gap-card:12px;--sd-gap-row:8px;--sd-radius:16px;--sd-radius-sm:11px}',
       // 页面骨架
       // 面板外壳:中心列是 overflow:hidden 的 flex 列,滚动条只能由我们自己出
       '.sd-panel{display:flex;flex-direction:column;flex:1;min-width:0;min-height:0;width:100%;height:100%;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}',
@@ -2938,6 +3231,29 @@ window.__ModuleLoader__.load({
       '.sd-mini-late{color:var(--dsw-alias-state-error-primary)}',
       '.sd-mini-fab{position:fixed;right:18px;bottom:18px;z-index:900;pointer-events:auto;-webkit-app-region:no-drag;will-change:left,top;width:34px;height:34px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-secondary);display:flex;align-items:center;justify-content:center;cursor:grab;touch-action:none;box-shadow:0 6px 20px rgba(0,0,0,.25)}',
       '.sd-mini-fab:hover{color:var(--dsw-alias-brand-primary)}',
+      // 每日计划表悬浮件:坑位规矩照抄迷你条(portal / pointer-events / no-drag / z-index:900),
+      // 默认位在迷你条上方(bottom:64px),落点自己存 plan-pos,互不打架
+      '.sd-plandock{position:fixed;right:18px;bottom:64px;z-index:900;pointer-events:auto;-webkit-app-region:no-drag;will-change:left,top;touch-action:none;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-layer-1));box-shadow:0 8px 26px rgba(0,0,0,.28);backdrop-filter:blur(10px);font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-primary);max-width:280px}',
+      '.sd-plandock-main{display:flex;align-items:center;gap:7px;padding:7px 9px;cursor:grab;touch-action:none;user-select:none}',
+      '.sd-plandock-main:active{cursor:grabbing}',
+      '.sd-plandock-icon{display:inline-flex;color:var(--dsw-alias-brand-primary);flex:none}',
+      '.sd-plandock-now{font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-primary);min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
+      '.sd-plandock-panel{border-top:1px solid var(--dsw-alias-border-l1);padding:9px 10px;display:flex;flex-direction:column;gap:5px;max-height:320px;overflow-y:auto}',
+      '.sd-plandock-fab{position:fixed;right:18px;bottom:64px;z-index:900;pointer-events:auto;-webkit-app-region:no-drag;will-change:left,top;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-secondary);display:flex;align-items:center;justify-content:center;cursor:grab;touch-action:none;box-shadow:0 6px 20px rgba(0,0,0,.25)}',
+      '.sd-plandock-fab:hover{color:var(--dsw-alias-brand-primary)}',
+      // 计划表分区卡 + 行(主面板与悬浮件共用同一套行样式)
+      '.sd-plan-card{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:var(--sd-radius);background:var(--dsw-alias-bg-layer-1);overflow:hidden}',
+      '.sd-plan-row{display:flex;align-items:center;gap:10px;padding:8px 14px;font-size:var(--sd-fs-body);color:var(--dsw-alias-label-secondary);border-top:1px solid var(--dsw-alias-border-l1)}',
+      '.sd-plan-row:first-child{border-top:none}',
+      '.sd-plan-time{flex:none;font-variant-numeric:tabular-nums;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:var(--sd-fs-cap);color:var(--dsw-alias-label-tertiary)}',
+      '.sd-plan-label{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary)}',
+      '.sd-plan-subject{flex:none;font-size:var(--sd-fs-micro);padding:1px 8px;border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}',
+      '.sd-plan-flag{flex:none;font-size:var(--sd-fs-micro)}',
+      '.sd-plan-row.now{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 8%,transparent)}',
+      '.sd-plan-row.now .sd-plan-time,.sd-plan-row.now .sd-plan-flag{color:var(--dsw-alias-state-business-primary)}',
+      '.sd-plan-row.now .sd-plan-label{color:var(--dsw-alias-label-primary);font-weight:600}',
+      '.sd-plan-row.past{opacity:.55}',
+      '.sd-plan-row.past .sd-plan-flag{color:var(--dsw-alias-label-tertiary)}',
       '.sd-error{margin:16px;padding:14px 16px;border:1px solid var(--dsw-alias-state-error-primary);border-radius:10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:var(--sd-fs-body);line-height:1.6}',
       '.sd-error-stack{margin:10px 0 0;padding:10px;max-height:320px;overflow:auto;white-space:pre-wrap;font-size:var(--sd-fs-micro);line-height:1.5;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border-radius:6px}',
       // 设置
@@ -3076,6 +3392,13 @@ window.__ModuleLoader__.load({
         () => h(MiniBar, { key: 'mini' }),
         'shell.overlay')
 
+      // 每日计划表悬浮件:与迷你条并列的第二个 shell.overlay。
+      // 自身渲染与否由 dailyPlan.enabled && dailyPlan.float 决定(关了返回 null,槽位仍占着)
+      register('shell.overlay',
+        { name: 'shell.overlay', id: 'study-desk-plan', order: 81, label: '每日计划表' },
+        () => h(PlanDock, { key: 'plandock' }),
+        'shell.overlay.plandock')
+
       // 设置页
       register('settings.section',
         { name: 'settings.section', id: 'study-desk', order: 27, label: '学习工作台' },
@@ -3095,7 +3418,7 @@ window.__ModuleLoader__.load({
         () => h(BbdcPanel, { key: 'bbdc' }),
         'main.bbdc')
 
-      reportDiag({ kind: 'slots-registered', where: 'client', message: '六个槽位注册调用已完成' })
+      reportDiag({ kind: 'slots-registered', where: 'client', message: '七个槽位注册调用已完成' })
     }
 
     exports.apply = apply
diff --git a/desk.js b/desk.js
index 95a333d..30a6366 100644
--- a/desk.js
+++ b/desk.js
@@ -184,6 +184,7 @@ function freshState() {
     journal: {},
     timer: null,
     settings: { ...DEFAULT_SETTINGS, subjects: DEFAULT_SETTINGS.subjects.map((s) => ({ ...s })) },
+    dailyPlan: { enabled: false, float: false, items: [] },
     createdAt: Date.now(),
     updatedAt: Date.now(),
   }
@@ -241,6 +242,7 @@ export function normalize(raw) {
     reviews: Array.isArray(raw.reviews) ? raw.reviews : [],
     journal: raw.journal && typeof raw.journal === 'object' ? raw.journal : {},
     settings: { ...base.settings, ...(raw.settings && typeof raw.settings === 'object' ? raw.settings : {}) },
+    dailyPlan: normalizeDailyPlan(raw.dailyPlan),
   }
   if (!Array.isArray(state.settings.subjects) || state.settings.subjects.length === 0) {
     state.settings.subjects = base.settings.subjects.map((s) => ({ ...s }))
@@ -524,6 +526,82 @@ export function updateSettings(state, patch) {
   return next
 }
 
+// ---------------------------------------------------------------------------
+// 每日计划表:哪个时间段做什么事。每天的固定模板(不按日期存,改模板=改每一天)。
+// 数据在 state.dailyPlan;「现在该做什么」由 dailyPlanNow 单点算——
+// 注入 system prompt 与快照都从它取,客户端只画不算。
+// ---------------------------------------------------------------------------
+
+/** 'HH:MM' → 当天分钟数。非法输入返回 NaN。 */
+export function planMinutes(hhmm) {
+  const m = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || '').trim())
+  if (!m) return NaN
+  const h = Number(m[1])
+  const min = Number(m[2])
+  if (h > 23 || min > 59) return NaN
+  return h * 60 + min
+}
+
+const MAX_PLAN_ITEMS = 48
+
+function normalizeDailyPlan(raw) {
+  const base = { enabled: false, float: false, items: [] }
+  if (!raw || typeof raw !== 'object') return base
+  const seen = new Set()
+  const items = []
+  for (const it of Array.isArray(raw.items) ? raw.items : []) {
+    if (!it || typeof it !== 'object') continue
+    const start = String(it.start || '').trim()
+    const end = String(it.end || '').trim()
+    const label = String(it.label || '').trim().slice(0, 60)
+    if (!/^\d{2}:\d{2}$/.test(start) || !/^\d{2}:\d{2}$/.test(end)) continue
+    if (!label) continue
+    const s = planMinutes(start)
+    const e = planMinutes(end)
+    if (!Number.isFinite(s) || !Number.isFinite(e) || s >= e) continue // 跨午夜不支持;写反的整条丢掉
+    let id = String(it.id || '').trim()
+    if (!id || seen.has(id)) id = randomUUID()
+    seen.add(id)
+    items.push({ id, start, end, label, subject: String(it.subject || '').trim().slice(0, 30) })
+    if (items.length >= MAX_PLAN_ITEMS) break
+  }
+  items.sort((a, b) => planMinutes(a.start) - planMinutes(b.start) || planMinutes(a.end) - planMinutes(b.end))
+  return {
+    enabled: raw.enabled === true,
+    float: raw.float === true,
+    items,
+  }
+}
+
+export function updateDailyPlan(state, patch) {
+  const p = patch || {}
+  const cur = normalizeDailyPlan(state.dailyPlan)
+  state.dailyPlan = normalizeDailyPlan({
+    enabled: p.enabled !== undefined ? p.enabled : cur.enabled,
+    float: p.float !== undefined ? p.float : cur.float,
+    items: p.items !== undefined ? p.items : cur.items,
+  })
+  return state.dailyPlan
+}
+
+/** 现在在哪个计划区间里、下一场是什么。enabled=false 或没条目时 items 照发,current/next 为 null。 */
+export function dailyPlanNow(state, now = Date.now()) {
+  const plan = state.dailyPlan || { enabled: false, items: [] }
+  const items = Array.isArray(plan.items) ? plan.items : []
+  const empty = { current: null, next: null, items }
+  if (plan.enabled !== true || !items.length) return empty
+  const d = new Date(now)
+  const mins = d.getHours() * 60 + d.getMinutes()
+  let current = null
+  let next = null
+  for (const it of items) {
+    const s = planMinutes(it.start)
+    if (mins >= s && mins < planMinutes(it.end)) current = it
+    else if (s > mins && !next) next = it
+  }
+  return { current, next, items }
+}
+
 // ---------------------------------------------------------------------------
 // 间隔重复与日复盘
 // ---------------------------------------------------------------------------
diff --git a/index.js b/index.js
index e33eea3..021d753 100644
--- a/index.js
+++ b/index.js
@@ -27,6 +27,7 @@ import {
   STATUSES,
   addTask,
   allTags,
+  dailyPlanNow,
   dayKey,
   daysUntil,
   deskDir,
@@ -54,6 +55,7 @@ import {
   summary,
   todayMinutes,
   unscheduleTask,
+  updateDailyPlan,
   updateSettings,
   updateTask,
   weeklyReport,
@@ -274,6 +276,8 @@ function snapshot(state) {
     // 饼图:按「这一段的名字」聚合,今天和近 30 天各算一份,客户端只画
     focusPie: { today: focusByName(state, 1, now), month: focusByName(state, 30, now) },
     plan: reviewPlan(state, now, 3),
+    // 每日计划表:宿主单点算好「现在该做什么/下一场是什么」,客户端只画
+    dayPlan: dailyPlanNow(state, now),
     tags: allTags(state),
     weekly: weeklyReport(state, 8, now),
     journal: journalRecent(state, now, 14),
@@ -342,6 +346,7 @@ const MUTATIONS = {
   'task.delete': (state, p) => removeTask(state, p.id || p.title),
   'session.log': (state, p) => logSession(state, p),
   'settings.update': (state, p) => updateSettings(state, p.patch || p),
+  'dailyPlan.update': (state, p) => updateDailyPlan(state, p.patch || p),
   'review.grade': (state, p) => gradeTask(state, p.id || p.title, p.grade),
   'review.schedule': (state, p) => scheduleTask(state, p.id || p.title, p.due),
   'review.unschedule': (state, p) => unscheduleTask(state, p.id || p.title),
@@ -451,6 +456,17 @@ function boardText(state) {
   const ms = nextMilestone(state)
   if (ms) lines.push(`- 最近节点:${ms.label} ${ms.start}${ms.end !== ms.start ? '~' + ms.end : ''}(${ms.text}${ms.approx ? ',日期按往年惯例估' : ''})`)
   lines.push(`- 今日专注 ${fmtMinutes(s.today.minutes)} / 目标 ${fmtMinutes(s.today.goal)},连续打卡 ${s.streak} 天`)
+  // 每日计划表:开了就告诉模型「现在按计划该做什么」,它才能对着计划督促
+  const dayPlan = dailyPlanNow(state)
+  if (dayPlan.items.length) {
+    const hhmm = new Date().toTimeString().slice(0, 5)
+    if (dayPlan.current) {
+      lines.push(`- 按每日计划,现在(${hhmm})应该在:「${dayPlan.current.label}」(${dayPlan.current.start}~${dayPlan.current.end})${dayPlan.current.subject ? `【${dayPlan.current.subject}】` : ''}`)
+    } else {
+      lines.push(`- 按每日计划,现在(${hhmm})不在任何计划时间段里`)
+    }
+    if (dayPlan.next) lines.push(`- 下一场 ${dayPlan.next.start} 开始:「${dayPlan.next.label}」${dayPlan.next.subject ? `【${dayPlan.next.subject}】` : ''}`)
+  }
   if (s.doing.length) {
     lines.push('- 进行中 ' + s.counts.doing + ':' + s.doing.map((t) => `${t.title}${t.dueDate ? `【${t.dueDate} 截止】` : ''}${t.minutes ? `(已投入 ${fmtMinutes(t.minutes)})` : ''}`).join(' | '))
   } else {
diff --git a/test/desk.test.js b/test/desk.test.js
index ef07fa5..ee33788 100644
--- a/test/desk.test.js
+++ b/test/desk.test.js
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
 
 import {
   addTask,
+  dailyPlanNow,
   dayKey,
   daysUntil,
   dueQueue,
@@ -16,6 +17,7 @@ import {
   normalize,
   normalizeTags,
   parseMilestones,
+  planMinutes,
   reorderTask,
   reviewForecast,
   reviewPlan,
@@ -27,6 +29,7 @@ import {
   subjectBreakdown,
   summary,
   todayMinutes,
+  updateDailyPlan,
   updateSettings,
   updateTask,
   weeklyReport,
@@ -658,3 +661,63 @@ test('updateSettings 能开关 notifySessionOnTimerEnd,不传不动它', () =>
   updateSettings(s, { focusMin: 30 })
   assert.equal(s.settings.notifySessionOnTimerEnd, true, '别的设置项不该动它')
 })
+
+// ---------------------------------------------------------------------------
+// 每日计划表:时间段模板 + 「现在该做什么」单点计算
+// ---------------------------------------------------------------------------
+
+test('normalizeDailyPlan:坏行丢掉、好行保留、按开始时刻排序', () => {
+  const s = normalize({ dailyPlan: { enabled: true, items: [
+    { start: '14:00', end: '16:00', label: '864 论述练习' },
+    '这不是对象',
+    { start: '08:00', end: '09:30', label: '背单词', subject: '英语' },
+    { start: '09:30', end: '08:00', label: '起终写反' },
+    { start: '25:00', end: '26:00', label: '非法时刻' },
+    { start: '08:00', end: '09:00', label: '' },            // 没名字的丢
+    { start: '12:00', end: '13:00', label: '午休', id: 'x' },
+    { start: '07:00', end: '07:40', label: '早读', id: 'x' }, // id 撞了会重发一个
+  ] } })
+  const items = s.dailyPlan.items
+  assert.equal(items.length, 4)
+  assert.deepEqual(items.map((x) => x.label), ['早读', '背单词', '午休', '864 论述练习'], '按开始时刻排好了')
+  assert.notEqual(items[1].id, 'x', '撞 id 的那条要重新发 id')
+  assert.equal(s.dailyPlan.enabled, true)
+  assert.equal(s.dailyPlan.float, false)
+})
+
+test('dailyPlanNow:区间命中与下一场', () => {
+  const s = normalize({ dailyPlan: { enabled: true, items: [
+    { start: '08:00', end: '09:30', label: '背单词' },
+    { start: '14:00', end: '16:00', label: '864 论述练习' },
+  ] } })
+  // 用固定一天里的时刻构造 now(本地时区)
+  const at = (h, m) => new Date(2026, 10, 5, h, m).getTime()
+  assert.equal(dailyPlanNow(s, at(8, 15)).current.label, '背单词')
+  assert.equal(dailyPlanNow(s, at(8, 15)).next.label, '864 论述练习')
+  assert.equal(dailyPlanNow(s, at(9, 30)).current, null, 'end 时刻已切出区间')
+  assert.equal(dailyPlanNow(s, at(9, 30)).next.label, '864 论述练习')
+  assert.equal(dailyPlanNow(s, at(23, 59)).current, null)
+  assert.equal(dailyPlanNow(s, at(23, 59)).next, null, '当天没有下一场了')
+  assert.equal(dailyPlanNow(s, at(15, 0)).current.minutes, undefined, 'current 就是条目本身')
+})
+
+test('dailyPlanNow:没启用就只发条目不发判定', () => {
+  const s = normalize({ dailyPlan: { items: [{ start: '08:00', end: '09:00', label: '背单词' }] } })
+  const now = dailyPlanNow(s)
+  assert.equal(now.current, null)
+  assert.equal(now.items.length, 1, '条目照发,客户端设置页编辑器还要用它')
+  assert.equal(dailyPlanNow(normalize(null)).items.length, 0)
+})
+
+test('updateDailyPlan:单独开关 enabled/float,互不牵连', () => {
+  const s = normalize({ dailyPlan: { enabled: true, float: true, items: [
+    { start: '08:00', end: '09:00', label: '背单词' },
+  ] } })
+  updateDailyPlan(s, { float: false })
+  assert.equal(s.dailyPlan.enabled, true)
+  assert.equal(s.dailyPlan.float, false)
+  assert.equal(s.dailyPlan.items.length, 1, '开关不动条目')
+  updateDailyPlan(s, { enabled: false, items: [] })
+  assert.equal(s.dailyPlan.enabled, false)
+  assert.equal(s.dailyPlan.items.length, 0)
+})
diff --git a/test/mini-dock.test.js b/test/mini-dock.test.js
index 8b2102d..340bd7f 100644
--- a/test/mini-dock.test.js
+++ b/test/mini-dock.test.js
@@ -63,3 +63,38 @@ test('迷你条不许停进 Windows 标题栏那条拖拽带', () => {
   attrs.delete('data-windows-titlebar')
   assert.equal(clampMiniPos({ x: 500, y: 6 }, W, H).y, 6, '非 Windows 标题栏不该凭空留 40px')
 })
+
+// 每日计划表悬浮件是第二个 shell.overlay:坑位规矩必须与迷你条一致,
+// 否则就是「看得见、点不动、拖不动」那类真机故障的复刻。
+
+test('计划表悬浮件声明 no-drag 与自持命中(pointer-events / z-index:900)', () => {
+  const { CSS } = loadClient()
+  for (const sel of ['.sd-plandock', '.sd-plandock-fab']) {
+    const rule = CSS.split('}').find((r) => r.startsWith(`${sel}{`))
+    assert.ok(rule, `${sel} 规则没找到`)
+    assert.match(rule, /-webkit-app-region:\s*no-drag/, `${sel} 没声明 no-drag`)
+    assert.match(rule, /pointer-events:\s*auto/, `${sel} 没声明 pointer-events:auto`)
+    assert.match(rule, /z-index:\s*900/, `${sel} 没声明 z-index:900`)
+  }
+})
+
+test('计划表悬浮件默认位在迷你条上方,两个悬浮件默认位不打架', () => {
+  const { CSS } = loadClient()
+  const ruleFor = (sel) => CSS.split('}').find((r) => r.startsWith(`${sel}{`))
+  const mini = ruleFor('.sd-mini')
+  const plan = ruleFor('.sd-plandock')
+  assert.match(mini, /bottom:\s*18px/, '迷你条默认 bottom 变了的话这条也要跟着改')
+  assert.match(plan, /bottom:\s*64px/, '计划表悬浮件默认压在迷你条身上了')
+})
+
+test('计划表悬浮件也走 clampMiniPos(标题栏拖拽带的守卫自动继承)', () => {
+  // 拖动手势与钳制函数是同一份:源码里 PlanDock 用的就是 clampMiniPos,
+  // 这里用形状校验锁住「没另起炉灶」——另写一份迟早漏掉标题栏那条带。
+  const code = readClientSource()
+  const dockStart = code.indexOf('function PlanDock(')
+  const dockEnd = code.indexOf('// 设置页', dockStart)
+  assert.ok(dockStart > 0 && dockEnd > dockStart, 'PlanDock 没找到')
+  const dock = code.slice(dockStart, dockEnd)
+  assert.ok(dock.includes('clampMiniPos'), 'PlanDock 没用 clampMiniPos 钳制落点')
+  assert.ok(!/function\s+clamp/.test(dock), 'PlanDock 里另写了一份钳制实现')
+})
diff --git a/tools/shot-harness.mjs b/tools/shot-harness.mjs
index 9692b04..e021917 100644
--- a/tools/shot-harness.mjs
+++ b/tools/shot-harness.mjs
@@ -35,10 +35,13 @@ for (const f of ['desk.json', 'bbdc.json']) {
 process.env.DSH_HOME = HOME
 
 const routes = []
+const disposers = []
 const ctx = {
   tools: { register: () => () => {} },
   webServer: { register: (r) => { routes.push(r); return () => {} } },
-  effect: (f) => { f(); return () => {} },
+  // 快照取完就把 effect 的清理函数都跑掉:宿主半边现在带着 10 秒看门狗
+  // setInterval(番茄钟会话提醒的兜底),不清理这个进程永远退不出去。
+  effect: (f) => { const d = f(); if (typeof d === 'function') disposers.push(d); return () => {} },
   on: () => () => {},
   set: (k, v) => { services[k] = v },
   provide: (k, v) => { services[k] = v; return () => {} },
@@ -50,6 +53,7 @@ const ctx = {
 const services = {}
 const host = await import('../index.js')
 host.apply(ctx, {})
+for (const d of disposers) { try { d() } catch { /* 清理失败无所谓 */ } }
 
 function fakeRes() {
   const res = { code: 0, chunks: [] }
@@ -168,6 +172,13 @@ window.fetch = async (url, opts) => {
   // 让假宿主稍微带点状态:settings.update 真把 patch 合进快照,
   // 这样才能验「输入 90 → 秒表显示 90:00」这条链,而不是只验渲染出来了个输入框。
   if (body.op === 'settings.update' && body.patch) Object.assign(window.__SNAPSHOT__.state.settings, body.patch)
+  // ?plan=1 验每日计划表时要能点设置页开关:假宿主把 patch 合回 dailyPlan
+  if (body.op === 'dailyPlan.update' && body.patch) {
+    const dp = window.__SNAPSHOT__.state.dailyPlan = window.__SNAPSHOT__.state.dailyPlan || { enabled: false, float: false, items: [] }
+    if ('enabled' in body.patch) dp.enabled = !!body.patch.enabled
+    if ('float' in body.patch) dp.float = !!body.patch.float
+    if (body.patch.items) dp.items = body.patch.items
+  }
   return { ok: true, status: 200, json: async () => window.__SNAPSHOT__ }
 }
 `
@@ -220,6 +231,17 @@ setTimeout(() => {
       el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
     }
   }
+  // ?planopen=1:把计划表悬浮件展开(验全天列表渲染得出来)
+  if (Q.get('planopen') === '1') {
+    const btn = document.querySelector('.sd-plandock .sd-plandock-main button')
+    if (btn) ReactDOM.flushSync(() => btn.dispatchEvent(new MouseEvent('click', { bubbles: true })))
+  }
+  // ?planfloatoff=1:从设置页把「悬浮在桌面上」关掉(验开关 → 悬浮件消失这条链)。
+  // 设置页里的 checkbox 顺序:计划表启用(0) / 计划表悬浮(1) / 注入提示(2) / 走完提醒(3)
+  if (Q.get('planfloatoff') === '1') {
+    const cb = document.querySelectorAll('#settings input[type=checkbox]')[1]
+    if (cb) ReactDOM.flushSync(() => cb.dispatchEvent(new MouseEvent('click', { bubbles: true })))
+  }
   setTimeout(() => {
     const diag = document.getElementById('diag')
     const rectOf = (sel) => { const el = document.querySelector(sel); if (!el) return 'none'; const r = el.getBoundingClientRect(); return [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)].join(',') }
@@ -244,6 +266,12 @@ setTimeout(() => {
     'heatRect=' + rectOf('.sd-heat'),
     'forecastRect=' + rectOf('.sd-forecast'),
       'mini=' + document.querySelectorAll('.sd-mini').length,
+      'plandock=' + document.querySelectorAll('.sd-plandock').length,
+      'plandockFab=' + document.querySelectorAll('.sd-plandock-fab').length,
+      'plandockPanel=' + document.querySelectorAll('.sd-plandock-panel').length,
+      'planRows=' + document.querySelectorAll('.sd-plan-row').length,
+      'planNow=' + document.querySelectorAll('.sd-plan-row.now').length,
+      'planSection=' + document.querySelectorAll('.sd-plan-card').length,
       'calls=' + window.__calls.join(','),
       'errText=' + (err ? String(err.textContent).slice(0, 160) : 'none'),
       'bootErr=' + (window.__bootError || 'none'),
@@ -264,6 +292,23 @@ html,body{margin:0;height:100%;font-family:var(--dsw-font-family);}
 #main2,#settings{display:none}
 `
 
+// ?plan=1 时伪造一份「开了每日计划表」的快照:围绕当前钟点构造几段,
+// 让「现在」永远落在中间那段里(跨午夜的边角由 :59 收尾兜住)。
+// 悬浮件开不开、当前时段有没有,从诊断行的 plandock*/planRows/planNow 读。
+const planPatch = String.raw`
+if (new URLSearchParams(location.search).get('plan') === '1') {
+  const d = new Date()
+  const hh = (n) => String(n).padStart(2, '0')
+  const nowH = d.getHours()
+  const items = []
+  if (nowH > 0) items.push({ id: 'p0', start: hh(nowH - 1) + ':00', end: hh(nowH) + ':00', label: '上一段(已过)', subject: '政治' })
+  items.push({ id: 'p1', start: hh(nowH) + ':00', end: hh(nowH) + ':59', label: '现在的时段', subject: '631 公共管理' })
+  if (nowH < 22) items.push({ id: 'p2', start: hh(nowH + 1) + ':00', end: hh(nowH + 2) + ':00', label: '下一场', subject: '英语' })
+  window.__SNAPSHOT__.state.dailyPlan = { enabled: true, float: true, items }
+  window.__SNAPSHOT__.dayPlan = { current: items[1], next: items[2] || null, items }
+}
+`
+
 const html = `
 study-desk harness
 
@@ -277,7 +322,7 @@ const html = `
 

 
-
+
 
 
 
@@ -311,3 +356,5 @@ h2{font-size:12px;letter-spacing:.12em;font-weight:600;color:var(--dsw-alias-lab
 writeFileSync(join(OUT, 'palette-light.html'), palettePage(false))
 writeFileSync(join(OUT, 'palette-dark.html'), palettePage(true))
 console.log('[done] .shots/panel-{new,old}.html .shots/palette-{light,dark}.html (' + PIE_TONES.length + ' tones)')
+// client-loader 载入 client.js 会把盯倒计时的 setInterval 挂起来(apply-probe 同款坑),显式退出
+process.exit(0)