chore: release v1.0.27
Deploy / build-windows (push) Successful in 1m56s
Deploy / build-android (push) Has been cancelled
Deploy / build-ios (push) Has been cancelled
Deploy / release-deploy (push) Has been cancelled
Deploy / build-linux-web (push) Successful in 53s
Deploy / build-macos (push) Failing after 1m16s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-09 00:15:46 +08:00
parent 65d6b12a21
commit daa83bd408
9 changed files with 1535 additions and 360 deletions
+619 -133
View File
File diff suppressed because it is too large Load Diff
+176 -17
View File
@@ -1,9 +1,9 @@
{
"meta": {
"title": "酒库管理系统 — 项目 TODO",
"updated_at": "2026-06-08T14:40:04.464Z"
"updated_at": "2026-06-08T16:06:56.875Z"
},
"seq": 18,
"seq": 27,
"items": [
{
"id": 1,
@@ -17,7 +17,8 @@
"created_at": "2026-06-07T17:30:32.138Z",
"done": true,
"completed_at": "2026-06-07T17:48:38.554Z",
"version": "v1.0.23"
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 2,
@@ -30,7 +31,8 @@
"created_at": "2026-06-07T17:30:32.188Z",
"done": false,
"completed_at": null,
"version": null
"version": null,
"status": "open"
},
{
"id": 3,
@@ -44,7 +46,8 @@
"created_at": "2026-06-07T17:30:32.237Z",
"done": true,
"completed_at": "2026-06-08T14:33:22.523Z",
"version": "v1.0.25"
"version": "v1.0.25",
"status": "accepted"
},
{
"id": 4,
@@ -58,7 +61,8 @@
"created_at": "2026-06-07T17:30:32.287Z",
"done": true,
"completed_at": "2026-06-07T18:07:13.211Z",
"version": "v1.0.23"
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 5,
@@ -72,7 +76,8 @@
"created_at": "2026-06-07T17:30:32.334Z",
"done": true,
"completed_at": "2026-06-08T14:33:22.584Z",
"version": "v1.0.25"
"version": "v1.0.25",
"status": "accepted"
},
{
"id": 6,
@@ -86,7 +91,8 @@
"created_at": "2026-06-07T17:30:32.381Z",
"done": true,
"completed_at": "2026-06-08T14:33:22.648Z",
"version": "v1.0.25"
"version": "v1.0.25",
"status": "accepted"
},
{
"id": 8,
@@ -101,7 +107,8 @@
"created_at": "2026-06-07T18:08:24.432Z",
"done": true,
"completed_at": "2026-06-07T23:18:23.239Z",
"version": "v1.0.23"
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 9,
@@ -116,7 +123,8 @@
"created_at": "2026-06-07T18:08:32.748Z",
"done": true,
"completed_at": "2026-06-07T23:28:52.932Z",
"version": "v1.0.23"
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 10,
@@ -132,7 +140,8 @@
"created_at": "2026-06-07T18:12:21.217Z",
"done": true,
"completed_at": "2026-06-07T23:28:53.001Z",
"version": "v1.0.23"
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 13,
@@ -147,7 +156,8 @@
"created_at": "2026-06-07T18:12:43.136Z",
"done": true,
"completed_at": "2026-06-08T14:40:04.464Z",
"version": "v1.0.25"
"version": "v1.0.25",
"status": "accepted"
},
{
"id": 14,
@@ -162,7 +172,8 @@
"created_at": "2026-06-07T18:12:50.299Z",
"done": true,
"completed_at": "2026-06-08T00:06:15.299Z",
"version": "v1.0.24"
"version": "v1.0.24",
"status": "accepted"
},
{
"id": 15,
@@ -176,7 +187,8 @@
"created_at": "2026-06-07T22:51:51.881Z",
"done": true,
"completed_at": "2026-06-07T22:56:24.807Z",
"version": "v1.0.23"
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 16,
@@ -192,7 +204,8 @@
"created_at": "2026-06-07T22:51:59.988Z",
"done": true,
"completed_at": "2026-06-07T22:58:32.882Z",
"version": "v1.0.23"
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 17,
@@ -207,7 +220,8 @@
"created_at": "2026-06-07T22:52:07.331Z",
"done": true,
"completed_at": "2026-06-07T23:03:13.631Z",
"version": "v1.0.23"
"version": "v1.0.23",
"status": "accepted"
},
{
"id": 18,
@@ -222,7 +236,152 @@
"created_at": "2026-06-07T22:52:13.283Z",
"done": true,
"completed_at": "2026-06-08T00:06:15.371Z",
"version": "v1.0.24"
"version": "v1.0.24",
"status": "accepted"
},
{
"id": 19,
"title": "入库单新建表单增加产地/保质期/储存方式,选填项默认折叠",
"desc": "新建入库单的商品行增加产地、保质期、储存方式三个字典下拉选项(已有字典数据)。必填项(商品名、数量等)正常显示;选填项默认隐藏,点击展开按钮后在下一行展示。",
"level": "high",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-08T14:56:32.850Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done",
"reject_reason": null,
"rejected_at": null
},
{
"id": 20,
"title": "商品详情页重设计:展示所有字段,内容为公开页超集",
"desc": "当前 product_detail_screen.dart 显示内容过于简单。需先做页面设计(UI 方案),内容要求是公开扫码页的超集:包含商品基本信息、参数(产地/保质期/储存方式)、价格、描述文档、图片、库存批次、入库记录等管理侧专属字段。另:商品公开链接展示为可点击超链接(点击可跳转公开扫码页)。",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-08T14:59:08.342Z",
"done": false,
"completed_at": null,
"version": null,
"status": "open"
},
{
"id": 21,
"title": "授权信息功能方案设计",
"desc": "完整方案设计,涵盖:界面设计与交互、前端实现方案、后端接口与数据模型、安全策略(防盗用、防滥用、授权校验)等。需产出设计文档后再进入开发阶段。",
"level": "mid",
"tags": [
"前端",
"后端",
"安全",
"文档"
],
"created_at": "2026-06-08T15:02:58.896Z",
"done": false,
"completed_at": null,
"version": null,
"status": "open"
},
{
"id": 22,
"title": "关于我们页文档/更新日志链接更新,Web 新增版本历史页",
"desc": "1. 客户端「关于我们」页面的「打开文档」「更新日志」链接指向需更新为正确地址。2. Web 营销站新增版本更新页面(/changelog 或 /download/),从 CHANGELOG.md 自动渲染,展示所有历史版本更新记录。",
"level": "low",
"tags": [
"前端",
"Web",
"文档"
],
"created_at": "2026-06-08T15:05:00.447Z",
"done": false,
"completed_at": null,
"version": null,
"status": "open"
},
{
"id": 23,
"title": "注册/联系入口「发邮件」升级为「加客服微信」并优化文案",
"desc": "当前注册成功页和网站联系支持入口引导用户「发邮件」,体验较弱。升级为扫码加客服微信,同时梳理相关文案措辞,使表达更专业、有亲和力。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-08T15:06:00.175Z",
"done": false,
"completed_at": null,
"version": null,
"status": "open"
},
{
"id": 24,
"title": "服务条款与隐私政策页面",
"desc": "在营销网站(jiu.51yanmei.com)补充服务条款(Terms of Service)和隐私政策(Privacy Policy)页面,注册页底部添加链接。最低优先级,待其他功能稳定后处理。",
"level": "low",
"tags": [
"Web",
"文档"
],
"created_at": "2026-06-08T15:06:26.383Z",
"done": false,
"completed_at": null,
"version": null,
"status": "open"
},
{
"id": 25,
"title": "移动端左侧抽屉菜单支持手势侧滑唤出",
"desc": "窄屏(手机)模式下,导航 Drawer 当前需点击汉堡按钮打开,需支持从屏幕左边缘向右滑动手势唤出菜单,符合移动端操作习惯。涉及 app_shell.dart 的 Drawer 配置。",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-08T15:09:27.652Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 26,
"title": "入库单详情商品明细改为卡片式竖排布局",
"desc": "当前入库单详情的商品明细以横向表格展示,字段多时在窄屏上挤压严重。改为每件商品独占一个卡片区块,字段竖向排列(商品名、规格、数量、单价、产地、保质期等各占一行),信息层次清晰,移动端阅读体验更好。宽屏可保持表格,窄屏自动切换为卡片流(复用 MobileListCard 模式)。",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-08T15:11:09.971Z",
"done": false,
"completed_at": null,
"version": null,
"status": "done"
},
{
"id": 27,
"title": "桌面客户端单实例限制,禁止同时运行多个进程",
"desc": "Windows 和 macOS 客户端启动时检测是否已有实例运行;若有,则激活已有窗口并退出新进程。Flutter desktop 可通过 window_single_instance 或 dart:io 锁文件实现。",
"level": "mid",
"tags": [
"Windows",
"mac"
],
"status": "open",
"created_at": "2026-06-08T15:58:11.759Z",
"done": false,
"completed_at": null,
"version": null
}
]
}
+390 -104
View File
@@ -2,11 +2,20 @@
/**
* todo/todo.mjs — 项目 TODO 管理 CLI(零依赖,Node.js >= 18
*
* 状态流转:open → doing → done → accepted
* done ←─ reject(退回,升为最高优先级)
* open 待开始 Claude 可设
* doing 开发中 Claude 可设
* done 待验收 Claude 可设(开发完成,等用户确认)
* accepted 已验收 仅限用户设置(手动编辑 JSON 或 `done <id>` 命令)
*
* 子命令:
* add --title "..." [--level high|mid|low] [--tags a,b] [--desc "..."]
* done <id> [--version vX.Y.Z]
* reopen <id>
* rm <id>
* add --title "..." [--level high|mid|low] [--tags a,b] [--desc "..."]
* status <id> <open|doing|done> — 更新开发状态(Claude 可调用)
* done <id> [--version vX.Y.Z] — 标记已验收交付(仅用户调用)
* reject <id> --reason "原因" — 拒绝验收,退回 open 并升为 high(仅用户调用)
* reopen <id> — 重新开启(清空拒绝记录)
* rm <id>
* list
* render
*/
@@ -20,13 +29,28 @@ const __dir = dirname(fileURLToPath(import.meta.url));
const DB = resolve(__dir, 'todo.json');
const HTML = resolve(__dir, 'todo.html');
// ─── 常量 ────────────────────────────────────────────────────────────────────
const LEVEL_LABEL = { high: '高', mid: '中', low: '低' };
const LEVEL_ORDER = { high: 0, mid: 1, low: 2 };
const STATUS_LABEL = { open: '待开始', doing: '开发中', done: '待验收', accepted: '已验收' };
const STATUS_ORDER = { open: 0, doing: 1, done: 2, accepted: 3 };
// ─── 数据读写 ────────────────────────────────────────────────────────────────
function load() {
if (!existsSync(DB)) {
return { meta: { title: '酒库管理系统 — 项目 TODO', updated_at: now() }, seq: 0, items: [] };
}
return JSON.parse(readFileSync(DB, 'utf8'));
const db = JSON.parse(readFileSync(DB, 'utf8'));
// 迁移旧格式:无 status 字段时,依据 done 布尔值补全
for (const item of db.items) {
if (!item.status) {
item.status = item.done ? 'accepted' : 'open';
}
}
return db;
}
function save(db) {
@@ -54,29 +78,38 @@ function gitTag() {
// ─── 终端 summary ────────────────────────────────────────────────────────────
const LEVEL_LABEL = { high: '高', mid: '中', low: '低' };
const LEVEL_ORDER = { high: 0, mid: 1, low: 2 };
function levelText(level) {
return { high: '高优 · 紧急', mid: '重要', low: '一般 / 优化' }[level] || level;
}
function printSummary(db) {
const pending = db.items.filter(i => !i.done)
.sort((a,b) => (LEVEL_ORDER[a.level]??9) - (LEVEL_ORDER[b.level]??9) || a.id - b.id);
const done = db.items.filter(i => i.done)
.sort((a,b) => (b.completed_at||'') > (a.completed_at||'') ? 1 : -1);
const byStatus = { open: [], doing: [], done: [], accepted: [] };
for (const i of db.items) {
(byStatus[i.status] || byStatus.open).push(i);
}
for (const st of ['open','doing','done','accepted']) {
byStatus[st].sort((a,b) => (LEVEL_ORDER[a.level]??9) - (LEVEL_ORDER[b.level]??9) || a.id - b.id);
}
console.log(`\nTODO 共 ${db.items.length} · 未完成 ${pending.length} · 已完成 ${done.length}`);
const total = db.items.length;
const pending = byStatus.open.length + byStatus.doing.length + byStatus.done.length;
const accepted = byStatus.accepted.length;
if (pending.length) {
console.log('未完成(按重要度):');
for (const i of pending) {
const tags = i.tags && i.tags.length ? i.tags.join('/') : '';
console.log(`\nTODO 共 ${total} · 进行中 ${pending} · 已验收 ${accepted}`);
for (const [st, label] of [['open','待开始'],['doing','开发中'],['done','待验收']]) {
if (byStatus[st].length === 0) continue;
console.log(`${label}${byStatus[st].length}):`);
for (const i of byStatus[st]) {
const tags = i.tags && i.tags.length ? ' ' + i.tags.join('/') : '';
const lvl = LEVEL_LABEL[i.level] || i.level;
console.log(` [${lvl}] #${i.id} ${i.title}${tags ? ' ' + tags : ''}`);
console.log(` [${lvl}] #${i.id} ${i.title}${tags}`);
}
}
if (done.length) {
console.log('已完成(最近 5 条):');
for (const i of done.slice(0, 5)) {
if (byStatus.accepted.length) {
console.log(`已验收(最近 5 条):`);
for (const i of byStatus.accepted.slice(0, 5)) {
const ver = i.version ? `${i.version}` : '';
console.log(` ✅ #${i.id} ${i.title}${ver}`);
}
@@ -90,7 +123,6 @@ function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/** 把描述里的 `code` 渲染成 <code> */
function renderDesc(s) {
if (!s) return '';
return esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');
@@ -99,30 +131,44 @@ function renderDesc(s) {
function levelClass(level) {
return { high: 't-block', mid: 't-high', low: 't-low' }[level] || 't-low';
}
function levelText(level) {
return { high: '高优 · 紧急', mid: '重要', low: '一般 / 优化' }[level] || level;
function statusClass(status) {
return { open: 's-open', doing: 's-doing', done: 's-done', accepted: 's-accepted' }[status] || 's-open';
}
function renderItems(items) {
if (!items.length) return '<p class="empty-tip">暂无条目</p>';
return items.map(i => {
const tags = (i.tags||[]).map(t => `<span class="tag t-tag" data-tag="${esc(t)}">${esc(t)}</span>`).join(' ');
const desc = i.desc ? `<div class="item-desc">${renderDesc(i.desc)}</div>` : '';
const meta = `<div class="item-meta">
const tags = (i.tags||[]).map(t => `<span class="tag t-tag" data-tag="${esc(t)}">${esc(t)}</span>`).join(' ');
const desc = i.desc ? `<div class="item-desc">${renderDesc(i.desc)}</div>` : '';
const stCls = statusClass(i.status);
const stLbl = STATUS_LABEL[i.status] || i.status;
const rejectNote = i.reject_reason
? `<div class="reject-note">🚫 拒绝原因:${esc(i.reject_reason)}<span class="reject-date"> · ${fmtDate(i.rejected_at)}</span></div>`
: '';
const rejectBtn = i.status === 'done'
? `<button class="reject-btn" data-id="${i.id}" data-title="${esc(i.title)}">拒绝验收</button>`
: '';
const meta = `<div class="item-meta">
<span class="meta-date">🕐 ${fmtDate(i.created_at)}</span>
${i.done ? `<span class="meta-date">✅ 完成 ${fmtDate(i.completed_at)}${i.version ? ' · <span class="ver-badge">'+esc(i.version)+'</span>' : ''}</span>` : ''}
${i.status === 'accepted' ? `<span class="meta-date">✅ 验收 ${fmtDate(i.completed_at)}${i.version ? ' · <span class="ver-badge">'+esc(i.version)+'</span>' : ''}</span>` : ''}
</div>`;
const lvlCls = levelClass(i.level);
const lvlCls = levelClass(i.level);
return `
<li class="todo-card${i.done ? ' done' : ''}"
<li class="todo-card ${stCls}"
data-id="${i.id}"
data-level="${esc(i.level)}"
data-tags="${esc((i.tags||[]).join(','))}"
data-done="${i.done ? '1' : '0'}">
data-status="${esc(i.status)}"
data-tags="${esc((i.tags||[]).join(','))}">
<div class="card-header">
<span class="item-title">${esc(i.title)}</span>
<span class="tag ${lvlCls}">${levelText(i.level)}</span>
<div class="card-badges">
<span class="tag status-badge ${stCls}">${stLbl}</span>
<span class="tag ${lvlCls}">${levelText(i.level)}</span>
${rejectBtn}
</div>
</div>
${rejectNote}
${desc}
<div class="card-footer">
<div class="tag-row">${tags}</div>
@@ -134,10 +180,17 @@ function renderItems(items) {
function buildHtml(db) {
const allTags = [...new Set(db.items.flatMap(i => i.tags||[]))].sort();
const pending = db.items.filter(i => !i.done)
.sort((a,b) => (LEVEL_ORDER[a.level]??9) - (LEVEL_ORDER[b.level]??9) || a.id - b.id);
const done = db.items.filter(i => i.done)
.sort((a,b) => (b.completed_at||'') > (a.completed_at||'') ? 1 : -1);
const byStatus = { open: [], doing: [], done: [], accepted: [] };
for (const i of db.items) {
(byStatus[i.status] || byStatus.open).push(i);
}
const sortFn = (a,b) => (LEVEL_ORDER[a.level]??9) - (LEVEL_ORDER[b.level]??9) || a.id - b.id;
for (const st of Object.keys(byStatus)) byStatus[st].sort(sortFn);
byStatus.accepted.sort((a,b) => (b.completed_at||'') > (a.completed_at||'') ? 1 : -1);
const total = db.items.length;
const pending = byStatus.open.length + byStatus.doing.length + byStatus.done.length;
const tagChips = allTags.map(t =>
`<button class="filter-chip" data-filter-tag="${esc(t)}">${esc(t)}</button>`
@@ -163,7 +216,7 @@ header {
}
header h1 { margin: 0 0 6px; font-size: 24px; font-weight: 700; }
header .header-meta { color: #ADC9EA; font-size: 13px; margin-top: 4px; }
.stats { display: flex; gap: 16px; margin-top: 14px; }
.stats { display: flex; gap: 16px; margin-top: 14px; flex-wrap: wrap; }
.stat-pill {
background: rgba(255,255,255,0.12); border-radius: 20px;
padding: 4px 14px; font-size: 13px; color: #fff;
@@ -191,21 +244,41 @@ header .header-meta { color: #ADC9EA; font-size: 13px; margin-top: 4px; }
/* ── 内容区 ── */
.wrap { max-width: 980px; margin: 0 auto; padding: 0 24px; }
.section-block { margin-bottom: 8px; }
.section-title {
font-size: 16px; font-weight: 700; color: #0A1F3B;
border-left: 4px solid #2563AC; padding-left: 12px;
margin: 28px 0 12px;
font-size: 15px; font-weight: 700;
padding: 10px 14px; border-radius: 8px;
display: flex; align-items: center; gap: 8px; cursor: pointer;
margin: 20px 0 8px; user-select: none;
}
.section-title.done-title { border-color: #6cbb8a; }
.section-title .s-count {
font-size: 12px; font-weight: 500; padding: 1px 8px; border-radius: 10px;
background: rgba(0,0,0,0.08);
}
.section-title .s-arrow { margin-left: auto; font-size: 12px; color: inherit; opacity: .6; }
/* 状态主题色 */
.st-open { background: #eff6ff; color: #1e40af; border-left: 4px solid #3b82f6; }
.st-doing { background: #fff7ed; color: #9a3412; border-left: 4px solid #f97316; }
.st-done { background: #fefce8; color: #854d0e; border-left: 4px solid #eab308; }
.st-accepted{ background: #f0fdf4; color: #166534; border-left: 4px solid #22c55e; }
ul.todo-list { list-style: none; margin: 0; padding: 0; }
.todo-card {
background: #fff; border: 1px solid #e4e9ef; border-radius: 10px;
padding: 14px 18px; margin-bottom: 10px;
border-left-width: 4px;
}
.todo-card.done { background: #f3faf5; border-color: #b7e4c7; opacity: .75; }
/* 卡片左边框颜色与状态匹配 */
.todo-card.s-open { border-left-color: #3b82f6; }
.todo-card.s-doing { border-left-color: #f97316; }
.todo-card.s-done { border-left-color: #eab308; }
.todo-card.s-accepted { border-left-color: #22c55e; background: #f0fdf4; opacity: .8; }
.card-header { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
.item-title { font-weight: 600; font-size: 15px; flex: 1; }
.todo-card.done .item-title { text-decoration: line-through; color: #52606d; }
.todo-card.s-accepted .item-title { text-decoration: line-through; color: #52606d; }
.card-badges { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.item-desc { font-size: 13.5px; color: #52606d; margin-top: 6px; }
.item-desc code {
background: #f0f2f5; padding: 1px 5px; border-radius: 3px;
@@ -216,7 +289,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
.item-meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #8aa3c4; }
.meta-date { white-space: nowrap; }
/* ── tags ── */
/* ── 标签样式 ── */
.tag { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 12px; font-weight: 600; white-space: nowrap; }
.t-block { background: #fde8e8; color: #b91c1c; }
.t-high { background: #fef0d8; color: #9a6700; }
@@ -225,18 +298,65 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
.t-tag:hover { background: #d9e8fb; color: #1d4ed8; }
.ver-badge { font-weight: 700; color: #1f7a44; }
/* ── 折叠 ── */
.done-section { margin-bottom: 24px; }
.done-toggle {
background: none; border: 1.5px solid #d1d9e6; border-radius: 6px;
padding: 4px 14px; cursor: pointer; font-size: 13px; color: #52606d;
margin-bottom: 10px; transition: .15s;
}
.done-toggle:hover { border-color: #6cbb8a; color: #1f7a44; }
.empty-tip { font-size: 14px; color: #8aa3c4; margin: 8px 0 24px; }
/* 状态徽章 */
.status-badge { font-size: 11.5px; }
.s-open { background: #dbeafe; color: #1e40af; }
.s-doing { background: #ffedd5; color: #9a3412; }
.s-done { background: #fef9c3; color: #854d0e; }
.s-accepted { background: #dcfce7; color: #166534; }
/* 隐藏 */
/* ── 折叠 ── */
.section-list-wrap.collapsed { display: none; }
.empty-tip { font-size: 14px; color: #8aa3c4; margin: 8px 0 24px; }
.hidden { display: none !important; }
/* ── 拒绝按钮 & 拒绝原因 ── */
.reject-btn {
padding: 3px 10px; border-radius: 6px; border: 1.5px solid #dc2626;
background: #fff; color: #dc2626; font-size: 12px; font-weight: 600;
cursor: pointer; transition: all .15s; white-space: nowrap;
}
.reject-btn:hover { background: #dc2626; color: #fff; }
.reject-note {
margin: 8px 0 4px; padding: 7px 12px; border-radius: 6px;
background: #fef2f2; border: 1px solid #fecaca;
font-size: 13px; color: #b91c1c; line-height: 1.5;
}
.reject-date { color: #ef9999; font-size: 12px; }
/* ── 拒绝 Modal ── */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.45);
display: flex; align-items: center; justify-content: center; z-index: 999;
}
.modal-box {
background: #fff; border-radius: 12px; padding: 28px 32px;
width: min(480px, 94vw); box-shadow: 0 20px 60px rgba(0,0,0,.2);
}
.modal-box h3 { margin: 0 0 4px; font-size: 17px; color: #1f2933; }
.modal-subtitle { font-size: 13px; color: #52606d; margin: 0 0 18px; }
.modal-label { font-size: 13px; font-weight: 600; color: #374151; margin-bottom: 6px; display: block; }
.modal-label .req { color: #dc2626; margin-left: 2px; }
.modal-textarea {
width: 100%; border: 1.5px solid #d1d9e6; border-radius: 8px;
padding: 10px 12px; font-size: 14px; font-family: inherit; resize: vertical;
min-height: 88px; outline: none; transition: border-color .15s;
}
.modal-textarea:focus { border-color: #dc2626; }
.modal-textarea.error { border-color: #dc2626; background: #fff8f8; }
.modal-err { font-size: 12px; color: #dc2626; margin-top: 4px; display: none; }
.modal-err.show { display: block; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.modal-btn { padding: 8px 20px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: all .15s; }
.modal-btn-cancel { background: #f3f4f6; color: #374151; }
.modal-btn-cancel:hover { background: #e5e7eb; }
.modal-btn-reject { background: #dc2626; color: #fff; }
.modal-btn-reject:hover { background: #b91c1c; }
.modal-cmd-wrap { margin-top: 16px; padding: 12px 14px; background: #1e293b; border-radius: 8px; }
.modal-cmd-label { font-size: 12px; color: #94a3b8; margin-bottom: 8px; }
.modal-cmd-code { font-family: "JetBrains Mono", "Fira Code", monospace; font-size: 13px; color: #86efac; word-break: break-all; display: block; }
.modal-copy-btn { margin-top: 10px; padding: 5px 14px; border-radius: 6px; background: #334155; color: #e2e8f0; border: none; font-size: 12px; cursor: pointer; }
.modal-copy-btn:hover { background: #475569; }
</style>
</head>
<body>
@@ -246,9 +366,11 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<h1>${esc(db.meta.title)}</h1>
<div class="header-meta">生成于 ${fmtDate(db.meta.updated_at)} · 真相源 todo/todo.json</div>
<div class="stats">
<div class="stat-pill"><strong>${db.items.length}</strong>全部</div>
<div class="stat-pill"><strong>${pending.length}</strong>未完成</div>
<div class="stat-pill"><strong>${done.length}</strong>已完成</div>
<div class="stat-pill"><strong>${total}</strong>全部</div>
<div class="stat-pill"><strong>${byStatus.open.length}</strong>待开始</div>
<div class="stat-pill"><strong>${byStatus.doing.length}</strong>开发中</div>
<div class="stat-pill"><strong>${byStatus.done.length}</strong>待验收</div>
<div class="stat-pill"><strong>${byStatus.accepted.length}</strong>已验收</div>
</div>
</div>
</header>
@@ -264,9 +386,11 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">状态</span>
<button class="filter-btn active" data-filter-done="all">全部</button>
<button class="filter-btn" data-filter-done="0">未完成</button>
<button class="filter-btn" data-filter-done="1">已完成</button>
<button class="filter-btn active" data-filter-status="all">全部</button>
<button class="filter-btn" data-filter-status="open">待开始</button>
<button class="filter-btn" data-filter-status="doing">开发中</button>
<button class="filter-btn" data-filter-status="done">待验收</button>
<button class="filter-btn" data-filter-status="accepted">已验收</button>
</div>
${allTags.length ? `<div class="filter-sep"></div>
<div class="filter-group">
@@ -277,44 +401,78 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
<div class="wrap">
<div class="section-title">📋 未完成(${pending.length}</div>
<ul class="todo-list" id="pendingList">
${renderItems(pending)}
</ul>
${[
{ st: 'open', label: '📋 待开始', cls: 'st-open', collapsed: false },
{ st: 'doing', label: '🔨 开发中', cls: 'st-doing', collapsed: false },
{ st: 'done', label: '🔍 待验收', cls: 'st-done', collapsed: false },
{ st: 'accepted', label: '✅ 已验收', cls: 'st-accepted', collapsed: true },
].map(({ st, label, cls, collapsed }) => `
<div class="section-block" id="section-${st}">
<div class="section-title ${cls}" data-toggle="${st}">
${label} <span class="s-count">${byStatus[st].length}</span>
<span class="s-arrow">${collapsed ? '▾ 展开' : '▴ 收起'}</span>
</div>
<div class="section-list-wrap ${collapsed ? 'collapsed' : ''}" id="list-wrap-${st}">
<ul class="todo-list" id="list-${st}">
${renderItems(byStatus[st])}
</ul>
</div>
</div>`).join('')}
<div class="done-section">
<div class="section-title done-title">✅ 已完成(${done.length}</div>
<button class="done-toggle" id="doneToggle">▾ 展开已完成列表</button>
<ul class="todo-list hidden" id="doneList">
${renderItems(done)}
</ul>
</div>
<!-- 拒绝验收 Modal -->
<div id="reject-modal" class="modal-overlay" style="display:none">
<div class="modal-box">
<h3>拒绝验收</h3>
<p class="modal-subtitle" id="reject-modal-subtitle"></p>
<label class="modal-label">拒绝原因<span class="req">*</span></label>
<textarea class="modal-textarea" id="reject-reason-input" rows="3" placeholder="请说明具体问题,Claude 将以此为依据修复…"></textarea>
<div class="modal-err" id="reject-reason-err">请填写拒绝原因</div>
<div id="reject-cmd-wrap" class="modal-cmd-wrap" style="display:none">
<div class="modal-cmd-label">在终端运行以下命令</div>
<code class="modal-cmd-code" id="reject-cmd-text"></code>
<button class="modal-copy-btn" id="reject-copy-btn">复制命令</button>
</div>
<div class="modal-actions">
<button class="modal-btn modal-btn-cancel" id="reject-cancel-btn">取消</button>
<button class="modal-btn modal-btn-reject" id="reject-confirm-btn">确认拒绝</button>
</div>
</div>
</div>
<script>
(function() {
// 筛选状态
let curLevel = 'all';
let curDone = 'all';
let curTags = new Set();
let curLevel = 'all';
let curStatus = 'all';
let curTags = new Set();
function applyFilter() {
document.querySelectorAll('.todo-card').forEach(card => {
const lvl = card.dataset.level;
const dn = card.dataset.done;
const tags = card.dataset.tags ? card.dataset.tags.split(',') : [];
const lvl = card.dataset.level;
const status = card.dataset.status;
const tags = card.dataset.tags ? card.dataset.tags.split(',') : [];
let show = true;
if (curLevel !== 'all' && lvl !== curLevel) show = false;
if (curDone !== 'all' && dn !== curDone) show = false;
if (curLevel !== 'all' && lvl !== curLevel) show = false;
if (curStatus !== 'all' && status !== curStatus) show = false;
if (curTags.size > 0 && ![...curTags].some(t => tags.includes(t))) show = false;
card.classList.toggle('hidden', !show);
});
// 筛选后自动展开包含结果的区域
if (curStatus !== 'all') {
const wrap = document.getElementById('list-wrap-' + curStatus);
if (wrap) {
wrap.classList.remove('collapsed');
const title = document.querySelector('[data-toggle="' + curStatus + '"] .s-arrow');
if (title) title.textContent = '▴ 收起';
}
}
}
// 重要度
// 重要度筛选
document.querySelectorAll('[data-filter-level]').forEach(btn => {
btn.addEventListener('click', () => {
curLevel = btn.dataset.filterLevel;
@@ -323,16 +481,16 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
});
});
// 状态
document.querySelectorAll('[data-filter-done]').forEach(btn => {
// 状态筛选
document.querySelectorAll('[data-filter-status]').forEach(btn => {
btn.addEventListener('click', () => {
curDone = btn.dataset.filterDone;
document.querySelectorAll('[data-filter-done]').forEach(b => b.classList.toggle('active', b === btn));
curStatus = btn.dataset.filterStatus;
document.querySelectorAll('[data-filter-status]').forEach(b => b.classList.toggle('active', b === btn));
applyFilter();
});
});
// 标签(多选)
// 标签筛选(多选)
document.querySelectorAll('[data-filter-tag]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.filterTag;
@@ -342,7 +500,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
});
});
// 点击卡片标签 → 快速筛选
// 卡片标签 → 快速筛选
document.querySelectorAll('.t-tag').forEach(tag => {
tag.addEventListener('click', e => {
e.stopPropagation();
@@ -352,15 +510,78 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
});
});
// 已完成折叠
const toggle = document.getElementById('doneToggle');
const doneList = document.getElementById('doneList');
if (toggle && doneList) {
toggle.addEventListener('click', () => {
const collapsed = doneList.classList.toggle('hidden');
toggle.textContent = collapsed ? '▾ 展开已完成列表' : '▴ 收起已完成列表';
// 区域折叠切换
document.querySelectorAll('.section-title[data-toggle]').forEach(title => {
title.addEventListener('click', () => {
const st = title.dataset.toggle;
const wrap = document.getElementById('list-wrap-' + st);
const arrow = title.querySelector('.s-arrow');
if (!wrap) return;
const collapsed = wrap.classList.toggle('collapsed');
if (arrow) arrow.textContent = collapsed ? '▾ 展开' : '▴ 收起';
});
}
});
// ── 拒绝验收 Modal ──
const modal = document.getElementById('reject-modal');
const reasonInput = document.getElementById('reject-reason-input');
const reasonErr = document.getElementById('reject-reason-err');
const cmdWrap = document.getElementById('reject-cmd-wrap');
const cmdText = document.getElementById('reject-cmd-text');
const copyBtn = document.getElementById('reject-copy-btn');
let rejectId = null;
document.querySelectorAll('.reject-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
rejectId = btn.dataset.id;
document.getElementById('reject-modal-subtitle').textContent = '#' + rejectId + ' ' + btn.dataset.title;
reasonInput.value = '';
reasonInput.classList.remove('error');
reasonErr.classList.remove('show');
cmdWrap.style.display = 'none';
document.getElementById('reject-confirm-btn').style.display = '';
copyBtn.textContent = '复制命令';
modal.style.display = 'flex';
setTimeout(() => reasonInput.focus(), 80);
});
});
document.getElementById('reject-cancel-btn').addEventListener('click', () => {
modal.style.display = 'none';
});
modal.addEventListener('click', e => {
if (e.target === modal) modal.style.display = 'none';
});
document.getElementById('reject-confirm-btn').addEventListener('click', () => {
const reason = reasonInput.value.trim();
if (!reason) {
reasonInput.classList.add('error');
reasonErr.classList.add('show');
reasonInput.focus();
return;
}
const escaped = reason.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const cmd = 'node todo/todo.mjs reject ' + rejectId + ' --reason "' + escaped + '"';
cmdText.textContent = cmd;
cmdWrap.style.display = 'block';
document.getElementById('reject-confirm-btn').style.display = 'none';
});
reasonInput.addEventListener('input', () => {
if (reasonInput.value.trim()) {
reasonInput.classList.remove('error');
reasonErr.classList.remove('show');
}
});
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(cmdText.textContent).then(() => {
copyBtn.textContent = '已复制 ✓';
});
});
})();
</script>
</body>
@@ -398,9 +619,9 @@ function cmdAdd(db, args) {
const title = args.title || (args._.join(' ') || '').trim();
if (!title) { console.error('错误:--title 不能为空'); process.exit(1); }
const level = ['high','mid','low'].includes(args.level) ? args.level : 'mid';
const tags = args.tags ? args.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
const desc = args.desc || null;
const level = ['high','mid','low'].includes(args.level) ? args.level : 'mid';
const tags = args.tags ? args.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
const desc = args.desc || null;
db.seq++;
const item = {
@@ -409,6 +630,7 @@ function cmdAdd(db, args) {
desc,
level,
tags,
status: 'open',
created_at: now(),
done: false,
completed_at: null,
@@ -422,6 +644,33 @@ function cmdAdd(db, args) {
printSummary(db);
}
/** status <id> <open|doing|done> — Claude 可调用,accepted 由用户手动设置 */
function cmdStatus(db, args) {
const id = args._[0];
const newStatus = args._[1];
if (newStatus === 'accepted') {
console.error('❌ 状态 "accepted"(已验收)只能由用户手动修改 todo.json,或使用 `done <id>` 命令');
process.exit(1);
}
if (!['open','doing','done'].includes(newStatus)) {
console.error(`错误:无效状态 "${newStatus}",可选:open | doing | done`);
process.exit(1);
}
const item = findItem(db, id);
item.status = newStatus;
item.done = false;
save(db);
writeFileSync(HTML, buildHtml(db), 'utf8');
const emoji = { open: '📋', doing: '🔨', done: '🔍' }[newStatus];
console.log(`${emoji} #${item.id}${item.title}」→ ${STATUS_LABEL[newStatus]}`);
printSummary(db);
}
/** done <id> — 标记已验收交付(仅用户调用),记录版本号 */
function cmdDone(db, args) {
const id = args._[0];
const item = findItem(db, id);
@@ -432,6 +681,7 @@ function cmdDone(db, args) {
if (!version) console.warn('⚠️ 无法从 git 获取版本号,version 将为空');
}
item.status = 'accepted';
item.done = true;
item.completed_at = now();
item.version = version;
@@ -439,7 +689,39 @@ function cmdDone(db, args) {
save(db);
writeFileSync(HTML, buildHtml(db), 'utf8');
console.log(`✅ #${item.id}${item.title}」已标记完成${version ? ',记入版本 ' + version : ''}`);
console.log(`✅ #${item.id}${item.title}」已验收${version ? ',记入版本 ' + version : ''}`);
printSummary(db);
}
/** reject <id> --reason "原因" — 拒绝验收,退回 open 并升级为 high(仅 done 状态可用) */
function cmdReject(db, args) {
const id = args._[0];
const reason = (args.reason || '').trim();
if (!reason) {
console.error('错误:--reason 不能为空,请说明拒绝原因');
process.exit(1);
}
const item = findItem(db, id);
if (item.status !== 'done') {
console.error(`错误:只有「待验收(done)」状态的条目才能拒绝,当前状态:${item.status}`);
process.exit(1);
}
const prevLevel = item.level;
item.status = 'open';
item.done = false;
item.level = 'high';
item.reject_reason = reason;
item.rejected_at = now();
item.version = null;
save(db);
writeFileSync(HTML, buildHtml(db), 'utf8');
console.log(`🚫 #${item.id}${item.title}」已拒绝${prevLevel !== 'high' ? ',优先级升为最高' : ''}`);
console.log(` 原因:${reason}`);
printSummary(db);
}
@@ -447,9 +729,12 @@ function cmdReopen(db, args) {
const id = args._[0];
const item = findItem(db, id);
item.done = false;
item.completed_at = null;
item.version = null;
item.status = 'open';
item.done = false;
item.completed_at = null;
item.version = null;
item.reject_reason = null;
item.rejected_at = null;
save(db);
writeFileSync(HTML, buildHtml(db), 'utf8');
@@ -472,7 +757,6 @@ function cmdRm(db, args) {
}
function cmdList(db) {
// 确保 HTML 最新
writeFileSync(HTML, buildHtml(db), 'utf8');
printSummary(db);
console.log(`📄 HTMLtodo/todo.html`);
@@ -491,7 +775,9 @@ const args = parseArgs(rest);
switch (subcmd) {
case 'add': cmdAdd(db, args); break;
case 'status': cmdStatus(db, args); break;
case 'done': cmdDone(db, args); break;
case 'reject': cmdReject(db, args); break;
case 'reopen': cmdReopen(db, args); break;
case 'rm': cmdRm(db, args); break;
case 'list':
@@ -499,6 +785,6 @@ switch (subcmd) {
case '': cmdList(db); break;
case 'render': cmdRender(db); break;
default:
console.error(`未知子命令:${subcmd}\n用法:node todo.mjs add|done|reopen|rm|list|render`);
console.error(`未知子命令:${subcmd}\n用法:node todo.mjs add|status|done|reject|reopen|rm|list|render`);
process.exit(1);
}