Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ec609255c | |||
| 5ad1289419 | |||
| daa83bd408 | |||
| 65d6b12a21 | |||
| b0a74f3607 | |||
| 543bd2712f | |||
| fab7bd21dd | |||
| 3b2ebc0202 | |||
| ff32c8a845 | |||
| af64e29459 | |||
| f788a7ab74 | |||
| 8a87d4a82c | |||
| 0d17533120 | |||
| 4e64e5eb47 |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
description: 项目 TODO 管理(list / add / done / reopen / rm)
|
||||
argument-hint: "list | add <描述> | done <id> [version] | reopen <id> | rm <id>"
|
||||
description: 项目 TODO 管理(list / add / status / done / reject / reopen / rm)
|
||||
argument-hint: "list | add <描述> | status <id> <open|doing|done> | done <id> [version] | reject <id> <原因> | reopen <id> | rm <id>"
|
||||
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 <id> <open|doing|done>
|
||||
|
||||
**Claude 在开发过程中主动调用**,更新条目的开发状态:
|
||||
- 开始处理某个 todo → `status <id> doing`
|
||||
- 开发完成提交验收 → `status <id> done`
|
||||
- 退回重做 → `status <id> open`
|
||||
|
||||
```bash
|
||||
node todo/todo.mjs status <id> <open|doing|done>
|
||||
```
|
||||
|
||||
转述:「#id 状态已更新为 xxx」,并显示 summary。
|
||||
注意:`accepted` 不可通过此命令设置,仅用户可标记。
|
||||
|
||||
## done <id> [version]
|
||||
|
||||
从 `$ARGUMENTS` 提取 id(数字)和可选 version(格式 vX.Y.Z):
|
||||
**用户调用**,标记条目已验收交付,记录版本号。从 `$ARGUMENTS` 提取 id 和可选 version(格式 vX.Y.Z):
|
||||
|
||||
- 有 version:`node todo/todo.mjs done <id> --version <version>`
|
||||
- 无 version:`node todo/todo.mjs done <id>`(脚本自动用 `git describe --tags --abbrev=0`)
|
||||
|
||||
转述:「#id 标记完成,记入版本 vX.Y.Z」,并显示 summary。
|
||||
转述:「#id 已验收,记入版本 vX.Y.Z」,并显示 summary。
|
||||
|
||||
## reject <id> <原因>
|
||||
|
||||
**用户调用**,拒绝验收处于 `done` 状态的条目。拒绝后:
|
||||
- 状态退回 `open`
|
||||
- 优先级强制升为 `high`
|
||||
- 拒绝原因记录在条目上,HTML 卡片中可见
|
||||
|
||||
从 `$ARGUMENTS` 提取 id 和原因文字:
|
||||
|
||||
```bash
|
||||
node todo/todo.mjs reject <id> --reason "<原因>"
|
||||
```
|
||||
|
||||
转述:「#id 已拒绝,优先级升为最高,原因:xxx」,并显示 summary。
|
||||
|
||||
## reopen <id>
|
||||
|
||||
重新开启条目(清空拒绝记录和验收信息),退回 `open` 状态:
|
||||
|
||||
```bash
|
||||
node todo/todo.mjs reopen <id>
|
||||
```
|
||||
@@ -64,4 +99,4 @@ node todo/todo.mjs rm <id>
|
||||
|
||||
---
|
||||
|
||||
所有命令完成后,在终端显示来自脚本的完整 summary(共/未完成/已完成及分级列表)。
|
||||
所有命令完成后,在终端显示来自脚本的完整 summary(按 open / doing / done / accepted 分组)。
|
||||
|
||||
@@ -5,6 +5,45 @@ 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.29] - 2026-06-09
|
||||
|
||||
### 新功能
|
||||
- Windows / macOS 桌面客户端现在限制只能运行一个实例;重复打开时弹窗提示「程序已在运行中」并自动退出
|
||||
|
||||
## [1.0.28] - 2026-06-09
|
||||
|
||||
### 修复
|
||||
- macOS 客户端打包脚本路径错误导致 CI 打包失败,修正后 macOS 安装包可正常构建并发布
|
||||
|
||||
## [1.0.27] - 2026-06-09
|
||||
|
||||
### 改进
|
||||
- 入库单新建表单:产地、保质期、储存方式、批次号、生产日期等选填字段默认折叠,点击「展开选填」按钮后在行内下方显示,减少初始界面信息量
|
||||
- 手机端现可从屏幕左边缘向右滑动打开导航菜单,无需点击汉堡图标
|
||||
- 入库单详情弹窗在手机上商品明细改为卡片式竖排布局,不再显示难以阅读的横向宽表格
|
||||
|
||||
### 修复
|
||||
- macOS 客户端下载 zip 解压失败:打包改用 ditto,正确保留 symlink 和执行权限
|
||||
|
||||
## [1.0.26] - 2026-06-08
|
||||
|
||||
### 新功能
|
||||
- 公开商品 API 新增商品编码、条码、当前库存数量字段,供扫码页及第三方集成使用
|
||||
- 公开页「商品报错」「意见反馈」提交时自动携带门店编号和商品信息,方便运营追踪
|
||||
|
||||
### 修复
|
||||
- 注册页 API 地址改为配置文件驱动,支持分离部署场景(不再依赖 window.location.origin)
|
||||
- 注册成功提示的客户端导航路径由「系统设置 → 关于」修正为「系统设置」
|
||||
- 公开页页脚「关于岩美」链接改为配置项,不再硬编码
|
||||
|
||||
## [1.0.25] - 2026-06-08
|
||||
|
||||
### 新功能
|
||||
- 基础数据页新增「产地」「保质期」「储存方式」「描述文档」4 个字典维护 Tab,可直接在基础数据中增删改查
|
||||
- 商品详情页展示产地、保质期、储存方式参数,并在介绍区上方显示建议零售价
|
||||
- 入库单详情中商品列展示产地、保质期、储存方式信息
|
||||
- 公开扫码页新增「商品报错」「意见反馈」功能,用户可提交反馈
|
||||
|
||||
## [1.0.24] - 2026-06-08
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -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 <id>`,脚本自动记入当前 git tag 版本
|
||||
- **状态**:`open`(待开始)→ `doing`(开发中)→ `done`(待验收)→ `accepted`(已验收)
|
||||
- **完成即标记**:开发完成后 `/todo status <id> done`,验收通过后用户运行 `/todo done <id>` 记入版本
|
||||
|
||||
常用命令:
|
||||
```
|
||||
/todo — 查看 summary(含未完成分级列表)
|
||||
/todo add 修复... — 新增待办(Claude 自动推断级别/标签)
|
||||
/todo done <id> — 标记完成,自动记入版本号
|
||||
/todo reopen <id> — 撤销完成
|
||||
/todo rm <id> — 删除
|
||||
/todo — 查看 summary(按状态分组)
|
||||
/todo add 修复... — 新增待办(Claude 自动推断级别/标签)
|
||||
/todo status <id> doing — 标记开发中(Claude 开始处理时调用)
|
||||
/todo status <id> done — 标记待验收(Claude 完成开发时调用)
|
||||
/todo done <id> — 用户验收通过,自动记入版本号
|
||||
/todo reject <id> <原因> — 用户拒绝验收,退回 open 并升为最高优先级
|
||||
/todo reopen <id> — 重新开启(清空验收/拒绝记录)
|
||||
/todo rm <id> — 删除
|
||||
```
|
||||
|
||||
底层脚本:`node todo/todo.mjs <子命令> [参数]`
|
||||
|
||||
@@ -17,16 +17,17 @@ func NewErrorReportHandler(db *gorm.DB) *ErrorReportHandler {
|
||||
}
|
||||
|
||||
type submitErrorRequest struct {
|
||||
ErrorType string `json:"error_type" binding:"required"`
|
||||
AppVersion string `json:"app_version"`
|
||||
Platform string `json:"platform"`
|
||||
Username string `json:"username"`
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
ShopNo string `json:"shop_no"`
|
||||
Role string `json:"role"`
|
||||
ErrorMsg string `json:"error_msg" binding:"required"`
|
||||
StackTrace string `json:"stack_trace"`
|
||||
OccurredAt int64 `json:"occurred_at"` // Unix 毫秒
|
||||
ErrorType string `json:"error_type" binding:"required"`
|
||||
AppVersion string `json:"app_version"`
|
||||
Platform string `json:"platform"`
|
||||
Username string `json:"username"`
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
ShopNo string `json:"shop_no"`
|
||||
Role string `json:"role"`
|
||||
ProductInfo string `json:"product_info"`
|
||||
ErrorMsg string `json:"error_msg" binding:"required"`
|
||||
StackTrace string `json:"stack_trace"`
|
||||
OccurredAt int64 `json:"occurred_at"` // Unix 毫秒
|
||||
}
|
||||
|
||||
// Submit POST /api/v1/public/errors
|
||||
@@ -49,17 +50,18 @@ func (h *ErrorReportHandler) Submit(c *gin.Context) {
|
||||
}
|
||||
|
||||
report := model.ErrorReport{
|
||||
ErrorType: req.ErrorType,
|
||||
AppVersion: req.AppVersion,
|
||||
Platform: req.Platform,
|
||||
Username: req.Username,
|
||||
ShopID: req.ShopID,
|
||||
ShopNo: req.ShopNo,
|
||||
Role: req.Role,
|
||||
ClientIP: c.ClientIP(),
|
||||
ErrorMsg: req.ErrorMsg,
|
||||
StackTrace: stack,
|
||||
OccurredAt: occurredAt,
|
||||
ErrorType: req.ErrorType,
|
||||
AppVersion: req.AppVersion,
|
||||
Platform: req.Platform,
|
||||
Username: req.Username,
|
||||
ShopID: req.ShopID,
|
||||
ShopNo: req.ShopNo,
|
||||
Role: req.Role,
|
||||
ProductInfo: req.ProductInfo,
|
||||
ClientIP: c.ClientIP(),
|
||||
ErrorMsg: req.ErrorMsg,
|
||||
StackTrace: stack,
|
||||
OccurredAt: occurredAt,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&report).Error; err != nil {
|
||||
|
||||
@@ -165,6 +165,7 @@ func (h *ProductHandler) Detail(c *gin.Context) {
|
||||
var product model.Product
|
||||
if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", id, shopID).
|
||||
Preload("Category").Preload("Images").
|
||||
Preload("Origin").Preload("ShelfLife").Preload("Storage").
|
||||
First(&product).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
|
||||
@@ -72,6 +72,7 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
|
||||
"production_date": pdStr,
|
||||
"batch_no": inv.BatchNo,
|
||||
"in_stock_date": inv.CreatedAt.Format("2006-01-02"),
|
||||
"quantity": inv.Quantity,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +101,8 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
|
||||
"data": gin.H{
|
||||
"id": product.ID,
|
||||
"name": product.Name,
|
||||
"code": product.Code,
|
||||
"barcode": product.Barcode,
|
||||
"series": product.Series,
|
||||
"spec": product.Spec,
|
||||
"brand": product.Brand,
|
||||
|
||||
@@ -62,7 +62,9 @@ func (h *StockInHandler) List(c *gin.Context) {
|
||||
func (h *StockInHandler) Get(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var order model.StockInOrder
|
||||
if err := h.db.Preload("Items.Product").Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
||||
if err := h.db.Preload("Items.Product").Preload("Items.Product.Origin").
|
||||
Preload("Items.Product.ShelfLife").Preload("Items.Product.Storage").
|
||||
Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
||||
Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID).
|
||||
First(&order).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
|
||||
@@ -12,8 +12,9 @@ type ErrorReport struct {
|
||||
Username string `gorm:"size:50;index" json:"username"`
|
||||
ShopID uint64 `gorm:"index" json:"shop_id"`
|
||||
ShopNo string `gorm:"size:50" json:"shop_no"`
|
||||
Role string `gorm:"size:20" json:"role"`
|
||||
ClientIP string `gorm:"size:60" json:"client_ip"`
|
||||
Role string `gorm:"size:20" json:"role"`
|
||||
ProductInfo string `gorm:"size:200" json:"product_info"`
|
||||
ClientIP string `gorm:"size:60" json:"client_ip"`
|
||||
ErrorMsg string `gorm:"type:text;not null" json:"error_msg"`
|
||||
StackTrace string `gorm:"type:text" json:"stack_trace"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
|
||||
@@ -34,6 +34,11 @@ class AppConfig {
|
||||
defaultValue: '',
|
||||
);
|
||||
|
||||
static const aboutUrl = String.fromEnvironment(
|
||||
'ABOUT_URL',
|
||||
defaultValue: 'https://jiu.51yanmei.com',
|
||||
);
|
||||
|
||||
static String get baseUrl => _baseUrl;
|
||||
static String get apiBaseUrl => '$_baseUrl/api/v1';
|
||||
static String get healthUrl => '$_baseUrl/health';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
// Keep the RAF open so the OS lock stays alive for the lifetime of the process.
|
||||
// ignore: unused_element
|
||||
RandomAccessFile? _raf;
|
||||
|
||||
/// Returns true if this process successfully acquired the single-instance lock.
|
||||
/// Returns false if another instance is already running.
|
||||
/// On Web / mobile / Linux the function always returns true (no restriction).
|
||||
Future<bool> acquire() async {
|
||||
if (kIsWeb) return true;
|
||||
if (!Platform.isWindows && !Platform.isMacOS) return true;
|
||||
|
||||
try {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final lockFile = File('${dir.path}/jiu.lock');
|
||||
final raf = await lockFile.open(mode: FileMode.write);
|
||||
await raf.lock(FileLock.exclusive);
|
||||
_raf = raf;
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -6,6 +7,7 @@ import 'package:flutter_web_plugins/url_strategy.dart';
|
||||
import 'core/auth/auth_state.dart';
|
||||
import 'core/config/app_info.dart';
|
||||
import 'core/errors/error_reporter.dart';
|
||||
import 'core/platform/single_instance.dart' as single_instance;
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'providers/connectivity_provider.dart';
|
||||
@@ -27,6 +29,12 @@ void main() {
|
||||
runZonedGuarded(
|
||||
() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
if (!await single_instance.acquire()) {
|
||||
runApp(const _AlreadyRunningApp());
|
||||
return;
|
||||
}
|
||||
|
||||
await AppInfo.load(); // 从配置文件加载「关于我们」品牌信息
|
||||
runApp(const ProviderScope(child: JiuApp()));
|
||||
},
|
||||
@@ -43,6 +51,38 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
class _AlreadyRunningApp extends StatelessWidget {
|
||||
const _AlreadyRunningApp();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Builder(
|
||||
builder: (context) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => AlertDialog(
|
||||
title: const Text('岩美酒库已在运行'),
|
||||
content: const Text('请勿重复打开,程序已在运行中。\n请在任务栏或 Dock 中找到已打开的窗口。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => exit(0),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
return const Scaffold();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class JiuApp extends ConsumerStatefulWidget {
|
||||
const JiuApp({super.key});
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ class Product {
|
||||
final String? remark;
|
||||
final Map<String, dynamic>? customFields;
|
||||
final List<ProductImage> images;
|
||||
// 产地/保质期/储存方式名称(Detail 接口 Preload 后填充)
|
||||
final String? originName;
|
||||
final String? shelfLifeName;
|
||||
final String? storageName;
|
||||
|
||||
const Product({
|
||||
required this.id,
|
||||
@@ -37,6 +41,9 @@ class Product {
|
||||
this.remark,
|
||||
this.customFields,
|
||||
this.images = const [],
|
||||
this.originName,
|
||||
this.shelfLifeName,
|
||||
this.storageName,
|
||||
});
|
||||
|
||||
factory Product.fromJson(Map<String, dynamic> json) => Product(
|
||||
@@ -68,6 +75,9 @@ class Product {
|
||||
?.map((e) => ProductImage.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
originName: (json['origin'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
shelfLifeName: (json['shelf_life'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
storageName: (json['storage'] as Map<String, dynamic>?)?['name'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
|
||||
@@ -12,6 +12,9 @@ class StockInItem {
|
||||
final String? productSeries;
|
||||
final String? productSpec;
|
||||
final String? productUnit;
|
||||
final String? productOrigin;
|
||||
final String? productShelfLife;
|
||||
final String? productStorage;
|
||||
|
||||
const StockInItem({
|
||||
this.orderId,
|
||||
@@ -26,6 +29,9 @@ class StockInItem {
|
||||
this.productSeries,
|
||||
this.productSpec,
|
||||
this.productUnit,
|
||||
this.productOrigin,
|
||||
this.productShelfLife,
|
||||
this.productStorage,
|
||||
});
|
||||
|
||||
factory StockInItem.fromJson(Map<String, dynamic> json) => StockInItem(
|
||||
@@ -43,6 +49,12 @@ class StockInItem {
|
||||
productSeries: (json['product'] as Map<String, dynamic>?)?['series'] as String?,
|
||||
productSpec: (json['product'] as Map<String, dynamic>?)?['spec'] as String?,
|
||||
productUnit: (json['product'] as Map<String, dynamic>?)?['unit'] as String?,
|
||||
productOrigin: ((json['product'] as Map<String, dynamic>?)?['origin']
|
||||
as Map<String, dynamic>?)?['name'] as String?,
|
||||
productShelfLife: ((json['product'] as Map<String, dynamic>?)?['shelf_life']
|
||||
as Map<String, dynamic>?)?['name'] as String?,
|
||||
productStorage: ((json['product'] as Map<String, dynamic>?)?['storage']
|
||||
as Map<String, dynamic>?)?['name'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
|
||||
@@ -129,35 +129,155 @@ class ProductSpecListNotifier extends AsyncNotifier<List<ProductSpecOption>> {
|
||||
// ── 产地 ──────────────────────────────────────────────────────
|
||||
|
||||
final productOriginListProvider =
|
||||
FutureProvider<List<ProductOriginOption>>((ref) async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
return ref.read(productOptionRepositoryProvider).listOrigins();
|
||||
});
|
||||
AsyncNotifierProvider<ProductOriginListNotifier, List<ProductOriginOption>>(
|
||||
ProductOriginListNotifier.new,
|
||||
);
|
||||
|
||||
class ProductOriginListNotifier extends AsyncNotifier<List<ProductOriginOption>> {
|
||||
@override
|
||||
Future<List<ProductOriginOption>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
return ref.read(productOptionRepositoryProvider).listOrigins();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
ref.read(productOptionRepositoryProvider).listOrigins().then(
|
||||
(data) => state = AsyncValue.data(data),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> create(Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).createOrigin(data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> updateItem(int id, Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).updateOrigin(id, data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(productOptionRepositoryProvider).deleteOrigin(id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 保质期 ────────────────────────────────────────────────────
|
||||
|
||||
final productShelfLifeListProvider =
|
||||
FutureProvider<List<ProductShelfLifeOption>>((ref) async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
return ref.read(productOptionRepositoryProvider).listShelfLives();
|
||||
});
|
||||
AsyncNotifierProvider<ProductShelfLifeListNotifier, List<ProductShelfLifeOption>>(
|
||||
ProductShelfLifeListNotifier.new,
|
||||
);
|
||||
|
||||
class ProductShelfLifeListNotifier extends AsyncNotifier<List<ProductShelfLifeOption>> {
|
||||
@override
|
||||
Future<List<ProductShelfLifeOption>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
return ref.read(productOptionRepositoryProvider).listShelfLives();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
ref.read(productOptionRepositoryProvider).listShelfLives().then(
|
||||
(data) => state = AsyncValue.data(data),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> create(Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).createShelfLife(data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> updateItem(int id, Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).updateShelfLife(id, data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(productOptionRepositoryProvider).deleteShelfLife(id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 储存方式 ──────────────────────────────────────────────────
|
||||
|
||||
final productStorageListProvider =
|
||||
FutureProvider<List<ProductStorageOption>>((ref) async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
return ref.read(productOptionRepositoryProvider).listStorages();
|
||||
});
|
||||
AsyncNotifierProvider<ProductStorageListNotifier, List<ProductStorageOption>>(
|
||||
ProductStorageListNotifier.new,
|
||||
);
|
||||
|
||||
class ProductStorageListNotifier extends AsyncNotifier<List<ProductStorageOption>> {
|
||||
@override
|
||||
Future<List<ProductStorageOption>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
return ref.read(productOptionRepositoryProvider).listStorages();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
ref.read(productOptionRepositoryProvider).listStorages().then(
|
||||
(data) => state = AsyncValue.data(data),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> create(Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).createStorage(data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> updateItem(int id, Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).updateStorage(id, data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(productOptionRepositoryProvider).deleteStorage(id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 描述文档 ──────────────────────────────────────────────────
|
||||
|
||||
final productDescriptionDocListProvider =
|
||||
FutureProvider<List<ProductDescriptionDoc>>((ref) async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
return ref.read(productOptionRepositoryProvider).listDescriptionDocs();
|
||||
});
|
||||
AsyncNotifierProvider<ProductDescriptionDocListNotifier, List<ProductDescriptionDoc>>(
|
||||
ProductDescriptionDocListNotifier.new,
|
||||
);
|
||||
|
||||
class ProductDescriptionDocListNotifier extends AsyncNotifier<List<ProductDescriptionDoc>> {
|
||||
@override
|
||||
Future<List<ProductDescriptionDoc>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
ref.watch(networkRecoveryCountProvider);
|
||||
return ref.read(productOptionRepositoryProvider).listDescriptionDocs();
|
||||
}
|
||||
|
||||
void reload() {
|
||||
state = const AsyncValue.loading();
|
||||
ref.read(productOptionRepositoryProvider).listDescriptionDocs().then(
|
||||
(data) => state = AsyncValue.data(data),
|
||||
onError: (e, st) => state = AsyncValue.error(e, st),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> create(Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).createDescriptionDoc(data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> updateItem(int id, Map<String, dynamic> data) async {
|
||||
await ref.read(productOptionRepositoryProvider).updateDescriptionDoc(id, data);
|
||||
reload();
|
||||
}
|
||||
|
||||
Future<void> delete(int id) async {
|
||||
await ref.read(productOptionRepositoryProvider).deleteDescriptionDoc(id);
|
||||
reload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +141,33 @@ class ProductOptionRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createOrigin(Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.post('/product-options/origins', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '创建失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateOrigin(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.put('/product-options/origins/$id', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '更新失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteOrigin(int id) async {
|
||||
try {
|
||||
await _client.delete('/product-options/origins/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '删除失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 保质期 ──────────────────────────────────────────────────
|
||||
|
||||
Future<List<ProductShelfLifeOption>> listShelfLives() async {
|
||||
@@ -154,6 +181,33 @@ class ProductOptionRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createShelfLife(Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.post('/product-options/shelf-lives', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '创建失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateShelfLife(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.put('/product-options/shelf-lives/$id', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '更新失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteShelfLife(int id) async {
|
||||
try {
|
||||
await _client.delete('/product-options/shelf-lives/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '删除失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 储存方式 ─────────────────────────────────────────────────
|
||||
|
||||
Future<List<ProductStorageOption>> listStorages() async {
|
||||
@@ -167,6 +221,33 @@ class ProductOptionRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createStorage(Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.post('/product-options/storages', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '创建失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateStorage(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.put('/product-options/storages/$id', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '更新失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteStorage(int id) async {
|
||||
try {
|
||||
await _client.delete('/product-options/storages/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '删除失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 描述文档 ─────────────────────────────────────────────────
|
||||
|
||||
Future<List<ProductDescriptionDoc>> listDescriptionDocs() async {
|
||||
@@ -179,4 +260,31 @@ class ProductOptionRepository {
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> createDescriptionDoc(Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.post('/product-options/description-docs', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '创建失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateDescriptionDoc(int id, Map<String, dynamic> data) async {
|
||||
try {
|
||||
await _client.put('/product-options/description-docs/$id', data: data);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '更新失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteDescriptionDoc(int id) async {
|
||||
try {
|
||||
await _client.delete('/product-options/description-docs/$id');
|
||||
} on DioException catch (e) {
|
||||
throw AppException(e.response?.data?['error'] as String? ?? '删除失败',
|
||||
statusCode: e.response?.statusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +269,10 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
||||
_buildImageSection(p),
|
||||
const SizedBox(height: 20),
|
||||
_buildInfoSection(p),
|
||||
if (p.salePrice != null && p.salePrice! > 0) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildPriceSection(p),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_buildDescSection(),
|
||||
const SizedBox(height: 20),
|
||||
@@ -350,7 +354,12 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
||||
SizedBox(width: 200, child: row('品牌', p.brand?.isEmpty ?? true ? '-' : p.brand!)),
|
||||
SizedBox(width: 200, child: row('单位', p.unit.isEmpty ? '-' : p.unit)),
|
||||
SizedBox(width: 200, child: row('进价', p.purchasePrice != null ? '¥${p.purchasePrice!.toStringAsFixed(2)}' : '-')),
|
||||
SizedBox(width: 200, child: row('售价', p.salePrice != null ? '¥${p.salePrice!.toStringAsFixed(2)}' : '-')),
|
||||
if (p.originName?.isNotEmpty ?? false)
|
||||
SizedBox(width: 200, child: row('产地', p.originName!)),
|
||||
if (p.shelfLifeName?.isNotEmpty ?? false)
|
||||
SizedBox(width: 200, child: row('保质期', p.shelfLifeName!)),
|
||||
if (p.storageName?.isNotEmpty ?? false)
|
||||
SizedBox(width: 200, child: row('储存方式', p.storageName!)),
|
||||
if (p.barcode?.isNotEmpty ?? false)
|
||||
SizedBox(width: 200, child: row('条码', p.barcode!)),
|
||||
],
|
||||
@@ -359,6 +368,35 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPriceSection(Product p) {
|
||||
final price = p.salePrice!;
|
||||
final priceStr = price % 1 == 0
|
||||
? price.toInt().toString()
|
||||
: price.toStringAsFixed(2);
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
side: BorderSide(color: AppTheme.border, width: 0.5),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('建议零售价',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const Spacer(),
|
||||
Text('¥$priceStr',
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppTheme.danger)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDescSection() {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
|
||||
@@ -22,16 +22,28 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
||||
final _nameSearchCtrl = TextEditingController();
|
||||
final _seriesSearchCtrl = TextEditingController();
|
||||
final _specSearchCtrl = TextEditingController();
|
||||
final _originSearchCtrl = TextEditingController();
|
||||
final _shelfLifeSearchCtrl = TextEditingController();
|
||||
final _storageSearchCtrl = TextEditingController();
|
||||
final _descDocSearchCtrl = TextEditingController();
|
||||
|
||||
int _namePage = 1; int _namePageSize = 20;
|
||||
int _seriesPage = 1; int _seriesPageSize = 20;
|
||||
int _specPage = 1; int _specPageSize = 20;
|
||||
int _namePage = 1; int _namePageSize = 20;
|
||||
int _seriesPage = 1; int _seriesPageSize = 20;
|
||||
int _specPage = 1; int _specPageSize = 20;
|
||||
int _originPage = 1; int _originPageSize = 20;
|
||||
int _shelfLifePage = 1; int _shelfLifePageSize = 20;
|
||||
int _storagePage = 1; int _storagePageSize = 20;
|
||||
int _descDocPage = 1; int _descDocPageSize = 20;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameSearchCtrl.dispose();
|
||||
_seriesSearchCtrl.dispose();
|
||||
_specSearchCtrl.dispose();
|
||||
_originSearchCtrl.dispose();
|
||||
_shelfLifeSearchCtrl.dispose();
|
||||
_storageSearchCtrl.dispose();
|
||||
_descDocSearchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -43,12 +55,20 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
||||
Tab(text: '商品名称'),
|
||||
Tab(text: '系列'),
|
||||
Tab(text: '规格'),
|
||||
Tab(text: '产地'),
|
||||
Tab(text: '保质期'),
|
||||
Tab(text: '储存方式'),
|
||||
Tab(text: '描述文档'),
|
||||
Tab(text: '仓库'),
|
||||
],
|
||||
tabViews: [
|
||||
_buildNameTab(),
|
||||
_buildSeriesTab(),
|
||||
_buildSpecTab(),
|
||||
_buildOriginTab(),
|
||||
_buildShelfLifeTab(),
|
||||
_buildStorageTab(),
|
||||
_buildDescDocTab(),
|
||||
_buildWarehousesTab(),
|
||||
],
|
||||
);
|
||||
@@ -455,6 +475,362 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 产地 tab ──────────────────────────────────────────────
|
||||
|
||||
Widget _buildOriginTab() {
|
||||
final async = ref.watch(productOriginListProvider);
|
||||
return async.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => _buildError(() => ref.read(productOriginListProvider.notifier).reload()),
|
||||
data: (items) {
|
||||
final keyword = _originSearchCtrl.text.toLowerCase();
|
||||
final filtered = keyword.isEmpty
|
||||
? items
|
||||
: items.where((it) => it.name.toLowerCase().contains(keyword) ||
|
||||
(it.code?.toLowerCase().contains(keyword) ?? false)).toList();
|
||||
final paged = filtered.skip((_originPage - 1) * _originPageSize).take(_originPageSize).toList();
|
||||
return DataTableCard(
|
||||
totalCount: filtered.length,
|
||||
page: _originPage,
|
||||
pageSize: _originPageSize,
|
||||
onPageChanged: (p) => setState(() => _originPage = p),
|
||||
onPageSizeChanged: (s) => setState(() { _originPageSize = s; _originPage = 1; }),
|
||||
toolbar: _buildToolbar(
|
||||
searchCtrl: _originSearchCtrl,
|
||||
hint: '搜索产地/编号',
|
||||
onSearchChanged: () => setState(() => _originPage = 1),
|
||||
onAdd: () => _showOptionDialog(
|
||||
title: '新建产地',
|
||||
hasQuantity: false,
|
||||
onSave: (data) => ref.read(productOriginListProvider.notifier).create(data),
|
||||
),
|
||||
onExport: () => exportExcel(
|
||||
filename: '产地',
|
||||
headers: ['编号', '名称', '备注'],
|
||||
rows: items.map((it) => [it.code ?? '', it.name, it.remark ?? '']).toList(),
|
||||
),
|
||||
),
|
||||
mobileCards: paged.map((it) => _optionCard(
|
||||
code: it.code, name: it.name, remark: it.remark,
|
||||
onEdit: () => _showOptionDialog(
|
||||
title: '编辑产地', hasQuantity: false,
|
||||
initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''},
|
||||
onSave: (data) => ref.read(productOriginListProvider.notifier).updateItem(it.id, data),
|
||||
),
|
||||
onDelete: () => _confirmDelete('删除产地「${it.name}」?',
|
||||
() => ref.read(productOriginListProvider.notifier).delete(it.id)),
|
||||
)).toList(),
|
||||
columns: const [DataColumn(label: Text('编号')), DataColumn(label: Text('名称')), DataColumn(label: Text('备注')), DataColumn(label: Text('操作'))],
|
||||
rows: paged.isEmpty
|
||||
? [DataRow(cells: [const DataCell(SizedBox()), const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), const DataCell(SizedBox()), const DataCell(SizedBox())])]
|
||||
: paged.map((it) => DataRow(cells: [
|
||||
DataCell(Text(it.code ?? '-', style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))),
|
||||
DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
DataCell(Text(it.remark ?? '-')),
|
||||
DataCell(_actionButtons(
|
||||
onEdit: () => _showOptionDialog(
|
||||
title: '编辑产地', hasQuantity: false,
|
||||
initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''},
|
||||
onSave: (data) => ref.read(productOriginListProvider.notifier).updateItem(it.id, data),
|
||||
),
|
||||
onDelete: () => _confirmDelete('删除产地「${it.name}」?',
|
||||
() => ref.read(productOriginListProvider.notifier).delete(it.id)),
|
||||
)),
|
||||
])).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── 保质期 tab ─────────────────────────────────────────────
|
||||
|
||||
Widget _buildShelfLifeTab() {
|
||||
final async = ref.watch(productShelfLifeListProvider);
|
||||
return async.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => _buildError(() => ref.read(productShelfLifeListProvider.notifier).reload()),
|
||||
data: (items) {
|
||||
final keyword = _shelfLifeSearchCtrl.text.toLowerCase();
|
||||
final filtered = keyword.isEmpty
|
||||
? items
|
||||
: items.where((it) => it.name.toLowerCase().contains(keyword) ||
|
||||
(it.code?.toLowerCase().contains(keyword) ?? false)).toList();
|
||||
final paged = filtered.skip((_shelfLifePage - 1) * _shelfLifePageSize).take(_shelfLifePageSize).toList();
|
||||
return DataTableCard(
|
||||
totalCount: filtered.length,
|
||||
page: _shelfLifePage,
|
||||
pageSize: _shelfLifePageSize,
|
||||
onPageChanged: (p) => setState(() => _shelfLifePage = p),
|
||||
onPageSizeChanged: (s) => setState(() { _shelfLifePageSize = s; _shelfLifePage = 1; }),
|
||||
toolbar: _buildToolbar(
|
||||
searchCtrl: _shelfLifeSearchCtrl,
|
||||
hint: '搜索保质期/编号',
|
||||
onSearchChanged: () => setState(() => _shelfLifePage = 1),
|
||||
onAdd: () => _showOptionDialog(
|
||||
title: '新建保质期',
|
||||
hasQuantity: false,
|
||||
onSave: (data) => ref.read(productShelfLifeListProvider.notifier).create(data),
|
||||
),
|
||||
onExport: () => exportExcel(
|
||||
filename: '保质期',
|
||||
headers: ['编号', '名称', '备注'],
|
||||
rows: items.map((it) => [it.code ?? '', it.name, it.remark ?? '']).toList(),
|
||||
),
|
||||
),
|
||||
mobileCards: paged.map((it) => _optionCard(
|
||||
code: it.code, name: it.name, remark: it.remark,
|
||||
onEdit: () => _showOptionDialog(
|
||||
title: '编辑保质期', hasQuantity: false,
|
||||
initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''},
|
||||
onSave: (data) => ref.read(productShelfLifeListProvider.notifier).updateItem(it.id, data),
|
||||
),
|
||||
onDelete: () => _confirmDelete('删除保质期「${it.name}」?',
|
||||
() => ref.read(productShelfLifeListProvider.notifier).delete(it.id)),
|
||||
)).toList(),
|
||||
columns: const [DataColumn(label: Text('编号')), DataColumn(label: Text('名称')), DataColumn(label: Text('备注')), DataColumn(label: Text('操作'))],
|
||||
rows: paged.isEmpty
|
||||
? [DataRow(cells: [const DataCell(SizedBox()), const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), const DataCell(SizedBox()), const DataCell(SizedBox())])]
|
||||
: paged.map((it) => DataRow(cells: [
|
||||
DataCell(Text(it.code ?? '-', style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))),
|
||||
DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
DataCell(Text(it.remark ?? '-')),
|
||||
DataCell(_actionButtons(
|
||||
onEdit: () => _showOptionDialog(
|
||||
title: '编辑保质期', hasQuantity: false,
|
||||
initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''},
|
||||
onSave: (data) => ref.read(productShelfLifeListProvider.notifier).updateItem(it.id, data),
|
||||
),
|
||||
onDelete: () => _confirmDelete('删除保质期「${it.name}」?',
|
||||
() => ref.read(productShelfLifeListProvider.notifier).delete(it.id)),
|
||||
)),
|
||||
])).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── 储存方式 tab ───────────────────────────────────────────
|
||||
|
||||
Widget _buildStorageTab() {
|
||||
final async = ref.watch(productStorageListProvider);
|
||||
return async.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => _buildError(() => ref.read(productStorageListProvider.notifier).reload()),
|
||||
data: (items) {
|
||||
final keyword = _storageSearchCtrl.text.toLowerCase();
|
||||
final filtered = keyword.isEmpty
|
||||
? items
|
||||
: items.where((it) => it.name.toLowerCase().contains(keyword) ||
|
||||
(it.code?.toLowerCase().contains(keyword) ?? false)).toList();
|
||||
final paged = filtered.skip((_storagePage - 1) * _storagePageSize).take(_storagePageSize).toList();
|
||||
return DataTableCard(
|
||||
totalCount: filtered.length,
|
||||
page: _storagePage,
|
||||
pageSize: _storagePageSize,
|
||||
onPageChanged: (p) => setState(() => _storagePage = p),
|
||||
onPageSizeChanged: (s) => setState(() { _storagePageSize = s; _storagePage = 1; }),
|
||||
toolbar: _buildToolbar(
|
||||
searchCtrl: _storageSearchCtrl,
|
||||
hint: '搜索储存方式/编号',
|
||||
onSearchChanged: () => setState(() => _storagePage = 1),
|
||||
onAdd: () => _showOptionDialog(
|
||||
title: '新建储存方式',
|
||||
hasQuantity: false,
|
||||
onSave: (data) => ref.read(productStorageListProvider.notifier).create(data),
|
||||
),
|
||||
onExport: () => exportExcel(
|
||||
filename: '储存方式',
|
||||
headers: ['编号', '名称', '备注'],
|
||||
rows: items.map((it) => [it.code ?? '', it.name, it.remark ?? '']).toList(),
|
||||
),
|
||||
),
|
||||
mobileCards: paged.map((it) => _optionCard(
|
||||
code: it.code, name: it.name, remark: it.remark,
|
||||
onEdit: () => _showOptionDialog(
|
||||
title: '编辑储存方式', hasQuantity: false,
|
||||
initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''},
|
||||
onSave: (data) => ref.read(productStorageListProvider.notifier).updateItem(it.id, data),
|
||||
),
|
||||
onDelete: () => _confirmDelete('删除储存方式「${it.name}」?',
|
||||
() => ref.read(productStorageListProvider.notifier).delete(it.id)),
|
||||
)).toList(),
|
||||
columns: const [DataColumn(label: Text('编号')), DataColumn(label: Text('名称')), DataColumn(label: Text('备注')), DataColumn(label: Text('操作'))],
|
||||
rows: paged.isEmpty
|
||||
? [DataRow(cells: [const DataCell(SizedBox()), const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), const DataCell(SizedBox()), const DataCell(SizedBox())])]
|
||||
: paged.map((it) => DataRow(cells: [
|
||||
DataCell(Text(it.code ?? '-', style: const TextStyle(fontFamily: 'monospace', fontSize: 12, color: AppTheme.textSecondary))),
|
||||
DataCell(Text(it.name, style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
DataCell(Text(it.remark ?? '-')),
|
||||
DataCell(_actionButtons(
|
||||
onEdit: () => _showOptionDialog(
|
||||
title: '编辑储存方式', hasQuantity: false,
|
||||
initial: {'code': it.code ?? '', 'name': it.name, 'remark': it.remark ?? ''},
|
||||
onSave: (data) => ref.read(productStorageListProvider.notifier).updateItem(it.id, data),
|
||||
),
|
||||
onDelete: () => _confirmDelete('删除储存方式「${it.name}」?',
|
||||
() => ref.read(productStorageListProvider.notifier).delete(it.id)),
|
||||
)),
|
||||
])).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── 描述文档 tab ───────────────────────────────────────────
|
||||
|
||||
Widget _buildDescDocTab() {
|
||||
final async = ref.watch(productDescriptionDocListProvider);
|
||||
return async.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => _buildError(() => ref.read(productDescriptionDocListProvider.notifier).reload()),
|
||||
data: (items) {
|
||||
final keyword = _descDocSearchCtrl.text.toLowerCase();
|
||||
final filtered = keyword.isEmpty
|
||||
? items
|
||||
: items.where((it) => it.title.toLowerCase().contains(keyword)).toList();
|
||||
final paged = filtered.skip((_descDocPage - 1) * _descDocPageSize).take(_descDocPageSize).toList();
|
||||
return DataTableCard(
|
||||
totalCount: filtered.length,
|
||||
page: _descDocPage,
|
||||
pageSize: _descDocPageSize,
|
||||
onPageChanged: (p) => setState(() => _descDocPage = p),
|
||||
onPageSizeChanged: (s) => setState(() { _descDocPageSize = s; _descDocPage = 1; }),
|
||||
toolbar: _buildToolbar(
|
||||
searchCtrl: _descDocSearchCtrl,
|
||||
hint: '搜索标题',
|
||||
onSearchChanged: () => setState(() => _descDocPage = 1),
|
||||
onAdd: () => _showDescDocDialog(
|
||||
title: '新建描述文档',
|
||||
onSave: (data) => ref.read(productDescriptionDocListProvider.notifier).create(data),
|
||||
),
|
||||
onExport: () => exportExcel(
|
||||
filename: '描述文档',
|
||||
headers: ['标题', '内容', '备注'],
|
||||
rows: items.map((it) => [it.title, it.content ?? '', it.remark ?? '']).toList(),
|
||||
),
|
||||
),
|
||||
mobileCards: paged.map((it) => MobileListCard(
|
||||
title: Text(it.title),
|
||||
fields: [
|
||||
if (it.content?.isNotEmpty == true)
|
||||
MobileCardField('内容', it.content),
|
||||
if (it.remark?.isNotEmpty == true)
|
||||
MobileCardField('备注', it.remark),
|
||||
],
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => _showDescDocDialog(
|
||||
title: '编辑描述文档',
|
||||
initial: {'title': it.title, 'content': it.content ?? '', 'remark': it.remark ?? ''},
|
||||
onSave: (data) => ref.read(productDescriptionDocListProvider.notifier).updateItem(it.id, data),
|
||||
),
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _confirmDelete('删除描述文档「${it.title}」?',
|
||||
() => ref.read(productDescriptionDocListProvider.notifier).delete(it.id)),
|
||||
child: const Text('删除', style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
)).toList(),
|
||||
columns: const [DataColumn(label: Text('标题')), DataColumn(label: Text('内容预览')), DataColumn(label: Text('备注')), DataColumn(label: Text('操作'))],
|
||||
rows: paged.isEmpty
|
||||
? [DataRow(cells: [const DataCell(SizedBox()), const DataCell(Text('暂无数据', style: TextStyle(color: AppTheme.textSecondary))), const DataCell(SizedBox()), const DataCell(SizedBox())])]
|
||||
: paged.map((it) {
|
||||
final preview = (it.content ?? '').length > 30
|
||||
? '${it.content!.substring(0, 30)}…'
|
||||
: (it.content ?? '-');
|
||||
return DataRow(cells: [
|
||||
DataCell(Text(it.title, style: const TextStyle(fontWeight: FontWeight.w500))),
|
||||
DataCell(Text(preview, style: const TextStyle(fontSize: 12, color: AppTheme.textSecondary))),
|
||||
DataCell(Text(it.remark ?? '-')),
|
||||
DataCell(_actionButtons(
|
||||
onEdit: () => _showDescDocDialog(
|
||||
title: '编辑描述文档',
|
||||
initial: {'title': it.title, 'content': it.content ?? '', 'remark': it.remark ?? ''},
|
||||
onSave: (data) => ref.read(productDescriptionDocListProvider.notifier).updateItem(it.id, data),
|
||||
),
|
||||
onDelete: () => _confirmDelete('删除描述文档「${it.title}」?',
|
||||
() => ref.read(productDescriptionDocListProvider.notifier).delete(it.id)),
|
||||
)),
|
||||
]);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showDescDocDialog({
|
||||
required String title,
|
||||
required Future<void> Function(Map<String, dynamic>) onSave,
|
||||
Map<String, dynamic>? initial,
|
||||
}) async {
|
||||
final titleCtrl = TextEditingController(text: initial?['title'] as String? ?? '');
|
||||
final contentCtrl = TextEditingController(text: initial?['content'] as String? ?? '');
|
||||
final remarkCtrl = TextEditingController(text: initial?['remark'] as String? ?? '');
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
await showAppDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: SizedBox(
|
||||
width: context.dialogWidth(400),
|
||||
child: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: titleCtrl,
|
||||
decoration: const InputDecoration(labelText: '标题 *(如:酱香型白酒)'),
|
||||
validator: (v) => (v == null || v.trim().isEmpty) ? '请输入标题' : null,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: contentCtrl,
|
||||
decoration: const InputDecoration(labelText: '内容(商品介绍正文)'),
|
||||
maxLines: 5,
|
||||
minLines: 3,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: remarkCtrl,
|
||||
decoration: const InputDecoration(labelText: '备注'),
|
||||
maxLines: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('取消')),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
if (!formKey.currentState!.validate()) return;
|
||||
final data = <String, dynamic>{
|
||||
'title': titleCtrl.text.trim(),
|
||||
if (contentCtrl.text.isNotEmpty) 'content': contentCtrl.text.trim(),
|
||||
if (remarkCtrl.text.isNotEmpty) 'remark': remarkCtrl.text.trim(),
|
||||
};
|
||||
Navigator.of(ctx).pop();
|
||||
try {
|
||||
await onSave(data);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('保存失败:$e'), backgroundColor: AppTheme.danger),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 仓库 tab ──────────────────────────────────────────────
|
||||
|
||||
Widget _buildWarehousesTab() {
|
||||
|
||||
@@ -59,6 +59,40 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showFeedbackDialog({
|
||||
required String title,
|
||||
required String hint,
|
||||
required String errorType,
|
||||
}) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => _FeedbackDialog(
|
||||
title: title,
|
||||
hint: hint,
|
||||
onSubmit: (msg) async {
|
||||
final shopNo = (_data?['shop'] as Map<String, dynamic>?)?['code'] as String? ?? '';
|
||||
final productName = _data?['name'] as String? ?? '';
|
||||
final productCode = _data?['code'] as String? ?? '';
|
||||
final productInfo = [
|
||||
if (productName.isNotEmpty) productName,
|
||||
if (productCode.isNotEmpty) productCode,
|
||||
if (widget.publicId.isNotEmpty) widget.publicId,
|
||||
].join(' / ');
|
||||
await _dio.post(
|
||||
'${AppConfig.apiBaseUrl}/public/errors',
|
||||
data: {
|
||||
'error_type': errorType,
|
||||
'error_msg': msg,
|
||||
'platform': 'web-scan',
|
||||
'shop_no': shopNo,
|
||||
if (productInfo.isNotEmpty) 'product_info': productInfo,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
@@ -196,7 +230,20 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
|
||||
if (shop != null)
|
||||
SliverToBoxAdapter(child: _ShopCard(shop: shop)),
|
||||
// 9. Footer
|
||||
const SliverToBoxAdapter(child: _Footer()),
|
||||
SliverToBoxAdapter(
|
||||
child: _Footer(
|
||||
onReport: () => _showFeedbackDialog(
|
||||
title: '商品报错',
|
||||
hint: '请描述商品信息有误的地方...',
|
||||
errorType: 'product_error',
|
||||
),
|
||||
onFeedback: () => _showFeedbackDialog(
|
||||
title: '意见反馈',
|
||||
hint: '请输入您的建议或意见...',
|
||||
errorType: 'feedback',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
),
|
||||
@@ -1387,7 +1434,9 @@ class _ShopInfoRow extends StatelessWidget {
|
||||
// ── Footer ────────────────────────────────────────────────────────────
|
||||
|
||||
class _Footer extends StatelessWidget {
|
||||
const _Footer();
|
||||
final VoidCallback? onReport;
|
||||
final VoidCallback? onFeedback;
|
||||
const _Footer({this.onReport, this.onFeedback});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -1440,11 +1489,11 @@ class _Footer extends StatelessWidget {
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_FooterTextLink(text: '商品报错'),
|
||||
_FooterTextLink(text: '商品报错', onTap: onReport),
|
||||
const _FooterDivider(),
|
||||
_FooterTextLink(text: '意见反馈'),
|
||||
_FooterTextLink(text: '意见反馈', onTap: onFeedback),
|
||||
const _FooterDivider(),
|
||||
_FooterTextLink(text: '关于岩美', url: 'https://www.yanmei.com'),
|
||||
_FooterTextLink(text: '关于岩美', url: AppConfig.aboutUrl),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1456,21 +1505,23 @@ class _Footer extends StatelessWidget {
|
||||
class _FooterTextLink extends StatelessWidget {
|
||||
final String text;
|
||||
final String? url;
|
||||
const _FooterTextLink({required this.text, this.url});
|
||||
final VoidCallback? onTap;
|
||||
const _FooterTextLink({required this.text, this.url, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = url != null || onTap != null;
|
||||
return GestureDetector(
|
||||
onTap: url != null
|
||||
onTap: onTap ?? (url != null
|
||||
? () => launchUrl(Uri.parse(url!), mode: LaunchMode.externalApplication)
|
||||
: null,
|
||||
: null),
|
||||
child: Text(text,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: url != null ? const Color(0xFF2563AC) : _kTextMid,
|
||||
color: active ? const Color(0xFF2563AC) : _kTextMid,
|
||||
letterSpacing: 0.02,
|
||||
decoration: url != null ? TextDecoration.underline : null,
|
||||
decorationColor: url != null ? const Color(0xFF2563AC) : null,
|
||||
decoration: active ? TextDecoration.underline : null,
|
||||
decorationColor: active ? const Color(0xFF2563AC) : null,
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -1487,3 +1538,94 @@ class _FooterDivider extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feedback / Report Dialog ──────────────────────────────────────────
|
||||
|
||||
class _FeedbackDialog extends StatefulWidget {
|
||||
final String title;
|
||||
final String hint;
|
||||
final Future<void> Function(String) onSubmit;
|
||||
const _FeedbackDialog({required this.title, required this.hint, required this.onSubmit});
|
||||
|
||||
@override
|
||||
State<_FeedbackDialog> createState() => _FeedbackDialogState();
|
||||
}
|
||||
|
||||
class _FeedbackDialogState extends State<_FeedbackDialog> {
|
||||
final _ctrl = TextEditingController();
|
||||
bool _submitting = false;
|
||||
bool _done = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_ctrl.text.trim().isEmpty) return;
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await widget.onSubmit(_ctrl.text.trim());
|
||||
if (mounted) setState(() { _done = true; _submitting = false; });
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_done) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: const Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.check_circle_outline, size: 48, color: _kSuccess),
|
||||
SizedBox(height: 12),
|
||||
Text('提交成功,感谢您的反馈!',
|
||||
style: TextStyle(fontSize: 14, color: _kInkDeep)),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: SizedBox(
|
||||
width: 320,
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
maxLines: 4,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hint,
|
||||
hintStyle: const TextStyle(fontSize: 13, color: _kTextMid),
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _submitting ? null : _submit,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _kBurgundy, foregroundColor: Colors.white),
|
||||
child: _submitting
|
||||
? const SizedBox(width: 16, height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
||||
: const Text('提交'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppShell> {
|
||||
child: Scaffold(
|
||||
key: _scaffoldKey,
|
||||
drawer: isMobile ? _buildDrawer(context, user, location) : null,
|
||||
drawerEnableOpenDragGesture: !kIsWeb && isMobile,
|
||||
body: Column(
|
||||
children: [
|
||||
// Top Bar
|
||||
|
||||
@@ -556,39 +556,12 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
|
||||
)),
|
||||
)
|
||||
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<StockInFormScreen> {
|
||||
?.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<StockInFormScreen> {
|
||||
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<StockInFormScreen> {
|
||||
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;
|
||||
|
||||
@@ -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,20 +847,51 @@ 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!,
|
||||
].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 ?? '-'),
|
||||
_TableCell(item.productName ?? '-'),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(item.productName ?? '-',
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
if (attrs.isNotEmpty)
|
||||
Text(attrs,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF888888))),
|
||||
],
|
||||
),
|
||||
),
|
||||
_TableCell(item.productSeries ?? '-'),
|
||||
_TableCell(item.productSpec ?? '-'),
|
||||
_TableCell(item.quantity.toStringAsFixed(3)),
|
||||
|
||||
@@ -22,22 +22,12 @@ flutter build macos --release \
|
||||
cd ..
|
||||
|
||||
# Package .app bundle into zip
|
||||
# ditto preserves symlinks, Unix permissions, and extended attributes (code-signing).
|
||||
# Python's zipfile follows symlinks and drops permissions — do NOT use it for .app bundles.
|
||||
mkdir -p dist
|
||||
python3 - <<'PYEOF'
|
||||
import zipfile, os
|
||||
|
||||
src = os.path.join('client', 'build', 'macos', 'Build', 'Products', 'Release', 'jiu.app')
|
||||
dst = os.path.join('dist', 'jiu-macos-x64.zip')
|
||||
|
||||
with zipfile.ZipFile(dst, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(src):
|
||||
for fname in files:
|
||||
fp = os.path.join(root, fname)
|
||||
zf.write(fp, os.path.join('jiu.app', os.path.relpath(fp, src)))
|
||||
|
||||
size = os.path.getsize(dst)
|
||||
print(f'Packaged {dst} ({size // 1024 // 1024} MB)')
|
||||
PYEOF
|
||||
APP_SRC="client/build/macos/Build/Products/Release/jiu_client.app"
|
||||
ditto -c -k --keepParent "${APP_SRC}" dist/jiu-macos-x64.zip
|
||||
echo "Packaged dist/jiu-macos-x64.zip ($(du -sh dist/jiu-macos-x64.zip | cut -f1))"
|
||||
|
||||
echo "==> compile-macos: done — dist/ contents:"
|
||||
ls -lh dist/
|
||||
|
||||
+633
-147
File diff suppressed because it is too large
Load Diff
+185
-26
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "酒库管理系统 — 项目 TODO",
|
||||
"updated_at": "2026-06-07T23:28:53.001Z"
|
||||
"updated_at": "2026-06-09T04:11:47.912Z"
|
||||
},
|
||||
"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,
|
||||
@@ -42,9 +44,10 @@
|
||||
"Web"
|
||||
],
|
||||
"created_at": "2026-06-07T17:30:32.237Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"done": true,
|
||||
"completed_at": "2026-06-08T14:33:22.523Z",
|
||||
"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,
|
||||
@@ -70,9 +74,10 @@
|
||||
"Web"
|
||||
],
|
||||
"created_at": "2026-06-07T17:30:32.334Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"done": true,
|
||||
"completed_at": "2026-06-08T14:33:22.584Z",
|
||||
"version": "v1.0.25",
|
||||
"status": "accepted"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
@@ -84,9 +89,10 @@
|
||||
"Web"
|
||||
],
|
||||
"created_at": "2026-06-07T17:30:32.381Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"done": true,
|
||||
"completed_at": "2026-06-08T14:33:22.648Z",
|
||||
"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,
|
||||
@@ -145,9 +154,10 @@
|
||||
"Android"
|
||||
],
|
||||
"created_at": "2026-06-07T18:12:43.136Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"done": true,
|
||||
"completed_at": "2026-06-08T14:40:04.464Z",
|
||||
"version": "v1.0.25",
|
||||
"status": "accepted"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
@@ -160,9 +170,10 @@
|
||||
"Android"
|
||||
],
|
||||
"created_at": "2026-06-07T18:12:50.299Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
"done": true,
|
||||
"completed_at": "2026-06-08T00:06:15.299Z",
|
||||
"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,
|
||||
@@ -220,6 +234,151 @@
|
||||
"Android"
|
||||
],
|
||||
"created_at": "2026-06-07T22:52:13.283Z",
|
||||
"done": true,
|
||||
"completed_at": "2026-06-08T00:06:15.371Z",
|
||||
"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": "done",
|
||||
"created_at": "2026-06-08T15:58:11.759Z",
|
||||
"done": false,
|
||||
"completed_at": null,
|
||||
"version": null
|
||||
|
||||
+390
-104
@@ -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 <id>` 命令)
|
||||
*
|
||||
* 子命令:
|
||||
* add --title "..." [--level high|mid|low] [--tags a,b] [--desc "..."]
|
||||
* done <id> [--version vX.Y.Z]
|
||||
* reopen <id>
|
||||
* rm <id>
|
||||
* add --title "..." [--level high|mid|low] [--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
|
||||
*/
|
||||
@@ -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,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
/** 把描述里的 `code` 渲染成 <code> */
|
||||
function renderDesc(s) {
|
||||
if (!s) return '';
|
||||
return esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
@@ -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 '<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">
|
||||
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 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>` : ''}
|
||||
${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 lvlCls = levelClass(i.level);
|
||||
return `
|
||||
<li class="todo-card${i.done ? ' done' : ''}"
|
||||
<li class="todo-card ${stCls}"
|
||||
data-id="${i.id}"
|
||||
data-level="${esc(i.level)}"
|
||||
data-tags="${esc((i.tags||[]).join(','))}"
|
||||
data-done="${i.done ? '1' : '0'}">
|
||||
data-status="${esc(i.status)}"
|
||||
data-tags="${esc((i.tags||[]).join(','))}">
|
||||
<div class="card-header">
|
||||
<span class="item-title">${esc(i.title)}</span>
|
||||
<span class="tag ${lvlCls}">${levelText(i.level)}</span>
|
||||
<div class="card-badges">
|
||||
<span class="tag status-badge ${stCls}">${stLbl}</span>
|
||||
<span class="tag ${lvlCls}">${levelText(i.level)}</span>
|
||||
${rejectBtn}
|
||||
</div>
|
||||
</div>
|
||||
${rejectNote}
|
||||
${desc}
|
||||
<div class="card-footer">
|
||||
<div class="tag-row">${tags}</div>
|
||||
@@ -134,10 +180,17 @@ function renderItems(items) {
|
||||
|
||||
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 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>`
|
||||
@@ -163,7 +216,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;
|
||||
@@ -191,21 +244,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;
|
||||
@@ -216,7 +289,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; }
|
||||
@@ -225,18 +298,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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -246,9 +366,11 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<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 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>
|
||||
@@ -264,9 +386,11 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
<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>
|
||||
<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>
|
||||
${allTags.length ? `<div class="filter-sep"></div>
|
||||
<div class="filter-group">
|
||||
@@ -277,44 +401,78 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<div class="section-title">📋 未完成(${pending.length})</div>
|
||||
<ul class="todo-list" id="pendingList">
|
||||
${renderItems(pending)}
|
||||
</ul>
|
||||
${[
|
||||
{ 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 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>
|
||||
|
||||
<!-- 拒绝验收 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 curDone = 'all';
|
||||
let curTags = new Set();
|
||||
let curLevel = 'all';
|
||||
let curStatus = '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(',') : [];
|
||||
const lvl = card.dataset.level;
|
||||
const status = card.dataset.status;
|
||||
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 (curLevel !== 'all' && lvl !== curLevel) show = false;
|
||||
if (curStatus !== 'all' && status !== curStatus) 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;
|
||||
@@ -323,16 +481,16 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
});
|
||||
});
|
||||
|
||||
// 状态
|
||||
document.querySelectorAll('[data-filter-done]').forEach(btn => {
|
||||
// 状态筛选
|
||||
document.querySelectorAll('[data-filter-status]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
curDone = btn.dataset.filterDone;
|
||||
document.querySelectorAll('[data-filter-done]').forEach(b => b.classList.toggle('active', b === btn));
|
||||
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;
|
||||
@@ -342,7 +500,7 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
});
|
||||
});
|
||||
|
||||
// 点击卡片标签 → 快速筛选
|
||||
// 卡片标签 → 快速筛选
|
||||
document.querySelectorAll('.t-tag').forEach(tag => {
|
||||
tag.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
@@ -352,15 +510,78 @@ ul.todo-list { list-style: none; margin: 0; padding: 0; }
|
||||
});
|
||||
});
|
||||
|
||||
// 已完成折叠
|
||||
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 ? '▾ 展开已完成列表' : '▴ 收起已完成列表';
|
||||
// 区域折叠切换
|
||||
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>
|
||||
@@ -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 <id> <open|doing|done> — Claude 可调用,accepted 由用户手动设置 */
|
||||
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);
|
||||
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> — 标记已验收交付(仅用户调用),记录版本号 */
|
||||
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 <id> --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);
|
||||
}
|
||||
|
||||
+2
-2
@@ -161,7 +161,7 @@ pageStyle: |
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="success-note">如忘记门店编码,登录后可在「系统设置 → 关于」中查看。</p>
|
||||
<p class="success-note">如忘记门店编码,登录后可在「系统设置」中查看。</p>
|
||||
<a href="/app/" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:8px;">
|
||||
<i data-lucide="log-in" class="icon"></i>前往登录
|
||||
</a>
|
||||
@@ -172,7 +172,7 @@ pageStyle: |
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var API_URL = window.location.origin + '/api/v1/public/register';
|
||||
var API_URL = "{{ site.appBaseUrl }}/api/v1/public/register";
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user