#!/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] [--tags a,b] [--desc "..."] * status — 更新开发状态(Claude 可调用) * done [--version vX.Y.Z] — 标记已验收交付(仅用户调用) * reject --reason "原因" — 拒绝验收,退回 open 并升为 high(仅用户调用) * reopen — 重新开启(清空拒绝记录) * rm * list * render */ 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 }; // ─── 数据读写 ──────────────────────────────────────────────────────────────── function load() { if (!existsSync(DB)) { return { meta: { title: '酒库管理系统 — 项目 TODO', updated_at: now() }, seq: 0, items: [] }; } 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) { 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; } } // ─── 终端 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; console.log(` [${lvl}] #${i.id} ${i.title}${tags}`); } } 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 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 meta = `
🕐 ${fmtDate(i.created_at)} ${i.status === 'accepted' ? `✅ 验收 ${fmtDate(i.completed_at)}${i.version ? ' · '+esc(i.version)+'' : ''}` : ''}
`; const lvlCls = levelClass(i.level); return `
  • ${esc(i.title)}
    ${stLbl} ${levelText(i.level)} ${rejectBtn}
    ${rejectNote} ${desc}
  • `; }).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; db.seq++; const item = { id: db.seq, title, desc, level, tags, status: 'open', created_at: now(), done: false, completed_at: null, version: null, }; db.items.push(item); save(db); writeFileSync(HTML, buildHtml(db), 'utf8'); console.log(`✅ 已添加 #${item.id} [${levelText(level)}] ${title}${tags.length ? ' ' + tags.join('/') : ''}`); printSummary(db); } /** status — Claude 可调用,accepted 由用户手动设置 */ 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); 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 — 标记已验收交付(仅用户调用),记录版本号 */ function cmdDone(db, args) { const id = args._[0]; const item = findItem(db, id); 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 "原因" — 拒绝验收,退回 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); } 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 已重新渲染'); } // ─── 入口 ──────────────────────────────────────────────────────────────────── 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 '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|list|render`); process.exit(1); }