18cd2497c1
- 新增 `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>
1067 lines
42 KiB
JavaScript
1067 lines
42 KiB
JavaScript
#!/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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
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(`📄 HTML:todo/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);
|
||
}
|