Files
jiu/todo/todo.mjs
T
wangjia e124ed7c65 feat(todo): 新增 /todo slash command,支持 add/done/list/reopen/rm
- todo/todo.json:结构化 JSON 真相源(id/标题/重要度3级/平台标签/版本)
- todo/todo.mjs:Node 零依赖单文件 CLI,写操作自动重渲染 HTML
- todo/todo.html:重新设计布局(未完成置顶/已完成置底/sticky 筛选栏)
- .claude/commands/todo.md:/todo slash command
- CLAUDE.md:追加 TODO 管理规范(唯一真相源、完成即标记、禁止使用其它 todo 工具)

旧 todo.html 备份为 todo.legacy.html;存量 6 条待办已导入 todo.json

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

505 lines
17 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
*
* 子命令:
* add --title "..." [--level high|mid|low] [--tags a,b] [--desc "..."]
* done <id> [--version vX.Y.Z]
* reopen <id>
* rm <id>
* 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');
// ─── 数据读写 ────────────────────────────────────────────────────────────────
function load() {
if (!existsSync(DB)) {
return { meta: { title: '酒库管理系统 — 项目 TODO', updated_at: now() }, seq: 0, items: [] };
}
return JSON.parse(readFileSync(DB, 'utf8'));
}
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 ────────────────────────────────────────────────────────────
const LEVEL_LABEL = { high: '高', mid: '中', low: '低' };
const LEVEL_ORDER = { high: 0, mid: 1, low: 2 };
function printSummary(db) {
const pending = db.items.filter(i => !i.done)
.sort((a,b) => (LEVEL_ORDER[a.level]??9) - (LEVEL_ORDER[b.level]??9) || a.id - b.id);
const done = db.items.filter(i => i.done)
.sort((a,b) => (b.completed_at||'') > (a.completed_at||'') ? 1 : -1);
console.log(`\nTODO 共 ${db.items.length} · 未完成 ${pending.length} · 已完成 ${done.length}`);
if (pending.length) {
console.log('未完成(按重要度):');
for (const i of pending) {
const tags = i.tags && i.tags.length ? i.tags.join('/') : '';
const lvl = LEVEL_LABEL[i.level] || i.level;
console.log(` [${lvl}] #${i.id} ${i.title}${tags ? ' ' + tags : ''}`);
}
}
if (done.length) {
console.log('已完成(最近 5 条):');
for (const i of done.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;');
}
/** 把描述里的 `code` 渲染成 <code> */
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 levelText(level) {
return { high: '高优 · 紧急', mid: '重要', low: '一般 / 优化' }[level] || level;
}
function renderItems(items) {
if (!items.length) return '<p class="empty-tip">暂无条目</p>';
return items.map(i => {
const tags = (i.tags||[]).map(t => `<span class="tag t-tag" data-tag="${esc(t)}">${esc(t)}</span>`).join(' ');
const desc = i.desc ? `<div class="item-desc">${renderDesc(i.desc)}</div>` : '';
const meta = `<div class="item-meta">
<span class="meta-date">🕐 ${fmtDate(i.created_at)}</span>
${i.done ? `<span class="meta-date">✅ 完成 ${fmtDate(i.completed_at)}${i.version ? ' · <span class="ver-badge">'+esc(i.version)+'</span>' : ''}</span>` : ''}
</div>`;
const lvlCls = levelClass(i.level);
return `
<li class="todo-card${i.done ? ' done' : ''}"
data-id="${i.id}"
data-level="${esc(i.level)}"
data-tags="${esc((i.tags||[]).join(','))}"
data-done="${i.done ? '1' : '0'}">
<div class="card-header">
<span class="item-title">${esc(i.title)}</span>
<span class="tag ${lvlCls}">${levelText(i.level)}</span>
</div>
${desc}
<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 pending = db.items.filter(i => !i.done)
.sort((a,b) => (LEVEL_ORDER[a.level]??9) - (LEVEL_ORDER[b.level]??9) || a.id - b.id);
const done = db.items.filter(i => i.done)
.sort((a,b) => (b.completed_at||'') > (a.completed_at||'') ? 1 : -1);
const 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; }
.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-title {
font-size: 16px; font-weight: 700; color: #0A1F3B;
border-left: 4px solid #2563AC; padding-left: 12px;
margin: 28px 0 12px;
}
.section-title.done-title { border-color: #6cbb8a; }
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;
}
.todo-card.done { background: #f3faf5; border-color: #b7e4c7; opacity: .75; }
.card-header { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
.item-title { font-weight: 600; font-size: 15px; flex: 1; }
.todo-card.done .item-title { text-decoration: line-through; color: #52606d; }
.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; }
/* ── tags ── */
.tag { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 12px; font-weight: 600; white-space: nowrap; }
.t-block { background: #fde8e8; color: #b91c1c; }
.t-high { background: #fef0d8; color: #9a6700; }
.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; }
/* ── 折叠 ── */
.done-section { margin-bottom: 24px; }
.done-toggle {
background: none; border: 1.5px solid #d1d9e6; border-radius: 6px;
padding: 4px 14px; cursor: pointer; font-size: 13px; color: #52606d;
margin-bottom: 10px; transition: .15s;
}
.done-toggle:hover { border-color: #6cbb8a; color: #1f7a44; }
.empty-tip { font-size: 14px; color: #8aa3c4; margin: 8px 0 24px; }
/* 隐藏 */
.hidden { display: none !important; }
</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>${db.items.length}</strong>全部</div>
<div class="stat-pill"><strong>${pending.length}</strong>未完成</div>
<div class="stat-pill"><strong>${done.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-done="all">全部</button>
<button class="filter-btn" data-filter-done="0">未完成</button>
<button class="filter-btn" data-filter-done="1">已完成</button>
</div>
${allTags.length ? `<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">平台 / 标签</span>
${tagChips}
</div>` : ''}
</div>
<div class="wrap">
<div class="section-title">📋 未完成(${pending.length}</div>
<ul class="todo-list" id="pendingList">
${renderItems(pending)}
</ul>
<div class="done-section">
<div class="section-title done-title">✅ 已完成(${done.length}</div>
<button class="done-toggle" id="doneToggle">▾ 展开已完成列表</button>
<ul class="todo-list hidden" id="doneList">
${renderItems(done)}
</ul>
</div>
</div>
<script>
(function() {
// 筛选状态
let curLevel = 'all';
let curDone = 'all';
let curTags = new Set();
function applyFilter() {
document.querySelectorAll('.todo-card').forEach(card => {
const lvl = card.dataset.level;
const dn = card.dataset.done;
const tags = card.dataset.tags ? card.dataset.tags.split(',') : [];
let show = true;
if (curLevel !== 'all' && lvl !== curLevel) show = false;
if (curDone !== 'all' && dn !== curDone) show = false;
if (curTags.size > 0 && ![...curTags].some(t => tags.includes(t))) show = false;
card.classList.toggle('hidden', !show);
});
}
// 重要度
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-done]').forEach(btn => {
btn.addEventListener('click', () => {
curDone = btn.dataset.filterDone;
document.querySelectorAll('[data-filter-done]').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();
});
});
// 已完成折叠
const toggle = document.getElementById('doneToggle');
const doneList = document.getElementById('doneList');
if (toggle && doneList) {
toggle.addEventListener('click', () => {
const collapsed = doneList.classList.toggle('hidden');
toggle.textContent = collapsed ? '▾ 展开已完成列表' : '▴ 收起已完成列表';
});
}
})();
</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;
db.seq++;
const item = {
id: db.seq,
title,
desc,
level,
tags,
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);
}
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.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);
}
function cmdReopen(db, args) {
const id = args._[0];
const item = findItem(db, id);
item.done = false;
item.completed_at = null;
item.version = 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) {
// 确保 HTML 最新
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 已重新渲染');
}
// ─── 入口 ────────────────────────────────────────────────────────────────────
const [,, subcmd, ...rest] = process.argv;
const db = load();
const args = parseArgs(rest);
switch (subcmd) {
case 'add': cmdAdd(db, args); break;
case 'done': cmdDone(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|done|reopen|rm|list|render`);
process.exit(1);
}