feat(client): 价签默认模板品名默认展示 + 二维码放大留小边框
- 默认模板改为代码只读基准:readLabelTemplates 加载时强制用 builtinDefault() 覆盖 builtin-default 项,忽略存储里的旧脏几何 (旧默认品名被关/旧 QR 坐标等一律自愈回代码基准) - 二维码放大 x198/y44/尺寸96 → x192/y32/尺寸120,四周留 ~1mm 小边框 - 原型 label-template-editor.html 高级 QR 输入同步;golden 重生 - 新增存储测试钉死「内置默认永远回代码基准」 原型与代码同提交(design-first 双边一致)
This commit is contained in:
@@ -54,7 +54,8 @@ Future<ui.Image> renderLabelImage({
|
||||
lay.headerFontSize, w - 2 * lay.headerTextX,
|
||||
bold: lay.headerBold,
|
||||
textAlign: _uiAlign(lay.headerAlign),
|
||||
color: cCream);
|
||||
color: cCream,
|
||||
centerInH: lay.headerHeight); // 店名在深蓝带内上下居中(textY 作微调)
|
||||
}
|
||||
|
||||
// ── 右列二维码(正方形,绝对坐标)─────────────────────────────
|
||||
@@ -127,12 +128,15 @@ Future<Uint8List?> renderLabelPngBytes({
|
||||
return bd?.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// 在 canvas 上画一行文字(默认黑色,单行不换行,CJK 走系统字体回退)
|
||||
/// 在 canvas 上画一行文字(默认黑色,单行不换行,CJK 走系统字体回退)。
|
||||
/// [centerInH] > 0 时:在 [y, y+centerInH] 区间内竖直居中(用紧排行高 1.0 量高后
|
||||
/// 上下留白均分),供抬头店名在深蓝带内上下居中;[y] 作为居中基础上的手动微调量。
|
||||
void _drawText(
|
||||
ui.Canvas canvas, String s, double x, double y, double size, double maxW,
|
||||
{bool bold = false,
|
||||
ui.TextAlign textAlign = ui.TextAlign.left,
|
||||
ui.Color color = const ui.Color(0xFF000000)}) // ds-ignore: 纸面固定色(默认黑)
|
||||
ui.Color color = const ui.Color(0xFF000000), // ds-ignore: 纸面固定色(默认黑)
|
||||
double centerInH = 0})
|
||||
{
|
||||
if (s.isEmpty) return;
|
||||
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
|
||||
@@ -142,11 +146,13 @@ void _drawText(
|
||||
textAlign: textAlign,
|
||||
maxLines: 1,
|
||||
ellipsis: '',
|
||||
height: centerInH > 0 ? 1.0 : null, // 居中时用紧排行高,量出的高≈字号,居中更准
|
||||
))
|
||||
..pushStyle(ui.TextStyle(color: color)) // ds-ignore: 打印画布纸面固定色
|
||||
..addText(s);
|
||||
final p = pb.build()..layout(ui.ParagraphConstraints(width: maxW));
|
||||
canvas.drawParagraph(p, ui.Offset(x, y));
|
||||
final double dy = centerInH > 0 ? y + (centerInH - p.height) / 2 : y;
|
||||
canvas.drawParagraph(p, ui.Offset(x, dy));
|
||||
}
|
||||
|
||||
/// 版式对齐 → 画布 TextAlign。
|
||||
|
||||
@@ -147,7 +147,7 @@ class LabelHeader {
|
||||
this.show = true,
|
||||
this.height = 24,
|
||||
this.textX = 10,
|
||||
this.textY = 4,
|
||||
this.textY = 0, // 0=渲染器按 height 竖直居中;正负值作微调
|
||||
this.fontSize = 14,
|
||||
this.bold = true,
|
||||
this.align = LabelAlign.left,
|
||||
@@ -167,7 +167,7 @@ class LabelHeader {
|
||||
show: j['show'] as bool? ?? true,
|
||||
height: (j['height'] as num?)?.toDouble() ?? 24,
|
||||
textX: (j['textX'] as num?)?.toDouble() ?? 10,
|
||||
textY: (j['textY'] as num?)?.toDouble() ?? 4,
|
||||
textY: (j['textY'] as num?)?.toDouble() ?? 0,
|
||||
fontSize: (j['fontSize'] as num?)?.toDouble() ?? 14,
|
||||
bold: j['bold'] as bool? ?? true,
|
||||
align: _alignFrom(j['align'] as String?),
|
||||
@@ -534,7 +534,7 @@ class LabelTemplate {
|
||||
show: true,
|
||||
height: 24,
|
||||
textX: 10,
|
||||
textY: 4,
|
||||
textY: 0, // 0=纯居中(渲染器按 height 竖直居中),正负值作微调
|
||||
fontSize: 14,
|
||||
bold: true,
|
||||
align: LabelAlign.left),
|
||||
@@ -564,12 +564,41 @@ class LabelTemplate {
|
||||
align: LabelAlign.center),
|
||||
],
|
||||
),
|
||||
qr: LabelQr(show: true, x: 178, y: 25, size: 134),
|
||||
qr: LabelQr(show: true, x: 192, y: 32, size: 120), // 放大占满右列,四周留 ~8px(≈1mm) 小边框;左距条码/文字(x≤172) 20px
|
||||
barcode:
|
||||
LabelBarcode(show: true, x: 8, y: 114, width: 164, height: 40),
|
||||
);
|
||||
}
|
||||
|
||||
/// 历史内置默认的 QR 坐标签名(x/y/size)。加载老店铺模板时,凡 QR 恰好等于某个历史默认,
|
||||
/// 自动升级为当前 [LabelTemplate.builtinDefault] 的居中坐标——老模板无需手动「重置」即可拿到
|
||||
/// 二维码居中+四周留白的新版式。用户手动挪过的 QR 不匹配任一签名,保持不动。
|
||||
const List<List<double>> _legacyDefaultQrXYS = [
|
||||
[178, 25, 134], // 最初默认(贴头顶、无留白)
|
||||
[190, 34, 116], // 中间一版(缩小但仍偏上)
|
||||
];
|
||||
|
||||
/// 就地把匹配历史默认签名的 QR 升级到当前默认坐标;返回同一实例便于链式 map。
|
||||
LabelTemplate _migrateLegacyDefaults(LabelTemplate t) {
|
||||
final q = t.qr;
|
||||
for (final s in _legacyDefaultQrXYS) {
|
||||
if (q.x == s[0] && q.y == s[1] && q.size == s[2]) {
|
||||
final def = LabelTemplate.builtinDefault().qr;
|
||||
q.x = def.x;
|
||||
q.y = def.y;
|
||||
q.size = def.size;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/// 内置默认模板是「代码真相 + 只读锁」:无论存储里存了什么旧版式(旧 QR、品名被关等),
|
||||
/// 加载时一律用当前 [LabelTemplate.builtinDefault] 覆盖,保证默认永远是最新基准。
|
||||
/// 用户的定制一律走「复制」出来的副本(另一 id),不受影响。
|
||||
LabelTemplate _forceBuiltinDefaultFromCode(LabelTemplate t) =>
|
||||
t.id == 'builtin-default' ? LabelTemplate.builtinDefault() : t;
|
||||
|
||||
/// 从 shop.custom_fields 读取店级模板列表;为空/损坏时回退单个 [LabelTemplate.builtinDefault]。
|
||||
List<LabelTemplate> readLabelTemplates(Map<String, dynamic> cf) {
|
||||
final raw = cf['label_templates'];
|
||||
@@ -577,6 +606,8 @@ List<LabelTemplate> readLabelTemplates(Map<String, dynamic> cf) {
|
||||
final list = raw
|
||||
.whereType<Map>()
|
||||
.map((m) => LabelTemplate.fromJson(m.cast<String, dynamic>()))
|
||||
.map(_migrateLegacyDefaults)
|
||||
.map(_forceBuiltinDefaultFromCode)
|
||||
.toList();
|
||||
if (list.isNotEmpty) return list;
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@ class LabelTemplateEditorScreen extends ConsumerStatefulWidget {
|
||||
const List<List<LabelBinding>> _lineGroups = [
|
||||
[LabelBinding.code, LabelBinding.series], // 行 0
|
||||
[LabelBinding.spec, LabelBinding.productionDate], // 行 1
|
||||
[LabelBinding.batchNo], // 行 2
|
||||
[LabelBinding.remark], // 行 3
|
||||
];
|
||||
|
||||
enum _FKind { name, header, sub, qr, barcode }
|
||||
@@ -64,8 +62,6 @@ const List<_Fd> _fields = [
|
||||
_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),
|
||||
];
|
||||
@@ -93,9 +89,15 @@ class _LabelTemplateEditorScreenState
|
||||
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();
|
||||
@@ -179,21 +181,49 @@ class _LabelTemplateEditorScreenState
|
||||
}
|
||||
|
||||
/// 预览/打印共用的示例数据(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;
|
||||
final shopName =
|
||||
ref.read(shopInfoProvider).valueOrNull?.name ?? '鼎晟酒行';
|
||||
return LabelData(
|
||||
qrBytes: qr,
|
||||
name: '贵州茅台酒',
|
||||
code: 'P1001',
|
||||
series: '飞天53度',
|
||||
spec: '500ml',
|
||||
productionDate: '2024-05-18',
|
||||
name: _sampleOf('name'),
|
||||
code: _sampleOf('code'),
|
||||
series: _sampleOf('series'),
|
||||
spec: _sampleOf('spec'),
|
||||
productionDate: _sampleOf('date'),
|
||||
batchNo: '20240518-01',
|
||||
remark: '整箱现货',
|
||||
shopName: shopName,
|
||||
shopName: _sampleOf('shop'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -282,6 +312,13 @@ class _LabelTemplateEditorScreenState
|
||||
_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,
|
||||
@@ -531,7 +568,9 @@ class _LabelTemplateEditorScreenState
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
),
|
||||
const Spacer(),
|
||||
DsButton('重置', icon: LucideIcons.refreshCw, onPressed: _reset),
|
||||
DsButton('重置',
|
||||
icon: LucideIcons.refreshCw,
|
||||
onPressed: _isLocked ? null : _reset),
|
||||
const SizedBox(width: 10),
|
||||
DsButton('打印', icon: LucideIcons.printer, onPressed: _print),
|
||||
const SizedBox(width: 10),
|
||||
@@ -681,6 +720,10 @@ class _LabelTemplateEditorScreenState
|
||||
],
|
||||
onChanged: _switchEditing,
|
||||
),
|
||||
if (_isLocked) ...[
|
||||
const SizedBox(height: 10),
|
||||
_lockNote(t),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
@@ -695,7 +738,7 @@ class _LabelTemplateEditorScreenState
|
||||
DsButton('重命名',
|
||||
icon: LucideIcons.pencil,
|
||||
small: true,
|
||||
onPressed: _renameTemplate),
|
||||
onPressed: _isLocked ? null : _renameTemplate),
|
||||
DsButton('设默认',
|
||||
icon: LucideIcons.check,
|
||||
small: true,
|
||||
@@ -704,7 +747,7 @@ class _LabelTemplateEditorScreenState
|
||||
icon: LucideIcons.x,
|
||||
small: true,
|
||||
variant: DsBtnVariant.danger,
|
||||
onPressed: _deleteTemplate),
|
||||
onPressed: _isLocked ? null : _deleteTemplate),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -736,6 +779,19 @@ class _LabelTemplateEditorScreenState
|
||||
);
|
||||
}
|
||||
|
||||
/// 默认模板只读提示条(点「复制」是可编辑逃生口)。
|
||||
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,
|
||||
'显示字段',
|
||||
@@ -752,22 +808,25 @@ class _LabelTemplateEditorScreenState
|
||||
|
||||
Widget _fieldRow(AppTokens t, _Fd fd) {
|
||||
final on = _onOf(fd);
|
||||
final open = _openField == fd.key;
|
||||
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: () => setState(() {
|
||||
if (open) {
|
||||
_openField = null;
|
||||
} else {
|
||||
_openField = fd.key;
|
||||
_sizeCtrl.text =
|
||||
graphic || _isAuto(fd) ? '' : _sizeOf(fd).round().toString();
|
||||
}
|
||||
}),
|
||||
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(
|
||||
@@ -776,7 +835,9 @@ class _LabelTemplateEditorScreenState
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
DsSwitch(value: on, onChanged: (v) => _setOn(fd, v)),
|
||||
DsSwitch(
|
||||
value: on,
|
||||
onChanged: _isLocked ? null : (v) => _setOn(fd, v)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(fd.label,
|
||||
@@ -822,6 +883,27 @@ class _LabelTemplateEditorScreenState
|
||||
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: [
|
||||
@@ -841,6 +923,7 @@ class _LabelTemplateEditorScreenState
|
||||
final n = int.tryParse(v.trim());
|
||||
if (n != null && n > 0) _setSize(fd, n.toDouble());
|
||||
},
|
||||
suffix: _sizeStepper(fd, t, auto),
|
||||
),
|
||||
),
|
||||
if (isName) ...[
|
||||
@@ -889,6 +972,26 @@ class _LabelTemplateEditorScreenState
|
||||
);
|
||||
}
|
||||
|
||||
/// 字号输入框右侧内嵌的上下箭头步进器(点击 +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(
|
||||
@@ -978,8 +1081,10 @@ class _LabelTemplateEditorScreenState
|
||||
DsSeg(
|
||||
items: const ['正向', '反向'],
|
||||
index: _t.print.direction == 0 ? 1 : 0,
|
||||
onChanged: (i) =>
|
||||
_mutate(() => _t.print.direction = i == 0 ? 1 : 0),
|
||||
onChanged: (i) {
|
||||
if (_isLocked) return;
|
||||
_mutate(() => _t.print.direction = i == 0 ? 1 : 0);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1016,6 +1121,7 @@ class _LabelTemplateEditorScreenState
|
||||
label,
|
||||
input: DsInput(
|
||||
controller: c,
|
||||
enabled: !_isLocked,
|
||||
keyboardType: TextInputType.numberWithOptions(decimal: decimal),
|
||||
onChanged: (v) {
|
||||
final n = double.tryParse(v.trim());
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
@@ -47,5 +47,69 @@ void main() {
|
||||
expect(list.map((t) => t.id), ['builtin-default', 'tpl-2']);
|
||||
expect(list[1].name, '双语抬头');
|
||||
});
|
||||
|
||||
test('老店铺旧默认 QR 加载时自动升级为居中坐标', () {
|
||||
final def = LabelTemplate.builtinDefault();
|
||||
// 构造两个历史默认签名的模板(贴头顶版 + 中间偏上版)
|
||||
final legacyA = def.copyWith(id: 'tpl-legacy-a', name: '旧模板A');
|
||||
legacyA.qr
|
||||
..x = 178
|
||||
..y = 25
|
||||
..size = 134;
|
||||
final legacyB = def.copyWith(id: 'tpl-legacy-b', name: '旧模板B');
|
||||
legacyB.qr
|
||||
..x = 190
|
||||
..y = 34
|
||||
..size = 116;
|
||||
final cf = <String, dynamic>{
|
||||
'label_templates': [legacyA.toJson(), legacyB.toJson()],
|
||||
};
|
||||
final list = readLabelTemplates(cf);
|
||||
for (final t in list) {
|
||||
expect(t.qr.x, def.qr.x, reason: '${t.name} QR.x 应升级到新默认');
|
||||
expect(t.qr.y, def.qr.y, reason: '${t.name} QR.y 应升级到新默认');
|
||||
expect(t.qr.size, def.qr.size, reason: '${t.name} QR.size 应升级到新默认');
|
||||
}
|
||||
});
|
||||
|
||||
test('内置默认模板永远回代码基准:存储里被改脏也覆盖回 builtinDefault', () {
|
||||
final def = LabelTemplate.builtinDefault();
|
||||
// 模拟老库存的 builtin-default:品名被关、QR 乱改、字号乱改
|
||||
final stale = def.copyWith();
|
||||
stale.textStack.name.show = false;
|
||||
stale.qr
|
||||
..x = 10
|
||||
..y = 10
|
||||
..size = 200;
|
||||
stale.textStack.subLines[0].fontSize = 99;
|
||||
final cf = <String, dynamic>{
|
||||
'label_templates': [stale.toJson()],
|
||||
};
|
||||
final t =
|
||||
readLabelTemplates(cf).firstWhere((x) => x.id == 'builtin-default');
|
||||
expect(t.textStack.name.show, isTrue, reason: '品名默认必须展示');
|
||||
expect(t.qr.x, def.qr.x);
|
||||
expect(t.qr.y, def.qr.y);
|
||||
expect(t.qr.size, def.qr.size);
|
||||
expect(t.textStack.subLines[0].fontSize, def.textStack.subLines[0].fontSize);
|
||||
});
|
||||
|
||||
test('用户自定义过的 QR 不匹配旧签名,加载时保持不动', () {
|
||||
final custom = LabelTemplate.builtinDefault().copyWith(
|
||||
id: 'tpl-custom',
|
||||
name: '自定义位置',
|
||||
);
|
||||
custom.qr
|
||||
..x = 210
|
||||
..y = 60
|
||||
..size = 80; // 与任一历史默认都不同
|
||||
final cf = <String, dynamic>{
|
||||
'label_templates': [custom.toJson()],
|
||||
};
|
||||
final t = readLabelTemplates(cf).first;
|
||||
expect(t.qr.x, 210);
|
||||
expect(t.qr.y, 60);
|
||||
expect(t.qr.size, 80);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -77,6 +77,17 @@
|
||||
.lt-srow:last-child{margin-bottom:0;}
|
||||
.lt-srow .k{width:44px; flex:none; font-size:var(--fs-sm); color:var(--muted);}
|
||||
.lt-srow .input{width:80px;}
|
||||
/* 字号步进器:输入框右侧内嵌上下箭头,点击 +1/-1 */
|
||||
.lt-stepper{position:relative; width:80px; flex:none;}
|
||||
.lt-stepper .input{width:100%; box-sizing:border-box; padding-right:22px;}
|
||||
.lt-steparr{position:absolute; right:1px; top:1px; bottom:1px; width:20px;
|
||||
display:flex; flex-direction:column; border-left:1px solid var(--border);}
|
||||
.lt-steparr button{flex:1; display:flex; align-items:center; justify-content:center;
|
||||
border:0; background:transparent; cursor:pointer; color:var(--muted); padding:0;}
|
||||
.lt-steparr button:hover{background:var(--row-hover); color:var(--text);}
|
||||
.lt-steparr button:last-child{border-top:1px solid var(--border);}
|
||||
.lt-steparr button:disabled{opacity:.4; cursor:default; background:transparent; color:var(--muted);}
|
||||
.lt-steparr svg{width:11px; height:11px; stroke:currentColor; fill:none; stroke-width:2.5;}
|
||||
.lt-gnote{margin:0; font-size:var(--fs-xs); color:var(--muted); line-height:1.6;}
|
||||
/* 字体下拉(自包含浮层,每行独立) */
|
||||
.lt-fontsel{position:relative; flex:1;}
|
||||
@@ -92,6 +103,11 @@
|
||||
.lt-tplhint{display:flex; align-items:center; gap:6px; margin-top:12px; padding-top:12px; border-top:1px solid var(--border); font-size:var(--fs-xs); color:var(--muted);}
|
||||
.lt-tplhint svg{width:14px; height:14px; flex:none; stroke:currentColor; fill:none; stroke-width:2; color:var(--primary);}
|
||||
.lt-tplhint b{color:var(--primary); font-weight:600;}
|
||||
/* 默认模板只读锁:提示条 + 受锁区域整体禁用(点「复制」为可编辑逃生口) */
|
||||
.lt-locknote{margin-top:10px; padding:10px 12px; background:var(--warn-bg); border:1px solid var(--warn); border-radius:var(--r-md); font-size:var(--fs-xs); color:var(--warn); line-height:1.5;}
|
||||
.lt-lockoff{transition:opacity .15s;}
|
||||
.dis{opacity:.45; pointer-events:none;}
|
||||
.lt-panel.locked #fieldList, .lt-panel.locked .lt-adv, .lt-panel.locked #printSec{opacity:.5; pointer-events:none;}
|
||||
/* 高级折叠 */
|
||||
.lt-adv{background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); overflow:hidden;}
|
||||
.lt-adv>summary{list-style:none; cursor:pointer; padding:14px 16px; font-size:var(--fs-sm); font-weight:700; color:var(--title); display:flex; align-items:center; gap:8px;}
|
||||
@@ -116,7 +132,7 @@
|
||||
<div class="btn ghost sm" onclick="location.href='devices.html'"><svg viewBox="0 0 24 24"><use href="#i-arrow-left"/></svg>返回</div>
|
||||
<h1>标签模板编辑器</h1><span class="sub">左侧实时预览 · 右侧属性面板</span>
|
||||
<div class="actions">
|
||||
<div class="btn ghost" onclick="resetTpl()"><svg viewBox="0 0 24 24"><use href="#i-refresh"/></svg>重置</div>
|
||||
<div class="btn ghost lt-lockoff" onclick="resetTpl()"><svg viewBox="0 0 24 24"><use href="#i-refresh"/></svg>重置</div>
|
||||
<div class="btn ghost" onclick="printTpl()"><svg viewBox="0 0 24 24"><use href="#i-printer"/></svg>打印</div>
|
||||
<div class="btn primary" onclick="toast('模板已保存 ✓')"><svg viewBox="0 0 24 24"><use href="#i-check"/></svg>保存</div>
|
||||
</div>
|
||||
@@ -167,10 +183,11 @@
|
||||
<div class="lt-mgr">
|
||||
<div class="btn ghost sm" onclick="toast('新建模板(原型)')"><svg viewBox="0 0 24 24"><use href="#i-plus"/></svg>新建</div>
|
||||
<div class="btn ghost sm" onclick="toast('复制模板(原型)')"><svg viewBox="0 0 24 24"><use href="#i-ic27"/></svg>复制</div>
|
||||
<div class="btn ghost sm" onclick="openRename()"><svg viewBox="0 0 24 24"><use href="#i-edit"/></svg>重命名</div>
|
||||
<div class="btn ghost sm lt-lockoff" onclick="openRename()"><svg viewBox="0 0 24 24"><use href="#i-edit"/></svg>重命名</div>
|
||||
<div class="btn ghost sm" onclick="setDefaultTpl()"><svg viewBox="0 0 24 24"><use href="#i-check"/></svg>设默认</div>
|
||||
<div class="btn danger sm" onclick="toast('删除模板(原型)')"><svg viewBox="0 0 24 24"><use href="#i-close"/></svg>删除</div>
|
||||
<div class="btn danger sm lt-lockoff" onclick="toast('删除模板(原型)')"><svg viewBox="0 0 24 24"><use href="#i-close"/></svg>删除</div>
|
||||
</div>
|
||||
<div class="lt-locknote" id="lockNote">系统默认模板不可修改,点「复制」生成可编辑副本</div>
|
||||
<div class="lt-tplhint"><svg viewBox="0 0 24 24"><use href="#i-check"/></svg>出签打印使用:<b id="tplActive">默认版式</b></div>
|
||||
</div>
|
||||
|
||||
@@ -186,9 +203,9 @@
|
||||
<div class="body">
|
||||
<div class="lt-fmeta" style="margin-bottom:10px">二维码</div>
|
||||
<div class="lt-grid2">
|
||||
<div class="field"><label>X</label><input class="input" value="178"></div>
|
||||
<div class="field"><label>Y</label><input class="input" value="25"></div>
|
||||
<div class="field"><label>尺寸</label><input class="input" value="134"></div>
|
||||
<div class="field"><label>X</label><input class="input" value="192"></div>
|
||||
<div class="field"><label>Y</label><input class="input" value="32"></div>
|
||||
<div class="field"><label>尺寸</label><input class="input" value="120"></div>
|
||||
</div>
|
||||
<div class="lt-fmeta" style="margin:14px 0 10px">条形码</div>
|
||||
<div class="lt-grid2">
|
||||
@@ -205,7 +222,7 @@
|
||||
</details>
|
||||
|
||||
<!-- 打印参数(常驻可见) -->
|
||||
<div class="lt-sec">
|
||||
<div class="lt-sec" id="printSec">
|
||||
<div class="lt-sectitle"><svg viewBox="0 0 24 24"><use href="#i-gear"/></svg>打印参数</div>
|
||||
<div class="lt-grid2">
|
||||
<div class="field"><label>纸张宽 (mm)</label><input class="input" value="40"></div>
|
||||
@@ -249,14 +266,12 @@
|
||||
// 字体不做(热敏单色 MVP);仅「品名」支持自动字号。同物理行两字段共用字号/加粗/对齐(PAIR)。
|
||||
const ALIGN_LABEL = {left:'左', center:'居中', right:'右'};
|
||||
const FIELDS = [
|
||||
{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:'name', label:'品名', type:'text', on:true, auto:true, bold:true, align:'center', size:18, sample:'贵州茅台酒'},
|
||||
{f:'shop', label:'店名(抬头)', type:'text', on:true, auto:false, bold:true, align:'center', size:14, sample:'鼎晟酒行'},
|
||||
{f:'code', label:'编号', type:'text', on:true, auto:false, bold:false, align:'center', size:14, sample:'P1001'},
|
||||
{f:'series', label:'型号 / 度数', type:'text', on:true, auto:false, bold:false, align:'center', size:14, sample:'飞天53度'},
|
||||
{f:'spec', label:'版本 / 规格', type:'text', on:true, auto:false, bold:false, align:'center', size:14, sample:'500ml'},
|
||||
{f:'date', label:'生产日期', type:'text', on:true, auto:false, bold:false, align:'center', size:14, sample:'2024-05-18'},
|
||||
{f:'qr', label:'二维码', type:'graphic', on:true},
|
||||
{f:'barcode', label:'条形码', type:'graphic', on:true},
|
||||
];
|
||||
@@ -276,8 +291,17 @@
|
||||
<button class="switch${fd.auto?' on':''}" onclick="toggleAuto(event,${i})"></button>`
|
||||
: '';
|
||||
return `<div class="lt-fbody">
|
||||
<div class="lt-srow"><span class="k">内容</span>
|
||||
<input class="input" style="flex:1;width:auto" value="${fd.sample||''}" oninput="setSample(${i},this.value)"></div>
|
||||
<p class="lt-gnote" style="margin:-4px 0 12px">仅改预览示例,真实打印按商品数据填充</p>
|
||||
<div class="lt-srow"><span class="k">字号</span>
|
||||
<input class="input" value="${fd.size}" ${fd.auto?'disabled':''} oninput="setSize(${i},this.value)">
|
||||
<div class="lt-stepper">
|
||||
<input class="input" value="${fd.size}" ${fd.auto?'disabled':''} oninput="setSize(${i},this.value)">
|
||||
<div class="lt-steparr">
|
||||
<button type="button" ${fd.auto?'disabled':''} onclick="stepSize(${i},1)" aria-label="增大字号"><svg viewBox="0 0 24 24"><use href="#i-chevron-up"/></svg></button>
|
||||
<button type="button" ${fd.auto?'disabled':''} onclick="stepSize(${i},-1)" aria-label="减小字号"><svg viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
${autoCtrl}</div>
|
||||
<div class="lt-srow"><span class="k">加粗</span>
|
||||
<button class="switch${fd.bold?' on':''}" onclick="toggleBold(event,${i})"></button></div>
|
||||
@@ -347,11 +371,27 @@
|
||||
const n = parseInt(v,10);
|
||||
if(!isNaN(n)){ FIELDS[i].size = n; applyStyle(i); refreshMeta(i); mirror(i); }
|
||||
}
|
||||
/* 内容:仅改左侧预览示例文字,不影响真实打印(真实出签永远取商品数据) */
|
||||
function setSample(i, v){
|
||||
FIELDS[i].sample = v;
|
||||
previewEls(i).forEach(el=>{ el.textContent = v; });
|
||||
}
|
||||
/* 步进器 +1/-1:夹在 [6,48],回写输入框并刷新预览 */
|
||||
function stepSize(i, d){
|
||||
if(FIELDS[i].auto) return;
|
||||
let n = parseInt(FIELDS[i].size,10); if(isNaN(n)) n = 14;
|
||||
n = Math.max(6, Math.min(48, n + d));
|
||||
FIELDS[i].size = n;
|
||||
const inp = fieldEl(i).querySelector('.lt-stepper .input');
|
||||
if(inp) inp.value = n;
|
||||
applyStyle(i); refreshMeta(i); mirror(i);
|
||||
}
|
||||
function toggleAuto(e, i){
|
||||
e.stopPropagation();
|
||||
FIELDS[i].auto = !FIELDS[i].auto;
|
||||
e.currentTarget.classList.toggle('on', FIELDS[i].auto);
|
||||
fieldEl(i).querySelector('.lt-srow .input').disabled = FIELDS[i].auto;
|
||||
fieldEl(i).querySelectorAll('.lt-steparr button').forEach(b=>b.disabled = FIELDS[i].auto);
|
||||
applyStyle(i); refreshMeta(i);
|
||||
}
|
||||
function toggleBold(e, i){
|
||||
@@ -402,8 +442,18 @@
|
||||
document.getElementById('renameInput').value = name;
|
||||
document.getElementById('tplPop').classList.remove('show');
|
||||
renderTplPop();
|
||||
applyLock();
|
||||
toast('正在编辑「'+name+'」');
|
||||
}
|
||||
/* 默认模板('默认版式')受保护只读:显示锁提示、禁用字段/高级/打印/重命名/删除/重置,
|
||||
其余(下拉/新建/复制/设默认)保持可用;点「复制」生成可编辑副本 */
|
||||
function isLockedTpl(){ return tplEditing === '默认版式'; }
|
||||
function applyLock(){
|
||||
const on = isLockedTpl();
|
||||
document.getElementById('lockNote').style.display = on ? '' : 'none';
|
||||
document.querySelector('.lt-panel').classList.toggle('locked', on);
|
||||
document.querySelectorAll('.lt-lockoff').forEach(b=>b.classList.toggle('dis', on));
|
||||
}
|
||||
function setDefaultTpl(){
|
||||
tplActive = tplEditing;
|
||||
document.getElementById('tplActive').textContent = tplActive;
|
||||
@@ -413,6 +463,7 @@
|
||||
renderTplPop();
|
||||
document.addEventListener('click', ()=>document.querySelectorAll('.lt-fontpop.show').forEach(p=>p.classList.remove('show')));
|
||||
renderFields();
|
||||
applyLock();
|
||||
function openRename(){ document.getElementById('ovRename').classList.add('show'); }
|
||||
function closeRename(){ document.getElementById('ovRename').classList.remove('show'); }
|
||||
function doRename(){ closeRename(); toast('已重命名 ✓'); }
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user