From 7b3a0ecdeb52972a532a4616d3a5c9e6f983e268 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 30 Aug 2026 09:56:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(client):=20=E6=A0=87=E7=AD=BE=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E7=BC=96=E8=BE=91=E5=99=A8=20P2=20=C2=B7=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=B1=8F+=E5=AD=98=E5=82=A8+=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E8=81=94=E5=8A=A8(=E5=8E=9F=E5=9E=8B=E5=90=8C?= =?UTF-8?q?=E6=8F=90=E4=BA=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设备管理「价签·编辑」进入独立编辑器屏 /devices/label-template: - 左实时预览走真实 renderLabelPreview 渲染路径(示例数据, debounce 重渲) - 右属性面板:多模板管理(编辑对象/打印默认分离)、显示字段手风琴、 高级区域位置、打印参数 - 存 shop.custom_fields.label_templates + label_template_active(零后端改) - Option A:同物理行两字段(编号+型号 / 版本+日期)共用字号·加粗·对齐, 各自独立开关;仅品名支持自动字号;字体不做(热敏单色) - 打印联动:LabelPreviewDialog 改 ConsumerStatefulWidget, 未显式传模板时 自动取本店 active 模板→所有打印入口(商品详情/出入库列表/编辑抽屉)一致生效 守护: - 默认输出逐像素零变化由 label_render_golden_test 守(改内置默认常量才触发) - 存储读写由 label_template_storage_test 单测守(纯内存, 不触真实库) - 编辑器挂载由 label_template_editor_smoke_test 守 - 本屏不入自动 fidelity/golden 闸(左预览 Picture.toImage 需 runAsync, pumpAndSettle 骨架驱动不了), 原因+替代守法记入 CONTRACT 块4 design-first:原型 label-template-editor.html 去字体选择器、加 Option A 配对 镜像, 与真实屏同提交。真机热敏打印坐标/条码可扫须用户在打印机验收。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5 --- client/lib/core/router/app_router.dart | 6 + client/lib/core/utils/label_template.dart | 26 + .../devices/device_management_screen.dart | 12 +- .../devices/label_template_editor_screen.dart | 1056 +++++++++++++++++ client/lib/widgets/label_preview_dialog.dart | 25 +- .../label_template_editor_smoke_test.dart | 47 + client/test/label_template_storage_test.dart | 51 + design/CONTRACT.md | 1 + .../screens/label-template-editor.html | 69 +- 9 files changed, 1244 insertions(+), 49 deletions(-) create mode 100644 client/lib/screens/devices/label_template_editor_screen.dart create mode 100644 client/test/label_template_editor_smoke_test.dart create mode 100644 client/test/label_template_storage_test.dart diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index d561660..173d3de 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -20,6 +20,7 @@ import '../../screens/settings/settings_screen.dart'; import '../../screens/settings/users_screen.dart'; import '../../screens/about/about_screen.dart'; import '../../screens/devices/device_management_screen.dart'; +import '../../screens/devices/label_template_editor_screen.dart'; import '../../screens/me/me_screen.dart'; import '../../screens/license/license_screen.dart'; import '../auth/auth_state.dart'; @@ -183,6 +184,11 @@ final appRouterProvider = Provider((ref) { path: '/devices', pageBuilder: (_, __) => _noTransition(const DeviceManagementScreen())), + // 标签价签模板编辑器(照原型 label-template-editor.html) + GoRoute( + path: '/devices/label-template', + pageBuilder: (_, __) => + _noTransition(const LabelTemplateEditorScreen())), ]), // 7 系统设置(面板:门店信息/用户管理/编号规则/偏好设置; // 授权管理 2026-07-10 提升为独立一级屏,见 branch 10 `/license`) diff --git a/client/lib/core/utils/label_template.dart b/client/lib/core/utils/label_template.dart index 0f245e1..d052ddb 100644 --- a/client/lib/core/utils/label_template.dart +++ b/client/lib/core/utils/label_template.dart @@ -570,6 +570,32 @@ class LabelTemplate { ); } +/// 从 shop.custom_fields 读取店级模板列表;为空/损坏时回退单个 [LabelTemplate.builtinDefault]。 +List readLabelTemplates(Map cf) { + final raw = cf['label_templates']; + if (raw is List && raw.isNotEmpty) { + final list = raw + .whereType() + .map((m) => LabelTemplate.fromJson(m.cast())) + .toList(); + if (list.isNotEmpty) return list; + } + return [LabelTemplate.builtinDefault()]; +} + +/// 当前出签实际使用的「打印默认」模板:由 custom_fields.label_template_active(id)指定, +/// 缺失/匹配不到时取列表首个;列表空时 builtinDefault。渲染/打印路径统一取此。 +LabelTemplate activeLabelTemplate(Map cf) { + final list = readLabelTemplates(cf); + final id = cf['label_template_active']; + if (id is String && id.isNotEmpty) { + for (final t in list) { + if (t.id == id) return t; + } + } + return list.first; +} + /// 二维码最小边长(逻辑像素@203dpi ≈ 10mm):低于此扫码不可靠,computeLayout 兜底钳制。 const double kMinQrSize = 80; diff --git a/client/lib/screens/devices/device_management_screen.dart b/client/lib/screens/devices/device_management_screen.dart index 497f029..5f4c592 100644 --- a/client/lib/screens/devices/device_management_screen.dart +++ b/client/lib/screens/devices/device_management_screen.dart @@ -7,12 +7,14 @@ // 打印模板窄屏不渲染(拍板:移动端无打印)。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:qr_flutter/qr_flutter.dart'; import '../../core/auth/auth_state.dart'; -import '../../core/utils/print_util.dart' show LabelData; +import '../../core/utils/print_util.dart' + show LabelData, activeLabelTemplate; import '../../widgets/label_preview_dialog.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_dims.g.dart'; @@ -989,7 +991,8 @@ class _DeviceManagementScreenState onPressed: onPreview ?? () => _snack('模板预览即将上线')), const SizedBox(width: 8), DsButton('编辑', - small: true, onPressed: () => _snack('模板编辑即将上线')), + small: true, + onPressed: () => context.push('/devices/label-template')), ]), ], ), @@ -1023,7 +1026,10 @@ class _DeviceManagementScreenState if (!mounted) return; showAppDialog( context: context, - builder: (_) => LabelPreviewDialog(labels: [label]), + builder: (_) => LabelPreviewDialog( + labels: [label], + template: shop != null ? activeLabelTemplate(shop.customFields) : null, + ), ); } diff --git a/client/lib/screens/devices/label_template_editor_screen.dart b/client/lib/screens/devices/label_template_editor_screen.dart new file mode 100644 index 0000000..b9320ad --- /dev/null +++ b/client/lib/screens/devices/label_template_editor_screen.dart @@ -0,0 +1,1056 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import '../../core/responsive/responsive.dart'; +import '../../core/theme/app_dims.g.dart'; +import '../../core/theme/app_tokens.dart'; +import '../../core/theme/context_tokens.dart'; +import '../../core/utils/dialog_util.dart'; +import '../../core/utils/label_template.dart'; +import '../../core/utils/print_util.dart' show renderLabelPreview, LabelData; +import '../../providers/shop_provider.dart'; +import '../../widgets/ds/ds_atoms.dart'; +import '../../widgets/ds/ds_switch.dart'; +import '../../widgets/ds/ds_toast.dart'; + +/// 标签价签模板编辑器(原型 `design/prototype/screens/label-template-editor.html`)。 +/// +/// 左实时预览(走真实 [renderLabelPreview] 渲染路径,示例数据)+ 右属性面板: +/// 模板管理(编辑对象/打印默认分离)· 显示字段(手风琴,Option A:同物理行两字段 +/// 共用字号/加粗/对齐,各自可独立开关)· 高级区域位置 · 打印参数。 +/// +/// 存储:`shop.custom_fields.label_templates`(List)+ `label_template_active`(id), +/// 照抄 `device_management_screen._savePeripherals` 的 updateInfo 增量写,零后端改动。 +class LabelTemplateEditorScreen extends ConsumerStatefulWidget { + const LabelTemplateEditorScreen({super.key}); + + @override + ConsumerState createState() => + _LabelTemplateEditorScreenState(); +} + +/// 副字段行的规范化分组(与 builtinDefault 一致):编辑器把 subLines 归一到这四行, +/// 每行的样式(字号/加粗/对齐)为组内共用(Option A)。 +const List> _lineGroups = [ + [LabelBinding.code, LabelBinding.series], // 行 0 + [LabelBinding.spec, LabelBinding.productionDate], // 行 1 + [LabelBinding.batchNo], // 行 2 + [LabelBinding.remark], // 行 3 +]; + +enum _FKind { name, header, sub, qr, barcode } + +class _Fd { + final String key; + final String label; + final _FKind kind; + final int line; // sub 专用,其余 -1 + final LabelBinding? binding; // sub 专用 + const _Fd(this.key, this.label, this.kind, {this.line = -1, this.binding}); +} + +const List<_Fd> _fields = [ + _Fd('name', '品名', _FKind.name), + _Fd('shop', '店名(抬头)', _FKind.header), + _Fd('code', '编号', _FKind.sub, line: 0, binding: LabelBinding.code), + _Fd('series', '型号 / 度数', _FKind.sub, line: 0, binding: LabelBinding.series), + _Fd('spec', '版本 / 规格', _FKind.sub, line: 1, binding: LabelBinding.spec), + _Fd('date', '生产日期', _FKind.sub, + line: 1, binding: LabelBinding.productionDate), + _Fd('batch', '批次号', _FKind.sub, line: 2, binding: LabelBinding.batchNo), + _Fd('remark', '备注', _FKind.sub, line: 3, binding: LabelBinding.remark), + _Fd('qr', '二维码', _FKind.qr), + _Fd('barcode', '条形码', _FKind.barcode), +]; + +const Map _alignLabel = { + LabelAlign.left: '左', + LabelAlign.center: '居中', + LabelAlign.right: '右', +}; + +class _LabelTemplateEditorScreenState + extends ConsumerState { + List _templates = []; + String _editingId = ''; + String _activeId = ''; + bool _loaded = false; + bool _saving = false; + + Uint8List? _qrBytes; + Uint8List? _previewPng; + double _zoom = 1.0; + String? _openField; + Timer? _renderDebounce; + + final Map _ctrls = {}; + final TextEditingController _sizeCtrl = TextEditingController(); + + LabelTemplate get _t => + _templates.firstWhere((t) => t.id == _editingId, orElse: () => _templates.first); + + @override + void initState() { + super.initState(); + _bootstrap(); + } + + @override + void dispose() { + _renderDebounce?.cancel(); + for (final c in _ctrls.values) { + c.dispose(); + } + _sizeCtrl.dispose(); + super.dispose(); + } + + Future _bootstrap() async { + // 门店先到即可点亮主界面(不阻塞在二维码光栅化上,首帧更快)。 + final shop = await ref.read(shopInfoProvider.future); + final list = readLabelTemplates(shop.customFields) + .map((t) => _normalize(t)) + .toList(); + final activeId = activeLabelTemplate(shop.customFields).id; + if (!mounted) return; + setState(() { + _templates = list; + _activeId = activeId; + _editingId = activeId; + _loaded = true; + }); + + // 示例二维码(与真实标签同一渲染路径,仅数据是示例)。光栅化异步,好则渲染预览。 + final painter = QrPainter( + data: 'https://jiu.51yanmei.com/p/sample', + version: QrVersions.auto, + gapless: true, + ); + final img = await painter.toImageData(240); + if (!mounted || img == null) return; + _qrBytes = img.buffer.asUint8List(); + _renderPreview(); + } + + /// 把任意模板的副字段行归一到 [_lineGroups] 四行(保留原行样式,缺行补空行), + /// 使字段增删=绑定的加减,同物理行样式天然共用。空行渲染时被跳过,出参不变。 + LabelTemplate _normalize(LabelTemplate src) { + final t = src.copyWith(); + final present = {}; + final styleOf = {}; + for (final line in t.textStack.subLines) { + for (final b in line.bindings) { + present.add(b); + styleOf[b] = line; + } + } + final canon = []; + for (final group in _lineGroups) { + LabelSubLine? ref; + for (final b in group) { + if (styleOf[b] != null) { + ref = styleOf[b]; + break; + } + } + canon.add(LabelSubLine( + bindings: group.where(present.contains).toList(), + fontSize: ref?.fontSize ?? 14, + bold: ref?.bold ?? false, + align: ref?.align ?? LabelAlign.center, + )); + } + t.textStack.subLines = canon; + return t; + } + + // ── 预览渲染(debounce) ───────────────────────────────────────────── + void _scheduleRender() { + _renderDebounce?.cancel(); + _renderDebounce = + Timer(const Duration(milliseconds: 150), _renderPreview); + } + + Future _renderPreview() async { + final qr = _qrBytes; + if (qr == null) return; + final shopName = + ref.read(shopInfoProvider).valueOrNull?.name ?? '鼎晟酒行'; + final sample = LabelData( + qrBytes: qr, + name: '贵州茅台酒', + code: 'P1001', + series: '飞天53度', + spec: '500ml', + productionDate: '2024-05-18', + batchNo: '20240518-01', + remark: '整箱现货', + shopName: shopName, + ); + final png = await renderLabelPreview(sample, template: _t); + if (mounted && png != null) setState(() => _previewPng = png); + } + + void _mutate(VoidCallback fn) { + setState(fn); + _scheduleRender(); + } + + // ── 字段读写(Option A:sub 按物理行共用样式) ──────────────────────── + bool _onOf(_Fd fd) => switch (fd.kind) { + _FKind.name => _t.textStack.name.show, + _FKind.header => _t.header.show, + _FKind.qr => _t.qr.show, + _FKind.barcode => _t.barcode.show, + _FKind.sub => + _t.textStack.subLines[fd.line].bindings.contains(fd.binding), + }; + + void _setOn(_Fd fd, bool v) => _mutate(() { + switch (fd.kind) { + case _FKind.name: + _t.textStack.name.show = v; + case _FKind.header: + _t.header.show = v; + case _FKind.qr: + _t.qr.show = v; + case _FKind.barcode: + _t.barcode.show = v; + case _FKind.sub: + final group = _lineGroups[fd.line]; + final line = _t.textStack.subLines[fd.line]; + final cur = line.bindings.toSet(); + if (v) { + cur.add(fd.binding!); + } else { + cur.remove(fd.binding!); + } + line.bindings = group.where(cur.contains).toList(); + } + }); + + bool _isAuto(_Fd fd) => + fd.kind == _FKind.name && _t.textStack.name.fontSize == null; + + double _sizeOf(_Fd fd) => switch (fd.kind) { + _FKind.name => _t.textStack.name.fontSize ?? _t.textStack.name.fontMax, + _FKind.header => _t.header.fontSize, + _FKind.sub => _t.textStack.subLines[fd.line].fontSize, + _ => 14, + }; + + void _setSize(_Fd fd, double v) => _mutate(() { + switch (fd.kind) { + case _FKind.name: + _t.textStack.name.fontSize = v; + case _FKind.header: + _t.header.fontSize = v; + case _FKind.sub: + _t.textStack.subLines[fd.line].fontSize = v; + default: + break; + } + }); + + void _setAuto(_Fd fd, bool v) => _mutate(() { + _t.textStack.name.fontSize = v ? null : _t.textStack.name.fontMax; + }); + + bool _boldOf(_Fd fd) => switch (fd.kind) { + _FKind.name => _t.textStack.name.bold, + _FKind.header => _t.header.bold, + _FKind.sub => _t.textStack.subLines[fd.line].bold, + _ => false, + }; + + void _setBold(_Fd fd, bool v) => _mutate(() { + switch (fd.kind) { + case _FKind.name: + _t.textStack.name.bold = v; + case _FKind.header: + _t.header.bold = v; + case _FKind.sub: + _t.textStack.subLines[fd.line].bold = v; + default: + break; + } + }); + + LabelAlign _alignOf(_Fd fd) => switch (fd.kind) { + _FKind.name => _t.textStack.name.align, + _FKind.header => _t.header.align, + _FKind.sub => _t.textStack.subLines[fd.line].align, + _ => LabelAlign.center, + }; + + void _setAlign(_Fd fd, LabelAlign a) => _mutate(() { + switch (fd.kind) { + case _FKind.name: + _t.textStack.name.align = a; + case _FKind.header: + _t.header.align = a; + case _FKind.sub: + _t.textStack.subLines[fd.line].align = a; + default: + break; + } + }); + + String _metaText(_Fd fd) { + if (fd.kind == _FKind.qr || fd.kind == _FKind.barcode) { + return '图形 · 位置见高级'; + } + final parts = [ + _isAuto(fd) ? '自动字号' : '${_sizeOf(fd).round()}px', + ]; + if (_boldOf(fd)) parts.add('加粗'); + parts.add(_alignLabel[_alignOf(fd)]!); + return parts.join(' · '); + } + + // ── 模板管理 ───────────────────────────────────────────────────────── + void _switchEditing(String id) { + setState(() { + _editingId = id; + _openField = null; + }); + _scheduleRender(); + } + + String _uniqueName(String base) { + final names = _templates.map((t) => t.name).toSet(); + if (!names.contains(base)) return base; + var i = 2; + while (names.contains('$base $i')) { + i++; + } + return '$base $i'; + } + + String _newId() => + 'tpl-${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}'; + + void _newTemplate() { + final t = _normalize(LabelTemplate.builtinDefault()) + .copyWith(id: _newId(), name: _uniqueName('新模板')); + setState(() { + _templates = [..._templates, t]; + _editingId = t.id; + _openField = null; + }); + _scheduleRender(); + _toast('已新建「${t.name}」'); + } + + void _duplicateTemplate() { + final src = _t; + final t = src.copyWith(id: _newId(), name: _uniqueName('${src.name} 副本')); + setState(() { + _templates = [..._templates, t]; + _editingId = t.id; + _openField = null; + }); + _scheduleRender(); + _toast('已复制为「${t.name}」'); + } + + Future _renameTemplate() async { + final ctrl = TextEditingController(text: _t.name); + final ok = await showAppDialog( + context: context, + builder: (ctx) => _NameDialog(title: '重命名模板', controller: ctrl), + ); + if (ok == true) { + final v = ctrl.text.trim(); + if (v.isNotEmpty) { + setState(() => _t.name = v); + _toast('已重命名 ✓'); + } + } + ctrl.dispose(); + } + + void _deleteTemplate() { + if (_templates.length <= 1) { + _toast('至少保留一个模板', err: true); + return; + } + final gone = _t; + setState(() { + _templates = _templates.where((t) => t.id != gone.id).toList(); + if (_activeId == gone.id) _activeId = _templates.first.id; + _editingId = _templates.first.id; + _openField = null; + }); + _scheduleRender(); + _toast('已删除「${gone.name}」'); + } + + void _setDefault() { + setState(() => _activeId = _editingId); + _toast('已设为打印默认:${_t.name}'); + } + + void _reset() { + final fresh = _normalize(LabelTemplate.builtinDefault()) + .copyWith(id: _t.id, name: _t.name); + final idx = _templates.indexWhere((t) => t.id == _editingId); + setState(() { + _templates[idx] = fresh; + _openField = null; + }); + _scheduleRender(); + _toast('已重置为默认版式'); + } + + Future _save() async { + if (_saving) return; + setState(() => _saving = true); + try { + final shop = await ref.read(shopInfoProvider.future); + final cf = Map.from(shop.customFields) + ..['label_templates'] = _templates.map((t) => t.toJson()).toList() + ..['label_template_active'] = _activeId; + await ref.read(shopRepositoryProvider).updateInfo({ + 'name': shop.name, + 'address': shop.address, + 'phone': shop.phone, + 'manager_name': shop.managerName, + 'wechat_id': shop.wechatId, + if (shop.logoUrl.isNotEmpty) 'logo_url': shop.logoUrl, + 'custom_fields': cf, + }); + ref.invalidate(shopInfoProvider); + if (mounted) _toast('模板已保存 ✓'); + } catch (e) { + if (mounted) _toast('保存失败:$e', err: true); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + void _toast(String msg, {bool err = false}) { + showDsToast(context, msg, + bg: err ? context.tokens.danger : context.tokens.success); + } + + // ── build ──────────────────────────────────────────────────────────── + @override + Widget build(BuildContext context) { + final t = context.tokens; + if (!_loaded) { + return Container( + color: t.bg, + child: const Center(child: CircularProgressIndicator()), + ); + } + final mobile = context.isMobile; + return Container( + color: t.bg, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _header(t), + Expanded( + child: mobile + ? SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 20), + child: Column( + children: [ + _stage(t, compact: true), + const SizedBox(height: 16), + _panel(t), + ], + ), + ) + : Padding( + padding: const EdgeInsets.fromLTRB(26, 0, 26, 22), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _stage(t)), + const SizedBox(width: 22), + SizedBox( + width: 400, + child: SingleChildScrollView(child: _panel(t)), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _header(AppTokens t) => Padding( + padding: const EdgeInsets.fromLTRB(26, 22, 26, 18), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + DsButton('返回', + icon: LucideIcons.arrowLeft, + small: true, + onPressed: () => context.pop()), + const SizedBox(width: 14), + Text('标签模板编辑器', + style: TextStyle( + fontSize: AppDims.fsH1, + fontWeight: FontWeight.w700, + color: t.heading)), + const SizedBox(width: 10), + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text('左侧实时预览 · 右侧属性面板', + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + ), + const Spacer(), + DsButton('重置', icon: LucideIcons.refreshCw, onPressed: _reset), + const SizedBox(width: 10), + DsButton(_saving ? '保存中…' : '保存', + icon: LucideIcons.check, + variant: DsBtnVariant.primary, + onPressed: _saving ? null : _save), + ], + ), + ); + + // ── 左:实时预览舞台 ────────────────────────────────────────────────── + Widget _stage(AppTokens t, {bool compact = false}) { + final png = _previewPng; + return Container( + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rLg), + ), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('实时预览', + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w600, + color: t.muted)), + SizedBox(height: compact ? 16 : 22), + Center( + child: Container( + width: 320 * _zoom, + height: 160 * _zoom, + decoration: BoxDecoration( + color: Colors.white, // ds-ignore: 价签纸面模拟固定色 + borderRadius: BorderRadius.circular(AppDims.rSm), + boxShadow: [ + BoxShadow( + color: t.shadow, + blurRadius: 10, + offset: const Offset(0, 4)), + ], + ), + clipBehavior: Clip.antiAlias, + child: png != null + ? Image.memory(png, fit: BoxFit.fill, gaplessPlayback: true) + : Center( + child: Text('预览生成中…', + style: TextStyle( + fontSize: AppDims.fsXs, + color: t.muted))), // 静态占位,~150ms 后被首帧替换 + ), + ), + SizedBox(height: compact ? 16 : 22), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('缩放', + style: + TextStyle(fontSize: AppDims.fsXs, color: t.muted)), + const SizedBox(width: 12), + DsSeg( + items: const ['75%', '100%', '125%'], + index: _zoom == 0.75 + ? 0 + : (_zoom == 1.25 ? 2 : 1), + onChanged: (i) => setState( + () => _zoom = i == 0 ? 0.75 : (i == 2 ? 1.25 : 1.0)), + ), + ], + ), + const SizedBox(height: 12), + Center( + child: Text('40 × 20 mm · 203 dpi · 示例数据', + style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), + ), + ], + ), + ); + } + + // ── 右:属性面板 ────────────────────────────────────────────────────── + Widget _panel(AppTokens t) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _templateSection(t), + const SizedBox(height: 14), + _fieldSection(t), + const SizedBox(height: 14), + _advancedSection(t), + const SizedBox(height: 14), + _printSection(t), + ], + ); + + Widget _sectionCard(AppTokens t, String title, IconData icon, Widget body, + {Widget? titleTrailing}) { + return Container( + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rLg), + ), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: t.primary), + const SizedBox(width: 8), + Text(title, + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w700, + color: t.title)), + if (titleTrailing != null) ...[ + const Spacer(), + titleTrailing, + ], + ], + ), + const SizedBox(height: 12), + body, + ], + ), + ); + } + + Widget _templateSection(AppTokens t) { + final activeName = + _templates.firstWhere((x) => x.id == _activeId, orElse: () => _t).name; + return _sectionCard( + t, + '模板', + LucideIcons.layoutTemplate, + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DsSelect( + value: _editingId, + options: [ + for (final tpl in _templates) + (tpl.id, tpl.id == _activeId ? '${tpl.name}(打印默认)' : tpl.name), + ], + onChanged: _switchEditing, + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + DsButton('新建', + icon: LucideIcons.plus, small: true, onPressed: _newTemplate), + DsButton('复制', + icon: LucideIcons.copy, + small: true, + onPressed: _duplicateTemplate), + DsButton('重命名', + icon: LucideIcons.pencil, + small: true, + onPressed: _renameTemplate), + DsButton('设默认', + icon: LucideIcons.check, + small: true, + onPressed: _setDefault), + DsButton('删除', + icon: LucideIcons.x, + small: true, + variant: DsBtnVariant.danger, + onPressed: _deleteTemplate), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.only(top: 12), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: t.border)), + ), + child: Row( + children: [ + Icon(LucideIcons.check, size: 14, color: t.primary), + const SizedBox(width: 6), + Text('出签打印使用:', + style: + TextStyle(fontSize: AppDims.fsXs, color: t.muted)), + Flexible( + child: Text(activeName, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppDims.fsXs, + fontWeight: FontWeight.w600, + color: t.primary)), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _fieldSection(AppTokens t) => _sectionCard( + t, + '显示字段', + LucideIcons.eye, + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final fd in _fields) _fieldRow(t, fd), + ], + ), + titleTrailing: Text('点字段名展开样式', + style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), + ); + + Widget _fieldRow(AppTokens t, _Fd fd) { + final on = _onOf(fd); + final open = _openField == fd.key; + final graphic = fd.kind == _FKind.qr || fd.kind == _FKind.barcode; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + borderRadius: BorderRadius.circular(AppDims.rMd), + onTap: () => setState(() { + if (open) { + _openField = null; + } else { + _openField = fd.key; + _sizeCtrl.text = + graphic || _isAuto(fd) ? '' : _sizeOf(fd).round().toString(); + } + }), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9), + decoration: BoxDecoration( + color: open ? t.accentSoft : null, + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row( + children: [ + DsSwitch(value: on, onChanged: (v) => _setOn(fd, v)), + const SizedBox(width: 10), + Expanded( + child: Text(fd.label, + style: TextStyle( + fontSize: AppDims.fsBody, + color: on ? t.text : t.muted, + decoration: + on ? null : TextDecoration.lineThrough)), + ), + Text(_metaText(fd), + style: + TextStyle(fontSize: AppDims.fsXs, color: t.muted)), + const SizedBox(width: 8), + AnimatedRotation( + turns: open ? 0.5 : 0, + duration: const Duration(milliseconds: 180), + child: Icon(LucideIcons.chevronDown, + size: 16, color: t.muted), + ), + ], + ), + ), + ), + if (open) + Padding( + padding: const EdgeInsets.fromLTRB(8, 6, 8, 12), + child: graphic ? _graphicBody(t) : _textBody(t, fd), + ), + ], + ); + } + + Widget _graphicBody(AppTokens t) => Text( + '图形字段,无文字样式。位置与尺寸在下方「高级 · 区域位置」中调整。', + style: TextStyle( + fontSize: AppDims.fsXs, color: t.muted, height: 1.6), + ); + + Widget _textBody(AppTokens t, _Fd fd) { + final auto = _isAuto(fd); + final isName = fd.kind == _FKind.name; + final align = _alignOf(fd); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 字号(品名可切自动) + Row( + children: [ + SizedBox( + width: 44, + child: Text('字号', + style: TextStyle( + fontSize: AppDims.fsSm, color: t.muted))), + SizedBox( + width: 88, + child: DsInput( + controller: _sizeCtrl, + enabled: !auto, + keyboardType: + const TextInputType.numberWithOptions(decimal: false), + onChanged: (v) { + final n = int.tryParse(v.trim()); + if (n != null && n > 0) _setSize(fd, n.toDouble()); + }, + ), + ), + if (isName) ...[ + const SizedBox(width: 12), + Text('自动', + style: + TextStyle(fontSize: AppDims.fsXs, color: t.muted)), + const SizedBox(width: 6), + DsSwitch( + value: auto, + onChanged: (v) { + _setAuto(fd, v); + _sizeCtrl.text = v ? '' : _sizeOf(fd).round().toString(); + }, + ), + ], + ], + ), + const SizedBox(height: 12), + Row( + children: [ + SizedBox( + width: 44, + child: Text('加粗', + style: TextStyle( + fontSize: AppDims.fsSm, color: t.muted))), + DsSwitch(value: _boldOf(fd), onChanged: (v) => _setBold(fd, v)), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + SizedBox( + width: 44, + child: Text('对齐', + style: TextStyle( + fontSize: AppDims.fsSm, color: t.muted))), + DsSeg( + items: const ['左', '居中', '右'], + index: align.index, + onChanged: (i) => _setAlign(fd, LabelAlign.values[i]), + ), + ], + ), + ], + ); + } + + // ── 高级 · 区域位置 ─────────────────────────────────────────────────── + Widget _advancedSection(AppTokens t) { + return Container( + decoration: BoxDecoration( + color: t.surface, + border: Border.all(color: t.border), + borderRadius: BorderRadius.circular(AppDims.rLg), + ), + clipBehavior: Clip.antiAlias, + child: Material( + type: MaterialType.transparency, + child: Theme( + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), + child: ExpansionTile( + tilePadding: const EdgeInsets.symmetric(horizontal: 16), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + title: Row( + children: [ + Icon(LucideIcons.layoutTemplate, size: 16, color: t.primary), + const SizedBox(width: 8), + Text('高级 · 区域位置', + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w700, + color: t.title)), + ], + ), + children: [ + _subLabel(t, '二维码'), + _grid2([ + _num('qrX', 'X', _t.qr.x, (v) => _t.qr.x = v), + _num('qrY', 'Y', _t.qr.y, (v) => _t.qr.y = v), + _num('qrSize', '尺寸', _t.qr.size, (v) => _t.qr.size = v), + ]), + const SizedBox(height: 14), + _subLabel(t, '条形码'), + _grid2([ + _num('barX', 'X', _t.barcode.x, (v) => _t.barcode.x = v), + _num('barY', 'Y', _t.barcode.y, (v) => _t.barcode.y = v), + _num('barW', '宽', _t.barcode.width, (v) => _t.barcode.width = v), + _num('barH', '高', _t.barcode.height, + (v) => _t.barcode.height = v), + ]), + const SizedBox(height: 14), + _subLabel(t, '抬头'), + _grid2([ + _num('hdH', '高度', _t.header.height, (v) => _t.header.height = v), + ]), + ], + ), + ), + ), + ); + } + + Widget _printSection(AppTokens t) => _sectionCard( + t, + '打印参数', + LucideIcons.settings, + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _grid2([ + _num('pw', '纸张宽 (mm)', _t.paper.widthMm, + (v) => _t.paper.widthMm = v), + _num('ph', '纸张高 (mm)', _t.paper.heightMm, + (v) => _t.paper.heightMm = v), + _num('dpi', 'DPI', _t.paper.dpi.toDouble(), + (v) => _t.paper.dpi = v.round()), + _num('gap', '间隔 (mm)', _t.print.gapMm, + (v) => _t.print.gapMm = v), + _num('den', '打印密度', _t.print.density.toDouble(), + (v) => _t.print.density = v.round()), + _num('spd', '打印速度', _t.print.speed.toDouble(), + (v) => _t.print.speed = v.round()), + _num('cop', '份数', _t.print.copies.toDouble(), + (v) => _t.print.copies = v.round()), + ]), + const SizedBox(height: 14), + Row( + children: [ + SizedBox( + width: 64, + child: Text('打印方向', + style: TextStyle( + fontSize: AppDims.fsSm, color: t.muted))), + DsSeg( + items: const ['正向', '反向'], + index: _t.print.direction == 0 ? 1 : 0, + onChanged: (i) => + _mutate(() => _t.print.direction = i == 0 ? 1 : 0), + ), + ], + ), + ], + ), + ); + + Widget _subLabel(AppTokens t, String s) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Align( + alignment: Alignment.centerLeft, + child: Text(s, + style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), + ), + ); + + Widget _grid2(List children) => Wrap( + spacing: 14, + runSpacing: 10, + children: [ + for (final c in children) + SizedBox(width: 158, child: c), + ], + ); + + Widget _num(String key, String label, double value, void Function(double) set, + {bool decimal = false}) { + final ck = '$_editingId/$key'; + final c = _ctrls.putIfAbsent( + ck, + () => TextEditingController( + text: decimal ? _fmt(value) : value.round().toString())); + return DsField( + label, + input: DsInput( + controller: c, + keyboardType: TextInputType.numberWithOptions(decimal: decimal), + onChanged: (v) { + final n = double.tryParse(v.trim()); + if (n != null) _mutate(() => set(n)); + }, + ), + ); + } + + String _fmt(double v) => + v == v.roundToDouble() ? v.round().toString() : v.toString(); +} + +/// 简单的重命名/命名对话框:DsInput + 取消/保存。 +class _NameDialog extends StatelessWidget { + final String title; + final TextEditingController controller; + const _NameDialog({required this.title, required this.controller}); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Container( + width: context.dialogWidth(420), + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(title, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w700, + color: t.heading)), + const SizedBox(height: 16), + DsField('模板名称', + input: DsInput( + controller: controller, + onSubmitted: (_) => Navigator.of(context).pop(true), + )), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + DsButton('取消', + onPressed: () => Navigator.of(context).pop(false)), + const SizedBox(width: 8), + DsButton('保存', + variant: DsBtnVariant.primary, + onPressed: () => Navigator.of(context).pop(true)), + ], + ), + ], + ), + ), + ); + } +} diff --git a/client/lib/widgets/label_preview_dialog.dart b/client/lib/widgets/label_preview_dialog.dart index 11d1d67..4e1d565 100644 --- a/client/lib/widgets/label_preview_dialog.dart +++ b/client/lib/widgets/label_preview_dialog.dart @@ -1,11 +1,13 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../core/errors/error_reporter.dart'; import '../core/theme/context_tokens.dart'; import '../core/responsive/responsive.dart'; import '../core/utils/print_util.dart'; +import '../providers/shop_provider.dart'; import 'ds/ds_atoms.dart'; const _kPrinterKey = 'label_printer'; @@ -14,23 +16,37 @@ const _kPrinterKey = 'label_printer'; /// /// 单张模式(labels.length == 1):隐藏缩略图条,直接显示大图。 /// 多张模式:左侧竖排缩略图(带勾选),右侧大图主视图。 -class LabelPreviewDialog extends StatefulWidget { +class LabelPreviewDialog extends ConsumerStatefulWidget { final List labels; /// 多张懒加载:按 productId 拉取 QR PNG 字节。单张场景可不传(qrBytes 已在 LabelData 中)。 final Future Function(int productId)? qrFetcher; + /// 出签使用的版式模板;显式传入优先,否则自动取本店 active 模板 + /// ([activeLabelTemplate],缺失回退 [LabelTemplate.builtinDefault]), + /// 使店家自定义的价签版式在所有打印入口一致生效。 + final LabelTemplate? template; + const LabelPreviewDialog({ super.key, required this.labels, this.qrFetcher, + this.template, }); @override - State createState() => _LabelPreviewDialogState(); + ConsumerState createState() => + _LabelPreviewDialogState(); } -class _LabelPreviewDialogState extends State { +class _LabelPreviewDialogState extends ConsumerState { + /// 有效模板:显式 template 优先,否则读本店 active 模板(异步未就绪时回退默认)。 + LabelTemplate? get _effectiveTemplate { + if (widget.template != null) return widget.template; + final shop = ref.read(shopInfoProvider).valueOrNull; + return shop != null ? activeLabelTemplate(shop.customFields) : null; + } + int _selectedIndex = 0; late List _previews; late List _include; @@ -80,7 +96,7 @@ class _LabelPreviewDialogState extends State { label.qrBytes = await widget.qrFetcher!(label.productId!); } if (label.qrBytes == null) continue; - final png = await renderLabelPreview(label); + final png = await renderLabelPreview(label, template: _effectiveTemplate); if (mounted && png != null) { setState(() => _previews[i] = png); } @@ -132,6 +148,7 @@ class _LabelPreviewDialogState extends State { shopAddress: label.shopAddress, shopPhone: label.shopPhone, printerName: _printer, + template: _effectiveTemplate, ); done++; if (mounted) { diff --git a/client/test/label_template_editor_smoke_test.dart b/client/test/label_template_editor_smoke_test.dart new file mode 100644 index 0000000..5c15576 --- /dev/null +++ b/client/test/label_template_editor_smoke_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/core/theme/themes.dart'; +import 'package:jiu_client/models/shop.dart'; +import 'package:jiu_client/providers/shop_provider.dart'; +import 'package:jiu_client/screens/devices/label_template_editor_screen.dart'; + +/// 冒烟测试:编辑器屏在真 provider 下能构建/挂载(本屏无法走 golden——左预览用 +/// `Picture.toImage` 直画光栅化,pumpAndSettle 骨架驱动不了,见 CONTRACT 块4)。 +/// 这里只验证脚手架/面板正常渲染(预览停在静态占位,不阻塞 settle)。 +const _shop = ShopInfo( + id: 1, + code: 'DSJH-001', + name: '鼎晟酒行', + address: '浙江省杭州市', + phone: '0571-88886666', + managerName: '王经理', +); + +void main() { + testWidgets('标签模板编辑器 · 挂载渲染面板', (tester) async { + tester.view.physicalSize = const Size(1280, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(ProviderScope( + overrides: [shopInfoProvider.overrideWith((ref) => _shop)], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: buildTheme('a'), + home: const Scaffold(body: LabelTemplateEditorScreen()), + ), + )); + await tester.pumpAndSettle(); + + expect(find.text('标签模板编辑器'), findsOneWidget); + expect(find.text('显示字段'), findsOneWidget); + expect(find.text('打印参数'), findsOneWidget); + expect(find.text('模板'), findsWidgets); + // 字段行齐全(品名 + 二维码等) + expect(find.text('品名'), findsOneWidget); + expect(find.text('二维码'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/client/test/label_template_storage_test.dart b/client/test/label_template_storage_test.dart new file mode 100644 index 0000000..36531ee --- /dev/null +++ b/client/test/label_template_storage_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/core/utils/label_template.dart'; + +/// 锁死模板存储读写(走 shop.custom_fields,零后端改动)与 JSON 往返, +/// 不触真实库——纯内存对象校验。 +void main() { + group('LabelTemplate 存储 helpers', () { + test('custom_fields 无模板时回退内置默认', () { + final list = readLabelTemplates({}); + expect(list, hasLength(1)); + expect(list.first.id, 'builtin-default'); + }); + + test('activeLabelTemplate 按 id 命中,缺失回退第一项', () { + final a = LabelTemplate.builtinDefault(); + final b = a.copyWith(id: 'tpl-2', name: '大字号'); + final cf = { + 'label_templates': [a.toJson(), b.toJson()], + 'label_template_active': 'tpl-2', + }; + expect(activeLabelTemplate(cf).id, 'tpl-2'); + + cf['label_template_active'] = 'nope'; + expect(activeLabelTemplate(cf).id, 'builtin-default'); + }); + + test('toJson → fromJson 往返保真(几何/开关/样式)', () { + final src = LabelTemplate.builtinDefault(); + src.textStack.subLines[0].fontSize = 16; + src.textStack.subLines[0].bold = true; + src.qr.size = 120; + src.print.direction = 0; + final back = LabelTemplate.fromJson(src.toJson()); + expect(back.textStack.subLines[0].fontSize, 16); + expect(back.textStack.subLines[0].bold, true); + expect(back.qr.size, 120); + expect(back.print.direction, 0); + }); + + test('存 → 读回列表保持顺序与命名', () { + final a = LabelTemplate.builtinDefault(); + final b = a.copyWith(id: 'tpl-2', name: '双语抬头'); + final cf = { + 'label_templates': [a.toJson(), b.toJson()], + }; + final list = readLabelTemplates(cf); + expect(list.map((t) => t.id), ['builtin-default', 'tpl-2']); + expect(list[1].name, '双语抬头'); + }); + }); +} diff --git a/design/CONTRACT.md b/design/CONTRACT.md index 45986cc..109f3c4 100644 --- a/design/CONTRACT.md +++ b/design/CONTRACT.md @@ -107,6 +107,7 @@ AppTokens 目前**仅颜色**。原型还驱动: | 出库列表(桌/移) | `stock-out-list.html` | `stock_out_list_screen.dart` | 同步 | ✅ 副标 + 共享 StatusPill;**2026-07-14 六项优化**:详情抽屉明细同增生产日期+批次号两列(抽屉 660)、状态筛选改多选 chip 且默认「草稿+待审核」、新建单默认填充默认仓库(原型 stock-out-list.html 同步);fidelity a 3.228% / b 2.217% / c 3.538%(均 ≤ 阈值 6%),移动 golden 自比全绿 | ✅ golden | ✅ golden | | 财务(桌/移) | `finance.html` | `finance_screen.dart` | 同步 | ✅ **Phase2 重建**:时间范围 chips(真实过滤)+KPI 4 卡(stock summary 环比)+收支趋势柱状图(DsBarChart, /finance/trend)+应收应付汇总(/finance/summary)+流水表+往来抽屉+登记收支(补往来单位);**2026-07-14 六项优化**:流水表筛选新增「往来单位」下拉、时间范围统一改用通用自定义时间组件(原型 finance.html/m-finance.html 同步);fidelity a 4.036% / b 3.131% / c 4.340%(均 ≤ 阈值 6%),移动 golden 自比全绿;**2026-07-14 追加**:应收/应付汇总卡头部新增「往来单位」搜索——本地即时过滤(`/finance/summary` 全量非分页,搜索口径=显示口径),桌面 `DsSearchBox`(`_secHead` trailing)/移动 应收·应付 分段头 `MSearchRow`(流水分段沿用既有 chip→sheet 单选,不重复),原型 finance.html `.sum-search` / m-finance.html 应收应付分段 `.m-search` 同步,finance 相关 6 个整屏 golden + m_finance 3 个 golden 已按新布局 `--update-goldens`;**2026-07-14 验收反馈调整**:桌面「往来单位」搜索由卡标题行 `DsSearchBox` 挪进汇总表表头「往来单位」列头右侧,改为下拉单选 `ComboSearchField`(同 `_flowTable` 往来单位筛选控件,选中精确匹配一行/× 清除恢复全量,候选=当前汇总行往来单位);原型 `.sec-head .sum-search` 移除,改 `.th-party-wrap` 内嵌 `.combo` + `openSearchMenu` 弹层;移动端未动。desktop `finance` golden 3 主题已 `--update-goldens`(3.11% 差异,目检确认新版式),`finance_mobile`/`m_finance` 无变化、原样通过;**2026-07-14 验收反馈2**(实机截图 3 处细节):①下拉候选由「仅当前汇总行往来单位」改为「全量往来单位」(`allPartnersProvider`,与 `_flowTable` toolbar 同源),选中一个汇总没有余额行的单位时走既有「没有匹配的往来单位」空态;②占位符叠词修复——`ComboSearchField.hint` 由 `'搜索往来单位'` 改 `'往来单位'`(内部会自动拼「搜索」前缀),弹层内搜索框恢复正常显示「搜索往来单位…」,收起态触发器文案对齐 `_flowTable` 写法同为「往来单位」;③表头行下拉垂直位置——`header` 外包 `SizedBox(height: 34)` 显式钉住 Row 高度消除潜在字体行高偏差,不改表头行 pad 11/14 既有行高口径。原型 finance.html 同步:`ALL_PARTNERS`(`SUMMARY` 全部单位 + 1 个无余额行的「鼎丰超市」演示空态)替换 `sumPartyId`/`SUMMARY` 候选、`#sumPartyInput` 收起态 placeholder 改「往来单位」;`.th-party-wrap{align-items:center}` + 浏览器 `th` 默认 `vertical-align:middle` 本就居中,未改 CSS。desktop `finance` golden 3 主题已 `--update-goldens`(0.04% 差异,目检确认新候选/占位文案,表头对齐无回归),`finance_mobile`/`m_finance` 无变化、原样通过 | ✅ fidelity | ✅ fidelity | | 设备管理(桌/移) | `devices.html` | `device_management_screen.dart` | 同步 | ✅ **Phase2 重建**:会话表(DsTable)+外设卡网格(custom_fields.peripherals 本地存档)+打印模板;fidelity 2.4–3.0%≤8% | ✅ fidelity | ✅ fidelity | +| 标签模板编辑器(桌/移) | `label-template-editor.html` | `label_template_editor_screen.dart`(`/devices/label-template`,设备管理「价签·编辑」进入) | 同步 | ✅ **2026-08 P2 落地**:左实时预览(走真实 `renderLabelPreview` 渲染路径,示例数据)+ 右属性面板(模板管理[编辑对象/打印默认分离]·显示字段手风琴·高级区域位置·打印参数);存 `shop.custom_fields.label_templates`+`label_template_active`(零后端改)。**Option A**:同物理行两字段(编号+型号 / 版本+日期)共用字号·加粗·对齐,各自独立开关;仅品名支持自动字号;字体不做(热敏单色)。默认输出逐像素零变化由 `label_render_golden_test.dart` 守(改内置默认常量才触发)。⚠️ **本屏不入自动 fidelity/golden 闸**:左预览用 `Picture.toImage` 直画光栅化(同 label_render,需 `runAsync`),`pumpAndSettle` 的 widget-golden 骨架无法驱动→整屏 golden 会把预览拍成占位。原型=代码一致由 serve 目检 + 本行保证;存储读写由 `label_template_storage_test.dart` 单测守;真机打印坐标/条码可扫须用户在热敏机验收 | ⚠️ 见左(不入闸) | ⚠️ 见左(不入闸) | | 系统设置(桌/移) | `settings.html` | `settings_screen.dart` | 同步 | ✅ **Phase2 重建**;假「系统参数」已删;fidelity 1.7–2.3%≤8%;**2026-07-10 授权管理面板迁出**(提升为独立一级屏 `license.html`),subnav 剩 门店信息/用户管理/偏好——**真实端已同步完成**(`settings_screen.dart` 子导航同步摘除授权管理项,golden 已按新态重生成,见授权管理两行) | ✅ fidelity | ✅ fidelity | | 用户管理(桌/移) | `users.html` | `users_screen.dart`(`/settings/users`) | 同步 | ✅ **Phase2 新独立页**:KPI 4 卡+搜索/角色筛选+rcard 弹窗,角色四级拉平;fidelity 1.3–2.1%≤8% | ✅ fidelity | ✅ fidelity | | 关于我们(桌/移) | `about.html` | `about_screen.dart` | 同步 | ✅ **Phase2 重建**:Hero/产品信息/更新日志 timeline(/public/release 同源);fidelity 2.2–2.7%≤8%;授权信息卡 2026-07-04 迁至 设置→授权管理 | ✅ fidelity | ✅ fidelity | diff --git a/design/prototype/screens/label-template-editor.html b/design/prototype/screens/label-template-editor.html index b2f788e..092842f 100644 --- a/design/prototype/screens/label-template-editor.html +++ b/design/prototype/screens/label-template-editor.html @@ -245,25 +245,22 @@ /* ── 字段清单:样式内嵌每行,手风琴展开 ── */ /* 1 期字体:仅系统字体 + 应用已内置思源黑体,不打包新字体(宋体已撤)。 楷体/等宽蹭系统,缺失回退默认。 */ - const FONTS = [ - ['系统默认', ''], - ['思源黑体(内置)', 'sans-serif'], - ['楷体', 'KaiTi, STKaiti, serif'], - ['等宽数字', 'ui-monospace, monospace'], - ]; + // 字体不做(热敏单色 MVP);仅「品名」支持自动字号。同物理行两字段共用字号/加粗/对齐(PAIR)。 const ALIGN_LABEL = {left:'左', center:'居中', right:'右'}; const FIELDS = [ - {f:'name', label:'品名', type:'text', on:true, auto:true, bold:true, align:'center', font:0, size:18}, - {f:'shop', label:'店名(抬头)', type:'text', on:true, auto:false, bold:true, align:'center', font:0, size:14}, - {f:'code', label:'编号', type:'text', on:true, auto:false, bold:false, align:'center', font:0, size:14}, - {f:'series', label:'型号 / 度数', type:'text', on:true, auto:false, bold:false, align:'center', font:0, size:14}, - {f:'spec', label:'版本 / 规格', type:'text', on:true, auto:false, bold:false, align:'center', font:0, size:14}, - {f:'date', label:'生产日期', type:'text', on:true, auto:false, bold:false, align:'center', font:0, size:14}, - {f:'batch', label:'批次号', type:'text', on:false, auto:false, bold:false, align:'center', font:0, size:14}, - {f:'remark', label:'备注', type:'text', on:false, auto:false, bold:false, align:'center', font:0, size:14}, + {f:'name', label:'品名', type:'text', on:true, auto:true, bold:true, align:'center', size:18}, + {f:'shop', label:'店名(抬头)', type:'text', on:true, auto:false, bold:true, align:'center', size:14}, + {f:'code', label:'编号', type:'text', on:true, auto:false, bold:false, align:'center', size:14}, + {f:'series', label:'型号 / 度数', type:'text', on:true, auto:false, bold:false, align:'center', size:14}, + {f:'spec', label:'版本 / 规格', type:'text', on:true, auto:false, bold:false, align:'center', size:14}, + {f:'date', label:'生产日期', type:'text', on:true, auto:false, bold:false, align:'center', size:14}, + {f:'batch', label:'批次号', type:'text', on:false, auto:false, bold:false, align:'center', size:14}, + {f:'remark', label:'备注', type:'text', on:false, auto:false, bold:false, align:'center', size:14}, {f:'qr', label:'二维码', type:'graphic', on:true}, {f:'barcode', label:'条形码', type:'graphic', on:true}, ]; + // 编号↔型号 同物理行、版本↔日期 同物理行:字号/加粗/对齐共用(Option A) + const PAIR = {2:3, 3:2, 4:5, 5:4}; function metaText(fd){ if(fd.type==='graphic') return '图形 · 位置见高级'; @@ -273,18 +270,14 @@ return parts.join(' · '); } function fontBody(fd, i){ - const opts = FONTS.map((ft,fi)=> - `
${ft[0]}
`).join(''); + const autoCtrl = fd.f==='name' + ? `自动 + ` + : ''; return `
-
字体 -
-
${FONTS[fd.font][0]}
-
${opts}
-
字号 - 自动 -
+ ${autoCtrl}
加粗
对齐 @@ -322,13 +315,20 @@ previewEls(i).forEach(el=>{ el.classList.toggle('lt-off', !fd.on); if(fd.type==='text'){ - el.style.fontFamily = FONTS[fd.font][1]; el.style.fontWeight = fd.bold ? '700' : '400'; el.style.textAlign = fd.align; el.style.fontSize = fd.auto ? '' : fd.size+'px'; } }); } + /* 同物理行配对字段(编号↔型号、版本↔日期)共用字号/加粗/对齐 */ + function mirror(i){ + const p = PAIR[i]; if(p==null) return; + FIELDS[p].size = FIELDS[i].size; + FIELDS[p].bold = FIELDS[i].bold; + FIELDS[p].align = FIELDS[i].align; + applyStyle(p); refreshMeta(p); + } /* 手风琴:一次只展开一行 */ function expandField(i){ const el = fieldEl(i), wasOpen = el.classList.contains('open'); @@ -342,24 +342,9 @@ fieldEl(i).classList.toggle('disabled', !FIELDS[i].on); applyStyle(i); } - function toggleFont(e, i){ - e.stopPropagation(); - const pop = fieldEl(i).querySelector('.lt-fontpop'), open = pop.classList.contains('show'); - document.querySelectorAll('.lt-fontpop.show').forEach(p=>p.classList.remove('show')); - if(!open) pop.classList.add('show'); - } - function pickFont(e, i, fi){ - e.stopPropagation(); - FIELDS[i].font = fi; - const el = fieldEl(i); - el.querySelectorAll('.lt-fontitem').forEach((it,x)=>it.classList.toggle('on', x===fi)); - el.querySelector('.lt-fontname').textContent = FONTS[fi][0]; - el.querySelector('.lt-fontpop').classList.remove('show'); - applyStyle(i); - } function setSize(i, v){ const n = parseInt(v,10); - if(!isNaN(n)){ FIELDS[i].size = n; applyStyle(i); refreshMeta(i); } + if(!isNaN(n)){ FIELDS[i].size = n; applyStyle(i); refreshMeta(i); mirror(i); } } function toggleAuto(e, i){ e.stopPropagation(); @@ -372,14 +357,14 @@ e.stopPropagation(); FIELDS[i].bold = !FIELDS[i].bold; e.currentTarget.classList.toggle('on', FIELDS[i].bold); - applyStyle(i); refreshMeta(i); + applyStyle(i); refreshMeta(i); mirror(i); } function setAlign(e, i, a){ e.stopPropagation(); FIELDS[i].align = a; e.currentTarget.parentElement.querySelectorAll('button').forEach(b=>b.classList.remove('on')); e.currentTarget.classList.add('on'); - applyStyle(i); refreshMeta(i); + applyStyle(i); refreshMeta(i); mirror(i); } function pickSeg(btn){