7dceefc55e
- 默认模板改为代码只读基准:readLabelTemplates 加载时强制用 builtinDefault() 覆盖 builtin-default 项,忽略存储里的旧脏几何 (旧默认品名被关/旧 QR 坐标等一律自愈回代码基准) - 二维码放大 x198/y44/尺寸96 → x192/y32/尺寸120,四周留 ~1mm 小边框 - 原型 label-template-editor.html 高级 QR 输入同步;golden 重生 - 新增存储测试钉死「内置默认永远回代码基准」 原型与代码同提交(design-first 双边一致)
1185 lines
40 KiB
Dart
1185 lines
40 KiB
Dart
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';
|
||
import '../../widgets/label_preview_dialog.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<LabelTemplateEditorScreen> createState() =>
|
||
_LabelTemplateEditorScreenState();
|
||
}
|
||
|
||
/// 副字段行的规范化分组(与 builtinDefault 一致):编辑器把 subLines 归一到这四行,
|
||
/// 每行的样式(字号/加粗/对齐)为组内共用(Option A)。
|
||
const List<List<LabelBinding>> _lineGroups = [
|
||
[LabelBinding.code, LabelBinding.series], // 行 0
|
||
[LabelBinding.spec, LabelBinding.productionDate], // 行 1
|
||
];
|
||
|
||
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('qr', '二维码', _FKind.qr),
|
||
_Fd('barcode', '条形码', _FKind.barcode),
|
||
];
|
||
|
||
const Map<LabelAlign, String> _alignLabel = {
|
||
LabelAlign.left: '左',
|
||
LabelAlign.center: '居中',
|
||
LabelAlign.right: '右',
|
||
};
|
||
|
||
class _LabelTemplateEditorScreenState
|
||
extends ConsumerState<LabelTemplateEditorScreen> {
|
||
List<LabelTemplate> _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<String, TextEditingController> _ctrls = {};
|
||
final TextEditingController _sizeCtrl = TextEditingController();
|
||
|
||
/// 系统自带的默认模板 id(受保护基准版式)。
|
||
static const String _builtinDefaultId = 'builtin-default';
|
||
|
||
LabelTemplate get _t =>
|
||
_templates.firstWhere((t) => t.id == _editingId, orElse: () => _templates.first);
|
||
|
||
/// 默认模板只读:字段/字号/位置/打印参数一律不可改,要改必须先「复制」。
|
||
bool get _isLocked => _t.id == _builtinDefaultId;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_bootstrap();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_renderDebounce?.cancel();
|
||
for (final c in _ctrls.values) {
|
||
c.dispose();
|
||
}
|
||
_sizeCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _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 = <LabelBinding>{};
|
||
final styleOf = <LabelBinding, LabelSubLine>{};
|
||
for (final line in t.textStack.subLines) {
|
||
for (final b in line.bindings) {
|
||
present.add(b);
|
||
styleOf[b] = line;
|
||
}
|
||
}
|
||
final canon = <LabelSubLine>[];
|
||
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);
|
||
}
|
||
|
||
/// 预览/打印共用的示例数据(QR 已在 bootstrap 生成)。
|
||
/// 预览示例的默认内容(仅用于左侧预览;真实出签永远取商品真实数据)。
|
||
String _sampleDefault(String key) {
|
||
switch (key) {
|
||
case 'name':
|
||
return '贵州茅台酒';
|
||
case 'code':
|
||
return 'P1001';
|
||
case 'series':
|
||
return '飞天53度';
|
||
case 'spec':
|
||
return '500ml';
|
||
case 'date':
|
||
return '2024-05-18';
|
||
case 'shop':
|
||
return ref.read(shopInfoProvider).valueOrNull?.name ?? '鼎晟酒行';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/// 字段内容编辑框的控制器(复用 [_ctrls] 池,`sample/` 命名空间,随店级预览而非模板)。
|
||
TextEditingController _contentCtrl(_Fd fd) => _ctrls.putIfAbsent(
|
||
'sample/${fd.key}',
|
||
() => TextEditingController(text: _sampleDefault(fd.key)));
|
||
|
||
/// 当前预览示例取值:编辑框非空用编辑框,否则回默认。
|
||
String _sampleOf(String key) {
|
||
final v = _ctrls['sample/$key']?.text.trim() ?? '';
|
||
return v.isNotEmpty ? v : _sampleDefault(key);
|
||
}
|
||
|
||
LabelData? _sampleLabel() {
|
||
final qr = _qrBytes;
|
||
if (qr == null) return null;
|
||
return LabelData(
|
||
qrBytes: qr,
|
||
name: _sampleOf('name'),
|
||
code: _sampleOf('code'),
|
||
series: _sampleOf('series'),
|
||
spec: _sampleOf('spec'),
|
||
productionDate: _sampleOf('date'),
|
||
batchNo: '20240518-01',
|
||
remark: '整箱现货',
|
||
shopName: _sampleOf('shop'),
|
||
);
|
||
}
|
||
|
||
Future<void> _renderPreview() async {
|
||
final sample = _sampleLabel();
|
||
if (sample == null) return;
|
||
final png = await renderLabelPreview(sample, template: _t);
|
||
if (mounted && png != null) setState(() => _previewPng = png);
|
||
}
|
||
|
||
/// 打印当前预览:复用出签弹窗(选打印机/份数),传入正在编辑的模板 [_t]。
|
||
void _print() {
|
||
final sample = _sampleLabel();
|
||
if (sample == null) {
|
||
_toast('预览尚未就绪,请稍候', err: true);
|
||
return;
|
||
}
|
||
showDialog(
|
||
context: context,
|
||
builder: (_) => LabelPreviewDialog(labels: [sample], template: _t),
|
||
);
|
||
}
|
||
|
||
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;
|
||
});
|
||
|
||
/// 步进器 +1/-1:夹在 [6,48],回写模型与输入框(同原型 stepSize)。
|
||
void _stepSize(_Fd fd, int d) {
|
||
final n = (_sizeOf(fd).round() + d).clamp(6, 48);
|
||
_setSize(fd, n.toDouble());
|
||
_sizeCtrl.text = n.toString();
|
||
}
|
||
|
||
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 = <String>[
|
||
_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<void> _renameTemplate() async {
|
||
final ctrl = TextEditingController(text: _t.name);
|
||
final ok = await showAppDialog<bool>(
|
||
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<void> _save() async {
|
||
if (_saving) return;
|
||
setState(() => _saving = true);
|
||
try {
|
||
final shop = await ref.read(shopInfoProvider.future);
|
||
final cf = Map<String, dynamic>.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: _isLocked ? null : _reset),
|
||
const SizedBox(width: 10),
|
||
DsButton('打印', icon: LucideIcons.printer, onPressed: _print),
|
||
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<String>(
|
||
value: _editingId,
|
||
options: [
|
||
for (final tpl in _templates)
|
||
(tpl.id, tpl.id == _activeId ? '${tpl.name}(打印默认)' : tpl.name),
|
||
],
|
||
onChanged: _switchEditing,
|
||
),
|
||
if (_isLocked) ...[
|
||
const SizedBox(height: 10),
|
||
_lockNote(t),
|
||
],
|
||
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: _isLocked ? null : _renameTemplate),
|
||
DsButton('设默认',
|
||
icon: LucideIcons.check,
|
||
small: true,
|
||
onPressed: _setDefault),
|
||
DsButton('删除',
|
||
icon: LucideIcons.x,
|
||
small: true,
|
||
variant: DsBtnVariant.danger,
|
||
onPressed: _isLocked ? null : _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 _lockNote(AppTokens t) => Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: t.warnBg,
|
||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||
border: Border.all(color: t.warn),
|
||
),
|
||
child: Text('系统默认模板不可修改,点「复制」生成可编辑副本',
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsXs, color: t.warn, height: 1.5)),
|
||
);
|
||
|
||
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 = !_isLocked && _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: _isLocked
|
||
? null
|
||
: () => 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: _isLocked ? null : (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))),
|
||
Expanded(
|
||
child: DsInput(
|
||
controller: _contentCtrl(fd),
|
||
onChanged: (_) => _scheduleRender(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text('仅改预览示例,真实打印按商品数据填充',
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsXs, color: t.muted, height: 1.6)),
|
||
const SizedBox(height: 12),
|
||
// 字号(品名可切自动)
|
||
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());
|
||
},
|
||
suffix: _sizeStepper(fd, t, auto),
|
||
),
|
||
),
|
||
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]),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 字号输入框右侧内嵌的上下箭头步进器(点击 +1/-1,同原型 .lt-steparr)。
|
||
Widget _sizeStepper(_Fd fd, AppTokens t, bool auto) {
|
||
Widget arrow(IconData ic, int d) => InkWell(
|
||
onTap: auto ? null : () => _stepSize(fd, d),
|
||
child: SizedBox(
|
||
height: 15,
|
||
width: 18,
|
||
child: Icon(ic, size: 13, color: auto ? t.faint : t.muted),
|
||
),
|
||
);
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
arrow(LucideIcons.chevronUp, 1),
|
||
arrow(LucideIcons.chevronDown, -1),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ── 高级 · 区域位置 ───────────────────────────────────────────────────
|
||
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) {
|
||
if (_isLocked) return;
|
||
_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<Widget> 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,
|
||
enabled: !_isLocked,
|
||
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)),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|