From daa83bd4086122435421eb3bdc6058cd6d95e9c9 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 9 Jun 2026 00:15:46 +0800 Subject: [PATCH] chore: release v1.0.27 Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/todo.md | 45 +- CHANGELOG.md | 10 + CLAUDE.md | 16 +- client/lib/screens/shell/app_shell.dart | 2 + .../stock_in/stock_in_form_screen.dart | 314 ++++++-- .../stock_in/stock_in_list_screen.dart | 69 +- todo/todo.html | 752 ++++++++++++++---- todo/todo.json | 193 ++++- todo/todo.mjs | 494 +++++++++--- 9 files changed, 1535 insertions(+), 360 deletions(-) diff --git a/.claude/commands/todo.md b/.claude/commands/todo.md index 8112023..6cafc6c 100644 --- a/.claude/commands/todo.md +++ b/.claude/commands/todo.md @@ -1,6 +1,6 @@ --- -description: 项目 TODO 管理(list / add / done / reopen / rm) -argument-hint: "list | add <描述> | done [version] | reopen | rm " +description: 项目 TODO 管理(list / add / status / done / reject / reopen / rm) +argument-hint: "list | add <描述> | status | done [version] | reject <原因> | reopen | rm " model: claude-sonnet-4-6 allowed-tools: Bash, Read --- @@ -9,6 +9,10 @@ allowed-tools: Bash, Read **重要规则**:所有待办项必须通过本命令管理,禁止使用其它 todo 工具(TaskCreate/TodoWrite 等)。 +**状态流转**:`open`(待开始)→ `doing`(开发中)→ `done`(待验收)→ `accepted`(已验收) +- `done` 状态可被用户 `reject`(拒绝),退回 `open` 并升为最高优先级 +- Claude 可设:`open` / `doing` / `done`;用户专属:`accepted`(`/todo done`)、`reject` + --- 根据 `$ARGUMENTS` 分派,缺省等同 `list`: @@ -37,17 +41,48 @@ node todo/todo.mjs add --title "..." --level mid --tags "前端,Web" --desc "... 转述:「已添加 #id [级别] 标题 标签」,并显示 summary。 +## status + +**Claude 在开发过程中主动调用**,更新条目的开发状态: +- 开始处理某个 todo → `status doing` +- 开发完成提交验收 → `status done` +- 退回重做 → `status open` + +```bash +node todo/todo.mjs status +``` + +转述:「#id 状态已更新为 xxx」,并显示 summary。 +注意:`accepted` 不可通过此命令设置,仅用户可标记。 + ## done [version] -从 `$ARGUMENTS` 提取 id(数字)和可选 version(格式 vX.Y.Z): +**用户调用**,标记条目已验收交付,记录版本号。从 `$ARGUMENTS` 提取 id 和可选 version(格式 vX.Y.Z): - 有 version:`node todo/todo.mjs done --version ` - 无 version:`node todo/todo.mjs done `(脚本自动用 `git describe --tags --abbrev=0`) -转述:「#id 标记完成,记入版本 vX.Y.Z」,并显示 summary。 +转述:「#id 已验收,记入版本 vX.Y.Z」,并显示 summary。 + +## reject <原因> + +**用户调用**,拒绝验收处于 `done` 状态的条目。拒绝后: +- 状态退回 `open` +- 优先级强制升为 `high` +- 拒绝原因记录在条目上,HTML 卡片中可见 + +从 `$ARGUMENTS` 提取 id 和原因文字: + +```bash +node todo/todo.mjs reject --reason "<原因>" +``` + +转述:「#id 已拒绝,优先级升为最高,原因:xxx」,并显示 summary。 ## reopen +重新开启条目(清空拒绝记录和验收信息),退回 `open` 状态: + ```bash node todo/todo.mjs reopen ``` @@ -64,4 +99,4 @@ node todo/todo.mjs rm --- -所有命令完成后,在终端显示来自脚本的完整 summary(共/未完成/已完成及分级列表)。 +所有命令完成后,在终端显示来自脚本的完整 summary(按 open / doing / done / accepted 分组)。 diff --git a/CHANGELOG.md b/CHANGELOG.md index cc6299b..d90a2b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.0.27] - 2026-06-09 + +### 改进 +- 入库单新建表单:产地、保质期、储存方式、批次号、生产日期等选填字段默认折叠,点击「展开选填」按钮后在行内下方显示,减少初始界面信息量 +- 手机端现可从屏幕左边缘向右滑动打开导航菜单,无需点击汉堡图标 +- 入库单详情弹窗在手机上商品明细改为卡片式竖排布局,不再显示难以阅读的横向宽表格 + +### 修复 +- macOS 客户端下载 zip 解压失败:打包改用 ditto,正确保留 symlink 和执行权限 + ## [1.0.26] - 2026-06-08 ### 新功能 diff --git a/CLAUDE.md b/CLAUDE.md index d32fb27..e5e7006 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -315,15 +315,19 @@ CI/CD(Forgejo,`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译 - **真相源**:`todo/todo.json`(结构化 JSON,含 id/标题/重要度/平台标签/创建时间/完成版本) - **HTML**:`todo/todo.html` 为生成物,每次写操作自动重渲染 - **重要度**:`high`(阻断/紧急)/ `mid`(重要)/ `low`(一般/优化)三级 -- **完成即标记**:完成对应 todo 后运行 `/todo done `,脚本自动记入当前 git tag 版本 +- **状态**:`open`(待开始)→ `doing`(开发中)→ `done`(待验收)→ `accepted`(已验收) +- **完成即标记**:开发完成后 `/todo status done`,验收通过后用户运行 `/todo done ` 记入版本 常用命令: ``` -/todo — 查看 summary(含未完成分级列表) -/todo add 修复... — 新增待办(Claude 自动推断级别/标签) -/todo done — 标记完成,自动记入版本号 -/todo reopen — 撤销完成 -/todo rm — 删除 +/todo — 查看 summary(按状态分组) +/todo add 修复... — 新增待办(Claude 自动推断级别/标签) +/todo status doing — 标记开发中(Claude 开始处理时调用) +/todo status done — 标记待验收(Claude 完成开发时调用) +/todo done — 用户验收通过,自动记入版本号 +/todo reject <原因> — 用户拒绝验收,退回 open 并升为最高优先级 +/todo reopen — 重新开启(清空验收/拒绝记录) +/todo rm — 删除 ``` 底层脚本:`node todo/todo.mjs <子命令> [参数]` diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index dada9f5..5b059c3 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'dart:async'; +import 'package:flutter/foundation.dart' show kIsWeb; import '../../core/auth/auth_state.dart'; import '../../core/config/app_config.dart'; import '../../core/responsive/responsive.dart'; @@ -161,6 +162,7 @@ class _AppShellState extends ConsumerState { child: Scaffold( key: _scaffoldKey, drawer: isMobile ? _buildDrawer(context, user, location) : null, + drawerEnableOpenDragGesture: !kIsWeb && isMobile, body: Column( children: [ // Top Bar diff --git a/client/lib/screens/stock_in/stock_in_form_screen.dart b/client/lib/screens/stock_in/stock_in_form_screen.dart index adcdd0d..21a6935 100644 --- a/client/lib/screens/stock_in/stock_in_form_screen.dart +++ b/client/lib/screens/stock_in/stock_in_form_screen.dart @@ -556,39 +556,12 @@ class _StockInFormScreenState extends ConsumerState { )), ) else - Table( - columnWidths: const { - 0: FixedColumnWidth(36), // 序号 - 1: FlexColumnWidth(1.2), // 商品编码 - 2: FlexColumnWidth(2.0), // 名称 - 3: FlexColumnWidth(1.3), // 系列 - 4: FlexColumnWidth(1.3), // 规格 - 5: FlexColumnWidth(0.9), // 单品数量 - 6: FlexColumnWidth(1.0), // 数量 - 7: FlexColumnWidth(1.0), // 单价 - 8: FlexColumnWidth(1.0), // 金额 - 9: FlexColumnWidth(1.2), // 批次号 - 10: FlexColumnWidth(1.2), // 生产日期 - 11: FixedColumnWidth(60), // 操作 - }, + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - TableRow( - decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), - children: [ - '序号', '商品编码', '名称', '系列', '规格', '单品数量', '数量', '单价', '金额', '批次号', '生产日期', '操作', - ] - .map((h) => Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 10), - child: Text(h, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppTheme.primaryDark)), - )) - .toList(), - ), - ...List.generate(_items.length, (i) => _buildItemRow(i)), + _buildItemTableHeader(), + const Divider(height: 1), + ...List.generate(_items.length, _buildItemGroup), ], ), const Divider(height: 1), @@ -855,7 +828,36 @@ class _StockInFormScreenState extends ConsumerState { ?.code ?? ''; - TableRow _buildItemRow(int index) { + // ── 桌面表格:表头行 ────────────────────────────────────────────────────── + Widget _buildItemTableHeader() { + Widget th(String s) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: Text(s, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + ); + return Container( + color: const Color(0xFFF0F4FF), + child: Row(children: [ + SizedBox(width: 36, child: th('序号')), + Expanded(flex: 12, child: th('商品编码')), + Expanded(flex: 20, child: th('名称')), + Expanded(flex: 13, child: th('系列')), + Expanded(flex: 13, child: th('规格')), + Expanded(flex: 9, child: th('单品数量')), + Expanded(flex: 10, child: th('数量')), + Expanded(flex: 10, child: th('单价')), + Expanded(flex: 10, child: th('金额')), + const SizedBox(width: 32), + const SizedBox(width: 44), + ]), + ); + } + + // ── 桌面表格:每条明细(主行 + 可折叠选填区) ────────────────────────── + Widget _buildItemGroup(int index) { final item = _items[index]; final qty = double.tryParse(item.qtyCtrl.text) ?? 0; final price = double.tryParse(item.priceCtrl.text) ?? 0; @@ -863,60 +865,153 @@ class _StockInFormScreenState extends ConsumerState { final specQty = _specQtyOf(item); final productCode = _productCodeOf(item); - return TableRow( - decoration: BoxDecoration( - color: index.isEven ? Colors.white : const Color(0xFFFAFAFA), - ), + Widget tc(String s, {Color? color, FontWeight? weight}) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Text(s, + style: TextStyle( + fontSize: 13, + color: color, + fontWeight: weight)), + ); + + final hasOptional = item.batchNoCtrl.text.isNotEmpty || + item.productionDate != null || + item.selectedOriginId != null || + item.selectedShelfLifeId != null || + item.selectedStorageId != null || + item.selectedDescriptionDocId != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Text('${index + 1}', - style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary)), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Text(productCode, - style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary)), - ), - Padding(padding: const EdgeInsets.all(4), child: _nameField(item)), - Padding(padding: const EdgeInsets.all(4), child: _seriesField(item)), - Padding(padding: const EdgeInsets.all(4), child: _specField(item)), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Text( - specQty > 0 ? '$specQty' : '-', - style: TextStyle( - fontSize: 13, - color: specQty > 0 ? Colors.black87 : AppTheme.textSecondary, - ), - ), - ), - Padding(padding: const EdgeInsets.all(4), child: _qtyField(item)), - Padding(padding: const EdgeInsets.all(4), child: _priceField(item)), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - child: Text( - '¥${amount.toStringAsFixed(2)}', - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), - ), - ), - Padding(padding: const EdgeInsets.all(4), child: _batchField(item)), - Padding(padding: const EdgeInsets.all(4), child: _dateField(item)), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), - child: IconButton( - icon: const Icon(Icons.delete_outline, size: 18, color: AppTheme.danger), - onPressed: _items.length > 1 ? () => _removeItem(index) : null, - tooltip: '删除', - padding: EdgeInsets.zero, - constraints: const BoxConstraints(minWidth: 28, minHeight: 28), + IntrinsicHeight( + child: Container( + color: index.isEven ? Colors.white : const Color(0xFFFAFAFA), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + SizedBox( + width: 36, + child: tc('${index + 1}', color: AppTheme.textSecondary)), + Expanded( + flex: 12, + child: tc(productCode, color: AppTheme.textSecondary)), + Expanded( + flex: 20, + child: Padding( + padding: const EdgeInsets.all(4), + child: _nameField(item))), + Expanded( + flex: 13, + child: Padding( + padding: const EdgeInsets.all(4), + child: _seriesField(item))), + Expanded( + flex: 13, + child: Padding( + padding: const EdgeInsets.all(4), + child: _specField(item))), + Expanded( + flex: 9, + child: tc(specQty > 0 ? '$specQty' : '-', + color: specQty > 0 + ? Colors.black87 + : AppTheme.textSecondary)), + Expanded( + flex: 10, + child: Padding( + padding: const EdgeInsets.all(4), + child: _qtyField(item))), + Expanded( + flex: 10, + child: Padding( + padding: const EdgeInsets.all(4), + child: _priceField(item))), + Expanded( + flex: 10, + child: tc('¥${amount.toStringAsFixed(2)}', + weight: FontWeight.w500)), + SizedBox( + width: 32, + child: Tooltip( + message: item.expanded ? '收起选填' : '展开选填(批次/产地/保质期等)', + child: IconButton( + icon: Icon( + item.expanded ? Icons.expand_less : Icons.expand_more, + size: 18, + color: hasOptional + ? AppTheme.primary + : AppTheme.textSecondary, + ), + onPressed: () => + setState(() => item.expanded = !item.expanded), + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + ), + ), + ), + SizedBox( + width: 44, + child: IconButton( + icon: const Icon(Icons.delete_outline, + size: 18, color: AppTheme.danger), + onPressed: + _items.length > 1 ? () => _removeItem(index) : null, + tooltip: '删除', + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 28, minHeight: 28), + ), + ), + ]), ), ), + if (item.expanded) _buildOptionalSection(item), + const Divider(height: 1), ], ); } - /// 窄屏(手机):每个明细行渲染为一张卡片,字段竖排,避免表格横向溢出。 + // ── 可折叠选填区(桌面) ────────────────────────────────────────────────── + Widget _buildOptionalSection(_ItemRow item) { + return Container( + decoration: const BoxDecoration( + color: Color(0xFFF0F6FF), + border: Border(left: BorderSide(color: AppTheme.primary, width: 3)), + ), + padding: const EdgeInsets.fromLTRB(16, 10, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('选填信息', + style: TextStyle( + fontSize: 12, + color: AppTheme.textSecondary, + fontWeight: FontWeight.w500)), + const SizedBox(height: 10), + Wrap( + spacing: 16, + runSpacing: 12, + children: [ + _OptionalField( + label: '批次号', width: 180, child: _batchField(item)), + _OptionalField( + label: '生产日期', width: 160, child: _dateField(item)), + _OptionalField( + label: '产地', width: 180, child: _originField(item)), + _OptionalField( + label: '保质期', width: 180, child: _shelfLifeField(item)), + _OptionalField( + label: '储存方式', width: 180, child: _storageField(item)), + _OptionalField( + label: '介绍文档', width: 200, child: _descriptionDocField(item)), + ], + ), + ], + ), + ); + } + + /// 窄屏(手机):每个明细行渲染为一张卡片,必填字段竖排,选填字段默认折叠。 Widget _buildItemCard(int index) { final item = _items[index]; final qty = double.tryParse(item.qtyCtrl.text) ?? 0; @@ -942,12 +1037,30 @@ class _StockInFormScreenState extends ConsumerState { MobileCardField('数量', null, valueWidget: _qtyField(item)), MobileCardField('单价', null, valueWidget: _priceField(item)), MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'), - MobileCardField('批次号', null, valueWidget: _batchField(item)), - MobileCardField('生产日期', null, valueWidget: _dateField(item)), - MobileCardField('产地', null, valueWidget: _originField(item)), - MobileCardField('保质期', null, valueWidget: _shelfLifeField(item)), - MobileCardField('储存方式', null, valueWidget: _storageField(item)), - MobileCardField('介绍文档', null, valueWidget: _descriptionDocField(item)), + MobileCardField('', null, + valueWidget: TextButton.icon( + onPressed: () => setState(() => item.expanded = !item.expanded), + icon: Icon(item.expanded ? Icons.expand_less : Icons.expand_more, + size: 16), + label: Text(item.expanded ? '收起选填项' : '展开选填项(批次/产地/保质期…)'), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(0, 32), + foregroundColor: AppTheme.textSecondary, + ), + )), + if (item.expanded) + MobileCardField('批次号', null, valueWidget: _batchField(item)), + if (item.expanded) + MobileCardField('生产日期', null, valueWidget: _dateField(item)), + if (item.expanded) + MobileCardField('产地', null, valueWidget: _originField(item)), + if (item.expanded) + MobileCardField('保质期', null, valueWidget: _shelfLifeField(item)), + if (item.expanded) + MobileCardField('储存方式', null, valueWidget: _storageField(item)), + if (item.expanded) + MobileCardField('介绍文档', null, valueWidget: _descriptionDocField(item)), ], ); } @@ -1002,6 +1115,7 @@ class _ItemRow { final TextEditingController batchNoCtrl = TextEditingController(); final TextEditingController productionDateCtrl = TextEditingController(); DateTime? productionDate; + bool expanded = false; void dispose() { qtyCtrl.dispose(); @@ -1011,6 +1125,34 @@ class _ItemRow { } } +/// 选填区内单个字段:label + 字段控件,固定宽度。 +class _OptionalField extends StatelessWidget { + final String label; + final Widget child; + final double width; + + const _OptionalField( + {required this.label, required this.child, this.width = 180}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, + style: const TextStyle( + fontSize: 12, color: AppTheme.textSecondary)), + const SizedBox(height: 4), + child, + ], + ), + ); + } +} + class _FormField extends StatelessWidget { final String label; final Widget child; diff --git a/client/lib/screens/stock_in/stock_in_list_screen.dart b/client/lib/screens/stock_in/stock_in_list_screen.dart index 8ddd504..9043f33 100644 --- a/client/lib/screens/stock_in/stock_in_list_screen.dart +++ b/client/lib/screens/stock_in/stock_in_list_screen.dart @@ -788,7 +788,48 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> { if (o.items.isEmpty) const Text('无商品明细', style: TextStyle(color: AppTheme.textSecondary)) + else if (context.isMobile) + // 窄屏:每个商品渲染为卡片,字段竖排 + Column( + children: o.items.asMap().entries.map((e) { + final i = e.key; + final item = e.value; + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: MobileListCard( + title: Text('商品 ${i + 1} ${item.productName ?? ''}'), + subtitle: item.productCode != null + ? Text('编码 ${item.productCode}') + : null, + fields: [ + if (item.productSeries?.isNotEmpty ?? false) + MobileCardField('系列', item.productSeries!), + MobileCardField('规格', item.productSpec ?? '-'), + MobileCardField('数量', item.quantity.toStringAsFixed(3)), + MobileCardField( + '单价', '¥${item.unitPrice.toStringAsFixed(2)}'), + MobileCardField( + '金额', '¥${item.totalPrice.toStringAsFixed(2)}'), + if (item.batchNo?.isNotEmpty ?? false) + MobileCardField('批次号', item.batchNo!), + if (item.productionDate?.isNotEmpty ?? false) + MobileCardField('生产日期', + item.productionDate!.length >= 10 + ? item.productionDate!.substring(0, 10) + : item.productionDate!), + if (item.productOrigin?.isNotEmpty ?? false) + MobileCardField('产地', item.productOrigin!), + if (item.productShelfLife?.isNotEmpty ?? false) + MobileCardField('保质期', item.productShelfLife!), + if (item.productStorage?.isNotEmpty ?? false) + MobileCardField('储存方式', item.productStorage!), + ], + ), + ); + }).toList(), + ) else + // 宽屏:保持原有表格展示 Table( border: TableBorder.all(color: AppTheme.border, width: 0.5), columnWidths: const { @@ -806,27 +847,37 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> { decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), children: ['序号', '商品编码', '名称', '系列', '规格', '数量', '单价', '金额'] .map((h) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), - child: Text(h, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.primaryDark)), - )).toList(), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 10), + child: Text(h, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppTheme.primaryDark)), + )) + .toList(), ), ...o.items.asMap().entries.map((e) { final i = e.key; final item = e.value; - // 产地/保质期/储存拼成一行辅助文本 final attrs = [ - if (item.productOrigin?.isNotEmpty ?? false) item.productOrigin!, - if (item.productShelfLife?.isNotEmpty ?? false) item.productShelfLife!, - if (item.productStorage?.isNotEmpty ?? false) item.productStorage!, + if (item.productOrigin?.isNotEmpty ?? false) + item.productOrigin!, + if (item.productShelfLife?.isNotEmpty ?? false) + item.productShelfLife!, + if (item.productStorage?.isNotEmpty ?? false) + item.productStorage!, ].join(' · '); return TableRow( decoration: BoxDecoration( - color: i.isEven ? Colors.white : const Color(0xFFFAFAFA)), + color: + i.isEven ? Colors.white : const Color(0xFFFAFAFA)), children: [ _TableCell('${i + 1}'), _TableCell(item.productCode ?? '-'), Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, diff --git a/todo/todo.html b/todo/todo.html index 9cbe315..dbf59a7 100644 --- a/todo/todo.html +++ b/todo/todo.html @@ -18,7 +18,7 @@ header { } 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; } +.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; @@ -46,21 +46,41 @@ header .header-meta { color: #ADC9EA; font-size: 13px; margin-top: 4px; } /* ── 内容区 ── */ .wrap { max-width: 980px; margin: 0 auto; padding: 0 24px; } +.section-block { margin-bottom: 8px; } .section-title { - font-size: 16px; font-weight: 700; color: #0A1F3B; - border-left: 4px solid #2563AC; padding-left: 12px; - margin: 28px 0 12px; + 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.done-title { border-color: #6cbb8a; } +.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.done { background: #f3faf5; border-color: #b7e4c7; opacity: .75; } +/* 卡片左边框颜色与状态匹配 */ +.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.done .item-title { text-decoration: line-through; color: #52606d; } +.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; @@ -71,7 +91,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } .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; } @@ -80,18 +100,65 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; } .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; } +/* 状态徽章 */ +.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; } + +/* ── 拒绝按钮 & 拒绝原因 ── */ +.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; } + +/* ── 拒绝 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; } @@ -99,11 +166,13 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }

