Files
jiu/todo/todo.mjs
T
wangjia 18cd2497c1 feat(todo): 子任务系统 — 复杂任务支持子任务拆解与依赖追踪
- 新增 `sub add <parent_id>` / `sub status <sid>` CLI 命令
- SID 格式 {parentId}{Letter},支持依赖声明(deps)与校验
- HTML 新增子任务展开块、进度徽章、依赖色标(绿=完成/红=未完成)
- 父任务全部子任务完成时自动转为「待验收」;手动设 done 时检查子任务
- 更新 /todo 命令文档补充 sub 子命令说明
- #21 已录入 8 个实现子任务(21A–21H),含完整依赖链

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:16:10 +08:00

1067 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <id>` 命令)
*
* 子命令:
* add --title "..." [--level high|mid|low] [--tier 1|2|3] [--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
* sub add <parent_id> --title "..." [--tier 1|2|3] [--deps "21A,21B"]
* sub status <sid> <open|doing|done> — 更新子任务状态(如 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function renderDesc(s) {
if (!s) return '';
return esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');
}
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 ? `<span class="tag tier-${s.tier}">${TIER_LABEL[s.tier]}</span>` : '';
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 `<span class="dep-badge ${depDone ? 'dep-done' : 'dep-pending'}" title="${dep} ${depDone ? '已完成' : '未完成'}">${esc(dep)}</span>`;
}).join('');
const depsHtml = s.deps && s.deps.length
? `<span class="dep-section"><span class="dep-label">依赖:</span>${depBadges}</span>`
: '';
return `<li class="subtask-item ${stCls}" data-sid="${esc(s.sid)}">
<span class="sub-icon ${stCls}">${icon}</span>
<span class="sub-sid">${esc(s.sid)}</span>
<span class="sub-title">${esc(s.title)}</span>
<div class="sub-badges">${tierBadge}<span class="tag status-badge ${stCls}">${stLbl}</span>${depsHtml}</div>
</li>`;
}).join('\n');
return `<div class="subtask-block">
<div class="subtask-header">
<span class="subtask-label">子任务</span>
<span class="subtask-progress-text">${done} / ${total} 完成</span>
<div class="subtask-progress-bar"><div class="subtask-progress-fill" style="width:${pct}%"></div></div>
</div>
<ul class="subtask-list">${subItems}</ul>
</div>`;
}
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 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 { done: subDone, total: subTotal } = subtasksDoneCount(i);
const subProgressBadge = subTotal > 0
? `<span class="tag sub-progress-badge">${subDone}/${subTotal} 子任务</span>`
: '';
const meta = `<div class="item-meta">
<span class="meta-date">🕐 ${fmtDate(i.created_at)}</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 subtasksBlock = renderSubtasks(i);
return `
<li class="todo-card ${stCls}"
data-id="${i.id}"
data-level="${esc(i.level)}"
data-status="${esc(i.status)}"
data-tier="${esc(i.tier ?? '')}"
data-tags="${esc((i.tags||[]).join(','))}">
<div class="card-header">
<span class="item-title">${esc(i.title)}</span>
<div class="card-badges">
<span class="tag status-badge ${stCls}">${stLbl}</span>
<span class="tag ${lvlCls}">${levelText(i.level)}</span>
${i.tier ? `<span class="tag tier-${i.tier}">${TIER_LABEL[i.tier]}</span>` : ''}
${subProgressBadge}
${rejectBtn}
</div>
</div>
${rejectNote}
${desc}
${subtasksBlock}
<div class="card-footer">
<div class="tag-row">${tags}</div>
${meta}
</div>
</li>`;
}).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 =>
`<button class="filter-chip" data-filter-tag="${esc(t)}">${esc(t)}</button>`
).join('');
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${esc(db.meta.title)}</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; padding: 0 0 80px;
font-family: "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1f2933; background: #f7f9fb; line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
header {
background: linear-gradient(135deg, #0A1F3B 0%, #15407D 100%);
color: #fff; padding: 36px 32px 28px;
}
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; flex-wrap: wrap; }
.stat-pill {
background: rgba(255,255,255,0.12); border-radius: 20px;
padding: 4px 14px; font-size: 13px; color: #fff;
}
.stat-pill strong { font-size: 18px; font-weight: 700; margin-right: 2px; }
/* ── 筛选栏 ── */
.filter-bar {
position: sticky; top: 0; z-index: 10;
background: #fff; border-bottom: 1px solid #e4e9ef;
padding: 10px 28px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
}
.filter-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.filter-label { font-size: 12px; color: #7b8794; white-space: nowrap; }
.filter-chip, .filter-btn {
padding: 3px 12px; border-radius: 14px; border: 1.5px solid #d1d9e6;
background: #f7f9fb; color: #52606d; font-size: 12.5px; cursor: pointer;
transition: all .15s; white-space: nowrap;
}
.filter-chip:hover, .filter-btn:hover { border-color: #2563AC; color: #2563AC; }
.filter-chip.active, .filter-btn.active {
background: #2563AC; border-color: #2563AC; color: #fff;
}
.filter-sep { width: 1px; height: 20px; background: #e4e9ef; margin: 0 4px; }
/* ── 内容区 ── */
.wrap { max-width: 980px; margin: 0 auto; padding: 0 24px; }
.section-block { margin-bottom: 8px; }
.section-title {
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 .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.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.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;
font-family: "JetBrains Mono", monospace; font-size: 12.5px; color: #b91c1c;
}
.card-footer { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; }
.item-meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #8aa3c4; }
.meta-date { white-space: nowrap; }
/* ── 标签样式 ── */
.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; }
.t-low { background: #e6f0fb; color: #1d4ed8; }
.t-tag { background: #efefef; color: #555; cursor: pointer; transition: .12s; }
.t-tag:hover { background: #d9e8fb; color: #1d4ed8; }
.ver-badge { font-weight: 700; color: #1f7a44; }
.sub-progress-badge { background: #f0f4ff; color: #3730a3; }
/* 状态徽章 */
.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; }
/* ── 改动等级徽章 ── */
.tier-1 { background: #ede9fe; color: #6d28d9; }
.tier-2 { background: #ccfbf1; color: #0f766e; }
.tier-3 { background: #f1f5f9; color: #475569; }
/* ── 拒绝按钮 & 拒绝原因 ── */
.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; }
/* ── 子任务区 ── */
.subtask-block {
margin-top: 12px; padding: 10px 14px;
background: #f8fafc; border: 1px solid #e4e9ef; border-radius: 8px;
}
.subtask-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap;
}
.subtask-label {
font-size: 11px; font-weight: 700; color: #7b8794;
text-transform: uppercase; letter-spacing: .6px;
}
.subtask-progress-text { font-size: 12px; color: #52606d; }
.subtask-progress-bar {
flex: 1; height: 5px; background: #e4e9ef; border-radius: 3px; min-width: 60px;
}
.subtask-progress-fill { height: 100%; background: #22c55e; border-radius: 3px; transition: width .4s; }
.subtask-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 5px; }
.subtask-item {
display: flex; align-items: center; gap: 7px; flex-wrap: wrap;
padding: 6px 10px; border-radius: 6px; font-size: 13px;
background: #fff; border: 1px solid #e8edf2;
}
.subtask-item.s-done { background: #f0fdf4; border-color: #bbf7d0; }
.subtask-item.s-doing { background: #fff7ed; border-color: #fed7aa; }
.subtask-item.s-accepted { background: #f0fdf4; border-color: #bbf7d0; opacity: .75; }
.sub-icon {
width: 16px; text-align: center; font-size: 13px; font-weight: 700; flex-shrink: 0;
}
.sub-icon.s-done { color: #16a34a; }
.sub-icon.s-doing { color: #ea580c; }
.sub-icon.s-open { color: #93aec8; }
.sub-icon.s-accepted { color: #16a34a; }
.sub-sid {
font-family: "JetBrains Mono", monospace; font-size: 11.5px;
color: #52606d; font-weight: 700; min-width: 32px; flex-shrink: 0;
}
.sub-title { flex: 1; font-size: 13px; min-width: 120px; }
.subtask-item.s-done .sub-title,
.subtask-item.s-accepted .sub-title { text-decoration: line-through; color: #52606d; }
.sub-badges { display: flex; gap: 4px; flex-wrap: wrap; align-items: center; }
.dep-section { display: flex; align-items: center; gap: 3px; }
.dep-label { font-size: 11px; color: #8aa3c4; margin-right: 2px; }
.dep-badge {
padding: 1px 6px; border-radius: 8px; font-size: 11px;
font-weight: 700; font-family: monospace; cursor: default;
}
.dep-done { background: #dcfce7; color: #166534; }
.dep-pending { background: #fee2e2; color: #b91c1c; }
/* ── 拒绝 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>
<header>
<div class="wrap">
<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>${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>
<div class="filter-bar" id="filterBar">
<div class="filter-group">
<span class="filter-label">重要度</span>
<button class="filter-btn active" data-filter-level="all">全部</button>
<button class="filter-btn" data-filter-level="high">高优</button>
<button class="filter-btn" data-filter-level="mid">重要</button>
<button class="filter-btn" data-filter-level="low">一般</button>
</div>
<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">状态</span>
<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>
<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">改动等级</span>
<button class="filter-btn active" data-filter-tier="all">全部</button>
<button class="filter-btn" data-filter-tier="1">一级</button>
<button class="filter-btn" data-filter-tier="2">二级</button>
<button class="filter-btn" data-filter-tier="3">三级</button>
</div>
${allTags.length ? `<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">平台 / 标签</span>
${tagChips}
</div>` : ''}
</div>
<div class="wrap">
${[
{ 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>
<!-- 拒绝验收 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 curStatus = 'all';
let curTier = 'all';
let curTags = new Set();
function applyFilter() {
document.querySelectorAll('.todo-card').forEach(card => {
const lvl = card.dataset.level;
const status = card.dataset.status;
const tier = card.dataset.tier;
const tags = card.dataset.tags ? card.dataset.tags.split(',') : [];
let show = true;
if (curLevel !== 'all' && lvl !== curLevel) show = false;
if (curStatus !== 'all' && status !== curStatus) show = false;
if (curTier !== 'all' && String(tier) !== curTier) 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;
document.querySelectorAll('[data-filter-level]').forEach(b => b.classList.toggle('active', b === btn));
applyFilter();
});
});
document.querySelectorAll('[data-filter-tier]').forEach(btn => {
btn.addEventListener('click', () => {
curTier = btn.dataset.filterTier;
document.querySelectorAll('[data-filter-tier]').forEach(b => b.classList.toggle('active', b === btn));
applyFilter();
});
});
document.querySelectorAll('[data-filter-status]').forEach(btn => {
btn.addEventListener('click', () => {
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;
if (curTags.has(t)) { curTags.delete(t); btn.classList.remove('active'); }
else { curTags.add(t); btn.classList.add('active'); }
applyFilter();
});
});
document.querySelectorAll('.t-tag').forEach(tag => {
tag.addEventListener('click', e => {
e.stopPropagation();
const t = tag.dataset.tag;
const chip = document.querySelector('[data-filter-tag="'+t+'"]');
if (chip) chip.click();
});
});
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>
</html>`;
}
// ─── 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 <id> <open|doing|done> */
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);
// 有未完成子任务时,禁止手动将父任务标记为 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 <id> [--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 <id> --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(`📄 HTMLtodo/todo.html`);
}
function cmdRender(db) {
writeFileSync(HTML, buildHtml(db), 'utf8');
console.log('🖨️ todo/todo.html 已重新渲染');
}
// ─── 子任务命令 ───────────────────────────────────────────────────────────────
/** sub add <parent_id> --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 <sid> <open|doing|done> */
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 <subcmd> ... */
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 <parent_id> --title "..." [--tier N] [--deps "21A,21B"]\n node todo.mjs sub status <sid> <open|doing|done>`);
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);
}