Files
jiu/client/lib/widgets/product_editor_drawer.dart
T
wangjia d9f1f6956e feat(client): 库存卡片显示单价(进价)+ 成本按角色隐藏
- 手机库存卡副行追加进价(编号 · 规格 · ¥xxx),仅管理员可见
- 宽屏成本价/总价列、货值 KPI、商品抽屉成本行对非管理员隐藏(isAdminProvider)
- m-inventory 原型同步 + 移动 golden ×6 重录(管理员形态基准)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:11:15 +08:00

730 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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/auth/auth_state.dart';
import '../core/config/app_config.dart';
import '../core/utils/dialog_util.dart';
import '../core/responsive/responsive.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 '../providers/shop_provider.dart' show shopInfoProvider;
import 'ds/ds_atoms.dart';
import 'ds/grid_combo_cell.dart';
import 'ds/m_sheet.dart';
import 'searchable_option_field.dart';
import 'fullscreen_image_viewer.dart';
import '../core/utils/label_data.dart';
import 'label_preview_dialog.dart';
import 'write_guard.dart';
import 'ds/ds_toast.dart';
/// 打开商品编辑抽屉。库存上下文(当前库存/成本价/状态)由调用方传入,
/// 商品档案(编码/系列/规格/分类/图片/介绍)抽屉内 getDetail 拉取。
Future<void> showProductEditorDrawer(
BuildContext context, {
required int productId,
double? qty,
String? unit,
double? cost,
String? status, // 在售 / 预警 / 缺货
}) {
// 窄屏:底部 sheet 形态(原型 m-inventory openItem 内嵌公开页编辑)
if (context.isMobile) {
return showMSheet<void>(
context,
title: null,
scrollable: false,
builder: (ctx) => SizedBox(
height: MediaQuery.of(ctx).size.height * 0.86,
child: _ProductEditorDrawer(
productId: productId,
qty: qty,
unit: unit,
cost: cost,
status: status),
),
);
}
return showGeneralDialog<void>(
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<Offset>(begin: const Offset(1, 0), end: Offset.zero)
.animate(CurvedAnimation(parent: anim, curve: Curves.easeOutCubic)),
child: child,
),
);
}
/// 原型 PE_TILE3 个图片槽位标题。
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<void> _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}')),
// 打印标签(同入库管理打印标签;移动端无打印——用户拍板)
if (!context.isMobile) ...[
const SizedBox(width: 10),
DsButton('打印标签',
icon: LucideIcons.printer,
small: true,
onPressed: p == null ? null : () => _printLabel(p)),
],
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())
));
}
// 成本仅管理员可见(与库存列表/出库同口径);p.purchasePrice 兜底也要一并门控
final isAdmin = ref.watch(isAdminProvider);
final cost = widget.cost ?? p.purchasePrice;
if (isAdmin && cost != null && cost > 0) {
rows.add(('成本价(单瓶)', b(_fmtMoney(cost))));
if (widget.qty != null) {
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-gallery3 槽 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<ProductImage> 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<void> _pickAndUpload() async {
if (_isMobilePlatform) {
final source = await showModalBottomSheet<ImageSource>(
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<void> _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<void> _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);
}
}
}
// ── 保存:介绍正文 +(选了的话)介绍库文档 ──
/// 打印标签(同入库管理打印标签:QR + 品牌/型号/版本 + 门店信息)。
Future<void> _printLabel(Product p) async {
final qrBytes =
await ref.read(productRepositoryProvider).getQRCodeBytes(p.id);
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
if (!mounted) return;
final label = LabelData(
productId: p.id,
qrBytes: qrBytes,
name: p.name,
code: p.code,
series: (p.series ?? '').isEmpty ? null : p.series,
spec: (p.spec ?? '').isEmpty ? null : p.spec,
remark: (p.remark ?? '').isEmpty ? null : p.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
if (!mounted) return;
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [label]),
);
}
Future<void> _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;
// 抽屉内按钮不收抽屉(2026-07-04 拍板):保存后停留,可继续编辑
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;
}