feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏

- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-03 09:58:14 +08:00
parent ca7595b113
commit 6238b86dcb
286 changed files with 16831 additions and 11136 deletions
@@ -1,5 +1,5 @@
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/utils/dialog_util.dart';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:image_picker/image_picker.dart';
import 'package:flutter/foundation.dart';
@@ -21,6 +21,9 @@ import '../../providers/product_provider.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/shop_provider.dart' show shopInfoProvider;
import '../../widgets/kpi_card.dart';
import '../../core/theme/app_fonts.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.dart';
class ProductDetailScreen extends ConsumerStatefulWidget {
final int productId;
@@ -114,15 +117,11 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
_descChanged = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('描述已保存')),
);
showDsToast(context, '描述已保存');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存失败:$e'), backgroundColor: context.tokens.danger),
);
showDsToast(context, '保存失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _savingDesc = false);
@@ -153,12 +152,12 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
leading: const Icon(LucideIcons.camera),
title: const Text('拍照'),
onTap: () => Navigator.pop(ctx, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
leading: const Icon(LucideIcons.images),
title: const Text('从相册选择'),
onTap: () => Navigator.pop(ctx, ImageSource.gallery),
),
@@ -202,15 +201,11 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
bytes: bytes, fileName: fileName);
_updateImages([..._product!.images, img]);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('图片上传成功')),
);
showDsToast(context, '图片上传成功');
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('上传失败:$e'), backgroundColor: context.tokens.danger),
);
showDsToast(context, '上传失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _uploading = false);
@@ -219,13 +214,13 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
Future<void> _deleteImage(ProductImage img) async {
try {
await ref.read(productRepositoryProvider).deleteImage(_product!.id, img.id);
await ref
.read(productRepositoryProvider)
.deleteImage(_product!.id, img.id);
_updateImages(_product!.images.where((i) => i.id != img.id).toList());
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除失败:$e'), backgroundColor: context.tokens.danger),
);
showDsToast(context, '删除失败:$e', bg: context.tokens.danger);
}
}
}
@@ -234,13 +229,27 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
final p = _product!;
setState(() {
_product = Product(
id: p.id, publicId: p.publicId, code: p.code, barcode: p.barcode,
name: p.name, series: p.series, spec: p.spec, unit: p.unit,
categoryId: p.categoryId, brand: p.brand, purchasePrice: p.purchasePrice,
salePrice: p.salePrice, minStock: p.minStock, description: p.description,
remark: p.remark, customFields: p.customFields, images: images,
originName: p.originName, shelfLifeName: p.shelfLifeName,
storageName: p.storageName, categoryName: p.categoryName,
id: p.id,
publicId: p.publicId,
code: p.code,
barcode: p.barcode,
name: p.name,
series: p.series,
spec: p.spec,
unit: p.unit,
categoryId: p.categoryId,
brand: p.brand,
purchasePrice: p.purchasePrice,
salePrice: p.salePrice,
minStock: p.minStock,
description: p.description,
remark: p.remark,
customFields: p.customFields,
images: images,
originName: p.originName,
shelfLifeName: p.shelfLifeName,
storageName: p.storageName,
categoryName: p.categoryName,
descriptionDocTitle: p.descriptionDocTitle,
descriptionDocContent: p.descriptionDocContent,
);
@@ -250,7 +259,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
void _showQRCode() {
showAppDialog(
context: context,
builder: (_) => _QRCodeDialog(productId: widget.productId, product: _product!),
builder: (_) =>
_QRCodeDialog(productId: widget.productId, product: _product!),
);
}
@@ -260,7 +270,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
body: FutureBuilder<Product>(
future: _future,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting && _product == null) {
if (snap.connectionState == ConnectionState.waiting &&
_product == null) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError && _product == null) {
@@ -268,7 +279,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 40, color: context.tokens.danger),
Icon(LucideIcons.circleAlert,
size: 40, color: context.tokens.danger),
const SizedBox(height: 12),
Text('加载失败:${snap.error}'),
const SizedBox(height: 12),
@@ -287,7 +299,7 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => Navigator.of(context).pop(),
tooltip: '返回',
padding: EdgeInsets.zero,
@@ -301,7 +313,7 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
),
OutlinedButton.icon(
onPressed: _showQRCode,
icon: const Icon(Icons.qr_code, size: 16),
icon: const Icon(LucideIcons.qrCode, size: 16),
label: const Text('二维码'),
),
],
@@ -346,7 +358,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
// ── 富数据:KPI / 各仓库分布 / 近期流水(还原原型详情页) ──────────────
Widget _statCard({required String title, Widget? hint, required Widget body}) {
Widget _statCard(
{required String title, Widget? hint, required Widget body}) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
@@ -392,18 +405,18 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
KpiCard(
title: '当前库存',
value: currentStock.toStringAsFixed(0),
icon: Icons.inventory_2,
icon: LucideIcons.box,
tone: KpiTone.info,
delta: ''),
KpiCard(
title: '库存货值',
value: '¥${(stockValue / 10000).toStringAsFixed(2)}',
icon: Icons.account_balance_wallet,
icon: LucideIcons.wallet,
tone: KpiTone.ok),
KpiCard(
title: '近30天出库',
value: out30.toStringAsFixed(0),
icon: Icons.upload_outlined,
icon: LucideIcons.upload,
tone: KpiTone.blue,
delta: ''),
];
@@ -435,7 +448,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
return _statCard(
title: '各仓库库存分布',
hint: Text('合计 ${total.toStringAsFixed(0)}',
style: TextStyle(fontSize: AppDims.fsXs, color: context.tokens.muted)),
style:
TextStyle(fontSize: AppDims.fsXs, color: context.tokens.muted)),
body: Column(
children: byWh.entries.map((e) {
return Padding(
@@ -465,7 +479,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
return _statCard(
title: '价格历史',
hint: Text('进价变更',
style: TextStyle(fontSize: AppDims.fsXs, color: context.tokens.muted)),
style:
TextStyle(fontSize: AppDims.fsXs, color: context.tokens.muted)),
body: Column(
children: _prices.asMap().entries.map((e) {
final i = e.key;
@@ -487,16 +502,18 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
width: 96,
child: Text(p.date,
style: TextStyle(
fontFamily: 'monospace',
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: AppDims.fsSm,
color: context.tokens.muted)),
),
if (up || down)
Icon(up ? Icons.arrow_drop_up : Icons.arrow_drop_down,
Icon(up ? LucideIcons.chevronUp : LucideIcons.chevronDown,
size: 18, color: color),
Text('¥${p.price.toStringAsFixed(2)}',
style: TextStyle(
fontFamily: 'monospace',
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontWeight: FontWeight.w700,
color: color)),
],
@@ -511,7 +528,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
return _statCard(
title: '近期流水',
hint: Text('最近 ${_logs.length > 8 ? 8 : _logs.length}',
style: TextStyle(fontSize: AppDims.fsXs, color: context.tokens.muted)),
style:
TextStyle(fontSize: AppDims.fsXs, color: context.tokens.muted)),
body: Column(
children: _logs.take(8).map((l) {
final isIn = l.direction == 'in';
@@ -523,7 +541,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
width: 96,
child: Text(l.createdAt?.substring(0, 10) ?? '-',
style: TextStyle(
fontFamily: 'monospace',
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: AppDims.fsSm,
color: context.tokens.muted)),
),
@@ -538,7 +557,9 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
'${isIn ? '+' : '-'}${l.quantity.toStringAsFixed(0)}',
style: TextStyle(
fontWeight: FontWeight.w600,
color: isIn ? context.tokens.success : context.tokens.danger),
color: isIn
? context.tokens.success
: context.tokens.danger),
),
],
),
@@ -575,8 +596,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
)),
if (p.images.length < 5)
WriteGuard(
child:
_UploadButton(uploading: _uploading, onTap: _pickAndUpload),
child: _UploadButton(
uploading: _uploading, onTap: _pickAndUpload),
),
],
),
@@ -621,14 +642,28 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
spacing: 32,
runSpacing: 0,
children: [
SizedBox(width: 200, child: row('编号', p.code.isEmpty ? '-' : p.code)),
SizedBox(width: 200, child: row('系列', p.series?.isEmpty ?? true ? '-' : p.series!)),
SizedBox(width: 200, child: row('规格', p.spec?.isEmpty ?? true ? '-' : p.spec!)),
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.code.isEmpty ? '-' : p.code)),
SizedBox(
width: 200,
child: row('系列', p.series?.isEmpty ?? true ? '-' : p.series!)),
SizedBox(
width: 200,
child: row('规格', p.spec?.isEmpty ?? true ? '-' : p.spec!)),
SizedBox(
width: 200,
child: row('品牌', p.brand?.isEmpty ?? true ? '-' : p.brand!)),
SizedBox(
width: 200, child: row('单位', p.unit.isEmpty ? '-' : p.unit)),
if (p.categoryName?.isNotEmpty ?? false)
SizedBox(width: 200, child: row('分类', p.categoryName!)),
SizedBox(width: 200, child: row('进价', p.purchasePrice != null && p.purchasePrice! > 0 ? '¥${p.purchasePrice!.toStringAsFixed(2)}' : '-')),
SizedBox(
width: 200,
child: row(
'进价',
p.purchasePrice != null && p.purchasePrice! > 0
? '¥${p.purchasePrice!.toStringAsFixed(2)}'
: '-')),
if (p.minStock != null && p.minStock! > 0)
SizedBox(width: 200, child: row('库存预警', '${p.minStock}')),
if (p.originName?.isNotEmpty ?? false)
@@ -654,8 +689,7 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
if (fields == null || fields.isEmpty) return null;
return fields.entries
.where((e) => e.value != null && e.value.toString().isNotEmpty)
.map((e) =>
SizedBox(width: 200, child: row(e.key, e.value.toString())))
.map((e) => SizedBox(width: 200, child: row(e.key, e.value.toString())))
.toList();
}
@@ -672,8 +706,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(p.descriptionDocTitle!,
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600)),
style:
const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
if (p.descriptionDocContent?.isNotEmpty ?? false) ...[
const SizedBox(height: 10),
Text(p.descriptionDocContent!,
@@ -688,9 +722,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
Widget _buildPriceSection(Product p) {
final price = p.salePrice!;
final priceStr = price % 1 == 0
? price.toInt().toString()
: price.toStringAsFixed(2);
final priceStr =
price % 1 == 0 ? price.toInt().toString() : price.toStringAsFixed(2);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
@@ -730,8 +763,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
Row(
children: [
const Text('描述',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600)),
style:
TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const Spacer(),
if (_descChanged)
WriteGuard(
@@ -748,8 +781,8 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
minimumSize: Size.zero,
tapTargetSize:
MaterialTapTargetSize.shrinkWrap),
child:
const Text('保存', style: TextStyle(fontSize: 13)),
child: const Text('保存',
style: TextStyle(fontSize: 13)),
),
),
],
@@ -761,8 +794,7 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
minLines: 3,
decoration: InputDecoration(
hintText: '输入商品描述,例如酒的产地、工艺、口感等...',
hintStyle: TextStyle(
fontSize: 13, color: context.tokens.muted),
hintStyle: TextStyle(fontSize: 13, color: context.tokens.muted),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderSide: BorderSide(color: context.tokens.border)),
@@ -792,8 +824,7 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('公开链接',
style:
TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Row(
children: [
@@ -802,17 +833,16 @@ class _ProductDetailScreenState extends ConsumerState<ProductDetailScreen> {
style: TextStyle(
fontSize: 12,
color: context.tokens.primary,
fontFamily: 'monospace')),
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: publicUrl));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('链接已复制')),
);
showDsToast(context, '链接已复制');
},
icon: const Icon(Icons.copy, size: 14),
icon: const Icon(LucideIcons.copy, size: 14),
label: const Text('复制'),
),
],
@@ -850,8 +880,8 @@ class _ImageThumbnail extends StatelessWidget {
width: 110,
height: 110,
color: context.tokens.border,
child: Icon(Icons.broken_image,
color: context.tokens.muted),
child:
Icon(LucideIcons.imageOff, color: context.tokens.muted),
),
),
),
@@ -861,19 +891,19 @@ class _ImageThumbnail extends StatelessWidget {
right: 4,
child: WriteGuard(
child: GestureDetector(
onTap: onDelete,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(10),
onTap: onDelete,
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.black54, // ds-ignore: 图片遮罩媒体固定色
borderRadius: BorderRadius.circular(10),
),
child: const Icon(LucideIcons.x,
size: 14, color: Colors.white), // ds-ignore: 图片遮罩媒体固定色
),
child:
const Icon(Icons.close, size: 14, color: Colors.white),
),
),
),
),
],
),
@@ -907,12 +937,12 @@ class _UploadButton extends StatelessWidget {
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate_outlined,
Icon(LucideIcons.imagePlus,
size: 28, color: context.tokens.muted),
const SizedBox(height: 4),
Text('上传图片',
style: TextStyle(
fontSize: 11, color: context.tokens.muted)),
style:
TextStyle(fontSize: 11, color: context.tokens.muted)),
],
),
),
@@ -936,12 +966,11 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
@override
void initState() {
super.initState();
_future = ref
.read(productRepositoryProvider)
.getQRCodeBytes(widget.productId)
..then((b) {
if (mounted) setState(() => _bytes = b);
});
_future =
ref.read(productRepositoryProvider).getQRCodeBytes(widget.productId)
..then((b) {
if (mounted) setState(() => _bytes = b);
});
}
void _print() {
@@ -986,14 +1015,12 @@ class _QRCodeDialogState extends ConsumerState<_QRCodeDialog> {
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
FilledButton.icon(
DsButton('关闭', onPressed: () => Navigator.of(context).pop()),
DsButton(
'打印',
icon: LucideIcons.printer,
variant: DsBtnVariant.primary,
onPressed: _bytes == null ? null : _print,
icon: const Icon(Icons.print, size: 16),
label: const Text('打印'),
),
],
);
File diff suppressed because it is too large Load Diff