酒库管理系统 — 项目 TODO

-
生成于 2026-06-08 · 真相源 todo/todo.json
+
生成于 2026-06-09 · 真相源 todo/todo.json
-
15全部
-
1未完成
-
14已完成
+
24全部
+
7待开始
+
0开发中
+
3待验收
+
14已验收
@@ -119,31 +188,44 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
状态 - - - + + + + +
平台 / 标签 - +
-
📋 未完成(1)
-
    - -
  • +
    + 📋 待开始 7 + ▴ 收起 +
    +
    +
      + +
    • + data-status="open" + data-tags="Web">
      备案号为占位假值 - 高优 · 紧急 +
      + 待开始 + 高优 · 紧急 + +
      +
      现为沪ICP备2026000000号(全0),上线前必须替换真实备案号。
  • -
-
-
✅ 已完成(14)
- -
+ + + +
  • +
    + 授权信息功能方案设计 +
    + 待开始 + 重要 + +
    +
    + +
    完整方案设计,涵盖:界面设计与交互、前端实现方案、后端接口与数据模型、安全策略(防盗用、防滥用、授权校验)等。需产出设计文档后再进入开发阶段。
    + +
  • + +
  • +
    + 桌面客户端单实例限制,禁止同时运行多个进程 +
    + 待开始 + 重要 + +
    +
    + +
    Windows 和 macOS 客户端启动时检测是否已有实例运行;若有,则激活已有窗口并退出新进程。Flutter desktop 可通过 window_single_instance 或 dart:io 锁文件实现。
    + +
  • + +
  • +
    + 关于我们页文档/更新日志链接更新,Web 新增版本历史页 +
    + 待开始 + 一般 / 优化 + +
    +
    + +
    1. 客户端「关于我们」页面的「打开文档」「更新日志」链接指向需更新为正确地址。2. Web 营销站新增版本更新页面(/changelog 或 /download/),从 CHANGELOG.md 自动渲染,展示所有历史版本更新记录。
    + +
  • + +
  • +
    + 注册/联系入口「发邮件」升级为「加客服微信」并优化文案 +
    + 待开始 + 一般 / 优化 + +
    +
    + +
    当前注册成功页和网站联系支持入口引导用户「发邮件」,体验较弱。升级为扫码加客服微信,同时梳理相关文案措辞,使表达更专业、有亲和力。
    + +
  • + +
  • +
    + 服务条款与隐私政策页面 +
    + 待开始 + 一般 / 优化 + +
    +
    + +
    在营销网站(jiu.51yanmei.com)补充服务条款(Terms of Service)和隐私政策(Privacy Policy)页面,注册页底部添加链接。最低优先级,待其他功能稳定后处理。
    + +
  • + +
    + +
    +
    + 🔨 开发中 0 + ▴ 收起 +
    +
    +
      +

      暂无条目

      +
    +
    +
    +
    +
    + 🔍 待验收 3 + ▴ 收起 +
    +
    +
      + +
    • +
      + 入库单新建表单增加产地/保质期/储存方式,选填项默认折叠 +
      + 待验收 + 高优 · 紧急 + +
      +
      + +
      新建入库单的商品行增加产地、保质期、储存方式三个字典下拉选项(已有字典数据)。必填项(商品名、数量等)正常显示;选填项默认隐藏,点击展开按钮后在下一行展示。
      + +
    • + +
    • +
      + 移动端左侧抽屉菜单支持手势侧滑唤出 +
      + 待验收 + 一般 / 优化 + +
      +
      + +
      窄屏(手机)模式下,导航 Drawer 当前需点击汉堡按钮打开,需支持从屏幕左边缘向右滑动手势唤出菜单,符合移动端操作习惯。涉及 app_shell.dart 的 Drawer 配置。
      + +
    • + +
    • +
      + 入库单详情商品明细改为卡片式竖排布局 +
      + 待验收 + 一般 / 优化 + +
      +
      + +
      当前入库单详情的商品明细以横向表格展示,字段多时在窄屏上挤压严重。改为每件商品独占一个卡片区块,字段竖向排列(商品名、规格、数量、单价、产地、保质期等各占一行),信息层次清晰,移动端阅读体验更好。宽屏可保持表格,窄屏自动切换为卡片流(复用 MobileListCard 模式)。
      + +
    • +
    +
    +
    +
    +
    + ✅ 已验收 14 + ▾ 展开 +
    +
    + + + diff --git a/todo/todo.json b/todo/todo.json index fafb897..a401f57 100644 --- a/todo/todo.json +++ b/todo/todo.json @@ -1,9 +1,9 @@ { "meta": { "title": "酒库管理系统 — 项目 TODO", - "updated_at": "2026-06-08T14:40:04.464Z" + "updated_at": "2026-06-08T16:06:56.875Z" }, - "seq": 18, + "seq": 27, "items": [ { "id": 1, @@ -17,7 +17,8 @@ "created_at": "2026-06-07T17:30:32.138Z", "done": true, "completed_at": "2026-06-07T17:48:38.554Z", - "version": "v1.0.23" + "version": "v1.0.23", + "status": "accepted" }, { "id": 2, @@ -30,7 +31,8 @@ "created_at": "2026-06-07T17:30:32.188Z", "done": false, "completed_at": null, - "version": null + "version": null, + "status": "open" }, { "id": 3, @@ -44,7 +46,8 @@ "created_at": "2026-06-07T17:30:32.237Z", "done": true, "completed_at": "2026-06-08T14:33:22.523Z", - "version": "v1.0.25" + "version": "v1.0.25", + "status": "accepted" }, { "id": 4, @@ -58,7 +61,8 @@ "created_at": "2026-06-07T17:30:32.287Z", "done": true, "completed_at": "2026-06-07T18:07:13.211Z", - "version": "v1.0.23" + "version": "v1.0.23", + "status": "accepted" }, { "id": 5, @@ -72,7 +76,8 @@ "created_at": "2026-06-07T17:30:32.334Z", "done": true, "completed_at": "2026-06-08T14:33:22.584Z", - "version": "v1.0.25" + "version": "v1.0.25", + "status": "accepted" }, { "id": 6, @@ -86,7 +91,8 @@ "created_at": "2026-06-07T17:30:32.381Z", "done": true, "completed_at": "2026-06-08T14:33:22.648Z", - "version": "v1.0.25" + "version": "v1.0.25", + "status": "accepted" }, { "id": 8, @@ -101,7 +107,8 @@ "created_at": "2026-06-07T18:08:24.432Z", "done": true, "completed_at": "2026-06-07T23:18:23.239Z", - "version": "v1.0.23" + "version": "v1.0.23", + "status": "accepted" }, { "id": 9, @@ -116,7 +123,8 @@ "created_at": "2026-06-07T18:08:32.748Z", "done": true, "completed_at": "2026-06-07T23:28:52.932Z", - "version": "v1.0.23" + "version": "v1.0.23", + "status": "accepted" }, { "id": 10, @@ -132,7 +140,8 @@ "created_at": "2026-06-07T18:12:21.217Z", "done": true, "completed_at": "2026-06-07T23:28:53.001Z", - "version": "v1.0.23" + "version": "v1.0.23", + "status": "accepted" }, { "id": 13, @@ -147,7 +156,8 @@ "created_at": "2026-06-07T18:12:43.136Z", "done": true, "completed_at": "2026-06-08T14:40:04.464Z", - "version": "v1.0.25" + "version": "v1.0.25", + "status": "accepted" }, { "id": 14, @@ -162,7 +172,8 @@ "created_at": "2026-06-07T18:12:50.299Z", "done": true, "completed_at": "2026-06-08T00:06:15.299Z", - "version": "v1.0.24" + "version": "v1.0.24", + "status": "accepted" }, { "id": 15, @@ -176,7 +187,8 @@ "created_at": "2026-06-07T22:51:51.881Z", "done": true, "completed_at": "2026-06-07T22:56:24.807Z", - "version": "v1.0.23" + "version": "v1.0.23", + "status": "accepted" }, { "id": 16, @@ -192,7 +204,8 @@ "created_at": "2026-06-07T22:51:59.988Z", "done": true, "completed_at": "2026-06-07T22:58:32.882Z", - "version": "v1.0.23" + "version": "v1.0.23", + "status": "accepted" }, { "id": 17, @@ -207,7 +220,8 @@ "created_at": "2026-06-07T22:52:07.331Z", "done": true, "completed_at": "2026-06-07T23:03:13.631Z", - "version": "v1.0.23" + "version": "v1.0.23", + "status": "accepted" }, { "id": 18, @@ -222,7 +236,152 @@ "created_at": "2026-06-07T22:52:13.283Z", "done": true, "completed_at": "2026-06-08T00:06:15.371Z", - "version": "v1.0.24" + "version": "v1.0.24", + "status": "accepted" + }, + { + "id": 19, + "title": "入库单新建表单增加产地/保质期/储存方式,选填项默认折叠", + "desc": "新建入库单的商品行增加产地、保质期、储存方式三个字典下拉选项(已有字典数据)。必填项(商品名、数量等)正常显示;选填项默认隐藏,点击展开按钮后在下一行展示。", + "level": "high", + "tags": [ + "前端", + "iOS", + "Android" + ], + "created_at": "2026-06-08T14:56:32.850Z", + "done": false, + "completed_at": null, + "version": null, + "status": "done", + "reject_reason": null, + "rejected_at": null + }, + { + "id": 20, + "title": "商品详情页重设计:展示所有字段,内容为公开页超集", + "desc": "当前 product_detail_screen.dart 显示内容过于简单。需先做页面设计(UI 方案),内容要求是公开扫码页的超集:包含商品基本信息、参数(产地/保质期/储存方式)、价格、描述文档、图片、库存批次、入库记录等管理侧专属字段。另:商品公开链接展示为可点击超链接(点击可跳转公开扫码页)。", + "level": "mid", + "tags": [ + "前端", + "iOS", + "Android" + ], + "created_at": "2026-06-08T14:59:08.342Z", + "done": false, + "completed_at": null, + "version": null, + "status": "open" + }, + { + "id": 21, + "title": "授权信息功能方案设计", + "desc": "完整方案设计,涵盖:界面设计与交互、前端实现方案、后端接口与数据模型、安全策略(防盗用、防滥用、授权校验)等。需产出设计文档后再进入开发阶段。", + "level": "mid", + "tags": [ + "前端", + "后端", + "安全", + "文档" + ], + "created_at": "2026-06-08T15:02:58.896Z", + "done": false, + "completed_at": null, + "version": null, + "status": "open" + }, + { + "id": 22, + "title": "关于我们页文档/更新日志链接更新,Web 新增版本历史页", + "desc": "1. 客户端「关于我们」页面的「打开文档」「更新日志」链接指向需更新为正确地址。2. Web 营销站新增版本更新页面(/changelog 或 /download/),从 CHANGELOG.md 自动渲染,展示所有历史版本更新记录。", + "level": "low", + "tags": [ + "前端", + "Web", + "文档" + ], + "created_at": "2026-06-08T15:05:00.447Z", + "done": false, + "completed_at": null, + "version": null, + "status": "open" + }, + { + "id": 23, + "title": "注册/联系入口「发邮件」升级为「加客服微信」并优化文案", + "desc": "当前注册成功页和网站联系支持入口引导用户「发邮件」,体验较弱。升级为扫码加客服微信,同时梳理相关文案措辞,使表达更专业、有亲和力。", + "level": "low", + "tags": [ + "前端", + "Web" + ], + "created_at": "2026-06-08T15:06:00.175Z", + "done": false, + "completed_at": null, + "version": null, + "status": "open" + }, + { + "id": 24, + "title": "服务条款与隐私政策页面", + "desc": "在营销网站(jiu.51yanmei.com)补充服务条款(Terms of Service)和隐私政策(Privacy Policy)页面,注册页底部添加链接。最低优先级,待其他功能稳定后处理。", + "level": "low", + "tags": [ + "Web", + "文档" + ], + "created_at": "2026-06-08T15:06:26.383Z", + "done": false, + "completed_at": null, + "version": null, + "status": "open" + }, + { + "id": 25, + "title": "移动端左侧抽屉菜单支持手势侧滑唤出", + "desc": "窄屏(手机)模式下,导航 Drawer 当前需点击汉堡按钮打开,需支持从屏幕左边缘向右滑动手势唤出菜单,符合移动端操作习惯。涉及 app_shell.dart 的 Drawer 配置。", + "level": "low", + "tags": [ + "前端", + "iOS", + "Android" + ], + "created_at": "2026-06-08T15:09:27.652Z", + "done": false, + "completed_at": null, + "version": null, + "status": "done" + }, + { + "id": 26, + "title": "入库单详情商品明细改为卡片式竖排布局", + "desc": "当前入库单详情的商品明细以横向表格展示,字段多时在窄屏上挤压严重。改为每件商品独占一个卡片区块,字段竖向排列(商品名、规格、数量、单价、产地、保质期等各占一行),信息层次清晰,移动端阅读体验更好。宽屏可保持表格,窄屏自动切换为卡片流(复用 MobileListCard 模式)。", + "level": "low", + "tags": [ + "前端", + "iOS", + "Android" + ], + "created_at": "2026-06-08T15:11:09.971Z", + "done": false, + "completed_at": null, + "version": null, + "status": "done" + }, + { + "id": 27, + "title": "桌面客户端单实例限制,禁止同时运行多个进程", + "desc": "Windows 和 macOS 客户端启动时检测是否已有实例运行;若有,则激活已有窗口并退出新进程。Flutter desktop 可通过 window_single_instance 或 dart:io 锁文件实现。", + "level": "mid", + "tags": [ + "Windows", + "mac" + ], + "status": "open", + "created_at": "2026-06-08T15:58:11.759Z", + "done": false, + "completed_at": null, + "version": null } ] } diff --git a/todo/todo.mjs b/todo/todo.mjs index bc8d985..6714f3f 100644 --- a/todo/todo.mjs +++ b/todo/todo.mjs @@ -2,11 +2,20 @@ /** * todo/todo.mjs — 项目 TODO 管理 CLI(零依赖,Node.js >= 18) * + * 状态流转:open → doing → done → accepted + * done ←─ reject(退回,升为最高优先级) + * open 待开始 Claude 可设 + * doing 开发中 Claude 可设 + * done 待验收 Claude 可设(开发完成,等用户确认) + * accepted 已验收 仅限用户设置(手动编辑 JSON 或 `done ` 命令) + * * 子命令: - * add --title "..." [--level high|mid|low] [--tags a,b] [--desc "..."] - * done [--version vX.Y.Z] - * reopen - * rm + * add --title "..." [--level high|mid|low] [--tags a,b] [--desc "..."] + * status — 更新开发状态(Claude 可调用) + * done [--version vX.Y.Z] — 标记已验收交付(仅用户调用) + * reject --reason "原因" — 拒绝验收,退回 open 并升为 high(仅用户调用) + * reopen — 重新开启(清空拒绝记录) + * rm * list * render */ @@ -20,13 +29,28 @@ const __dir = dirname(fileURLToPath(import.meta.url)); const DB = resolve(__dir, 'todo.json'); const HTML = resolve(__dir, 'todo.html'); +// ─── 常量 ──────────────────────────────────────────────────────────────────── + +const LEVEL_LABEL = { high: '高', mid: '中', low: '低' }; +const LEVEL_ORDER = { high: 0, mid: 1, low: 2 }; + +const STATUS_LABEL = { open: '待开始', doing: '开发中', done: '待验收', accepted: '已验收' }; +const STATUS_ORDER = { open: 0, doing: 1, done: 2, accepted: 3 }; + // ─── 数据读写 ──────────────────────────────────────────────────────────────── function load() { if (!existsSync(DB)) { return { meta: { title: '酒库管理系统 — 项目 TODO', updated_at: now() }, seq: 0, items: [] }; } - return JSON.parse(readFileSync(DB, 'utf8')); + const db = JSON.parse(readFileSync(DB, 'utf8')); + // 迁移旧格式:无 status 字段时,依据 done 布尔值补全 + for (const item of db.items) { + if (!item.status) { + item.status = item.done ? 'accepted' : 'open'; + } + } + return db; } function save(db) { @@ -54,29 +78,38 @@ function gitTag() { // ─── 终端 summary ──────────────────────────────────────────────────────────── -const LEVEL_LABEL = { high: '高', mid: '中', low: '低' }; -const LEVEL_ORDER = { high: 0, mid: 1, low: 2 }; +function levelText(level) { + return { high: '高优 · 紧急', mid: '重要', low: '一般 / 优化' }[level] || level; +} 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); + 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); + } - console.log(`\nTODO 共 ${db.items.length} · 未完成 ${pending.length} · 已完成 ${done.length}`); + const total = db.items.length; + const pending = byStatus.open.length + byStatus.doing.length + byStatus.done.length; + const accepted = byStatus.accepted.length; - if (pending.length) { - console.log('未完成(按重要度):'); - for (const i of pending) { - const tags = i.tags && i.tags.length ? i.tags.join('/') : ''; + console.log(`\nTODO 共 ${total} · 进行中 ${pending} · 已验收 ${accepted}`); + + for (const [st, label] of [['open','待开始'],['doing','开发中'],['done','待验收']]) { + if (byStatus[st].length === 0) continue; + console.log(`${label}(${byStatus[st].length}):`); + for (const i of byStatus[st]) { + const tags = i.tags && i.tags.length ? ' ' + i.tags.join('/') : ''; const lvl = LEVEL_LABEL[i.level] || i.level; - console.log(` [${lvl}] #${i.id} ${i.title}${tags ? ' ' + tags : ''}`); + console.log(` [${lvl}] #${i.id} ${i.title}${tags}`); } } - if (done.length) { - console.log('已完成(最近 5 条):'); - for (const i of done.slice(0, 5)) { + 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}`); } @@ -90,7 +123,6 @@ function esc(s) { return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } -/** 把描述里的 `code` 渲染成 */ function renderDesc(s) { if (!s) return ''; return esc(s).replace(/`([^`]+)`/g, '$1'); @@ -99,30 +131,44 @@ function renderDesc(s) { 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 statusClass(status) { + return { open: 's-open', doing: 's-doing', done: 's-done', accepted: 's-accepted' }[status] || 's-open'; } function renderItems(items) { if (!items.length) return '

    暂无条目

    '; return items.map(i => { - const tags = (i.tags||[]).map(t => `${esc(t)}`).join(' '); - const desc = i.desc ? `
    ${renderDesc(i.desc)}
    ` : ''; - const meta = `
    + const tags = (i.tags||[]).map(t => `${esc(t)}`).join(' '); + const desc = i.desc ? `
    ${renderDesc(i.desc)}
    ` : ''; + const stCls = statusClass(i.status); + const stLbl = STATUS_LABEL[i.status] || i.status; + const rejectNote = i.reject_reason + ? `
    🚫 拒绝原因:${esc(i.reject_reason)} · ${fmtDate(i.rejected_at)}
    ` + : ''; + const rejectBtn = i.status === 'done' + ? `` + : ''; + const meta = `
    🕐 ${fmtDate(i.created_at)} - ${i.done ? `✅ 完成 ${fmtDate(i.completed_at)}${i.version ? ' · '+esc(i.version)+'' : ''}` : ''} + ${i.status === 'accepted' ? `✅ 验收 ${fmtDate(i.completed_at)}${i.version ? ' · '+esc(i.version)+'' : ''}` : ''}
    `; - const lvlCls = levelClass(i.level); + const lvlCls = levelClass(i.level); return ` -
  • + data-status="${esc(i.status)}" + data-tags="${esc((i.tags||[]).join(','))}">
    ${esc(i.title)} - ${levelText(i.level)} +
    + ${stLbl} + ${levelText(i.level)} + ${rejectBtn} +
    + ${rejectNote} ${desc} @@ -264,9 +386,11 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
    状态 - - - + + + + +
    ${allTags.length ? `
    @@ -277,44 +401,78 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
    -
    📋 未完成(${pending.length})
    -
      - ${renderItems(pending)} -
    + ${[ + { st: 'open', label: '📋 待开始', cls: 'st-open', collapsed: false }, + { st: 'doing', label: '🔨 开发中', cls: 'st-doing', collapsed: false }, + { st: 'done', label: '🔍 待验收', cls: 'st-done', collapsed: false }, + { st: 'accepted', label: '✅ 已验收', cls: 'st-accepted', collapsed: true }, + ].map(({ st, label, cls, collapsed }) => ` +
    +
    + ${label} ${byStatus[st].length} + ${collapsed ? '▾ 展开' : '▴ 收起'} +
    +
    +
      + ${renderItems(byStatus[st])} +
    +
    +
    `).join('')} -
    -
    ✅ 已完成(${done.length})
    - - +
    + + + @@ -398,9 +619,9 @@ 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 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 = { @@ -409,6 +630,7 @@ function cmdAdd(db, args) { desc, level, tags, + status: 'open', created_at: now(), done: false, completed_at: null, @@ -422,6 +644,33 @@ function cmdAdd(db, args) { printSummary(db); } +/** status — Claude 可调用,accepted 由用户手动设置 */ +function cmdStatus(db, args) { + const id = args._[0]; + const newStatus = args._[1]; + + if (newStatus === 'accepted') { + console.error('❌ 状态 "accepted"(已验收)只能由用户手动修改 todo.json,或使用 `done ` 命令'); + process.exit(1); + } + if (!['open','doing','done'].includes(newStatus)) { + console.error(`错误:无效状态 "${newStatus}",可选:open | doing | done`); + process.exit(1); + } + + const item = findItem(db, id); + item.status = newStatus; + item.done = false; + + save(db); + writeFileSync(HTML, buildHtml(db), 'utf8'); + + const emoji = { open: '📋', doing: '🔨', done: '🔍' }[newStatus]; + console.log(`${emoji} #${item.id}「${item.title}」→ ${STATUS_LABEL[newStatus]}`); + printSummary(db); +} + +/** done — 标记已验收交付(仅用户调用),记录版本号 */ function cmdDone(db, args) { const id = args._[0]; const item = findItem(db, id); @@ -432,6 +681,7 @@ function cmdDone(db, args) { if (!version) console.warn('⚠️ 无法从 git 获取版本号,version 将为空'); } + item.status = 'accepted'; item.done = true; item.completed_at = now(); item.version = version; @@ -439,7 +689,39 @@ function cmdDone(db, args) { save(db); writeFileSync(HTML, buildHtml(db), 'utf8'); - console.log(`✅ #${item.id}「${item.title}」已标记完成${version ? ',记入版本 ' + version : ''}`); + console.log(`✅ #${item.id}「${item.title}」已验收${version ? ',记入版本 ' + version : ''}`); + printSummary(db); +} + +/** reject --reason "原因" — 拒绝验收,退回 open 并升级为 high(仅 done 状态可用) */ +function cmdReject(db, args) { + const id = args._[0]; + const reason = (args.reason || '').trim(); + + if (!reason) { + console.error('错误:--reason 不能为空,请说明拒绝原因'); + process.exit(1); + } + + const item = findItem(db, id); + if (item.status !== 'done') { + console.error(`错误:只有「待验收(done)」状态的条目才能拒绝,当前状态:${item.status}`); + process.exit(1); + } + + const prevLevel = item.level; + item.status = 'open'; + item.done = false; + item.level = 'high'; + item.reject_reason = reason; + item.rejected_at = now(); + item.version = null; + + save(db); + writeFileSync(HTML, buildHtml(db), 'utf8'); + + console.log(`🚫 #${item.id}「${item.title}」已拒绝${prevLevel !== 'high' ? ',优先级升为最高' : ''}`); + console.log(` 原因:${reason}`); printSummary(db); } @@ -447,9 +729,12 @@ function cmdReopen(db, args) { const id = args._[0]; const item = findItem(db, id); - item.done = false; - item.completed_at = null; - item.version = null; + 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'); @@ -472,7 +757,6 @@ function cmdRm(db, args) { } function cmdList(db) { - // 确保 HTML 最新 writeFileSync(HTML, buildHtml(db), 'utf8'); printSummary(db); console.log(`📄 HTML:todo/todo.html`); @@ -491,7 +775,9 @@ const args = parseArgs(rest); switch (subcmd) { case 'add': cmdAdd(db, args); break; + case 'status': cmdStatus(db, args); break; case 'done': cmdDone(db, args); break; + case 'reject': cmdReject(db, args); break; case 'reopen': cmdReopen(db, args); break; case 'rm': cmdRm(db, args); break; case 'list': @@ -499,6 +785,6 @@ switch (subcmd) { case '': cmdList(db); break; case 'render': cmdRender(db); break; default: - console.error(`未知子命令:${subcmd}\n用法:node todo.mjs add|done|reopen|rm|list|render`); + console.error(`未知子命令:${subcmd}\n用法:node todo.mjs add|status|done|reject|reopen|rm|list|render`); process.exit(1); }