// widgets/product_editor_drawer.dart — 商品编辑抽屉(镜像原型 product-editor.js + atoms.css .pe-*)。 // 库存屏行点击 / 眼睛图标打开:商品信息(.drow) + 商品图片(3 槽 .pe-gallery) + 商品介绍(.pe-pick/.pe-text)。 // 外壳对齐 .drawer:宽 480(≤94vw) / 右滑入 250ms / mask=--scrim。 import 'dart:math' as math; import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../core/config/app_config.dart'; import '../core/theme/app_chrome.g.dart'; import '../core/theme/app_dims.g.dart'; import '../core/theme/context_tokens.dart'; import '../models/product.dart'; import '../models/product_image.dart'; import '../providers/product_option_provider.dart'; import '../providers/product_provider.dart'; import 'ds/ds_atoms.dart'; import 'ds/grid_combo_cell.dart'; import 'searchable_option_field.dart'; import 'fullscreen_image_viewer.dart'; import 'write_guard.dart'; import 'ds/ds_toast.dart'; /// 打开商品编辑抽屉。库存上下文(当前库存/成本价/状态)由调用方传入, /// 商品档案(编码/系列/规格/分类/图片/介绍)抽屉内 getDetail 拉取。 Future showProductEditorDrawer( BuildContext context, { required int productId, double? qty, String? unit, double? cost, String? status, // 在售 / 预警 / 缺货 }) { return showGeneralDialog( context: context, useRootNavigator: false, barrierDismissible: true, barrierLabel: '关闭', barrierColor: AppChrome.scrim, // .drawer-mask --scrim transitionDuration: const Duration(milliseconds: 250), pageBuilder: (ctx, _, __) { final w = math.min(480.0, MediaQuery.of(ctx).size.width * 0.94); return Align( alignment: Alignment.centerRight, child: Material( color: ctx.tokens.surface, elevation: 16, child: SelectionArea( child: SizedBox( width: w, height: double.infinity, child: _ProductEditorDrawer( productId: productId, qty: qty, unit: unit, cost: cost, status: status), ), ), ), ); }, transitionBuilder: (ctx, anim, _, child) => SlideTransition( position: Tween(begin: const Offset(1, 0), end: Offset.zero) .animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)), child: child, ), ); } /// 原型 PE_TILE:3 个图片槽位标题。 const _peTiles = ['主图', '细节图', '场景图']; class _ProductEditorDrawer extends ConsumerStatefulWidget { final int productId; final double? qty; final String? unit; final double? cost; final String? status; const _ProductEditorDrawer({ required this.productId, this.qty, this.unit, this.cost, this.status, }); @override ConsumerState<_ProductEditorDrawer> createState() => _ProductEditorDrawerState(); } class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> { Product? _product; Object? _error; bool _uploading = false; bool _saving = false; // 介绍选择态(.pe-pick / .pe-text) String? _introTitle; // 已选介绍库标题;null → 占位「从介绍库选择…」 int? _introDocId; final _introCtrl = TextEditingController(); final _introFocus = FocusNode(); @override void initState() { super.initState(); _load(); } @override void dispose() { _introCtrl.dispose(); _introFocus.dispose(); super.dispose(); } Future _load() async { try { final p = await ref.read(productRepositoryProvider).getDetail(widget.productId); if (!mounted) return; setState(() { _product = p; _introTitle = p.descriptionDocTitle; _introCtrl.text = p.description ?? ''; }); } catch (e) { if (mounted) setState(() => _error = e); } } @override Widget build(BuildContext context) { final t = context.tokens; final p = _product; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ── .drawer-head:商品名 + 预览 + X ── Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: t.border))), child: Row( children: [ Expanded( child: Text(p?.name ?? '商品', style: TextStyle( fontSize: AppDims.fsH2, fontWeight: FontWeight.w600, color: t.heading), maxLines: 1, overflow: TextOverflow.ellipsis), ), const SizedBox(width: 10), DsButton('预览', icon: LucideIcons.eye, small: true, onPressed: p == null || p.publicId.isEmpty ? null : () => context.push('/product/${p.publicId}')), const SizedBox(width: 10), InkWell( onTap: () => Navigator.of(context).pop(), borderRadius: BorderRadius.circular(AppDims.rSm), child: Icon(LucideIcons.x, size: 22, color: t.muted), ), ], ), ), // ── .drawer-body ── Expanded( child: _error != null ? Center( child: Text('加载失败:$_error', style: TextStyle(fontSize: AppDims.fsBody, color: t.danger))) : p == null ? Center( child: SizedBox( width: 30, height: 30, child: CircularProgressIndicator( strokeWidth: 3, color: t.primary))) : SingleChildScrollView( padding: const EdgeInsets.symmetric( horizontal: 20, vertical: 18), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _peCard(t, LucideIcons.table, '商品信息', body: _infoBody(t, p), bodyPadding: const EdgeInsets.symmetric( horizontal: 13, vertical: 2)), _peCard(t, LucideIcons.image, '商品图片', body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _gallery(t, p), const SizedBox(height: 9), Text('建议主图为白底产品图,可再加细节图(瓶身/防伪)与场景图。', style: TextStyle( fontSize: AppDims.fsXs, color: t.faint, height: 1.6)), ], )), _peCard(t, LucideIcons.menu, '商品介绍 / 卖点', manage: () => context.push('/products?tab=intro'), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _introPick(t), _introText(t), ], )), // ── .pe-foot ── Padding( padding: const EdgeInsets.only(top: 6), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ DsButton('取消', onPressed: () => Navigator.of(context).pop()), const SizedBox(width: 10), WriteGuard( child: DsButton('保存', icon: LucideIcons.check, variant: DsBtnVariant.primary, onPressed: _saving ? null : _save), ), ], ), ), ], ), ), ), ], ); } // ── .pe-card:边框 r-lg 卡,.pe-h 头(图标块+标题+管理入口) + .pe-b 体 ── Widget _peCard( dynamic t, IconData icon, String title, { required Widget body, EdgeInsets bodyPadding = const EdgeInsets.all(13), VoidCallback? manage, }) { return Container( margin: const EdgeInsets.only(bottom: 14), clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: t.surface, border: Border.all(color: t.border), borderRadius: BorderRadius.circular(AppDims.rLg), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 11), decoration: BoxDecoration( border: Border(bottom: BorderSide(color: t.borderSubtle))), child: Row( children: [ Container( width: 22, height: 22, decoration: BoxDecoration( color: t.brand50, borderRadius: BorderRadius.circular(AppDims.rSm)), alignment: Alignment.center, child: Icon(icon, size: 14, color: t.primary), ), const SizedBox(width: 8), Text(title, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.heading)), if (manage != null) ...[ const Spacer(), InkWell( onTap: manage, child: Text('管理介绍库', style: TextStyle( fontSize: AppDims.fsSm, color: t.primary)), ), ], ], ), ), Padding(padding: bodyPadding, child: body), ], ), ); } // ── 商品信息 .drow 行(条件行,末行无底线,对齐 product-editor.js infoRows) ── Widget _infoBody(dynamic t, Product p) { final rows = <(String, Widget)>[]; Widget b(String s, {bool mono = false}) => Text(s, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: t.text, fontFamily: mono ? 'monospace' : null)); if (p.code.isNotEmpty) rows.add(('编码', b(p.code, mono: true))); if ((p.series ?? '').isNotEmpty) rows.add(('系列', b(p.series!))); if ((p.spec ?? '').isNotEmpty) rows.add(('规格', b(p.spec!))); if ((p.categoryName ?? '').isNotEmpty) rows.add(('分类', b(p.categoryName!))); if (widget.qty != null) { rows.add(( '当前库存', b('${_fmtQty(widget.qty!)} ${widget.unit ?? p.unit}'.trim()) )); } final cost = widget.cost ?? p.purchasePrice; if (cost != null && cost > 0) rows.add(('成本价(单瓶)', b(_fmtMoney(cost)))); if (widget.qty != null && cost != null && cost > 0) { rows.add(('总成本价', b(_fmtMoney(widget.qty! * cost)))); } if ((widget.status ?? '').isNotEmpty) { rows.add(( '状态', DsBadge(widget.status!, tone: switch (widget.status) { '缺货' => DsBadgeTone.danger, '预警' => DsBadgeTone.warn, _ => DsBadgeTone.ok, }) )); } return Column( children: [ for (var i = 0; i < rows.length; i++) Container( padding: const EdgeInsets.symmetric(vertical: 11), decoration: BoxDecoration( border: i == rows.length - 1 ? null : Border(bottom: BorderSide(color: t.borderSubtle))), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(rows[i].$1, style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)), rows[i].$2, ], ), ), ], ); } // ── .pe-gallery:3 槽 86×106,空=虚线+加号+槽名,有图=图+右上删+底部槽名 ── Widget _gallery(dynamic t, Product p) { final imgs = [...p.images]..sort((a, b) => a.sortOrder - b.sortOrder); return Wrap( spacing: 9, runSpacing: 9, children: [ for (var i = 0; i < _peTiles.length; i++) _tile(t, i, i < imgs.length ? imgs[i] : null, imgs), ], ); } Widget _tile(dynamic t, int i, ProductImage? img, List imgs) { final has = img != null; return SizedBox( width: 86, height: 106, child: Stack( fit: StackFit.expand, children: [ InkWell( onTap: has ? null : (_uploading ? null : _pickAndUpload), // 双击已有图片 → 全屏预览(多图可翻页) onDoubleTap: has ? () => showFullscreenImages( context, [for (final m in imgs) AppConfig.baseUrl + m.url], initialIndex: imgs.indexOf(img), ) : null, borderRadius: BorderRadius.circular(AppDims.rMd), child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: has ? null : t.bg, border: Border.all( color: t.border, width: 1.5, style: has ? BorderStyle.solid : BorderStyle.none), borderRadius: BorderRadius.circular(AppDims.rMd), ), // 空槽虚线边框:Flutter 无原生 dashed,用 CustomPaint 描 child: has ? Image.network(AppConfig.baseUrl + img.url, fit: BoxFit.cover) : CustomPaint( painter: _DashedBorderPainter( color: t.border, radius: AppDims.rMd, strokeWidth: 1.5), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(LucideIcons.plus, size: 20, color: t.faint), const SizedBox(height: 5), Text(_peTiles[i], style: TextStyle( fontSize: AppDims.fsXs, color: t.faint)), ], ), ), ), ), if (has) ...[ // .cap 底部槽名(scrim 底白字) Positioned( left: 0, right: 0, bottom: 0, child: Container( padding: const EdgeInsets.symmetric(vertical: 1), color: AppChrome.scrim, alignment: Alignment.center, child: Text(_peTiles[i], style: TextStyle(fontSize: AppDims.fsXs, color: t.onPrimary)), ), ), // .rm 右上删除 Positioned( top: 3, right: 3, child: InkWell( onTap: () => _removeImage(img), child: Container( width: 18, height: 18, decoration: BoxDecoration(color: t.danger, shape: BoxShape.circle), alignment: Alignment.center, child: Icon(LucideIcons.x, size: 11, color: t.onPrimary), ), ), ), ], ], ), ); } // ── .pe-pick:从介绍库选择——搜索下拉(GridComboCell 表单形态,供应商选择器同款; // 原型为普通菜单,搜索为用户要求的增强,差异记 design/CONTRACT.md)── Widget _introPick(dynamic t) { final docs = ref.watch(productDescriptionDocListProvider).valueOrNull ?? const []; // 商品上只存标题快照(无 doc id):编辑已有商品时按标题回配选中态 final selectedId = _introDocId ?? docs.where((d) => d.title == _introTitle).map((d) => d.id).firstOrNull; return GridComboCell( focusNode: _introFocus, hint: _introTitle ?? '从介绍库选择…', searchHint: '搜索介绍标题…', showCount: true, options: [for (final d in docs) OptionItem(id: d.id, name: d.title)], selectedId: selectedId, onChanged: (id) { final doc = docs.where((d) => d.id == id).firstOrNull; if (doc == null) return; setState(() { _introTitle = doc.title; _introDocId = doc.id; _introCtrl.text = doc.content ?? ''; }); }, ); } // ── .pe-text:介绍正文 ── Widget _introText(dynamic t) { return Container( margin: const EdgeInsets.only(top: 10), constraints: const BoxConstraints(minHeight: 84), child: TextField( controller: _introCtrl, maxLines: null, minLines: 3, style: TextStyle(fontSize: AppDims.fsBody, color: t.text, height: 1.7), decoration: InputDecoration( isDense: true, hintText: '一句话卖点 + 商品介绍,可在选用后直接编辑…', hintStyle: TextStyle(fontSize: AppDims.fsBody, color: t.faint), contentPadding: const EdgeInsets.symmetric(horizontal: 11, vertical: 10), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppDims.rMd), borderSide: BorderSide(color: t.border), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppDims.rMd), borderSide: BorderSide(color: t.primary), ), ), ), ); } // ── 图片上传/删除(沿用商品详情页的取图管线) ── bool get _isMobilePlatform => !kIsWeb && (defaultTargetPlatform == TargetPlatform.android || defaultTargetPlatform == TargetPlatform.iOS); Future _pickAndUpload() async { if (_isMobilePlatform) { final source = await showModalBottomSheet( context: context, builder: (ctx) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(LucideIcons.camera), title: const Text('拍照'), onTap: () => Navigator.pop(ctx, ImageSource.camera), ), ListTile( leading: const Icon(LucideIcons.images), title: const Text('从相册选择'), onTap: () => Navigator.pop(ctx, ImageSource.gallery), ), ], ), ), ); if (source == null) return; final xfile = await ImagePicker() .pickImage(source: source, maxWidth: 2000, imageQuality: 85); if (xfile == null) return; await _uploadImage(filePath: xfile.path, fileName: xfile.name); } else { final result = await FilePicker.platform.pickFiles( type: FileType.image, allowMultiple: false, withData: true); if (result == null || result.files.isEmpty) return; final file = result.files.first; final String? filePath = kIsWeb ? null : file.path; if (filePath == null && file.bytes == null) return; await _uploadImage( filePath: filePath, bytes: file.bytes, fileName: file.name); } } Future _uploadImage( {String? filePath, Uint8List? bytes, required String fileName}) async { setState(() => _uploading = true); try { await ref.read(productRepositoryProvider).uploadImage( widget.productId, filePath, bytes: bytes, fileName: fileName); await _load(); } catch (e) { if (mounted) { showDsToast(context, '上传失败:$e', bg: context.tokens.danger); } } finally { if (mounted) setState(() => _uploading = false); } } Future _removeImage(ProductImage img) async { try { await ref .read(productRepositoryProvider) .deleteImage(widget.productId, img.id); await _load(); } catch (e) { if (mounted) { showDsToast(context, '删除失败:$e', bg: context.tokens.danger); } } } // ── 保存:介绍正文 +(选了的话)介绍库文档 ── Future _save() async { setState(() => _saving = true); try { await ref.read(productRepositoryProvider).update(widget.productId, { 'description': _introCtrl.text, if (_introDocId != null) 'description_doc_id': _introDocId, }); if (!mounted) return; Navigator.of(context).pop(); showDsToast(context, '商品介绍已保存 ✓'); } catch (e) { if (mounted) { showDsToast(context, '保存失败:$e', bg: context.tokens.danger); } } finally { if (mounted) setState(() => _saving = false); } } String _fmtQty(double v) => v == v.roundToDouble() ? v.toInt().toString() : v.toString(); String _fmtMoney(double v) { if (v >= 100000) return '¥${(v / 10000).toStringAsFixed(1)}万'; final s = v == v.roundToDouble() ? v.toInt().toString() : v.toStringAsFixed(2); // 千分位 final parts = s.split('.'); final buf = StringBuffer(); final digits = parts[0]; for (var i = 0; i < digits.length; i++) { if (i > 0 && (digits.length - i) % 3 == 0) buf.write(','); buf.write(digits[i]); } return '¥$buf${parts.length > 1 ? '.${parts[1]}' : ''}'; } } /// 空图片槽虚线边框(.pe-tile border:1.5 dashed)。 class _DashedBorderPainter extends CustomPainter { final Color color; final double radius; final double strokeWidth; _DashedBorderPainter( {required this.color, required this.radius, required this.strokeWidth}); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth; final rrect = RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)); final path = Path()..addRRect(rrect); const dash = 5.0, gap = 4.0; for (final metric in path.computeMetrics()) { var d = 0.0; while (d < metric.length) { canvas.drawPath( metric.extractPath(d, math.min(d + dash, metric.length)), paint); d += dash + gap; } } } @override bool shouldRepaint(_DashedBorderPainter old) => old.color != color || old.radius != radius; }