#!/usr/bin/env node /** * todo/todo.mjs — 项目 TODO 管理 CLI(零依赖,Node.js >= 18) * * 状态流转:open → doing → done → accepted * done ←─ reject(退回,升为最高优先级) * open 待开始 Claude 可设 * doing 开发中 Claude 可设 * done 待验收 Claude 可设(开发完成,等用户确认) * accepted 已验收 仅限用户设置(手动编辑 JSON 或 `done ` 命令) * * 子命令: * add --title "..." [--level high|mid|low] [--tier 1|2|3] [--tags a,b] [--desc "..."] * status — 更新开发状态(Claude 可调用) * done [--version vX.Y.Z] — 标记已验收交付(仅用户调用) * reject --reason "原因" — 拒绝验收,退回 open 并升为 high(仅用户调用) * reopen — 重新开启(清空拒绝记录) * rm * list * render * sub add --title "..." [--tier 1|2|3] [--deps "21A,21B"] * sub status — 更新子任务状态(如 21A) */ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import { execSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; 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 }; const TIER_LABEL = { 1: '一级', 2: '二级', 3: '三级' }; const SUB_STATUS_ICON = { open: '○', doing: '◐', done: '✓' }; const SUB_LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; // ─── 数据读写 ──────────────────────────────────────────────────────────────── function load() { if (!existsSync(DB)) { return { meta: { title: '酒库管理系统 — 项目 TODO', updated_at: now() }, seq: 0, items: [] }; } const db = JSON.parse(readFileSync(DB, 'utf8')); for (const item of db.items) { if (!item.status) { item.status = item.done ? 'accepted' : 'open'; } } return db; } function save(db) { db.meta.updated_at = now(); writeFileSync(DB, JSON.stringify(db, null, 2) + '\n', 'utf8'); } function now() { return new Date().toISOString(); } function fmtDate(iso) { if (!iso) return ''; const d = new Date(iso); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; } function gitTag() { try { return execSync('git describe --tags --abbrev=0', { encoding: 'utf8', stdio: ['ignore','pipe','ignore'] }).trim(); } catch { return null; } } // ─── 子任务工具 ────────────────────────────────────────────────────────────── function nextSubtaskLetter(item) { const existing = (item.subtasks || []).map(s => s.sid); for (const l of SUB_LETTERS) { const sid = `${item.id}${l}`; if (!existing.includes(sid)) return l; } return null; } function parseSid(sid) { const m = String(sid).match(/^(\d+)([A-Za-z])$/); if (!m) return null; return { parentId: parseInt(m[1], 10), letter: m[2].toUpperCase() }; } function findSubtask(db, sid) { const parsed = parseSid(sid); if (!parsed) { console.error(`错误:无效子任务 ID "${sid}",格式应为 21A`); process.exit(1); } const parent = db.items.find(i => i.id === parsed.parentId); if (!parent) { console.error(`错误:找不到父任务 id=${parsed.parentId}`); process.exit(1); } const normalSid = sid.toUpperCase(); const sub = (parent.subtasks||[]).find(s => s.sid === normalSid); if (!sub) { console.error(`错误:找不到子任务 ${normalSid}`); process.exit(1); } return { parent, sub }; } function subtasksDoneCount(item) { if (!item.subtasks || !item.subtasks.length) return { done: 0, total: 0 }; const done = item.subtasks.filter(s => s.status === 'done' || s.status === 'accepted').length; return { done, total: item.subtasks.length }; } function allSubtasksDone(item) { const { done, total } = subtasksDoneCount(item); return total > 0 && done === total; } // ─── 终端 summary ──────────────────────────────────────────────────────────── function levelText(level) { return { high: '高优 · 紧急', mid: '重要', low: '一般 / 优化' }[level] || level; } function printSummary(db) { 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); } const total = db.items.length; const pending = byStatus.open.length + byStatus.doing.length + byStatus.done.length; const accepted = byStatus.accepted.length; 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; const tier = i.tier ? '·' + TIER_LABEL[i.tier] : ''; const { done, total: stTotal } = subtasksDoneCount(i); const subInfo = stTotal > 0 ? ` [${done}/${stTotal}子任务]` : ''; console.log(` [${lvl}${tier}] #${i.id} ${i.title}${tags}${subInfo}`); if (stTotal > 0) { for (const s of i.subtasks) { const icon = SUB_STATUS_ICON[s.status] || '○'; const deps = s.deps && s.deps.length ? ` (需: ${s.deps.join(',')})` : ''; const stier = s.tier ? ` [${TIER_LABEL[s.tier]}]` : ''; console.log(` ${icon} ${s.sid}${stier} ${s.title}${deps}`); } } } } 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}`); } } console.log(''); } // ─── HTML 渲染 ─────────────────────────────────────────────────────────────── function esc(s) { return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function renderDesc(s) { if (!s) return ''; return esc(s).replace(/`([^`]+)`/g, '$1'); } function levelClass(level) { return { high: 't-block', mid: 't-high', low: 't-low' }[level] || 't-low'; } function statusClass(status) { return { open: 's-open', doing: 's-doing', done: 's-done', accepted: 's-accepted' }[status] || 's-open'; } function renderSubtasks(item) { if (!item.subtasks || item.subtasks.length === 0) return ''; const { done, total } = subtasksDoneCount(item); const pct = total > 0 ? Math.round(done / total * 100) : 0; const subItems = item.subtasks.map(s => { const stCls = statusClass(s.status); const stLbl = STATUS_LABEL[s.status] || s.status; const icon = SUB_STATUS_ICON[s.status] || '○'; const tierBadge = s.tier ? `${TIER_LABEL[s.tier]}` : ''; const depBadges = (s.deps||[]).map(dep => { const depSub = item.subtasks.find(x => x.sid === dep); const depDone = depSub && (depSub.status === 'done' || depSub.status === 'accepted'); return `${esc(dep)}`; }).join(''); const depsHtml = s.deps && s.deps.length ? `依赖:${depBadges}` : ''; return `
  • ${icon} ${esc(s.sid)} ${esc(s.title)}
    ${tierBadge}${stLbl}${depsHtml}
  • `; }).join('\n'); return `
    子任务 ${done} / ${total} 完成
      ${subItems}
    `; } function renderItems(items) { if (!items.length) return '

    暂无条目

    '; return items.map(i => { const tags = (i.tags||[]).map(t => `${esc(t)}`).join(' '); const desc = i.desc ? `
    ${renderDesc(i.desc)}
    ` : ''; const stCls = statusClass(i.status); const stLbl = STATUS_LABEL[i.status] || i.status; const rejectNote = i.reject_reason ? `
    🚫 拒绝原因:${esc(i.reject_reason)} · ${fmtDate(i.rejected_at)}
    ` : ''; const rejectBtn = i.status === 'done' ? `` : ''; const { done: subDone, total: subTotal } = subtasksDoneCount(i); const subProgressBadge = subTotal > 0 ? `${subDone}/${subTotal} 子任务` : ''; const meta = `
    🕐 ${fmtDate(i.created_at)} ${i.status === 'accepted' ? `✅ 验收 ${fmtDate(i.completed_at)}${i.version ? ' · '+esc(i.version)+'' : ''}` : ''}
    `; const lvlCls = levelClass(i.level); const subtasksBlock = renderSubtasks(i); return `
  • ${esc(i.title)}
    ${stLbl} ${levelText(i.level)} ${i.tier ? `${TIER_LABEL[i.tier]}` : ''} ${subProgressBadge} ${rejectBtn}
    ${rejectNote} ${desc} ${subtasksBlock}
  • `; }).join('\n'); } function buildHtml(db) { const allTags = [...new Set(db.items.flatMap(i => i.tags||[]))].sort(); 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 => `` ).join(''); return ` ${esc(db.meta.title)}

    ${esc(db.meta.title)}

    生成于 ${fmtDate(db.meta.updated_at)} · 真相源 todo/todo.json
    ${total}全部
    ${byStatus.open.length}待开始
    ${byStatus.doing.length}开发中
    ${byStatus.done.length}待验收
    ${byStatus.accepted.length}已验收
    重要度
    状态
    改动等级
    ${allTags.length ? `
    平台 / 标签 ${tagChips}
    ` : ''}
    ${[ { 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 }) => `
    ${label} ${byStatus[st].length} ${collapsed ? '▾ 展开' : '▴ 收起'}
      ${renderItems(byStatus[st])}
    `).join('')}
    `; } // ─── CLI 解析 ──────────────────────────────────────────────────────────────── function parseArgs(argv) { const args = {}; let pos = []; for (let i = 0; i < argv.length; i++) { if (argv[i].startsWith('--')) { const key = argv[i].slice(2); const val = argv[i+1] && !argv[i+1].startsWith('--') ? argv[++i] : true; args[key] = val; } else { pos.push(argv[i]); } } args._ = pos; return args; } function findItem(db, id) { const n = parseInt(id, 10); const item = db.items.find(i => i.id === n); if (!item) { console.error(`错误:找不到 id=${n} 的条目`); process.exit(1); } return item; } // ─── 子命令 ───────────────────────────────────────────────────────────────── 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 tierRaw = parseInt(args.tier, 10); const tier = [1,2,3].includes(tierRaw) ? tierRaw : null; db.seq++; const item = { id: db.seq, title, desc, level, tier, tags, status: 'open', created_at: now(), done: false, completed_at: null, version: null, }; db.items.push(item); save(db); writeFileSync(HTML, buildHtml(db), 'utf8'); const tierStr = tier ? '·' + TIER_LABEL[tier] : ''; console.log(`✅ 已添加 #${item.id} [${levelText(level)}${tierStr}] ${title}${tags.length ? ' ' + tags.join('/') : ''}`); printSummary(db); } /** status */ function cmdStatus(db, args) { const id = args._[0]; const newStatus = args._[1]; if (newStatus === 'accepted') { console.error('❌ 状态 "accepted"(已验收)只能由用户手动修改 todo.json,或使用 `done ` 命令'); process.exit(1); } if (!['open','doing','done'].includes(newStatus)) { console.error(`错误:无效状态 "${newStatus}",可选:open | doing | done`); process.exit(1); } const item = findItem(db, id); // 有未完成子任务时,禁止手动将父任务标记为 done if (newStatus === 'done' && !allSubtasksDone(item) && item.subtasks && item.subtasks.length > 0) { const { done: doneCount, total } = subtasksDoneCount(item); console.error(`❌ 父任务还有未完成的子任务(${doneCount}/${total} 完成),请先通过 sub status 完成所有子任务`); process.exit(1); } 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 [--version vX.Y.Z] */ function cmdDone(db, args) { const id = args._[0]; const item = findItem(db, id); // 有未完成子任务时提示警告(但允许用户强制验收) if (item.subtasks && item.subtasks.length > 0 && !allSubtasksDone(item)) { const { done: doneCount, total } = subtasksDoneCount(item); console.warn(`⚠️ 注意:还有未完成的子任务(${doneCount}/${total} 完成),已强制标记为验收`); } let version = args.version || null; if (!version) { version = gitTag(); if (!version) console.warn('⚠️ 无法从 git 获取版本号,version 将为空'); } item.status = 'accepted'; item.done = true; item.completed_at = now(); item.version = version; save(db); writeFileSync(HTML, buildHtml(db), 'utf8'); console.log(`✅ #${item.id}「${item.title}」已验收${version ? ',记入版本 ' + version : ''}`); printSummary(db); } /** reject --reason "原因" */ 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); } function cmdReopen(db, args) { const id = args._[0]; const item = findItem(db, id); 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'); console.log(`🔄 #${item.id}「${item.title}」已重新开启`); printSummary(db); } function cmdRm(db, args) { const id = parseInt(args._[0], 10); const idx = db.items.findIndex(i => i.id === id); if (idx < 0) { console.error(`错误:找不到 id=${id}`); process.exit(1); } const [removed] = db.items.splice(idx, 1); save(db); writeFileSync(HTML, buildHtml(db), 'utf8'); console.log(`🗑️ #${removed.id}「${removed.title}」已删除`); printSummary(db); } function cmdList(db) { writeFileSync(HTML, buildHtml(db), 'utf8'); printSummary(db); console.log(`📄 HTML:todo/todo.html`); } function cmdRender(db) { writeFileSync(HTML, buildHtml(db), 'utf8'); console.log('🖨️ todo/todo.html 已重新渲染'); } // ─── 子任务命令 ─────────────────────────────────────────────────────────────── /** sub add --title "..." [--tier 1|2|3] [--deps "21A,21B"] */ function cmdSubAdd(db, args) { const parentIdRaw = args._[0]; const title = (args.title || '').trim(); if (!parentIdRaw) { console.error('错误:请指定父任务 id,例如:sub add 21 --title "..."'); process.exit(1); } if (!title) { console.error('错误:--title 不能为空'); process.exit(1); } const parent = findItem(db, parentIdRaw); if (!parent.subtasks) parent.subtasks = []; const letter = nextSubtaskLetter(parent); if (!letter) { console.error('错误:子任务数量已达上限(26)'); process.exit(1); } const sid = `${parent.id}${letter}`; const tierRaw = parseInt(args.tier, 10); const tier = [1,2,3].includes(tierRaw) ? tierRaw : null; const deps = args.deps ? args.deps.split(',').map(d => d.trim().toUpperCase()).filter(Boolean) : []; // 校验依赖的 sid 是否存在 for (const dep of deps) { const exists = parent.subtasks.find(s => s.sid === dep); if (!exists) { console.error(`错误:依赖的子任务 ${dep} 不存在`); process.exit(1); } } const sub = { sid, title, tier, deps, status: 'open', created_at: now() }; parent.subtasks.push(sub); // 有子任务的父任务自动进入 doing if (parent.status === 'open') { parent.status = 'doing'; } save(db); writeFileSync(HTML, buildHtml(db), 'utf8'); const tierStr = tier ? ` [${TIER_LABEL[tier]}]` : ''; const depsStr = deps.length ? ` 依赖: ${deps.join(', ')}` : ''; console.log(`✅ 已添加子任务 ${sid}${tierStr}「${title}」→ #${parent.id}「${parent.title}」${depsStr}`); printSummary(db); } /** sub status */ function cmdSubStatus(db, args) { const sid = (args._[0] || '').toUpperCase(); const newStatus = args._[1]; if (!['open','doing','done'].includes(newStatus)) { console.error(`错误:无效状态 "${newStatus}",可选:open | doing | done`); process.exit(1); } const { parent, sub } = findSubtask(db, sid); sub.status = newStatus; // 所有子任务完成时自动将父任务推进为 done(待验收) if (newStatus === 'done' && allSubtasksDone(parent)) { parent.status = 'done'; parent.done = false; console.log(`🎉 所有子任务均已完成,父任务 #${parent.id}「${parent.title}」自动转为「待验收」`); } save(db); writeFileSync(HTML, buildHtml(db), 'utf8'); const emoji = { open: '📋', doing: '🔨', done: '🔍' }[newStatus]; console.log(`${emoji} ${sub.sid}「${sub.title}」→ ${STATUS_LABEL[newStatus]}`); printSummary(db); } /** sub ... */ function cmdSub(db, args) { const subCmd = args._[0]; const subArgs = { ...args, _: args._.slice(1) }; switch (subCmd) { case 'add': cmdSubAdd(db, subArgs); break; case 'status': cmdSubStatus(db, subArgs); break; default: console.error(`未知子命令:sub ${subCmd}\n用法:\n node todo.mjs sub add --title "..." [--tier N] [--deps "21A,21B"]\n node todo.mjs sub status `); process.exit(1); } } // ─── 入口 ──────────────────────────────────────────────────────────────────── const [,, subcmd, ...rest] = process.argv; const db = load(); 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 'sub': cmdSub(db, args); break; case 'list': case undefined: case '': cmdList(db); break; case 'render': cmdRender(db); break; default: console.error(`未知子命令:${subcmd}\n用法:node todo.mjs add|status|done|reject|reopen|rm|sub|list|render`); process.exit(1); }