Compare commits

...

14 Commits

Author SHA1 Message Date
wangjia 0ec609255c chore: release v1.0.29
Deploy / build-linux-web (push) Failing after 10m44s
Deploy / build-windows (push) Failing after 10m45s
Deploy / build-macos (push) Has been skipped
Deploy / build-android (push) Has been skipped
Deploy / build-ios (push) Has been skipped
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 12:12:26 +08:00
wangjia 5ad1289419 chore: release v1.0.28
Deploy / build-windows (push) Successful in 1m54s
Deploy / build-linux-web (push) Failing after 13m1s
Deploy / build-macos (push) Has been skipped
Deploy / build-android (push) Has been skipped
Deploy / build-ios (push) Has been skipped
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:35:01 +08:00
wangjia daa83bd408 chore: release v1.0.27
Deploy / build-windows (push) Successful in 1m56s
Deploy / build-android (push) Has been cancelled
Deploy / build-ios (push) Has been cancelled
Deploy / release-deploy (push) Has been cancelled
Deploy / build-linux-web (push) Successful in 53s
Deploy / build-macos (push) Failing after 1m16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 00:15:46 +08:00
wangjia 65d6b12a21 fix(ci): macOS 打包改用 ditto 保留 symlink 和文件权限,修复解压失败
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:51:18 +08:00
wangjia b0a74f3607 chore: release v1.0.26
Deploy / build-linux-web (push) Successful in 55s
Deploy / build-windows (push) Successful in 2m2s
Deploy / build-macos (push) Successful in 1m17s
Deploy / build-android (push) Successful in 1m30s
Deploy / build-ios (push) Successful in 6s
Deploy / release-deploy (push) Successful in 1m43s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:40:54 +08:00
wangjia 543bd2712f chore: update todo list
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:40:17 +08:00
wangjia fab7bd21dd feat: #13 公开商品 API 补充 code/barcode/batch.quantity,报错反馈携带商品和门店信息
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:39:59 +08:00
wangjia 3b2ebc0202 fix(web): 注册页 API URL 改用 site.appBaseUrl,修正成功提示导航路径
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:33:16 +08:00
wangjia ff32c8a845 fix(client): 公开页页脚「关于岩美」链接改为 AppConfig.aboutUrl 配置项
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:27:38 +08:00
wangjia af64e29459 fix(client): 公开页页脚「关于岩美」链接修正为 jiu.51yanmei.com
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 09:19:33 +08:00
wangjia f788a7ab74 chore: release v1.0.25
Deploy / build-windows (push) Successful in 1m56s
Deploy / build-linux-web (push) Failing after 13m56s
Deploy / build-macos (push) Has been skipped
Deploy / build-android (push) Has been skipped
Deploy / build-ios (push) Has been skipped
Deploy / release-deploy (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:20:48 +08:00
wangjia 8a87d4a82c feat(client): #14/#18 产地/保质期/储存方式/描述文档 provider & repo 及公开页意见反馈
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:20:00 +08:00
wangjia 0d17533120 feat(client): 基础数据管理新增产地/保质期/储存方式/描述文档 4 个字典维护 Tab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 08:06:21 +08:00
wangjia 4e64e5eb47 feat: 入库单/商品详情展示产地保质期储存方式,商品详情显示建议零售价
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 07:57:10 +08:00
26 changed files with 2557 additions and 455 deletions
+40 -5
View File
@@ -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 分组)。
+39
View File
@@ -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
### 新功能
+10 -6
View File
@@ -315,15 +315,19 @@ CI/CDForgejo`.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 <子命令> [参数]`
+23 -21
View File
@@ -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 {
+1
View File
@@ -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
+3
View File
@@ -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,
+3 -1
View File
@@ -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"})
+3 -2
View File
@@ -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"`
+5
View File
@@ -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;
}
}
+40
View File
@@ -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});
+10
View File
@@ -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
View File
@@ -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() => {
+140 -20
View File
@@ -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('提交'),
),
],
);
}
}
+2
View File
@@ -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)),
+5 -15
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+185 -26
View File
@@ -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
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/** 把描述里的 `code` 渲染成 <code> */
function renderDesc(s) {
if (!s) return '';
return esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');
@@ -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(`📄 HTMLtodo/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
View File
@@ -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); }