Compare commits
21 Commits
client-v1.1.12
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fcc254caff | |||
| 7dceefc55e | |||
| ced71bd8a1 | |||
| 195b4d22e7 | |||
| 7b3a0ecdeb | |||
| 27c5f94329 | |||
| eedcd9a385 | |||
| d67295234e | |||
| df242dd703 | |||
| 6610d629ce | |||
| 4ed2c612a9 | |||
| 78e6a4c192 | |||
| 801b8528ec | |||
| a1a3118c88 | |||
| 2797c998f8 | |||
| 90e6b25cec | |||
| 1655769827 | |||
| 46302d9e4a | |||
| d299d4b0b0 | |||
| 848a9ee2c6 | |||
| 7025ccd1b5 |
@@ -98,6 +98,9 @@ jobs:
|
||||
build-ios:
|
||||
needs: build-android
|
||||
runs-on: mac
|
||||
# iOS TestFlight 上传易抖/易卡,按 CLAUDE.md「iOS 不阻塞发版」:本 job 失败
|
||||
# 不拖垮整个 run,更不阻断下面的 release-deploy(它已改为只校验 4 个真实平台)。
|
||||
continue-on-error: true
|
||||
env:
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||
@@ -159,6 +162,14 @@ jobs:
|
||||
|
||||
release-deploy-client:
|
||||
needs: [build-client-web, build-macos, build-android, build-ios, build-windows]
|
||||
# iOS(build-ios)故意可失败:只要 4 个真实平台成功就照常发布部署。
|
||||
# always() 保证 build-ios 失败时本 job 不被跳过;再逐个校验其余平台必须成功。
|
||||
if: >-
|
||||
${{ always()
|
||||
&& needs.build-client-web.result == 'success'
|
||||
&& needs.build-macos.result == 'success'
|
||||
&& needs.build-android.result == 'success'
|
||||
&& needs.build-windows.result == 'success' }}
|
||||
runs-on: mac
|
||||
env:
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
|
||||
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.1.17] - 2026-08-31
|
||||
|
||||
### 新功能
|
||||
- 全新「商品价签模板编辑器」:在「设置 → 打印模板 · 商品价签」点「编辑」进入,可自定义价签版式——勾选要显示的字段(品名 / 店名 / 编号 / 型号 / 规格 / 生产日期 / 二维码 / 条形码),逐字段调字号、粗体、对齐,调整二维码 / 条码 / 抬头位置,左侧实时预览;支持保存多个命名模板、复制、重命名、设为默认并一键打印;纸张、密度、打印方向等参数收进「高级」。系统默认模板为只读基准,点「复制」即可生成可编辑副本。
|
||||
|
||||
### 改进
|
||||
- 默认价签模板优化:品名默认展示,二维码放大到占满右侧整列、四周只留约 1mm 小边框,扫码更清晰。
|
||||
- 电脑 / 网页版打印价签改为与热敏标签机一致的整图版式,三端观感统一。
|
||||
|
||||
## [1.1.16] - 2026-08-28
|
||||
|
||||
### 修复
|
||||
- 修复 Windows 桌面版打印商品价签时中文(店名、品名、编号等文字)整片不显示、只剩条码与二维码的问题;现在价签中文在 Windows 上正常清晰显示。
|
||||
|
||||
## [1.1.15] - 2026-08-28
|
||||
|
||||
### 改进
|
||||
- 库存管理「规格 / 系列」筛选改为可搜索单选下拉,长列表可直接打字过滤快速定位;「仓库」筛选为单选下拉,操作更顺手。
|
||||
- 热敏打印机(标签机)打印商品价签的中文字体更清晰锐利、字号整体调大,不再发虚模糊。
|
||||
|
||||
### 修复
|
||||
- 修复库存筛选下拉框过宽、列表过高溢出屏幕的问题:现在下拉宽度贴合筛选按钮,超长列表在框内滚动、底部结果计数常驻可见。
|
||||
|
||||
## [1.1.14] - 2026-08-28
|
||||
|
||||
### 改进
|
||||
- 商品价签(标签)版式优化:品名与下方字段在左栏居中排布,编号+度数一行、规格+日期一行,更整齐易读。
|
||||
- 去掉价签底部多余的米色页脚,一维条码与二维码放大填满纸面,扫码更清晰。
|
||||
|
||||
## [1.1.13] - 2026-08-27
|
||||
|
||||
### 改进
|
||||
- 扫码出库免按回车:扫码枪扫完商品二维码后自动加入明细,无需再手动敲一下回车。
|
||||
|
||||
### 修复
|
||||
- 修复热敏打印机(得力等 TSPL 标签机)打印商品价签时字体重叠、且底部多出一行酒行名称的问题;现在热敏打印效果与屏幕预览完全一致,不再失真。
|
||||
|
||||
## [1.1.12] - 2026-08-27
|
||||
|
||||
### 改进
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
/// 集中管理应用配置,通过 --dart-define=KEY=value 注入环境变量
|
||||
///
|
||||
/// 开发默认值:http://localhost:8080
|
||||
/// 生产部署示例:flutter run \
|
||||
/// --dart-define=BASE_URL=http://192.168.1.100:8080 \
|
||||
/// --dart-define=SHOP_NAME=强朋友名酒行 \
|
||||
/// --dart-define=SHOP_ADDRESS=贵州省贵阳市云岩区中华北路88号 \
|
||||
/// --dart-define=SHOP_PHONE=0851-12345678
|
||||
/// 默认值 = 线上服务(jiu.51yanmei.com):本地 flutter run 不带 define 也连生产。
|
||||
/// 需连本地后端时显式覆盖:flutter run \
|
||||
/// --dart-define=BASE_URL=http://localhost:8080 \
|
||||
/// --dart-define=PUBLIC_URL=http://localhost:8081
|
||||
class AppConfig {
|
||||
const AppConfig._();
|
||||
|
||||
static const _baseUrl = String.fromEnvironment(
|
||||
'BASE_URL',
|
||||
defaultValue: 'http://localhost:8080',
|
||||
defaultValue: 'https://jiu.51yanmei.com',
|
||||
);
|
||||
|
||||
static const _publicUrl = String.fromEnvironment(
|
||||
'PUBLIC_URL',
|
||||
defaultValue: 'http://localhost:8081',
|
||||
defaultValue: 'https://jiu.51yanmei.com',
|
||||
);
|
||||
|
||||
static const shopName = String.fromEnvironment(
|
||||
|
||||
@@ -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<GoRouter>((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`)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:barcode/barcode.dart' as bc;
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'label_data.dart';
|
||||
import 'label_template.dart';
|
||||
|
||||
/// 标签「画布位图」单源渲染器(平台无关,只依赖 dart:ui + package:barcode)。
|
||||
///
|
||||
/// 四条打印/预览路径共用同一套几何:
|
||||
/// - 桌面预览(`renderLabelPreviewImpl`);
|
||||
/// - 桌面热敏 TSPL 光栅(`_printFlatLabelThermal`,barcode/QR 关闭改走原生命令);
|
||||
/// - 桌面 PDF 回退(`printProductLabelImpl` 贴整幅位图);
|
||||
/// - Web 打印(`print_util_web.dart` 贴整幅位图 `<img>`)。
|
||||
///
|
||||
/// 版式坐标/字号/开关全从 [tpl].computeLayout() 读,确保「预览即实际输出」。
|
||||
|
||||
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
|
||||
/// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。
|
||||
Future<ui.Image> renderLabelImage({
|
||||
required LabelTemplate tpl, // 版式单源:坐标/字号/开关全从此读
|
||||
required LabelData data, // 待打印字段值 + qrBytes
|
||||
bool drawBarcode = true, // 热敏光栅路径置 false:条码改用原生 TSPL BARCODE 保证扫码
|
||||
bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码
|
||||
}) async {
|
||||
final int w = tpl.paper.logicalW; // 40mm@203 → 320(逻辑尺寸)
|
||||
final int h = tpl.paper.logicalH; // 20mm@203 → 160
|
||||
const scale = 4; // 4× 超采样,内部以 1280×640 绘制(子像素更密,二值化边缘更锐)
|
||||
final int sw = w * scale, sh = h * scale;
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas =
|
||||
ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
|
||||
canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
|
||||
|
||||
// 纸面固定配色(对齐设计原型 devices.html 标签块)——预览为彩色设计稿,
|
||||
// 热敏机为单色介质,实际打印时头/脚颜色会退化为黑白(TSPL 路径另行处理)。
|
||||
const cWhite = ui.Color(0xFFFFFFFF); // ds-ignore
|
||||
const cNavy = ui.Color(0xFF1F2A3A); // ds-ignore 深蓝店名头
|
||||
const cCream = ui.Color(0xFFF4ECD8); // ds-ignore 抬头店名字色
|
||||
const cInk = ui.Color(0xFF111111); // ds-ignore 正文
|
||||
const cCode = ui.Color(0xFF333333); // ds-ignore 编号 / 三字段
|
||||
|
||||
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
|
||||
ui.Paint()..color = cWhite);
|
||||
|
||||
// 解析栅格流版式(隐藏区域空间由邻居回收;全字段显示时逐值=模型存的绝对坐标 → 零回归)。
|
||||
final lay = tpl.computeLayout();
|
||||
|
||||
// ── 深蓝店名头(binding 固定 shopName)──────────────────────────
|
||||
if (lay.headerShow) {
|
||||
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), lay.headerHeight),
|
||||
ui.Paint()..color = cNavy);
|
||||
_drawText(canvas, data.shopName, lay.headerTextX, lay.headerTextY,
|
||||
lay.headerFontSize, w - 2 * lay.headerTextX,
|
||||
bold: lay.headerBold,
|
||||
textAlign: _uiAlign(lay.headerAlign),
|
||||
color: cCream,
|
||||
centerInH: lay.headerHeight); // 店名在深蓝带内上下居中(textY 作微调)
|
||||
}
|
||||
|
||||
// ── 右列二维码(正方形,绝对坐标)─────────────────────────────
|
||||
if (drawQr && lay.qrShow) {
|
||||
try {
|
||||
final codec = await ui.instantiateImageCodec(data.qrBytes!);
|
||||
final qrImg = (await codec.getNextFrame()).image;
|
||||
canvas.drawImageRect(
|
||||
qrImg,
|
||||
ui.Rect.fromLTWH(
|
||||
0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()),
|
||||
ui.Rect.fromLTWH(lay.qrX, lay.qrY, lay.qrSize, lay.qrSize),
|
||||
ui.Paint());
|
||||
} catch (e) {
|
||||
debugPrint('[label] QR 解码失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库 ──
|
||||
if (drawBarcode && lay.barcodeShow) {
|
||||
_drawBarcode(canvas, data.code, lay.barX, lay.barY, lay.barW, lay.barH);
|
||||
}
|
||||
|
||||
// ── 左列文字块:品名 + 若干副字段行,整体在 [top, bottom] 区间竖直均分 ──
|
||||
final double leftX = lay.textX;
|
||||
final double leftW = lay.textWidth;
|
||||
final double infoTop = lay.textTop;
|
||||
final double infoBot = lay.textBottom;
|
||||
final double infoH = infoBot - infoTop;
|
||||
|
||||
// 逐行装配(text, size, bold, align):品名自适应/固定;副字段行按各自样式。
|
||||
final rows = <(String, double, bool, LabelAlign)>[];
|
||||
if (lay.name.show && data.name.isNotEmpty) {
|
||||
final double nameSize = lay.name.fontSize ??
|
||||
_fitFont(data.name, leftW, 1000, lay.name.bold, lay.name.fontMax)
|
||||
.clamp(lay.name.fontMin, lay.name.fontMax);
|
||||
rows.add((data.name, nameSize, lay.name.bold, lay.name.align));
|
||||
}
|
||||
for (final line in lay.subLines) {
|
||||
final parts = line.bindings
|
||||
.map((b) => resolveBinding(b, data))
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
final s = parts.join(' ');
|
||||
if (s.isNotEmpty) rows.add((s, line.fontSize, line.bold, line.align));
|
||||
}
|
||||
|
||||
final double kLineH = lay.textLineHeight; // 行高倍数
|
||||
final double totalTextH = rows.fold(0.0, (s, r) => s + r.$2 * kLineH);
|
||||
final int n = rows.length;
|
||||
final double rawGap = n > 0 ? (infoH - totalTextH) / (n + 1) : 2.0;
|
||||
final double gap = rawGap < 1.0 ? 1.0 : rawGap;
|
||||
double yy = infoTop + gap;
|
||||
for (final r in rows) {
|
||||
_drawText(canvas, r.$1, leftX, yy, r.$2, leftW,
|
||||
bold: r.$3, color: r.$3 ? cInk : cCode, textAlign: _uiAlign(r.$4));
|
||||
yy += r.$2 * kLineH + gap;
|
||||
}
|
||||
|
||||
return recorder.endRecording().toImage(sw, sh); // 返回 4× 图(1280×640)
|
||||
}
|
||||
|
||||
/// 渲染标签为 PNG 字节(预览弹窗 / Web 打印共用)。QR 未就绪等失败返回 null。
|
||||
Future<Uint8List?> renderLabelPngBytes({
|
||||
required LabelTemplate tpl,
|
||||
required LabelData data,
|
||||
}) async {
|
||||
final img = await renderLabelImage(tpl: tpl, data: data);
|
||||
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
|
||||
return bd?.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// 在 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: 纸面固定色(默认黑)
|
||||
double centerInH = 0})
|
||||
{
|
||||
if (s.isEmpty) return;
|
||||
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
|
||||
fontFamily: 'NotoSansSC', // 打包字体,笔画统一、比系统兜底字更实(热敏二值化后不糊)
|
||||
fontSize: size,
|
||||
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
|
||||
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));
|
||||
final double dy = centerInH > 0 ? y + (centerInH - p.height) / 2 : y;
|
||||
canvas.drawParagraph(p, ui.Offset(x, dy));
|
||||
}
|
||||
|
||||
/// 版式对齐 → 画布 TextAlign。
|
||||
ui.TextAlign _uiAlign(LabelAlign a) {
|
||||
switch (a) {
|
||||
case LabelAlign.left:
|
||||
return ui.TextAlign.left;
|
||||
case LabelAlign.center:
|
||||
return ui.TextAlign.center;
|
||||
case LabelAlign.right:
|
||||
return ui.TextAlign.right;
|
||||
}
|
||||
}
|
||||
|
||||
/// 在 canvas 上把 [data] 画成 Code128 一维条码(黑条,铺满 x,y,w,h 区域,不带人读字符)。
|
||||
/// 扫码枪扫 1D 条码;条码内容 = 商品编号,与出库扫码一致。
|
||||
void _drawBarcode(
|
||||
ui.Canvas canvas, String data, double x, double y, double w, double h) {
|
||||
if (data.isEmpty) return;
|
||||
final paint = ui.Paint()
|
||||
..color = const ui.Color(0xFF000000) // ds-ignore: 打印画布纸面固定色
|
||||
..style = ui.PaintingStyle.fill;
|
||||
for (final e in bc.Barcode.code128().make(data, width: w, height: h)) {
|
||||
if (e is bc.BarcodeBar && e.black) {
|
||||
canvas.drawRect(
|
||||
ui.Rect.fromLTWH(x + e.left, y + e.top, e.width, e.height), paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 测量单行文本在指定字号下的宽度
|
||||
double _measureWidth(String s, double size, bool bold) {
|
||||
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
|
||||
fontFamily: 'NotoSansSC',
|
||||
fontSize: size,
|
||||
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
|
||||
maxLines: 1,
|
||||
))
|
||||
..addText(s);
|
||||
final p = pb.build()..layout(const ui.ParagraphConstraints(width: 100000));
|
||||
return p.maxIntrinsicWidth;
|
||||
}
|
||||
|
||||
/// 自适应字号:在 maxW × maxH 范围内尽量大(受宽度、行高、上限三者约束)
|
||||
double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
|
||||
if (s.isEmpty) return 0;
|
||||
final w100 = _measureWidth(s, 100, bold);
|
||||
final byW = w100 > 0 ? maxW / w100 * 100 : cap;
|
||||
var size = byW < maxH ? byW : maxH;
|
||||
if (size > cap) size = cap;
|
||||
return size;
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
import 'label_data.dart';
|
||||
|
||||
/// 价签版式声明式模型(单一真源)。
|
||||
///
|
||||
/// 设计铁律(2026-08-28 已评审通过):
|
||||
/// - **画布 = 唯一几何权威**。渲染器(`_renderLabelBitmap`)是「纯绝对坐标画笔」,
|
||||
/// header/textStack/qr/barcode 的坐标、字号、开关全从本模型读;
|
||||
/// 预览与热敏文字光栅共用同一套画布,所见即所得。
|
||||
/// - 热敏保留原生 BARCODE + 独立位图 QR,但**坐标同样由本模型驱动**(决策②),
|
||||
/// 与画布同源(不再各自硬编码)。
|
||||
/// - 编辑器负责「算坐标」(移动 QR 时联动重算左列宽度等),渲染器只读绝对值。
|
||||
/// - `builtinDefault()` 把重构前 `print_util_stub.dart` 的硬编码常量逐值冻结为绝对值,
|
||||
/// 保证默认模板渲染与重构前**逐像素一致**(由 `label_render_golden_test.dart` 守闸)。
|
||||
///
|
||||
/// MVP 边界:字体只做字号 + 粗体(不换字族、不增包,决策④);颜色不暴露(热敏单色,决策⑤)。
|
||||
/// 存储:整棵树 toJson 存 `shop.custom_fields.label_templates`(List)+ `label_template_active`(id)。
|
||||
|
||||
/// 文本水平对齐(MVP:左/中/右;渲染器映射到 `ui.TextAlign`)。
|
||||
enum LabelAlign { left, center, right }
|
||||
|
||||
/// 可绑定到标签字段的商品/门店数据源。
|
||||
enum LabelBinding {
|
||||
shopName,
|
||||
name,
|
||||
code,
|
||||
series,
|
||||
spec,
|
||||
productionDate,
|
||||
batchNo,
|
||||
remark,
|
||||
shopAddress,
|
||||
shopPhone,
|
||||
}
|
||||
|
||||
LabelAlign _alignFrom(String? s) =>
|
||||
LabelAlign.values.firstWhere((e) => e.name == s,
|
||||
orElse: () => LabelAlign.center);
|
||||
|
||||
LabelBinding? _bindingFrom(String? s) {
|
||||
for (final e in LabelBinding.values) {
|
||||
if (e.name == s) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 把绑定解析为其在 [d] 上的字符串值(空字段返回空串;日期截断到 10 字符)。
|
||||
String resolveBinding(LabelBinding b, LabelData d) {
|
||||
switch (b) {
|
||||
case LabelBinding.shopName:
|
||||
return d.shopName;
|
||||
case LabelBinding.name:
|
||||
return d.name;
|
||||
case LabelBinding.code:
|
||||
return d.code;
|
||||
case LabelBinding.series:
|
||||
return d.series ?? '';
|
||||
case LabelBinding.spec:
|
||||
return d.spec ?? '';
|
||||
case LabelBinding.productionDate:
|
||||
final p = d.productionDate ?? '';
|
||||
return p.length > 10 ? p.substring(0, 10) : p;
|
||||
case LabelBinding.batchNo:
|
||||
return d.batchNo ?? '';
|
||||
case LabelBinding.remark:
|
||||
return d.remark ?? '';
|
||||
case LabelBinding.shopAddress:
|
||||
return d.shopAddress;
|
||||
case LabelBinding.shopPhone:
|
||||
return d.shopPhone;
|
||||
}
|
||||
}
|
||||
|
||||
/// 纸张与打印分辨率。逻辑像素 = mm / 25.4 * dpi(四舍五入)。
|
||||
class LabelPaper {
|
||||
double widthMm;
|
||||
double heightMm;
|
||||
int dpi;
|
||||
|
||||
LabelPaper({this.widthMm = 40, this.heightMm = 20, this.dpi = 203});
|
||||
|
||||
int get logicalW => (widthMm / 25.4 * dpi).round(); // 40mm@203 → 320
|
||||
int get logicalH => (heightMm / 25.4 * dpi).round(); // 20mm@203 → 160
|
||||
|
||||
Map<String, dynamic> toJson() =>
|
||||
{'widthMm': widthMm, 'heightMm': heightMm, 'dpi': dpi};
|
||||
|
||||
factory LabelPaper.fromJson(Map<String, dynamic> j) => LabelPaper(
|
||||
widthMm: (j['widthMm'] as num?)?.toDouble() ?? 40,
|
||||
heightMm: (j['heightMm'] as num?)?.toDouble() ?? 20,
|
||||
dpi: (j['dpi'] as num?)?.toInt() ?? 203,
|
||||
);
|
||||
|
||||
LabelPaper copy() => LabelPaper(widthMm: widthMm, heightMm: heightMm, dpi: dpi);
|
||||
}
|
||||
|
||||
/// 热敏 TSPL 打印参数(高级配置区暴露)。
|
||||
class LabelPrint {
|
||||
int density;
|
||||
int speed;
|
||||
int direction;
|
||||
int copies;
|
||||
double gapMm;
|
||||
|
||||
LabelPrint({
|
||||
this.density = 10,
|
||||
this.speed = 2,
|
||||
this.direction = 1,
|
||||
this.copies = 1,
|
||||
this.gapMm = 2,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'density': density,
|
||||
'speed': speed,
|
||||
'direction': direction,
|
||||
'copies': copies,
|
||||
'gapMm': gapMm,
|
||||
};
|
||||
|
||||
factory LabelPrint.fromJson(Map<String, dynamic> j) => LabelPrint(
|
||||
density: (j['density'] as num?)?.toInt() ?? 10,
|
||||
speed: (j['speed'] as num?)?.toInt() ?? 2,
|
||||
direction: (j['direction'] as num?)?.toInt() ?? 1,
|
||||
copies: (j['copies'] as num?)?.toInt() ?? 1,
|
||||
gapMm: (j['gapMm'] as num?)?.toDouble() ?? 2,
|
||||
);
|
||||
|
||||
LabelPrint copy() => LabelPrint(
|
||||
density: density,
|
||||
speed: speed,
|
||||
direction: direction,
|
||||
copies: copies,
|
||||
gapMm: gapMm);
|
||||
}
|
||||
|
||||
/// 抬头栏:深蓝底反白店名(binding 固定为 shopName)。
|
||||
class LabelHeader {
|
||||
bool show;
|
||||
double height; // 逻辑像素
|
||||
double textX;
|
||||
double textY;
|
||||
double fontSize;
|
||||
bool bold;
|
||||
LabelAlign align;
|
||||
|
||||
LabelHeader({
|
||||
this.show = true,
|
||||
this.height = 24,
|
||||
this.textX = 10,
|
||||
this.textY = 0, // 0=渲染器按 height 竖直居中;正负值作微调
|
||||
this.fontSize = 14,
|
||||
this.bold = true,
|
||||
this.align = LabelAlign.left,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'show': show,
|
||||
'height': height,
|
||||
'textX': textX,
|
||||
'textY': textY,
|
||||
'fontSize': fontSize,
|
||||
'bold': bold,
|
||||
'align': align.name,
|
||||
};
|
||||
|
||||
factory LabelHeader.fromJson(Map<String, dynamic> j) => 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() ?? 0,
|
||||
fontSize: (j['fontSize'] as num?)?.toDouble() ?? 14,
|
||||
bold: j['bold'] as bool? ?? true,
|
||||
align: _alignFrom(j['align'] as String?),
|
||||
);
|
||||
|
||||
LabelHeader copy() => LabelHeader(
|
||||
show: show,
|
||||
height: height,
|
||||
textX: textX,
|
||||
textY: textY,
|
||||
fontSize: fontSize,
|
||||
bold: bold,
|
||||
align: align);
|
||||
}
|
||||
|
||||
/// 大号自适应品名字段。fontSize == null → 在 [fontMin, fontMax] 内自适应放大。
|
||||
class LabelNameField {
|
||||
bool show;
|
||||
double? fontSize; // null = 自适应
|
||||
double fontMin;
|
||||
double fontMax;
|
||||
bool bold;
|
||||
LabelAlign align;
|
||||
|
||||
LabelNameField({
|
||||
this.show = true,
|
||||
this.fontSize,
|
||||
this.fontMin = 12,
|
||||
this.fontMax = 18,
|
||||
this.bold = true,
|
||||
this.align = LabelAlign.center,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'show': show,
|
||||
'fontSize': fontSize,
|
||||
'fontMin': fontMin,
|
||||
'fontMax': fontMax,
|
||||
'bold': bold,
|
||||
'align': align.name,
|
||||
};
|
||||
|
||||
factory LabelNameField.fromJson(Map<String, dynamic> j) => LabelNameField(
|
||||
show: j['show'] as bool? ?? true,
|
||||
fontSize: (j['fontSize'] as num?)?.toDouble(),
|
||||
fontMin: (j['fontMin'] as num?)?.toDouble() ?? 12,
|
||||
fontMax: (j['fontMax'] as num?)?.toDouble() ?? 18,
|
||||
bold: j['bold'] as bool? ?? true,
|
||||
align: _alignFrom(j['align'] as String?),
|
||||
);
|
||||
|
||||
LabelNameField copy() => LabelNameField(
|
||||
show: show,
|
||||
fontSize: fontSize,
|
||||
fontMin: fontMin,
|
||||
fontMax: fontMax,
|
||||
bold: bold,
|
||||
align: align);
|
||||
}
|
||||
|
||||
/// 副字段行:一行内若干绑定按 4 空格拼接(任一为空则跳过;整行为空则该行消失)。
|
||||
/// 每行是「一个字段」的最小单元时即可实现「逐字段独立字号」。
|
||||
class LabelSubLine {
|
||||
List<LabelBinding> bindings;
|
||||
double fontSize;
|
||||
bool bold;
|
||||
LabelAlign align;
|
||||
|
||||
LabelSubLine({
|
||||
required this.bindings,
|
||||
this.fontSize = 14,
|
||||
this.bold = false,
|
||||
this.align = LabelAlign.center,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'bindings': bindings.map((b) => b.name).toList(),
|
||||
'fontSize': fontSize,
|
||||
'bold': bold,
|
||||
'align': align.name,
|
||||
};
|
||||
|
||||
factory LabelSubLine.fromJson(Map<String, dynamic> j) => LabelSubLine(
|
||||
bindings: ((j['bindings'] as List?) ?? const [])
|
||||
.map((s) => _bindingFrom(s as String?))
|
||||
.whereType<LabelBinding>()
|
||||
.toList(),
|
||||
fontSize: (j['fontSize'] as num?)?.toDouble() ?? 14,
|
||||
bold: j['bold'] as bool? ?? false,
|
||||
align: _alignFrom(j['align'] as String?),
|
||||
);
|
||||
|
||||
LabelSubLine copy() => LabelSubLine(
|
||||
bindings: List.of(bindings),
|
||||
fontSize: fontSize,
|
||||
bold: bold,
|
||||
align: align);
|
||||
}
|
||||
|
||||
/// 左列文字区域:品名 + 若干副字段行,整体在 [top, bottom] 竖直区间内均分。
|
||||
class LabelTextStack {
|
||||
double x;
|
||||
double width;
|
||||
double top;
|
||||
double bottom;
|
||||
double lineHeight;
|
||||
LabelNameField name;
|
||||
List<LabelSubLine> subLines;
|
||||
|
||||
LabelTextStack({
|
||||
this.x = 8,
|
||||
this.width = 164,
|
||||
this.top = 30,
|
||||
this.bottom = 110,
|
||||
this.lineHeight = 1.3,
|
||||
required this.name,
|
||||
required this.subLines,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'x': x,
|
||||
'width': width,
|
||||
'top': top,
|
||||
'bottom': bottom,
|
||||
'lineHeight': lineHeight,
|
||||
'name': name.toJson(),
|
||||
'subLines': subLines.map((l) => l.toJson()).toList(),
|
||||
};
|
||||
|
||||
factory LabelTextStack.fromJson(Map<String, dynamic> j) => LabelTextStack(
|
||||
x: (j['x'] as num?)?.toDouble() ?? 8,
|
||||
width: (j['width'] as num?)?.toDouble() ?? 164,
|
||||
top: (j['top'] as num?)?.toDouble() ?? 30,
|
||||
bottom: (j['bottom'] as num?)?.toDouble() ?? 110,
|
||||
lineHeight: (j['lineHeight'] as num?)?.toDouble() ?? 1.3,
|
||||
name: j['name'] is Map
|
||||
? LabelNameField.fromJson(
|
||||
(j['name'] as Map).cast<String, dynamic>())
|
||||
: LabelNameField(),
|
||||
subLines: ((j['subLines'] as List?) ?? const [])
|
||||
.map((m) =>
|
||||
LabelSubLine.fromJson((m as Map).cast<String, dynamic>()))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
LabelTextStack copy() => LabelTextStack(
|
||||
x: x,
|
||||
width: width,
|
||||
top: top,
|
||||
bottom: bottom,
|
||||
lineHeight: lineHeight,
|
||||
name: name.copy(),
|
||||
subLines: subLines.map((l) => l.copy()).toList(),
|
||||
);
|
||||
|
||||
/// 组装非空副字段行(复刻旧 `_labelSubFields` 语义)。
|
||||
List<String> composeSubs(LabelData d) {
|
||||
final out = <String>[];
|
||||
for (final line in subLines) {
|
||||
final parts = line.bindings
|
||||
.map((b) => resolveBinding(b, d))
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
final s = parts.join(' ');
|
||||
if (s.isNotEmpty) out.add(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/// 二维码区域(正方形,绝对坐标)。
|
||||
class LabelQr {
|
||||
bool show;
|
||||
double x;
|
||||
double y;
|
||||
double size;
|
||||
|
||||
LabelQr({this.show = true, this.x = 178, this.y = 25, this.size = 134});
|
||||
|
||||
Map<String, dynamic> toJson() =>
|
||||
{'show': show, 'x': x, 'y': y, 'size': size};
|
||||
|
||||
factory LabelQr.fromJson(Map<String, dynamic> j) => LabelQr(
|
||||
show: j['show'] as bool? ?? true,
|
||||
x: (j['x'] as num?)?.toDouble() ?? 178,
|
||||
y: (j['y'] as num?)?.toDouble() ?? 25,
|
||||
size: (j['size'] as num?)?.toDouble() ?? 134,
|
||||
);
|
||||
|
||||
LabelQr copy() => LabelQr(show: show, x: x, y: y, size: size);
|
||||
}
|
||||
|
||||
/// 一维条码区域(Code128,内容 = 商品编号,绝对坐标)。
|
||||
class LabelBarcode {
|
||||
bool show;
|
||||
double x;
|
||||
double y;
|
||||
double width;
|
||||
double height;
|
||||
|
||||
LabelBarcode(
|
||||
{this.show = true,
|
||||
this.x = 8,
|
||||
this.y = 114,
|
||||
this.width = 164,
|
||||
this.height = 40});
|
||||
|
||||
Map<String, dynamic> toJson() =>
|
||||
{'show': show, 'x': x, 'y': y, 'width': width, 'height': height};
|
||||
|
||||
factory LabelBarcode.fromJson(Map<String, dynamic> j) => LabelBarcode(
|
||||
show: j['show'] as bool? ?? true,
|
||||
x: (j['x'] as num?)?.toDouble() ?? 8,
|
||||
y: (j['y'] as num?)?.toDouble() ?? 114,
|
||||
width: (j['width'] as num?)?.toDouble() ?? 164,
|
||||
height: (j['height'] as num?)?.toDouble() ?? 40,
|
||||
);
|
||||
|
||||
LabelBarcode copy() =>
|
||||
LabelBarcode(show: show, x: x, y: y, width: width, height: height);
|
||||
}
|
||||
|
||||
/// 一套完整的价签版式。
|
||||
class LabelTemplate {
|
||||
final String id;
|
||||
String name;
|
||||
LabelPaper paper;
|
||||
LabelPrint print;
|
||||
LabelHeader header;
|
||||
LabelTextStack textStack;
|
||||
LabelQr qr;
|
||||
LabelBarcode barcode;
|
||||
|
||||
LabelTemplate({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.paper,
|
||||
required this.print,
|
||||
required this.header,
|
||||
required this.textStack,
|
||||
required this.qr,
|
||||
required this.barcode,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'paper': paper.toJson(),
|
||||
'print': print.toJson(),
|
||||
'header': header.toJson(),
|
||||
'textStack': textStack.toJson(),
|
||||
'qr': qr.toJson(),
|
||||
'barcode': barcode.toJson(),
|
||||
};
|
||||
|
||||
factory LabelTemplate.fromJson(Map<String, dynamic> j) {
|
||||
final def = builtinDefault();
|
||||
Map<String, dynamic>? sub(String k) =>
|
||||
j[k] is Map ? (j[k] as Map).cast<String, dynamic>() : null;
|
||||
return LabelTemplate(
|
||||
id: (j['id'] as String?)?.isNotEmpty == true
|
||||
? j['id'] as String
|
||||
: def.id,
|
||||
name: (j['name'] as String?)?.isNotEmpty == true
|
||||
? j['name'] as String
|
||||
: def.name,
|
||||
paper: sub('paper') != null ? LabelPaper.fromJson(sub('paper')!) : def.paper,
|
||||
print: sub('print') != null ? LabelPrint.fromJson(sub('print')!) : def.print,
|
||||
header:
|
||||
sub('header') != null ? LabelHeader.fromJson(sub('header')!) : def.header,
|
||||
textStack: sub('textStack') != null
|
||||
? LabelTextStack.fromJson(sub('textStack')!)
|
||||
: def.textStack,
|
||||
qr: sub('qr') != null ? LabelQr.fromJson(sub('qr')!) : def.qr,
|
||||
barcode: sub('barcode') != null
|
||||
? LabelBarcode.fromJson(sub('barcode')!)
|
||||
: def.barcode,
|
||||
);
|
||||
}
|
||||
|
||||
LabelTemplate copyWith({String? id, String? name}) => LabelTemplate(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
paper: paper.copy(),
|
||||
print: print.copy(),
|
||||
header: header.copy(),
|
||||
textStack: textStack.copy(),
|
||||
qr: qr.copy(),
|
||||
barcode: barcode.copy(),
|
||||
);
|
||||
|
||||
/// 把声明式模型解析成一套**绝对几何**(渲染器只读它,不再各自读原始字段)。
|
||||
///
|
||||
/// 1 期布局口径 = 栅格流/自动回流(2026-08 用户拍板,取代绝对坐标):
|
||||
/// 存的绝对坐标是「全字段显示」的基准版式;某结构区域隐藏时,其让出的空间
|
||||
/// 由相邻区域回收填充,纸面不留空。三处结构性回流:
|
||||
/// ① 条码隐藏 → 文字区向下吃掉条码纵向空间(textBottom 延到条码原下沿);
|
||||
/// ② 二维码隐藏 → 文字区向右吃掉整列(textWidth 延到二维码原右沿);
|
||||
/// ③ 抬头隐藏 → 正文/二维码/条码整体上移一个抬头高,顶部不留空带。
|
||||
/// 副字段(编号/系列/规格/日期/批次/备注)的增删本就由文字区竖直均分自适应,无需此处处理。
|
||||
///
|
||||
/// **零回归铁律**:全字段显示时,本方法逐值返回存的绝对坐标(三条回流均为 no-op、
|
||||
/// 安全地板默认值之上不触发),故默认模板渲染与重构前逐像素一致
|
||||
/// (由 `label_render_golden_test.dart` 守闸)。改这里先确认 golden 仍零 diff。
|
||||
LabelLayout computeLayout() {
|
||||
double tTop = textStack.top;
|
||||
double tBot = textStack.bottom;
|
||||
final double tX = textStack.x;
|
||||
double tW = textStack.width;
|
||||
double qY = qr.y;
|
||||
double bY = barcode.y;
|
||||
|
||||
// ① 条码隐藏:文字区向下延到条码原下沿,吃掉纵向空白
|
||||
if (!barcode.show) tBot = barcode.y + barcode.height;
|
||||
// ② 二维码隐藏:文字区向右延到二维码原右沿,吃掉右列空白
|
||||
if (!qr.show) tW = (qr.x + qr.size) - textStack.x;
|
||||
// ③ 抬头隐藏:正文/二维码/条码整体上移一个抬头高
|
||||
if (!header.show) {
|
||||
final double dy = header.height;
|
||||
tTop -= dy;
|
||||
qY -= dy;
|
||||
bY -= dy;
|
||||
}
|
||||
|
||||
// 打印安全地板(203dpi 热敏可扫下限):默认值均在地板之上 → 不触发、不改默认像素
|
||||
final double qSize = qr.size < kMinQrSize ? kMinQrSize : qr.size;
|
||||
final double barH = barcode.height < kMinBarcodeH ? kMinBarcodeH : barcode.height;
|
||||
|
||||
return LabelLayout(
|
||||
headerShow: header.show,
|
||||
headerHeight: header.height,
|
||||
headerTextX: header.textX,
|
||||
headerTextY: header.textY,
|
||||
headerFontSize: header.fontSize,
|
||||
headerBold: header.bold,
|
||||
headerAlign: header.align,
|
||||
qrShow: qr.show,
|
||||
qrX: qr.x,
|
||||
qrY: qY,
|
||||
qrSize: qSize,
|
||||
barcodeShow: barcode.show,
|
||||
barX: barcode.x,
|
||||
barY: bY,
|
||||
barW: barcode.width,
|
||||
barH: barH,
|
||||
textX: tX,
|
||||
textWidth: tW,
|
||||
textTop: tTop,
|
||||
textBottom: tBot,
|
||||
textLineHeight: textStack.lineHeight,
|
||||
name: textStack.name,
|
||||
subLines: textStack.subLines,
|
||||
);
|
||||
}
|
||||
|
||||
/// 内置默认模板:逐值冻结重构前 `print_util_stub.dart` 的画布常量。
|
||||
/// 改任一常量前先确认 `label_render_golden_test.dart` 仍零 diff。
|
||||
static LabelTemplate builtinDefault() => LabelTemplate(
|
||||
id: 'builtin-default',
|
||||
name: '默认版式',
|
||||
paper: LabelPaper(widthMm: 40, heightMm: 20, dpi: 203),
|
||||
print: LabelPrint(density: 10, speed: 2, direction: 1, copies: 1, gapMm: 2),
|
||||
header: LabelHeader(
|
||||
show: true,
|
||||
height: 24,
|
||||
textX: 10,
|
||||
textY: 0, // 0=纯居中(渲染器按 height 竖直居中),正负值作微调
|
||||
fontSize: 14,
|
||||
bold: true,
|
||||
align: LabelAlign.left),
|
||||
textStack: LabelTextStack(
|
||||
x: 8,
|
||||
width: 164,
|
||||
top: 30,
|
||||
bottom: 110,
|
||||
lineHeight: 1.3,
|
||||
name: LabelNameField(
|
||||
show: true,
|
||||
fontSize: null,
|
||||
fontMin: 12,
|
||||
fontMax: 18,
|
||||
bold: true,
|
||||
align: LabelAlign.center),
|
||||
subLines: [
|
||||
LabelSubLine(
|
||||
bindings: [LabelBinding.code, LabelBinding.series],
|
||||
fontSize: 14,
|
||||
bold: false,
|
||||
align: LabelAlign.center),
|
||||
LabelSubLine(
|
||||
bindings: [LabelBinding.spec, LabelBinding.productionDate],
|
||||
fontSize: 14,
|
||||
bold: false,
|
||||
align: LabelAlign.center),
|
||||
],
|
||||
),
|
||||
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'];
|
||||
if (raw is List && raw.isNotEmpty) {
|
||||
final list = raw
|
||||
.whereType<Map>()
|
||||
.map((m) => LabelTemplate.fromJson(m.cast<String, dynamic>()))
|
||||
.map(_migrateLegacyDefaults)
|
||||
.map(_forceBuiltinDefaultFromCode)
|
||||
.toList();
|
||||
if (list.isNotEmpty) return list;
|
||||
}
|
||||
return [LabelTemplate.builtinDefault()];
|
||||
}
|
||||
|
||||
/// 当前出签实际使用的「打印默认」模板:由 custom_fields.label_template_active(id)指定,
|
||||
/// 缺失/匹配不到时取列表首个;列表空时 builtinDefault。渲染/打印路径统一取此。
|
||||
LabelTemplate activeLabelTemplate(Map<String, dynamic> 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;
|
||||
|
||||
/// 一维条码最小高度(逻辑像素@203dpi ≈ 2.5mm):低于此扫码枪难识别,computeLayout 兜底钳制。
|
||||
const double kMinBarcodeH = 20;
|
||||
|
||||
/// `computeLayout()` 的产物:一套解析后的**绝对几何**,渲染器(画布 + 热敏 TSPL)只读它。
|
||||
///
|
||||
/// 全字段显示时逐值等于模型存的绝对坐标(栅格流回流均为 no-op),保证默认渲染零回归。
|
||||
/// 文字栈的品名/副字段沿用原对象(竖直均分 + 品名自适应逻辑在渲染器内,不变)。
|
||||
class LabelLayout {
|
||||
final bool headerShow;
|
||||
final double headerHeight;
|
||||
final double headerTextX;
|
||||
final double headerTextY;
|
||||
final double headerFontSize;
|
||||
final bool headerBold;
|
||||
final LabelAlign headerAlign;
|
||||
|
||||
final bool qrShow;
|
||||
final double qrX;
|
||||
final double qrY;
|
||||
final double qrSize;
|
||||
|
||||
final bool barcodeShow;
|
||||
final double barX;
|
||||
final double barY;
|
||||
final double barW;
|
||||
final double barH;
|
||||
|
||||
final double textX;
|
||||
final double textWidth;
|
||||
final double textTop;
|
||||
final double textBottom;
|
||||
final double textLineHeight;
|
||||
final LabelNameField name;
|
||||
final List<LabelSubLine> subLines;
|
||||
|
||||
const LabelLayout({
|
||||
required this.headerShow,
|
||||
required this.headerHeight,
|
||||
required this.headerTextX,
|
||||
required this.headerTextY,
|
||||
required this.headerFontSize,
|
||||
required this.headerBold,
|
||||
required this.headerAlign,
|
||||
required this.qrShow,
|
||||
required this.qrX,
|
||||
required this.qrY,
|
||||
required this.qrSize,
|
||||
required this.barcodeShow,
|
||||
required this.barX,
|
||||
required this.barY,
|
||||
required this.barW,
|
||||
required this.barH,
|
||||
required this.textX,
|
||||
required this.textWidth,
|
||||
required this.textTop,
|
||||
required this.textBottom,
|
||||
required this.textLineHeight,
|
||||
required this.name,
|
||||
required this.subLines,
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import '../../models/stock_out.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import 'label_data.dart';
|
||||
export 'label_data.dart';
|
||||
import 'label_template.dart';
|
||||
export 'label_template.dart';
|
||||
import 'order_print_meta.dart';
|
||||
import '../theme/context_tokens.dart';
|
||||
import '../../widgets/ds/ds_toast.dart';
|
||||
@@ -25,6 +27,7 @@ Future<void> printProductLabel({
|
||||
String shopAddress = '',
|
||||
String shopPhone = '',
|
||||
String? printerName,
|
||||
LabelTemplate? template,
|
||||
}) =>
|
||||
printProductLabelImpl(
|
||||
qrBytes: qrBytes,
|
||||
@@ -39,11 +42,13 @@ Future<void> printProductLabel({
|
||||
shopAddress: shopAddress,
|
||||
shopPhone: shopPhone,
|
||||
printerName: printerName,
|
||||
template: template,
|
||||
);
|
||||
|
||||
/// 渲染标签预览图(PNG 字节);Web 平台返回 null。
|
||||
Future<Uint8List?> renderLabelPreview(LabelData label) =>
|
||||
renderLabelPreviewImpl(label);
|
||||
Future<Uint8List?> renderLabelPreview(LabelData label,
|
||||
{LabelTemplate? template}) =>
|
||||
renderLabelPreviewImpl(label, template: template);
|
||||
|
||||
/// 枚举当前系统所有可用打印机名;Web 平台返回空列表。
|
||||
Future<List<String>> listLabelPrinters() => listLabelPrintersImpl();
|
||||
|
||||
@@ -3,8 +3,6 @@ import 'dart:convert' show ascii;
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
import 'dart:ffi';
|
||||
import 'package:barcode/barcode.dart' as bc;
|
||||
import 'package:charset_converter/charset_converter.dart';
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:win32/win32.dart'
|
||||
show
|
||||
@@ -15,8 +13,7 @@ import 'package:win32/win32.dart'
|
||||
EndPagePrinter,
|
||||
EndDocPrinter,
|
||||
ClosePrinter,
|
||||
DOC_INFO_1,
|
||||
WideCharToMultiByte;
|
||||
DOC_INFO_1;
|
||||
import 'package:flutter/foundation.dart'
|
||||
show kIsWeb, debugPrint, visibleForTesting;
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -26,7 +23,9 @@ import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:printing/printing.dart';
|
||||
import '../../models/stock_in.dart';
|
||||
import '../../models/stock_out.dart';
|
||||
import 'label_bitmap.dart';
|
||||
import 'label_data.dart';
|
||||
import 'label_template.dart';
|
||||
import 'order_print_meta.dart';
|
||||
|
||||
Future<pw.Font> _loadFont() async {
|
||||
@@ -47,12 +46,6 @@ String _fileTs() {
|
||||
return '${n.year}${p(n.month)}${p(n.day)}_${p(n.hour)}${p(n.minute)}${p(n.second)}';
|
||||
}
|
||||
|
||||
// ── 设计色彩 token ──────────────────────────────────────────────────────────
|
||||
const _navy = PdfColor(0.122, 0.165, 0.227); // #1F2A3A header/chip
|
||||
const _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 footer/reversed text
|
||||
const _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 footer text
|
||||
const _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text
|
||||
|
||||
// ── 热敏标签 TSPL 裸发(桌面端:dart:ui 画扁平标签位图 → BITMAP → lp -o raw)────────
|
||||
// 得力 DL-888B 等热敏机走系统打印(CUPS 通用驱动)会多走纸/空白,必须发原生 TSPL。
|
||||
// 直接画位图而非走 PDF 光栅化:避免二维码渲染丢失、SIZE 用整数避免多走纸。
|
||||
@@ -156,295 +149,19 @@ bool _winRawPrint(String printerName, Uint8List data) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 在 canvas 上画一行文字(默认黑色,单行不换行,CJK 走系统字体回退)
|
||||
void _drawText(
|
||||
ui.Canvas canvas, String s, double x, double y, double size, double maxW,
|
||||
{bool bold = false,
|
||||
bool center = false,
|
||||
ui.Color color = const ui.Color(0xFF000000)}) // ds-ignore: 纸面固定色(默认黑)
|
||||
{
|
||||
if (s.isEmpty) return;
|
||||
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
|
||||
fontSize: size,
|
||||
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
|
||||
textAlign: center ? ui.TextAlign.center : ui.TextAlign.left,
|
||||
maxLines: 1,
|
||||
ellipsis: '',
|
||||
))
|
||||
..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));
|
||||
}
|
||||
/// 毫米数格式化为 TSPL 数值(整数不带小数点:40.0 → "40")。
|
||||
String _tsplMm(double v) =>
|
||||
v == v.roundToDouble() ? v.round().toString() : v.toString();
|
||||
|
||||
/// 在 canvas 上把 [data] 画成 Code128 一维条码(黑条,铺满 x,y,w,h 区域,不带人读字符)。
|
||||
/// 扫码枪扫 1D 条码;条码内容 = 商品编号,与出库扫码一致。
|
||||
void _drawBarcode(
|
||||
ui.Canvas canvas, String data, double x, double y, double w, double h) {
|
||||
if (data.isEmpty) return;
|
||||
final paint = ui.Paint()
|
||||
..color = const ui.Color(0xFF000000) // ds-ignore: 打印画布纸面固定色
|
||||
..style = ui.PaintingStyle.fill;
|
||||
for (final e in bc.Barcode.code128().make(data, width: w, height: h)) {
|
||||
if (e is bc.BarcodeBar && e.black) {
|
||||
canvas.drawRect(
|
||||
ui.Rect.fromLTWH(x + e.left, y + e.top, e.width, e.height), paint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 测量单行文本在指定字号下的宽度
|
||||
double _measureWidth(String s, double size, bool bold) {
|
||||
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
|
||||
fontSize: size,
|
||||
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
|
||||
maxLines: 1,
|
||||
))
|
||||
..addText(s);
|
||||
final p = pb.build()..layout(const ui.ParagraphConstraints(width: 100000));
|
||||
return p.maxIntrinsicWidth;
|
||||
}
|
||||
|
||||
/// 自适应字号:在 maxW × maxH 范围内尽量大(受宽度、行高、上限三者约束)
|
||||
double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
|
||||
if (s.isEmpty) return 0;
|
||||
final w100 = _measureWidth(s, 100, bold);
|
||||
final byW = w100 > 0 ? maxW / w100 * 100 : cap;
|
||||
var size = byW < maxH ? byW : maxH;
|
||||
if (size > cap) size = cap;
|
||||
return size;
|
||||
}
|
||||
|
||||
/// 组装标签底部平铺的三字段:度数(型号) / 规格(版本) / 生产日期。
|
||||
/// 各值为空则跳过;日期截断到 10 字符(YYYY-MM-DD)。四路渲染共用,口径一致。
|
||||
List<String> _labelSubFields(
|
||||
String? series, String? spec, String? productionDate) {
|
||||
final date = (productionDate ?? '').isNotEmpty
|
||||
? (productionDate!.length > 10
|
||||
? productionDate.substring(0, 10)
|
||||
: productionDate)
|
||||
: '';
|
||||
return [
|
||||
if ((series ?? '').isNotEmpty) series!,
|
||||
if ((spec ?? '').isNotEmpty) spec!,
|
||||
if (date.isNotEmpty) date,
|
||||
];
|
||||
}
|
||||
|
||||
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
|
||||
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
|
||||
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
|
||||
Future<ui.Image> _renderLabelBitmap({
|
||||
required String shop,
|
||||
required String name,
|
||||
required String code,
|
||||
required List<String> subs, // 底部平铺字段:度数 / 规格 / 日期(已过滤空)
|
||||
required Uint8List qrBytes,
|
||||
}) async {
|
||||
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
|
||||
const scale = 2; // 2× 超采样,内部以 640×320 绘制
|
||||
const sw = w * scale, sh = h * scale;
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas =
|
||||
ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
|
||||
canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
|
||||
|
||||
// 纸面固定配色(对齐设计原型 devices.html 标签块)——预览为彩色设计稿,
|
||||
// 热敏机为单色介质,实际打印时头/脚颜色会退化为黑白(TSPL 路径另行处理)。
|
||||
const cWhite = ui.Color(0xFFFFFFFF); // ds-ignore
|
||||
const cNavy = ui.Color(0xFF1F2A3A); // ds-ignore 深蓝店名头
|
||||
const cCream = ui.Color(0xFFF4ECD8); // ds-ignore 米色页脚 / 头部字色
|
||||
const cInk = ui.Color(0xFF111111); // ds-ignore 正文
|
||||
const cCode = ui.Color(0xFF333333); // ds-ignore 编号 / 三字段
|
||||
const cFoot = ui.Color(0xFF5A4E35); // ds-ignore 页脚字
|
||||
|
||||
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
|
||||
ui.Paint()..color = cWhite);
|
||||
|
||||
// ── 深蓝店名头 ──────────────────────────────────────────────
|
||||
const headerH = 24.0;
|
||||
canvas.drawRect(
|
||||
ui.Rect.fromLTWH(0, 0, w.toDouble(), headerH), ui.Paint()..color = cNavy);
|
||||
_drawText(canvas, shop, 10, 5, 12, w - 20, bold: true, color: cCream);
|
||||
|
||||
// ── 米色页脚 ────────────────────────────────────────────────
|
||||
const footerH = 15.0;
|
||||
const footerTop = h - footerH; // 145
|
||||
canvas.drawRect(ui.Rect.fromLTWH(0, footerTop, w.toDouble(), footerH),
|
||||
ui.Paint()..color = cCream);
|
||||
_drawText(canvas, shop, 10, footerTop + 3.5, 8, 180, color: cFoot);
|
||||
final szW = _measureWidth('40×20mm', 8, false);
|
||||
_drawText(canvas, '40×20mm', w - 10 - szW, footerTop + 3.5, 8, szW,
|
||||
color: cFoot);
|
||||
|
||||
// ── 主体两列布局 ────────────────────────────────────────────
|
||||
const bodyTop = headerH; // 24
|
||||
const bodyBot = footerTop; // 145
|
||||
const padV = 6.0, padH = 8.0; // 收紧留白,给二维码腾空间
|
||||
const double colGap = 6.0; // 左列与二维码列间距(原 10,收小)
|
||||
|
||||
// 右列:二维码占主体整高(正方形)
|
||||
const double qrSize = (bodyBot - bodyTop) - 2 * padV; // 109
|
||||
const double qrTop = bodyTop + padV; // 30
|
||||
const double qrLeft = w - padH - qrSize; // 203
|
||||
try {
|
||||
final codec = await ui.instantiateImageCodec(qrBytes);
|
||||
final qrImg = (await codec.getNextFrame()).image;
|
||||
canvas.drawImageRect(
|
||||
qrImg,
|
||||
ui.Rect.fromLTWH(0, 0, qrImg.width.toDouble(), qrImg.height.toDouble()),
|
||||
const ui.Rect.fromLTWH(qrLeft, qrTop, qrSize, qrSize),
|
||||
ui.Paint());
|
||||
} catch (e) {
|
||||
debugPrint('[label] QR 解码失败: $e');
|
||||
}
|
||||
|
||||
// 左列几何
|
||||
const double leftX = padH; // 8
|
||||
const double leftRight = qrLeft - colGap; // 197
|
||||
const double leftW = leftRight - leftX; // 189
|
||||
|
||||
// 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库
|
||||
const double barH = 40.0;
|
||||
const double barBottom = bodyBot - padV; // 137
|
||||
const double barTop = barBottom - barH; // 97
|
||||
_drawBarcode(canvas, code, leftX, barTop, leftW, barH);
|
||||
|
||||
// 左列文字块(品名+编号 一行居中;度数/规格/日期 平铺一行),在条码上方居中
|
||||
const double infoTop = bodyTop + padV; // 32
|
||||
const double infoBot = barTop - 4; // 93
|
||||
const double infoH = infoBot - infoTop; // 61
|
||||
|
||||
// 品名自适应字号(为编号留出约 28% 宽度),上限 17
|
||||
final double nameSize =
|
||||
_fitFont(name, leftW * 0.72, 1000, true, 17.0).clamp(11.0, 17.0);
|
||||
const double codeSize = 10.0;
|
||||
const double subSize = 10.0;
|
||||
final double nameLineH = nameSize * 1.2;
|
||||
const double subLineH = subSize * 1.2;
|
||||
const double interGap = 8.0;
|
||||
final double blockH = nameLineH + interGap + subLineH;
|
||||
double blockTop = infoTop + (infoH - blockH) / 2;
|
||||
if (blockTop < infoTop) blockTop = infoTop;
|
||||
|
||||
// 品名 + 编号:整体水平居中,基线对齐
|
||||
final double nameW = _measureWidth(name, nameSize, true);
|
||||
final bool hasCode = code.isNotEmpty;
|
||||
const double codeGap = 9.0;
|
||||
final double codeW = hasCode ? _measureWidth(code, codeSize, false) : 0;
|
||||
final double groupW = nameW + (hasCode ? codeGap + codeW : 0);
|
||||
double gx = leftX + (leftW - groupW) / 2;
|
||||
if (gx < leftX) gx = leftX;
|
||||
_drawText(canvas, name, gx, blockTop, nameSize, leftW,
|
||||
bold: true, color: cInk);
|
||||
if (hasCode) {
|
||||
_drawText(canvas, code, gx + nameW + codeGap,
|
||||
blockTop + (nameSize - codeSize) * 0.82, codeSize, codeW + 4,
|
||||
color: cCode);
|
||||
}
|
||||
|
||||
// 三字段平铺一行(space-between,两侧留内边距使外白 > 中白)
|
||||
final double subY = blockTop + nameLineH + interGap;
|
||||
const double innerPad = 10.0; // 对齐原型 .lbl-sub padding:0 10px
|
||||
const double innerLeft = leftX + innerPad; // 18
|
||||
const double innerRight = leftRight - innerPad; // 187
|
||||
const double innerW = innerRight - innerLeft; // 169
|
||||
if (subs.length == 1) {
|
||||
final double sw0 = _measureWidth(subs[0], subSize, false);
|
||||
_drawText(
|
||||
canvas, subs[0], innerLeft + (innerW - sw0) / 2, subY, subSize, innerW,
|
||||
color: cCode);
|
||||
} else if (subs.isNotEmpty) {
|
||||
// 字段间必须留可见最小间隔;总宽超限时按比例缩字号,避免贴死
|
||||
const double minGap = 5.0;
|
||||
double fs = subSize;
|
||||
double sumW = subs.fold(
|
||||
0.0, (s, x) => s + _measureWidth(x, fs, false));
|
||||
final double avail = innerW - minGap * (subs.length - 1);
|
||||
if (sumW > avail && sumW > 0) {
|
||||
fs = (subSize * avail / sumW).clamp(7.0, subSize);
|
||||
sumW = subs.fold(0.0, (s, x) => s + _measureWidth(x, fs, false));
|
||||
}
|
||||
final double gap = subs.length > 1
|
||||
? ((innerW - sumW) / (subs.length - 1)).clamp(minGap, innerW)
|
||||
: 0.0;
|
||||
double cx = innerLeft;
|
||||
for (int i = 0; i < subs.length; i++) {
|
||||
final double wi = _measureWidth(subs[i], fs, false);
|
||||
_drawText(canvas, subs[i], cx, subY, fs, wi + 4, color: cCode);
|
||||
cx += wi + gap;
|
||||
}
|
||||
}
|
||||
|
||||
return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320)
|
||||
}
|
||||
|
||||
/// UTF-8 → GBK 字节:
|
||||
/// Windows 直接调 WideCharToMultiByte(936) FFI,无需插件;
|
||||
/// macOS 走 charset_converter(CoreFoundation 原生支持 GBK)。
|
||||
Uint8List _encodeGbk(String text) {
|
||||
if (text.isEmpty) return Uint8List(0);
|
||||
if (Platform.isWindows) {
|
||||
final wideStr = text.toNativeUtf16();
|
||||
try {
|
||||
final needed = WideCharToMultiByte(
|
||||
936, 0, wideStr, -1, nullptr, 0, nullptr, nullptr);
|
||||
if (needed <= 1) return Uint8List(0);
|
||||
final buf = calloc<Uint8>(needed);
|
||||
try {
|
||||
WideCharToMultiByte(
|
||||
936, 0, wideStr, -1, buf.cast(), needed, nullptr, nullptr);
|
||||
return Uint8List.fromList(buf.asTypedList(needed - 1)); // 不含 null 终止符
|
||||
} finally {
|
||||
calloc.free(buf);
|
||||
}
|
||||
} finally {
|
||||
calloc.free(wideStr);
|
||||
}
|
||||
}
|
||||
// macOS: charset_converter(同步 CoreFoundation)
|
||||
// encode() 在 macOS 上是同步 channel 调用,此处以 Future.value 包装保持接口一致
|
||||
throw UnsupportedError('call _encodeGbkAsync on macOS');
|
||||
}
|
||||
|
||||
Future<Uint8List> _encodeGbkAsync(String text) async {
|
||||
if (text.isEmpty) return Uint8List(0);
|
||||
if (Platform.isWindows) return _encodeGbk(text);
|
||||
return CharsetConverter.encode('GBK', text);
|
||||
}
|
||||
|
||||
/// 估算 TSPL 内置点阵字体下一行文本的像素宽度(dots):
|
||||
/// 全角(CJK) 字符宽 = 字高;半角(ASCII) 宽 = 字高/2。用于居中 / 平铺定位。
|
||||
int _estTsplWidth(String s, int fontH) {
|
||||
var w = 0;
|
||||
for (final r in s.runes) {
|
||||
w += r > 0x7F ? fontH : (fontH ~/ 2);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
/// TSPL TEXT 命令辅助:将文本以 GBK 编码写入 BytesBuilder。
|
||||
/// TSS24.BF2/TSS16.BF2 是 TSC 固件内置简体中文点阵字体,需 GBK 编码。
|
||||
Future<void> _addTextCmd(
|
||||
BytesBuilder buf, int x, int y, String font, String text) async {
|
||||
if (text.isEmpty) return;
|
||||
final gbk = await _encodeGbkAsync(text);
|
||||
buf.add(ascii.encode('TEXT $x,$y,"$font",0,1,1,"'));
|
||||
buf.add(gbk);
|
||||
buf.add(ascii.encode('"\r\n'));
|
||||
}
|
||||
|
||||
/// 桌面端把标签以 TSPL TEXT(内置中文点阵字体)+ QR BITMAP 混合方式裸发到热敏机。
|
||||
/// TEXT 走 TSS24.BF2/TSS16.BF2(GBK 编码,203 DPI 下比 TrueType 位图清晰数倍)。
|
||||
/// QR 仍走 BITMAP(内置 QRCODE 命令扫描不可靠)。
|
||||
/// 桌面端把整张标签「光栅化」成单色位图裸发到热敏机(TSPL BITMAP),令热敏输出与
|
||||
/// 预览画布 100% 一致——彻底消除内置点阵字(TSS16/24)与预览 TrueType 度量不一致
|
||||
/// 导致的字体重叠 / 失真。文字/抬头走光栅;条码(Code128)与二维码仍走原生
|
||||
/// BARCODE / BITMAP 命令保证扫码可靠(光栅化后细条/密码易糊)。
|
||||
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
|
||||
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
|
||||
Future<bool> _printFlatLabelThermal({
|
||||
required String shop,
|
||||
required String name,
|
||||
required String code,
|
||||
required List<String> subs, // 度数 / 规格 / 日期(已过滤空)
|
||||
required Uint8List qrBytes,
|
||||
required LabelTemplate tpl, // 版式单源:QR/条码坐标 + TSPL 参数全从此读
|
||||
required LabelData data,
|
||||
String? printerName,
|
||||
}) async {
|
||||
if (kIsWeb || !(Platform.isMacOS || Platform.isWindows)) return false;
|
||||
@@ -452,67 +169,101 @@ Future<bool> _printFlatLabelThermal({
|
||||
debugPrint('[label] thermal printer = $printer');
|
||||
if (printer == null) return false;
|
||||
|
||||
// 版面(320×160 dots,DIRECTION 1):深蓝头/米色脚在单色热敏上分别退化为
|
||||
// 「黑底反白店名」「白底黑字页脚」;主体两列:左=文字+条码,右=二维码占整高。
|
||||
const headerH = 26; // 头部黑带高
|
||||
const bodyTop = headerH; // 26
|
||||
const bodyBot = 134; // 主体下沿(页脚之上)
|
||||
final int w = tpl.paper.logicalW; // 40mm@203 → 320(逻辑尺寸)
|
||||
final int h = tpl.paper.logicalH; // 20mm@203 → 160
|
||||
final int wBytes = (w + 7) >> 3; // 40 bytes/row
|
||||
final code = data.code;
|
||||
|
||||
// 右列二维码:占主体整高(正方形),右对齐(放大,占满主体高度)
|
||||
const qrW = 104, qrH = 104;
|
||||
const qrX = 320 - 8 - qrW; // 208
|
||||
const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 28
|
||||
const qrWBytes = (qrW + 7) >> 3; // 13 bytes/row
|
||||
// ── 文字/抬头整幅光栅:复用预览画布(跳过条码+QR,二者改原生命令)─────────
|
||||
// 画布内部 4× 超采样(1280×640),此处按覆盖率投票降采样二值化:
|
||||
// 每个墨点看其 4×4=16 个子像素中"是墨"的占比,≥40% 即打黑。
|
||||
// 替代旧的"盒式平均+128 阈值"——后者先把边缘抹成灰再一刀切,正是糊字的根因。
|
||||
final img = await renderLabelImage(
|
||||
tpl: tpl,
|
||||
data: data,
|
||||
drawBarcode: false,
|
||||
drawQr: false,
|
||||
);
|
||||
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
final rgba = bd!.buffer.asUint8List();
|
||||
final iw = img.width; // 1280(4× 超采样)
|
||||
const int ss = 4; // 超采样倍率,须与 renderLabelImage 的 scale 一致
|
||||
const int subTotal = ss * ss; // 16 子像素/墨点
|
||||
|
||||
final codec = await ui.instantiateImageCodec(qrBytes,
|
||||
targetWidth: qrW, targetHeight: qrH);
|
||||
final qrImg = (await codec.getNextFrame()).image;
|
||||
final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
final qrRgba = qrBd!.buffer.asUint8List();
|
||||
// 解析栅格流版式(与画布同源):QR/条码坐标随隐藏字段回流,全字段显示时=模型绝对坐标。
|
||||
final lay = tpl.computeLayout();
|
||||
|
||||
// TSPL 头部参数由 template 驱动(纸张/间隙/方向/密度/速度)。
|
||||
final p = tpl.print;
|
||||
final buf = BytesBuilder();
|
||||
buf.add(ascii.encode(
|
||||
'SIZE 40 mm,20 mm\r\nGAP 2 mm,0 mm\r\nDIRECTION 1\r\n'
|
||||
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
|
||||
'SIZE ${_tsplMm(tpl.paper.widthMm)} mm,${_tsplMm(tpl.paper.heightMm)} mm\r\n'
|
||||
'GAP ${_tsplMm(p.gapMm)} mm,0 mm\r\nDIRECTION ${p.direction}\r\n'
|
||||
'REFERENCE 0,0\r\nDENSITY ${p.density}\r\nSPEED ${p.speed}\r\nCLS\r\n',
|
||||
));
|
||||
|
||||
// ── 深蓝店名头(黑底反白):先画黑字店名,再 REVERSE 整条头部 → 白字黑底 ──
|
||||
await _addTextCmd(buf, 8, 5, 'TSS16.BF2', shop);
|
||||
buf.add(ascii.encode('REVERSE 0,0,320,$headerH\r\n'));
|
||||
|
||||
// ── 米色页脚(白底黑字):店名 + 尺寸 ──
|
||||
const footerY = 140;
|
||||
await _addTextCmd(buf, 8, footerY, 'TSS16.BF2', shop);
|
||||
const sizeStr = '40×20mm';
|
||||
final sizeW = _estTsplWidth(sizeStr, 16);
|
||||
await _addTextCmd(
|
||||
buf, (320 - 8 - sizeW).clamp(8, 312), footerY, 'TSS16.BF2', sizeStr);
|
||||
|
||||
// ── 右列二维码 BITMAP ──
|
||||
buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,'));
|
||||
for (int yy = 0; yy < qrH; yy++) {
|
||||
final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF); // 默认白
|
||||
for (int xx = 0; xx < qrW; xx++) {
|
||||
final i = (yy * qrW + xx) * 4;
|
||||
final a = qrRgba[i + 3];
|
||||
final lum = a < 128
|
||||
? 255.0
|
||||
: 0.299 * qrRgba[i] + 0.587 * qrRgba[i + 1] + 0.114 * qrRgba[i + 2];
|
||||
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7));
|
||||
// 整幅文字位图(mode 0 覆写整版):深蓝抬头→黑底反白,其余白底黑字,
|
||||
// 均由亮度阈值自然还原,无需 REVERSE;bit 0=黑点,默认 0xFF=白。
|
||||
buf.add(ascii.encode('BITMAP 0,0,$wBytes,$h,0,'));
|
||||
for (int y = 0; y < h; y++) {
|
||||
final row = Uint8List(wBytes)..fillRange(0, wBytes, 0xFF);
|
||||
for (int x = 0; x < w; x++) {
|
||||
int ink = 0; // 该墨点内"是墨"的子像素数
|
||||
for (int dy = 0; dy < ss; dy++) {
|
||||
for (int dx = 0; dx < ss; dx++) {
|
||||
final i = ((y * ss + dy) * iw + (x * ss + dx)) * 4;
|
||||
if (rgba[i + 3] < 128) continue; // 透明子像素当白
|
||||
final lum =
|
||||
0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2];
|
||||
if (lum < 128) ink++; // 暗且不透明 = 墨
|
||||
}
|
||||
}
|
||||
// 覆盖率投票:≥40% 子像素是墨即打黑点。阈值偏低让细笔画不断(热敏易掉墨),
|
||||
// 且不引入盒式平均的灰边模糊。
|
||||
if (ink * 5 >= subTotal * 2) row[x >> 3] &= ~(0x80 >> (x & 7));
|
||||
}
|
||||
buf.add(row);
|
||||
}
|
||||
buf.add(ascii.encode('\r\n'));
|
||||
|
||||
// 左列几何(与二维码列间距收小到 6)
|
||||
const leftX = 8,
|
||||
leftRight = qrX - 6,
|
||||
leftW = leftRight - leftX; // 8..202, 194
|
||||
// ── 右列二维码(原生 BITMAP,避免光栅降采样糊码),坐标由 template 驱动(与画布同源)──
|
||||
if (lay.qrShow && data.qrBytes != null) {
|
||||
final int qrW = lay.qrSize.round();
|
||||
final int qrH = qrW;
|
||||
final int qrX = lay.qrX.round();
|
||||
final int qrY = lay.qrY.round();
|
||||
final int qrWBytes = (qrW + 7) >> 3;
|
||||
final qrCodec = await ui.instantiateImageCodec(data.qrBytes!,
|
||||
targetWidth: qrW, targetHeight: qrH);
|
||||
final qrImg = (await qrCodec.getNextFrame()).image;
|
||||
final qrBd = await qrImg.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
final qrRgba = qrBd!.buffer.asUint8List();
|
||||
buf.add(ascii.encode('BITMAP $qrX,$qrY,$qrWBytes,$qrH,1,'));
|
||||
for (int yy = 0; yy < qrH; yy++) {
|
||||
final row = Uint8List(qrWBytes)..fillRange(0, qrWBytes, 0xFF);
|
||||
for (int xx = 0; xx < qrW; xx++) {
|
||||
final i = (yy * qrW + xx) * 4;
|
||||
final a = qrRgba[i + 3];
|
||||
final lum = a < 128
|
||||
? 255.0
|
||||
: 0.299 * qrRgba[i] +
|
||||
0.587 * qrRgba[i + 1] +
|
||||
0.114 * qrRgba[i + 2];
|
||||
if (lum < 128) row[xx >> 3] &= ~(0x80 >> (xx & 7));
|
||||
}
|
||||
buf.add(row);
|
||||
}
|
||||
buf.add(ascii.encode('\r\n'));
|
||||
}
|
||||
|
||||
// ── 左列底部整幅一维条码(Code128,内容=商品编号),narrow 自适应铺满并居中 ──
|
||||
const kBarH = 28;
|
||||
const kBarY = bodyBot - 4 - kBarH; // 102
|
||||
if (code.isNotEmpty) {
|
||||
// ── 左列底部整幅一维条码(原生 BARCODE,Code128,内容=商品编号),坐标由 template 驱动 ──
|
||||
// narrow 模块宽自适应铺满区域并水平居中。
|
||||
if (lay.barcodeShow && code.isNotEmpty) {
|
||||
final int leftX = lay.barX.round();
|
||||
final int leftW = lay.barW.round();
|
||||
final int leftRight = leftX + leftW;
|
||||
final int kBarH = lay.barH.round();
|
||||
final int kBarY = lay.barY.round();
|
||||
final estModules = 11 * code.length + 35; // Code128B 估算模块数(含起止校验)
|
||||
final narrow = (leftW / estModules).floor().clamp(2, 4);
|
||||
final barW = estModules * narrow;
|
||||
@@ -521,50 +272,10 @@ Future<bool> _printFlatLabelThermal({
|
||||
'BARCODE $barX,$kBarY,"128",$kBarH,0,0,$narrow,$narrow,"$code"\r\n'));
|
||||
}
|
||||
|
||||
// ── 左列文字块:品名+编号 一行居中;度数/规格/日期 平铺一行;整体在条码上方居中 ──
|
||||
const blockTop = bodyTop, blockBot = kBarY - 4; // 26..98
|
||||
final nameFont = name.runes.length <= 7 ? 'TSS24.BF2' : 'TSS16.BF2';
|
||||
final nameH = nameFont == 'TSS24.BF2' ? 24 : 16;
|
||||
const subH = 16, kGap = 8;
|
||||
final blockH = nameH + kGap + subH;
|
||||
var nameY = blockTop + ((blockBot - blockTop) - blockH) ~/ 2;
|
||||
if (nameY < blockTop) nameY = blockTop;
|
||||
final subY = nameY + nameH + kGap;
|
||||
|
||||
// 品名 + 编号:整体水平居中,编号小字随后,基线对齐
|
||||
final nameW = _estTsplWidth(name, nameH);
|
||||
final codeW = code.isNotEmpty ? _estTsplWidth(code, 16) : 0;
|
||||
final groupW = nameW + (code.isNotEmpty ? kGap + codeW : 0);
|
||||
var nameX = leftX + (leftW - groupW) ~/ 2;
|
||||
if (nameX < leftX) nameX = leftX;
|
||||
await _addTextCmd(buf, nameX, nameY, nameFont, name);
|
||||
if (code.isNotEmpty) {
|
||||
await _addTextCmd(
|
||||
buf, nameX + nameW + kGap, nameY + (nameH - subH), 'TSS16.BF2', code);
|
||||
}
|
||||
|
||||
// 三字段平铺一行(space-between,两侧留内边距使外白 > 中白)
|
||||
const innerPad = 10; // 对齐原型 .lbl-sub padding:0 10px
|
||||
const innerLeft = leftX + innerPad, innerW = leftW - 2 * innerPad; // 18, 174
|
||||
if (subs.length == 1) {
|
||||
final w0 = _estTsplWidth(subs[0], subH);
|
||||
await _addTextCmd(
|
||||
buf, innerLeft + (innerW - w0) ~/ 2, subY, 'TSS16.BF2', subs[0]);
|
||||
} else if (subs.isNotEmpty) {
|
||||
final widths = subs.map((s) => _estTsplWidth(s, subH)).toList();
|
||||
final sumW = widths.fold(0, (s, x) => s + x);
|
||||
final gap = (innerW - sumW) / (subs.length - 1);
|
||||
var cx = innerLeft.toDouble();
|
||||
for (int i = 0; i < subs.length; i++) {
|
||||
await _addTextCmd(buf, cx.round(), subY, 'TSS16.BF2', subs[i]);
|
||||
cx += widths[i] + gap;
|
||||
}
|
||||
}
|
||||
|
||||
buf.add(ascii.encode('PRINT 1,1\r\n'));
|
||||
buf.add(ascii.encode('PRINT 1,${p.copies}\r\n'));
|
||||
final tspl = buf.toBytes();
|
||||
|
||||
debugPrint('[label] TEXT+BITMAP tspl ${tspl.length}B -> $printer');
|
||||
debugPrint('[label] raster BITMAP+QR+BARCODE tspl ${tspl.length}B -> $printer');
|
||||
await _sendRaw(printer, tspl);
|
||||
return true;
|
||||
}
|
||||
@@ -582,176 +293,49 @@ Future<void> printProductLabelImpl({
|
||||
String shopAddress = '',
|
||||
String shopPhone = '',
|
||||
String? printerName,
|
||||
LabelTemplate? template, // 版式单源;null → 内置默认(行为同重构前)
|
||||
}) async {
|
||||
try {
|
||||
final font = await _loadFont();
|
||||
final tpl = template ?? LabelTemplate.builtinDefault();
|
||||
final data = LabelData(
|
||||
qrBytes: qrBytes,
|
||||
name: name,
|
||||
code: code,
|
||||
spec: spec,
|
||||
series: series,
|
||||
batchNo: batchNo,
|
||||
productionDate: productionDate,
|
||||
remark: remark,
|
||||
shopName: shopName,
|
||||
shopAddress: shopAddress,
|
||||
shopPhone: shopPhone,
|
||||
);
|
||||
final doc = pw.Document();
|
||||
final qrImage = pw.MemoryImage(qrBytes);
|
||||
|
||||
// Label: 40 × 20 mm(匹配实际标签纸尺寸)
|
||||
const labelW = 40.0 * PdfPageFormat.mm;
|
||||
const labelH = 20.0 * PdfPageFormat.mm;
|
||||
const headerH = labelH * 0.20;
|
||||
const footerH = labelH * 0.11;
|
||||
|
||||
// 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、TSPL 一致)
|
||||
final subs = _labelSubFields(series, spec, productionDate);
|
||||
|
||||
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
|
||||
if (await _printFlatLabelThermal(
|
||||
shop: shopName,
|
||||
name: name,
|
||||
code: code,
|
||||
subs: subs,
|
||||
qrBytes: qrBytes,
|
||||
printerName: printerName)) {
|
||||
tpl: tpl, data: data, printerName: printerName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 商品名字号:随字数自适应(与预览一致,短名更大)
|
||||
final double nameFs =
|
||||
name.runes.length > 10 ? 6.5 : (name.runes.length > 7 ? 7.5 : 9.0);
|
||||
// ── P3:非热敏(PDF / 办公打印机)整幅贴画布位图 ───────────────────────────
|
||||
// 收口为「贴画布位图」:用与预览、热敏光栅同一套 _renderLabelBitmap(版式坐标 /
|
||||
// 字号 / 开关单源),把整张标签渲染成一张位图铺满 40×20mm 页——令 PDF 观感与
|
||||
// 预览、热敏 100% 一致;条码 / 二维码随位图光栅化(≥300dpi 办公机可扫,热敏
|
||||
// 203dpi 路径不受影响,仍走上面原生 TSPL BARCODE / BITMAP)。
|
||||
final labelImg = await renderLabelImage(tpl: tpl, data: data);
|
||||
final labelPngBd =
|
||||
await labelImg.toByteData(format: ui.ImageByteFormat.png);
|
||||
final labelImage = pw.MemoryImage(labelPngBd!.buffer.asUint8List());
|
||||
|
||||
doc.addPage(pw.Page(
|
||||
pageFormat: const PdfPageFormat(labelW, labelH),
|
||||
margin: pw.EdgeInsets.zero,
|
||||
build: (_) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── Header:深蓝店名头 ────────────────────────────────────────────────
|
||||
pw.Container(
|
||||
height: headerH,
|
||||
color: _navy,
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 5),
|
||||
alignment: pw.Alignment.centerLeft,
|
||||
child: pw.Text(shopName,
|
||||
maxLines: 1,
|
||||
overflow: pw.TextOverflow.clip,
|
||||
style: pw.TextStyle(
|
||||
font: font,
|
||||
fontSize: 6,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: _cream,
|
||||
letterSpacing: 0.5,
|
||||
)),
|
||||
),
|
||||
|
||||
// ── Body:左列(文字+条码) / 右列(二维码占整高) ─────────────────────────
|
||||
pw.Expanded(
|
||||
child: pw.Container(
|
||||
color: PdfColors.white,
|
||||
padding: const pw.EdgeInsets.fromLTRB(4, 3, 4, 3),
|
||||
child: pw.Row(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 左列
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 文字块(品名+编号 居中 / 三字段平铺),条码上方居中
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 品名 + 编号(整体居中,编号小字随后)
|
||||
pw.RichText(
|
||||
textAlign: pw.TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: pw.TextOverflow.clip,
|
||||
text: pw.TextSpan(children: [
|
||||
pw.TextSpan(
|
||||
text: name,
|
||||
style: pw.TextStyle(
|
||||
font: font,
|
||||
fontSize: nameFs,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: _ink,
|
||||
letterSpacing: 0.3)),
|
||||
if (code.isNotEmpty)
|
||||
pw.TextSpan(
|
||||
text: ' $code',
|
||||
style: pw.TextStyle(
|
||||
font: font,
|
||||
fontSize: 5,
|
||||
color: _footnote)),
|
||||
]),
|
||||
),
|
||||
pw.SizedBox(height: 4),
|
||||
// 三字段平铺一行(两侧留边距,外白 > 中白)
|
||||
if (subs.isNotEmpty)
|
||||
pw.Padding(
|
||||
padding: const pw.EdgeInsets.symmetric(
|
||||
horizontal: 4),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: subs.length == 1
|
||||
? pw.MainAxisAlignment.center
|
||||
: pw.MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
for (final s in subs)
|
||||
pw.Text(s,
|
||||
style: pw.TextStyle(
|
||||
font: font,
|
||||
fontSize: 5,
|
||||
color: _footnote)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 左列底部整幅一维条码(Code128,内容=商品编号),供扫码枪
|
||||
if (code.isNotEmpty) ...[
|
||||
pw.SizedBox(height: 3),
|
||||
pw.SizedBox(
|
||||
height: 13,
|
||||
child: pw.BarcodeWidget(
|
||||
barcode: pw.Barcode.code128(),
|
||||
data: code,
|
||||
drawText: false,
|
||||
color: _ink,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
pw.SizedBox(width: 4),
|
||||
// 右列:二维码占主体整高(正方形)
|
||||
pw.AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: pw.Image(qrImage, fit: pw.BoxFit.contain),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Footer:米色页脚(店名 + 尺寸)────────────────────────────────────
|
||||
pw.Container(
|
||||
height: footerH,
|
||||
color: _cream,
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 5),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
children: [
|
||||
pw.Flexible(
|
||||
child: pw.Text(shopName,
|
||||
maxLines: 1,
|
||||
overflow: pw.TextOverflow.clip,
|
||||
style: pw.TextStyle(
|
||||
font: font, fontSize: 3.5, color: _footnote)),
|
||||
),
|
||||
pw.Text('40×20mm',
|
||||
style: pw.TextStyle(
|
||||
font: font, fontSize: 3.5, color: _footnote)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
build: (_) => pw.Image(labelImage, fit: pw.BoxFit.fill),
|
||||
));
|
||||
|
||||
// 预生成 PDF 字节,避免 macOS NSPrintPanel 打开后再调 doc.save() 导致主线程卡住。
|
||||
@@ -771,14 +355,12 @@ Future<void> printProductLabelImpl({
|
||||
}
|
||||
|
||||
/// 渲染标签预览图(PNG 字节),与实际热敏打印使用相同画布逻辑(所见即所得)。
|
||||
/// Web 平台或 qrBytes 为空时抛异常。
|
||||
Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
|
||||
final img = await _renderLabelBitmap(
|
||||
shop: label.shopName,
|
||||
name: label.name,
|
||||
code: label.code,
|
||||
subs: _labelSubFields(label.series, label.spec, label.productionDate),
|
||||
qrBytes: label.qrBytes!,
|
||||
/// [template] 为空时用内置默认版式(行为同重构前)。Web 平台或 qrBytes 为空时抛异常。
|
||||
Future<Uint8List> renderLabelPreviewImpl(LabelData label,
|
||||
{LabelTemplate? template}) async {
|
||||
final img = await renderLabelImage(
|
||||
tpl: template ?? LabelTemplate.builtinDefault(),
|
||||
data: label,
|
||||
);
|
||||
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
|
||||
return bd!.buffer.asUint8List();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:js_interop';
|
||||
import 'dart:typed_data';
|
||||
import 'package:barcode/barcode.dart' as bc;
|
||||
import 'package:web/web.dart' as web;
|
||||
import '../../models/stock_in.dart';
|
||||
import '../../models/stock_out.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import 'label_bitmap.dart';
|
||||
import 'label_data.dart';
|
||||
import 'label_template.dart';
|
||||
import 'order_print_meta.dart';
|
||||
|
||||
void _openPrintWindow(String html) {
|
||||
@@ -31,130 +32,50 @@ Future<void> printProductLabelImpl({
|
||||
String shopAddress = '',
|
||||
String shopPhone = '',
|
||||
String? printerName,
|
||||
LabelTemplate? template,
|
||||
}) async {
|
||||
final base64Img = base64Encode(qrBytes);
|
||||
// 版式单源(null → 内置默认版式,口径同桌面预览/PDF/TSPL)。
|
||||
final tpl = template ?? LabelTemplate.builtinDefault();
|
||||
final data = LabelData(
|
||||
qrBytes: qrBytes,
|
||||
name: name,
|
||||
code: code,
|
||||
spec: spec,
|
||||
series: series,
|
||||
batchNo: batchNo,
|
||||
productionDate: productionDate,
|
||||
remark: remark,
|
||||
shopName: shopName,
|
||||
shopAddress: shopAddress,
|
||||
shopPhone: shopPhone,
|
||||
);
|
||||
|
||||
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
|
||||
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
|
||||
final dateVal = (productionDate ?? '').isNotEmpty
|
||||
? (productionDate!.length > 10
|
||||
? productionDate.substring(0, 10)
|
||||
: productionDate)
|
||||
: '';
|
||||
// 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、PDF、TSPL 一致)
|
||||
final subs = <String>[
|
||||
if (seriesVal.isNotEmpty) seriesVal,
|
||||
if (specVal.isNotEmpty) specVal,
|
||||
if (dateVal.isNotEmpty) dateVal,
|
||||
];
|
||||
final subSpans = subs.map((s) => '<span>$s</span>').join();
|
||||
// 商品名过长时缩小字号
|
||||
final nameLen = name.runes.length;
|
||||
final nameFontPt = nameLen > 10 ? 7.0 : (nameLen > 7 ? 8.0 : 9.0);
|
||||
|
||||
// 一维条码(Code128,内容=商品编号):矢量 SVG 内联,扫码枪扫码出库。
|
||||
final barcodeSvg = code.isNotEmpty
|
||||
? bc.Barcode.code128()
|
||||
.toSvg(code, width: 200, height: 46, drawText: false)
|
||||
: '';
|
||||
// P3:Web 打印收口为「贴画布位图」——与桌面预览/热敏/PDF 共用同一套 renderLabelImage
|
||||
// 把整张标签渲染成 PNG(版式坐标/字号/开关单源),铺满 40×20mm 页;令四端观感
|
||||
// 100% 一致(条码/二维码随位图光栅化,浏览器→办公打印机 ≥300dpi 可扫)。
|
||||
final png = await renderLabelPngBytes(tpl: tpl, data: data);
|
||||
if (png == null) {
|
||||
throw Exception('标签渲染失败:二维码未就绪,请稍后重试');
|
||||
}
|
||||
final base64Img = base64Encode(png);
|
||||
|
||||
final html = '''<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* 标签尺寸:40mm × 20mm,横向(对齐设计原型 devices.html 标签块)*/
|
||||
/* 标签尺寸:40mm × 20mm,横向(整幅贴位图,对齐 renderLabelImage 输出)*/
|
||||
@page { size: 40mm 20mm; margin: 0; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
|
||||
|
||||
.label {
|
||||
width: 40mm; height: 20mm;
|
||||
display: flex; flex-direction: column;
|
||||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* ── Header:深蓝店名头 3.2mm ── */
|
||||
.hdr {
|
||||
background: #1f2a3a; color: #f4ecd8;
|
||||
height: 3.2mm; flex-shrink: 0;
|
||||
display: flex; align-items: center;
|
||||
padding: 0 1.2mm; overflow: hidden;
|
||||
}
|
||||
.hdr-name {
|
||||
font-size: 6pt; font-weight: 700; letter-spacing: 0.05em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── 主体:左列(文字+条码) / 右列(二维码占整高) ── */
|
||||
.middle {
|
||||
flex: 1; min-height: 0; display: flex; gap: 0.8mm;
|
||||
padding: 0.7mm 0.9mm; overflow: hidden;
|
||||
}
|
||||
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.info {
|
||||
flex: 1; min-height: 0; display: flex; flex-direction: column;
|
||||
justify-content: center; gap: 1mm; overflow: hidden;
|
||||
}
|
||||
.pname {
|
||||
font-weight: 700; text-align: center; line-height: 1.1;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.pcode { font-size: 5pt; font-weight: 400; color: #333; margin-left: 1.2mm; }
|
||||
.sub {
|
||||
font-size: 5pt; color: #333; display: flex;
|
||||
justify-content: space-between; gap: 1mm; padding: 0 1.2mm;
|
||||
}
|
||||
.sub span { white-space: nowrap; }
|
||||
.bc { flex-shrink: 0; height: 4.6mm; }
|
||||
.bc svg { width: 100%; height: 100%; display: block; }
|
||||
|
||||
/* 右:二维码占主体整高(正方形)*/
|
||||
.qr { flex-shrink: 0; height: 100%; aspect-ratio: 1; }
|
||||
.qr img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
|
||||
/* ── Footer:米色页脚(店名 + 尺寸)── */
|
||||
.ftr {
|
||||
background: #f4ecd8; height: 2.4mm; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 1.2mm;
|
||||
font-size: 3.4pt; color: #5a4e35; overflow: hidden; white-space: nowrap;
|
||||
}
|
||||
html, body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
|
||||
img { width: 40mm; height: 20mm; display: block; object-fit: fill; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="label">
|
||||
|
||||
<div class="hdr">
|
||||
<span class="hdr-name">$shopName</span>
|
||||
</div>
|
||||
|
||||
<div class="middle">
|
||||
<div class="left">
|
||||
<div class="info">
|
||||
<div class="pname" style="font-size:${nameFontPt}pt">$name<span class="pcode">$code</span></div>
|
||||
<div class="sub">$subSpans</div>
|
||||
</div>
|
||||
<div class="bc">$barcodeSvg</div>
|
||||
</div>
|
||||
<div class="qr">
|
||||
<img src="data:image/png;base64,$base64Img" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ftr"><span>$shopName</span><span>40×20mm</span></div>
|
||||
|
||||
</div>
|
||||
<img src="data:image/png;base64,$base64Img" />
|
||||
<script>
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
document.fonts.ready.then(function() { window.print(); });
|
||||
} else {
|
||||
window.onload = function() { window.print(); };
|
||||
}
|
||||
window.onload = function() { window.print(); };
|
||||
</script>
|
||||
</body>
|
||||
</html>''';
|
||||
@@ -303,7 +224,10 @@ Future<void> printStockInOrderImpl(
|
||||
|
||||
// ── 预览 / 打印机枚举(Web 不支持热敏裸发,返回空值)─────────────────────────
|
||||
|
||||
Future<Uint8List?> renderLabelPreviewImpl(LabelData label) async => null;
|
||||
Future<Uint8List?> renderLabelPreviewImpl(LabelData label,
|
||||
{LabelTemplate? template}) async =>
|
||||
renderLabelPngBytes(
|
||||
tpl: template ?? LabelTemplate.builtinDefault(), data: label);
|
||||
|
||||
Future<List<String>> listLabelPrintersImpl() async => [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -208,43 +208,43 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
if (v != null && mounted) _applyStatusFilter(v);
|
||||
}
|
||||
|
||||
// ── 工具栏多选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移) ──
|
||||
Widget _multiChip(String label, List<String> options, Set<String> selected,
|
||||
ValueChanged<Set<String>> onChanged) {
|
||||
// ── 工具栏单选 chip(规格/系列/仓库,2026-08-28 从多选改单选,用户口径) ──
|
||||
// 状态仍存 Set<String>(沿用后端 setSpec/setSeries 列表口径 + 仓库客户端 contains),
|
||||
// 单选即「至多一项」:选中 → {v},× → {}。
|
||||
Widget _singleChip(String label, List<String> options, Set<String> selected,
|
||||
ValueChanged<Set<String>> onChanged,
|
||||
{String? searchHint}) {
|
||||
return Builder(
|
||||
builder: (chipCtx) => DsChip(
|
||||
label: label,
|
||||
value: selected.isEmpty
|
||||
? null
|
||||
: selected.length == 1
|
||||
? selected.first
|
||||
: '${selected.length} 项',
|
||||
onTap: () => _openMultiFilter(chipCtx,
|
||||
options: options, selected: selected, onChanged: onChanged),
|
||||
value: selected.isEmpty ? null : selected.first,
|
||||
onTap: () => _openSingleFilter(chipCtx,
|
||||
options: options,
|
||||
selected: selected,
|
||||
onChanged: onChanged,
|
||||
searchHint: searchHint),
|
||||
onClear: () => onChanged({}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 多选菜单(规格/系列/仓库,chip 锚定) ──
|
||||
void _openMultiFilter(
|
||||
// ── 单选菜单(规格/系列可搜索、仓库普通,chip 锚定) ──
|
||||
Future<void> _openSingleFilter(
|
||||
BuildContext anchorContext, {
|
||||
required List<String> options,
|
||||
required Set<String> selected,
|
||||
required ValueChanged<Set<String>> onChanged,
|
||||
}) {
|
||||
var cur = Set.of(selected);
|
||||
showDsMultiMenu<String>(
|
||||
String? searchHint, // 非空 → 可搜索单选下拉(规格/系列长名录)
|
||||
}) async {
|
||||
final v = await showDsMenu<String>(
|
||||
anchorContext,
|
||||
itemsBuilder: () => [
|
||||
searchHint: searchHint,
|
||||
items: [
|
||||
for (final o in options)
|
||||
DsMenuItem(value: o, label: o, selected: cur.contains(o)),
|
||||
DsMenuItem(value: o, label: o, selected: selected.contains(o)),
|
||||
],
|
||||
onToggle: (v) {
|
||||
cur.contains(v) ? cur.remove(v) : cur.add(v);
|
||||
onChanged(Set.of(cur));
|
||||
},
|
||||
);
|
||||
if (v != null && mounted) onChanged({v});
|
||||
}
|
||||
|
||||
// ── 窄屏筛选 sheet(原型移动无列头漏斗 → 详搜钮开底部 sheet)──
|
||||
@@ -937,21 +937,21 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
children: [
|
||||
SizedBox(width: 260, child: searchField),
|
||||
const SizedBox(width: AppDims.sp2),
|
||||
_multiChip('规格', specOptions, _filterSpec, (v) {
|
||||
_singleChip('规格', specOptions, _filterSpec, (v) {
|
||||
setState(() => _filterSpec = v);
|
||||
ref
|
||||
.read(inventoryListProvider.notifier)
|
||||
.setSpec(v.toList());
|
||||
}),
|
||||
}, searchHint: '搜索规格…'),
|
||||
const SizedBox(width: AppDims.sp2),
|
||||
_multiChip('系列', seriesOptions, _filterSeries, (v) {
|
||||
_singleChip('系列', seriesOptions, _filterSeries, (v) {
|
||||
setState(() => _filterSeries = v);
|
||||
ref
|
||||
.read(inventoryListProvider.notifier)
|
||||
.setSeries(v.toList());
|
||||
}),
|
||||
}, searchHint: '搜索系列…'),
|
||||
const SizedBox(width: AppDims.sp2),
|
||||
_multiChip('仓库', warehouseOptions, _filterWarehouse,
|
||||
_singleChip('仓库', warehouseOptions, _filterWarehouse,
|
||||
(v) => setState(() => _filterWarehouse = v)),
|
||||
const SizedBox(width: AppDims.sp2),
|
||||
Builder(
|
||||
|
||||
@@ -185,6 +185,8 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
Map<String, _PickerItem> _codeIndex = {};
|
||||
String _lastScanCode = '';
|
||||
int _lastScanMs = 0;
|
||||
// 扫码枪=HID键盘极快连打字符后停顿;停顿 120ms 无新输入即自动触发,免手动回车。
|
||||
Timer? _scanDebounce;
|
||||
|
||||
static const _fields = ['qty', 'sale'];
|
||||
|
||||
@@ -265,6 +267,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
_remarkCtrl.dispose();
|
||||
_partnerFocus.dispose();
|
||||
_warehouseFocus.dispose();
|
||||
_scanDebounce?.cancel();
|
||||
_scanCtrl.dispose();
|
||||
_scanFocus.dispose();
|
||||
for (final item in _items) {
|
||||
@@ -354,6 +357,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
/// 扫码枪回车触发。解析编码→本地整仓索引命中(已加载项)→未命中再走服务端
|
||||
/// 按编码精确查(覆盖库存超本地加载上限 1000 的大仓)→加行/累加数量。
|
||||
Future<void> _onScan(String raw) async {
|
||||
_scanDebounce?.cancel();
|
||||
// 立即清空并保持聚焦,承接下一次扫码
|
||||
_scanCtrl.clear();
|
||||
_scanFocus.requestFocus();
|
||||
@@ -697,6 +701,14 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
|
||||
controller: _scanCtrl,
|
||||
focusNode: _scanFocus,
|
||||
hintText: '扫码枪扫码…',
|
||||
onChanged: (v) {
|
||||
// 扫码枪整串打完会停顿,停顿 120ms 视为一次完整扫入 → 自动触发。
|
||||
// 带回车后缀的枪仍走 onSubmitted,_onScan 内 300ms 同码去重防重复入单。
|
||||
_scanDebounce?.cancel();
|
||||
if (v.trim().isEmpty) return;
|
||||
_scanDebounce = Timer(
|
||||
const Duration(milliseconds: 120), () => _onScan(v));
|
||||
},
|
||||
onSubmitted: _onScan,
|
||||
suffix: Icon(LucideIcons.scanLine,
|
||||
size: 16, color: context.tokens.muted),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../../core/theme/app_tokens.dart';
|
||||
import '../../core/theme/context_tokens.dart';
|
||||
import '../../core/theme/app_dims.g.dart';
|
||||
|
||||
@@ -25,28 +26,41 @@ class DsMenuItem<T> {
|
||||
}
|
||||
|
||||
/// 单选菜单:点击项返回其 value 并关闭;点外部关闭返回 null。
|
||||
///
|
||||
/// [searchHint] 非空 → 升级为「可搜索单选」下拉(镜像原型 openSearchMenu / .combo-pop,
|
||||
/// 照 stock-out 客户筛选写法):顶部持久搜索框 + 边打边过滤 + 命中高亮 + 底部结果统计,
|
||||
/// 点选即返回并关闭。宽度锚定触发器宽(`max(触发器宽, 220)` 固定,对齐原型 openSearchMenu
|
||||
/// `w = max(r.width, 220)`)、列表 264 封顶,供规格/系列等长名录单选筛选复用。
|
||||
Future<T?> showDsMenu<T>(
|
||||
BuildContext anchorContext, {
|
||||
required List<DsMenuItem<T>> items,
|
||||
double minWidth = 168,
|
||||
String? searchHint,
|
||||
}) {
|
||||
final rect = _anchorRect(anchorContext);
|
||||
return Navigator.of(anchorContext).push<T>(_DsMenuRoute<T>(
|
||||
anchorRect: rect,
|
||||
minWidth: minWidth,
|
||||
builder: (ctx) => _DsMenuPanel(
|
||||
children: [
|
||||
for (final it in items)
|
||||
_DsMenuItemTile(
|
||||
item: it,
|
||||
onTap: () => Navigator.of(ctx).pop(it.value),
|
||||
minWidth: searchHint != null ? math.max(minWidth, 220) : minWidth,
|
||||
search: searchHint != null,
|
||||
builder: (ctx) => searchHint != null
|
||||
? _DsSearchSinglePanel<T>(
|
||||
items: items,
|
||||
hint: searchHint,
|
||||
onPick: (v) => Navigator.of(ctx).pop(v),
|
||||
)
|
||||
: _DsMenuPanel(
|
||||
children: [
|
||||
for (final it in items)
|
||||
_DsMenuItemTile(
|
||||
item: it,
|
||||
onTap: () => Navigator.of(ctx).pop(it.value),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
/// 多选菜单(列设置 / 多选筛选):点击项切换选中、菜单保持打开;点外部关闭。
|
||||
/// 多选菜单(列设置):点击项切换选中、菜单保持打开;点外部关闭。
|
||||
/// [itemsBuilder] 每次切换后重建以刷新 ✓ 态;[onToggle] 通知外部改状态。
|
||||
Future<void> showDsMultiMenu<T>(
|
||||
BuildContext anchorContext, {
|
||||
@@ -89,10 +103,12 @@ Rect _anchorRect(BuildContext anchorContext) {
|
||||
class _DsMenuRoute<T> extends PopupRoute<T> {
|
||||
final Rect anchorRect;
|
||||
final double minWidth;
|
||||
final bool search;
|
||||
final WidgetBuilder builder;
|
||||
_DsMenuRoute({
|
||||
required this.anchorRect,
|
||||
required this.minWidth,
|
||||
this.search = false,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
@@ -109,7 +125,7 @@ class _DsMenuRoute<T> extends PopupRoute<T> {
|
||||
Widget buildPage(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation) {
|
||||
return CustomSingleChildLayout(
|
||||
delegate: _DsMenuLayout(anchorRect, minWidth),
|
||||
delegate: _DsMenuLayout(anchorRect, minWidth, search),
|
||||
child: builder(context),
|
||||
);
|
||||
}
|
||||
@@ -118,15 +134,23 @@ class _DsMenuRoute<T> extends PopupRoute<T> {
|
||||
class _DsMenuLayout extends SingleChildLayoutDelegate {
|
||||
final Rect anchor;
|
||||
final double minWidth;
|
||||
_DsMenuLayout(this.anchor, this.minWidth);
|
||||
final bool search;
|
||||
_DsMenuLayout(this.anchor, this.minWidth, this.search);
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
final maxW = math.max(minWidth, constraints.maxWidth - 16);
|
||||
final avail = constraints.maxWidth - 16;
|
||||
final maxH = math.max(0.0, constraints.maxHeight - 16);
|
||||
if (search) {
|
||||
// 可搜索下拉:宽度锚定触发器宽(对齐原型 openSearchMenu `w = max(r.width, 220)`),
|
||||
// tight 固定,避免 Expanded 搜索框撑满视口。触发器宽 → 弹层宽;窄 pill → 220。
|
||||
final w = math.min(math.max(anchor.width, minWidth), avail);
|
||||
return BoxConstraints(minWidth: w, maxWidth: w, maxHeight: maxH);
|
||||
}
|
||||
return BoxConstraints(
|
||||
minWidth: math.min(math.max(anchor.width, minWidth), maxW),
|
||||
maxWidth: maxW,
|
||||
maxHeight: math.max(0, constraints.maxHeight - 16),
|
||||
minWidth: math.min(math.max(anchor.width, minWidth), avail),
|
||||
maxWidth: avail,
|
||||
maxHeight: maxH,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,7 +168,9 @@ class _DsMenuLayout extends SingleChildLayoutDelegate {
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_DsMenuLayout old) =>
|
||||
old.anchor != anchor || old.minWidth != minWidth;
|
||||
old.anchor != anchor ||
|
||||
old.minWidth != minWidth ||
|
||||
old.search != search;
|
||||
}
|
||||
|
||||
/// .menu:surface / border / r-md / sh-2(0 4px 14px shadow) / pad 6。
|
||||
@@ -224,3 +250,203 @@ class _DsMenuItemTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 可搜索单选面板(镜像原型 .combo-pop + openSearchMenu,照 stock-out 客户筛选):
|
||||
/// 顶部 .cp-search 持久搜索框 → .cp-list 列表(命中子串高亮、选中项尾部 ✓、点选即返回并关闭)
|
||||
/// → .cp-foot 结果统计(匹配 X 条 · 共 N 条)。供工具栏规格/系列等长名录单选筛选复用。
|
||||
class _DsSearchSinglePanel<T> extends StatefulWidget {
|
||||
final List<DsMenuItem<T>> items;
|
||||
final ValueChanged<T> onPick;
|
||||
final String hint;
|
||||
const _DsSearchSinglePanel({
|
||||
required this.items,
|
||||
required this.onPick,
|
||||
required this.hint,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_DsSearchSinglePanel<T>> createState() =>
|
||||
_DsSearchSinglePanelState<T>();
|
||||
}
|
||||
|
||||
class _DsSearchSinglePanelState<T> extends State<_DsSearchSinglePanel<T>> {
|
||||
final _ctrl = TextEditingController();
|
||||
final _focus = FocusNode();
|
||||
String _kw = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) => _focus.requestFocus());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
final all = widget.items;
|
||||
final q = _kw.trim().toLowerCase();
|
||||
final hits =
|
||||
q.isEmpty ? all : all.where((it) => it.label.toLowerCase().contains(q)).toList();
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: t.shadow, offset: const Offset(0, 4), blurRadius: 14),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// .cp-search
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(11, 9, 11, 9),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: t.borderSubtle)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.search, size: 14, color: t.faint),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _ctrl,
|
||||
focusNode: _focus,
|
||||
style: TextStyle(fontSize: AppDims.fsBody, color: t.text),
|
||||
cursorColor: t.primary,
|
||||
onChanged: (v) => setState(() => _kw = v),
|
||||
decoration: InputDecoration(
|
||||
isCollapsed: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
hintText: widget.hint,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: AppDims.fsBody, color: t.faint),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// .cp-list(overflow auto,max-height:264 → 超出滚动、foot 常驻可见;空 → .cp-empty)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 264),
|
||||
child: hits.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text('无匹配结果',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm, color: t.muted)),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(5),
|
||||
shrinkWrap: true,
|
||||
itemCount: hits.length,
|
||||
itemBuilder: (c, i) => _item(t, hits[i]),
|
||||
),
|
||||
),
|
||||
// .cp-foot
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 7, 12, 7),
|
||||
decoration: BoxDecoration(
|
||||
color: t.bg,
|
||||
border: Border(top: BorderSide(color: t.borderSubtle)),
|
||||
),
|
||||
child: _foot(t, hits.length, all.length, q.isNotEmpty),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// .cp-item:点选即回调 onPick(其内 Navigator.pop 关闭菜单)→ 单选。
|
||||
Widget _item(AppTokens t, DsMenuItem<T> it) {
|
||||
final fg = it.selected ? t.primary : t.text;
|
||||
return InkWell(
|
||||
onTap: () => widget.onPick(it.value),
|
||||
hoverColor: t.rowHover,
|
||||
borderRadius: BorderRadius.circular(AppDims.rSm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: _highlight(t, it.label, fg, it.selected)),
|
||||
if (it.selected) ...[
|
||||
const SizedBox(width: 9),
|
||||
Icon(LucideIcons.check, size: 15, color: t.primary),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 命中子串高亮(.hl:primary fw700)
|
||||
Widget _highlight(AppTokens t, String name, Color fg, bool sel) {
|
||||
final base = TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
color: fg,
|
||||
fontWeight: sel ? FontWeight.w600 : FontWeight.w400);
|
||||
final kw = _kw.trim();
|
||||
final i =
|
||||
kw.isEmpty ? -1 : name.toLowerCase().indexOf(kw.toLowerCase());
|
||||
if (i < 0) {
|
||||
return Text(name,
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis, style: base);
|
||||
}
|
||||
return RichText(
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
text: TextSpan(style: base, children: [
|
||||
TextSpan(text: name.substring(0, i)),
|
||||
TextSpan(
|
||||
text: name.substring(i, i + kw.length),
|
||||
style: base.copyWith(
|
||||
color: t.primary, fontWeight: FontWeight.w700)),
|
||||
TextSpan(text: name.substring(i + kw.length)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// .cp-foot:q 空 → 共 N 条;有关键字 → 匹配 X 条 · 共 N 条。数字 mono 加粗。
|
||||
Widget _foot(AppTokens t, int hits, int total, bool filtered) {
|
||||
final base = TextStyle(fontSize: AppDims.fsXs, color: t.muted);
|
||||
final b = base.copyWith(
|
||||
color: t.text,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'JetBrainsMono');
|
||||
return RichText(
|
||||
text: TextSpan(style: base, children: [
|
||||
if (filtered) ...[
|
||||
const TextSpan(text: '匹配 '),
|
||||
TextSpan(text: '$hits', style: b),
|
||||
const TextSpan(text: ' 条 · 共 '),
|
||||
TextSpan(text: '$total', style: b),
|
||||
const TextSpan(text: ' 条'),
|
||||
] else ...[
|
||||
const TextSpan(text: '共 '),
|
||||
TextSpan(text: '$total', style: b),
|
||||
const TextSpan(text: ' 条'),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<LabelData> labels;
|
||||
|
||||
/// 多张懒加载:按 productId 拉取 QR PNG 字节。单张场景可不传(qrBytes 已在 LabelData 中)。
|
||||
final Future<Uint8List> Function(int productId)? qrFetcher;
|
||||
|
||||
/// 出签使用的版式模板;显式传入优先,否则自动取本店 active 模板
|
||||
/// ([activeLabelTemplate],缺失回退 [LabelTemplate.builtinDefault]),
|
||||
/// 使店家自定义的价签版式在所有打印入口一致生效。
|
||||
final LabelTemplate? template;
|
||||
|
||||
const LabelPreviewDialog({
|
||||
super.key,
|
||||
required this.labels,
|
||||
this.qrFetcher,
|
||||
this.template,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LabelPreviewDialog> createState() => _LabelPreviewDialogState();
|
||||
ConsumerState<LabelPreviewDialog> createState() =>
|
||||
_LabelPreviewDialogState();
|
||||
}
|
||||
|
||||
class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
|
||||
class _LabelPreviewDialogState extends ConsumerState<LabelPreviewDialog> {
|
||||
/// 有效模板:显式 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<Uint8List?> _previews;
|
||||
late List<bool> _include;
|
||||
@@ -80,7 +96,7 @@ class _LabelPreviewDialogState extends State<LabelPreviewDialog> {
|
||||
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<LabelPreviewDialog> {
|
||||
shopAddress: label.shopAddress,
|
||||
shopPhone: label.shopPhone,
|
||||
printerName: _printer,
|
||||
template: _effectiveTemplate,
|
||||
);
|
||||
done++;
|
||||
if (mounted) {
|
||||
|
||||
+5
-2
@@ -69,14 +69,17 @@ flutter:
|
||||
|
||||
assets:
|
||||
- assets/fonts/NotoSansSC-Regular.ttf
|
||||
- assets/fonts/NotoSansSC.ttf
|
||||
- assets/fonts/NotoSansSC.ttf # 可变字体:golden_harness / tools 按路径加载,勿从 assets 移除
|
||||
- assets/config/app_info.json
|
||||
- assets/brand/logo_mark.png
|
||||
|
||||
fonts:
|
||||
# family 指向静态 Regular(非可变字体):可变版 NotoSansSC.ttf 的 fvar 轴在 Windows
|
||||
# dart:ui(Skia) 下解析失败 → 标签 canvas 文字整片空白(macOS CoreText 能取默认实例故正常)。
|
||||
# 该 family 仅 print_util 标签 canvas 用,静态 Regular 三端一致可渲染,粗体走合成加粗。
|
||||
- family: NotoSansSC
|
||||
fonts:
|
||||
- asset: assets/fonts/NotoSansSC.ttf
|
||||
- asset: assets/fonts/NotoSansSC-Regular.ttf
|
||||
# 等宽真相源(原型 --font-mono 首选 JetBrains Mono,OFL 可再分发):
|
||||
# 打包进 app → 原型截图 / golden / 三端真机等宽渲染完全同源。
|
||||
- family: JetBrainsMono
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,83 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jiu_client/core/utils/print_util.dart';
|
||||
|
||||
import '../support/golden_harness.dart';
|
||||
|
||||
/// 标签价签默认版式「零回归」基准(P0 守法)。
|
||||
///
|
||||
/// 目的:`LabelTemplate` 抽单源 + 画布渲染器改读模型(`_renderLabelBitmap`)后,
|
||||
/// 内置默认模板(`builtinDefault()`)渲染出的价签必须与重构前**逐像素一致**。
|
||||
///
|
||||
/// 用法:
|
||||
/// 基准(必须在动渲染器之前,于当前 main 上生成一次):
|
||||
/// flutter test --update-goldens test/golden/label_render_golden_test.dart
|
||||
/// 回归(重构后跑,须零 diff):
|
||||
/// flutter test test/golden/label_render_golden_test.dart
|
||||
///
|
||||
/// 说明:`renderLabelPreview` 走 dart:ui 直画(`Picture.toImage`),不经 widget
|
||||
/// 管线;在测试 zone 里直接 await 会因无 frame 驱动光栅而挂死,故渲染 + golden
|
||||
/// 比对整体放进 `tester.runAsync()`。字体复用 `ensureGoldenFonts()` 的 NotoSansSC,
|
||||
/// 与真实渲染同源。QR 用固定棋盘图占位,保证确定性(真实 QR 数据不影响版式几何)。
|
||||
|
||||
const _goldenUri = 'goldens/label_render_default.png';
|
||||
|
||||
/// 生成一张确定的 QR 占位图(10×10 棋盘 → 240×240 PNG),供版式渲染填充二维码位。
|
||||
Future<Uint8List> _fixedQrPng() async {
|
||||
const cells = 10;
|
||||
const cell = 24.0; // 240×240
|
||||
final rec = ui.PictureRecorder();
|
||||
final canvas = ui.Canvas(rec);
|
||||
final paint = ui.Paint();
|
||||
for (var y = 0; y < cells; y++) {
|
||||
for (var x = 0; x < cells; x++) {
|
||||
paint.color = (x + y) % 2 == 0
|
||||
? const ui.Color(0xFF000000)
|
||||
: const ui.Color(0xFFFFFFFF);
|
||||
canvas.drawRect(ui.Rect.fromLTWH(x * cell, y * cell, cell, cell), paint);
|
||||
}
|
||||
}
|
||||
final img = await rec
|
||||
.endRecording()
|
||||
.toImage((cells * cell).round(), (cells * cell).round());
|
||||
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
|
||||
return bd!.buffer.asUint8List();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('价签默认版式 · 零回归基准', (tester) async {
|
||||
await ensureGoldenFonts();
|
||||
|
||||
Uint8List? png;
|
||||
bool comparePassed = false;
|
||||
|
||||
await tester.runAsync(() async {
|
||||
final label = LabelData(
|
||||
qrBytes: await _fixedQrPng(),
|
||||
name: '贵州茅台酒',
|
||||
code: 'P1001',
|
||||
series: '飞天53度',
|
||||
spec: '500ml',
|
||||
productionDate: '2024-05-18',
|
||||
shopName: '鼎晟酒行',
|
||||
);
|
||||
|
||||
png = await renderLabelPreview(label);
|
||||
if (png == null) return;
|
||||
|
||||
final uri = Uri.parse(_goldenUri);
|
||||
if (autoUpdateGoldenFiles) {
|
||||
await goldenFileComparator.update(uri, png!);
|
||||
comparePassed = true;
|
||||
} else {
|
||||
comparePassed = await goldenFileComparator.compare(png!, uri);
|
||||
}
|
||||
});
|
||||
|
||||
expect(png, isNotNull, reason: 'renderLabelPreview 在测试(VM/桌面)下应返回 PNG');
|
||||
expect(comparePassed, isTrue,
|
||||
reason: '默认模板渲染与基准不一致:零回归被破坏(先在重构前 main 上 --update-goldens 生成基准)');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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('打印'), findsOneWidget);
|
||||
expect(find.text('模板'), findsWidgets);
|
||||
// 字段行齐全(品名 + 二维码等)
|
||||
expect(find.text('品名'), findsOneWidget);
|
||||
expect(find.text('二维码'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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(<String, dynamic>{});
|
||||
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 = <String, dynamic>{
|
||||
'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 = <String, dynamic>{
|
||||
'label_templates': [a.toJson(), b.toJson()],
|
||||
};
|
||||
final list = readLabelTemplates(cf);
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
<a class="kit" href="screens/inventory-check.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic61"/></svg></div><div class="kt"><b>库存盘点</b><span>实盘录入 · 差异盈亏</span></div></a>
|
||||
<a class="kit" href="screens/product-detail.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic62"/></svg></div><div class="kt"><b>商品详情</b><span>库存分布 · 流水 · 价格历史</span></div></a>
|
||||
<a class="kit" href="screens/devices.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic63"/></svg></div><div class="kt"><b>设备管理</b><span>打印机/扫码枪 · 连接状态</span></div></a>
|
||||
<a class="kit" href="screens/label-template-editor.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic12"/></svg></div><div class="kt"><b>标签模板编辑器</b><span>实时预览画布 / 属性面板</span></div></a>
|
||||
<a class="kit" href="screens/notifications.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-bell"/></svg></div><div class="kt"><b>通知中心</b><span>列表 · 类型筛选 / 未读</span></div></a>
|
||||
<a class="kit" href="screens/notification.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-bell"/></svg></div><div class="kt"><b>通知详情</b><span>正文 · 关联单据 / 操作</span></div></a>
|
||||
<a class="kit" href="screens/about.html"><div class="thumb"><svg viewBox="0 0 24 24"><use href="#i-ic24"/></svg></div><div class="kt"><b>关于我们</b><span>版本 · 更新日志 · 授权</span></div></a>
|
||||
|
||||
@@ -61,22 +61,20 @@
|
||||
.tpl-desc{font-size:var(--fs-xs); color:var(--muted); margin-top:3px; line-height:1.4;}
|
||||
.tpl-acts{display:flex; gap:8px; margin-top:10px;}
|
||||
|
||||
/* ---- 标签预览(40×20mm 热敏价签:抬头 + 商品信息/二维码 + 底部整幅一维条码 + 页脚)----
|
||||
打印纸面为固定专色(navy 抬头 / cream 页脚 / 墨黑正文),不随 App 主题切换,故按 ds-allow 豁免。 */
|
||||
/* ---- 标签预览(40×20mm 热敏价签:抬头 + 商品信息/二维码 + 底部整幅一维条码,无页脚)----
|
||||
打印纸面为固定专色(navy 抬头 / 墨黑正文),不随 App 主题切换,故按 ds-allow 豁免。 */
|
||||
.lbl-wrap{display:flex; flex-direction:column; align-items:center; gap:14px;}
|
||||
.lbl-preview{width:340px; height:170px; border:1px solid var(--border); border-radius:var(--r-md); overflow:hidden; display:flex; flex-direction:column; box-shadow:var(--sh-2); background:#fff; color:#111;} /* ds-allow 打印纸面固定色 */
|
||||
.lbl-hdr{height:26px; flex:none; display:flex; align-items:center; padding:0 10px; font-size:13px; font-weight:700; letter-spacing:.05em; background:#1f2a3a; color:#f4ecd8;} /* ds-allow 打印纸面固定尺寸 */
|
||||
.lbl-mid{flex:1; min-height:0; display:flex; gap:6px; padding:6px 8px;}
|
||||
.lbl-left{flex:1; min-width:0; display:flex; flex-direction:column;}
|
||||
.lbl-info{flex:1; min-height:0; display:flex; flex-direction:column; justify-content:center; gap:9px;}
|
||||
.lbl-name{font-size:18px; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-align:center;}
|
||||
.lbl-code{font-size:11px; font-weight:400; color:#333; margin-left:10px;} /* ds-allow 打印纸面固定尺寸 */
|
||||
.lbl-sub{font-size:11px; color:#333; display:flex; justify-content:space-between; gap:6px; padding:0 10px;} /* ds-allow 打印纸面固定尺寸 */
|
||||
.lbl-info{flex:1; min-height:0; display:flex; flex-direction:column; justify-content:center; align-items:center; gap:5px;}
|
||||
.lbl-name{font-size:18px; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; text-align:center; max-width:100%;} /* ds-allow 打印纸面固定尺寸 */
|
||||
.lbl-sub{font-size:11px; color:#333; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; line-height:1.15; text-align:center; max-width:100%;} /* ds-allow 打印纸面固定尺寸;名下两行居中 */
|
||||
.lbl-bc{flex:none; height:46px; margin-top:4px;}
|
||||
.lbl-bc img{width:100%; height:100%; display:block; object-fit:fill;}
|
||||
.lbl-qr{flex:none; height:100%; aspect-ratio:1;}
|
||||
.lbl-qr img{width:100%; height:100%; display:block; object-fit:contain;}
|
||||
.lbl-ftr{height:16px; flex:none; display:flex; align-items:center; justify-content:space-between; padding:0 10px; font-size:9px; background:#f4ecd8; color:#5a4e35;} /* ds-allow 打印纸面固定尺寸 */
|
||||
.lbl-note{max-width:340px; font-size:var(--fs-sm); color:var(--muted); line-height:1.6; text-align:center;}
|
||||
/* 设计期返回悬浮球(右下角,非侵入) */
|
||||
.ds-fab{position:fixed; right:22px; bottom:22px; width:44px; height:44px; border-radius:50%; background:var(--surface); color:var(--muted); border:1px solid var(--border); display:flex; align-items:center; justify-content:center; box-shadow:var(--sh-2); z-index:var(--z-fab); opacity:.6; transition:opacity .18s,color .18s,border-color .18s,transform .18s,box-shadow .18s; text-decoration:none;}
|
||||
@@ -117,7 +115,7 @@
|
||||
<div class="tpl-desc">40×20mm · 店名 / 品名 / 编号 / 度数规格 / 生产日期 / 二维码 + 底部整幅一维条码</div>
|
||||
<div class="tpl-acts">
|
||||
<div class="btn ghost sm" onclick="openLabelPreview()"><svg viewBox="0 0 24 24"><use href="#i-eye"/></svg>预览</div>
|
||||
<div class="btn ghost sm" onclick="toast('编辑标签模板(原型)')"><svg viewBox="0 0 24 24"><use href="#i-edit"/></svg>编辑</div>
|
||||
<div class="btn ghost sm" onclick="location.href='label-template-editor.html'"><svg viewBox="0 0 24 24"><use href="#i-edit"/></svg>编辑</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,14 +163,14 @@
|
||||
<div class="lbl-mid">
|
||||
<div class="lbl-left">
|
||||
<div class="lbl-info">
|
||||
<div class="lbl-name">飞天茅台<span class="lbl-code">P1297</span></div>
|
||||
<div class="lbl-sub"><span>53°</span><span>500ml</span><span>2023-05-18</span></div>
|
||||
<div class="lbl-name">飞天茅台</div>
|
||||
<div class="lbl-sub">P1297 53°</div>
|
||||
<div class="lbl-sub">500ml 2023-05-18</div>
|
||||
</div>
|
||||
<div class="lbl-bc"><img alt="一维条码 P1297" src="data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwLjAwMDAwIDAuMDAwMDAgMzAwLjAwMDAwIDYwLjAwMDAwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Ik0gMC4wMDAwMCAwLjAwMDAwIGggNy41OTQ5NCB2IDYwLjAwMDAwIGggLTcuNTk0OTQgeiBNIDExLjM5MjQxIDAuMDAwMDAgaCAzLjc5NzQ3IHYgNjAuMDAwMDAgaCAtMy43OTc0NyB6IE0gMzAuMzc5NzUgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSA0MS43NzIxNSAwLjAwMDAwIGggMTEuMzkyNDEgdiA2MC4wMDAwMCBoIC0xMS4zOTI0MSB6IE0gNTYuOTYyMDMgMC4wMDAwMCBoIDExLjM5MjQxIHYgNjAuMDAwMDAgaCAtMTEuMzkyNDEgeiBNIDcyLjE1MTkwIDAuMDAwMDAgaCA3LjU5NDk0IHYgNjAuMDAwMDAgaCAtNy41OTQ5NCB6IE0gODMuNTQ0MzAgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSA5MS4xMzkyNCAwLjAwMDAwIGggMTEuMzkyNDEgdiA2MC4wMDAwMCBoIC0xMS4zOTI0MSB6IE0gMTA2LjMyOTExIDAuMDAwMDAgaCAxNS4xODk4NyB2IDYwLjAwMDAwIGggLTE1LjE4OTg3IHogTSAxMjUuMzE2NDYgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSAxMzIuOTExMzkgMC4wMDAwMCBoIDcuNTk0OTQgdiA2MC4wMDAwMCBoIC03LjU5NDk0IHogTSAxNDguMTAxMjcgMC4wMDAwMCBoIDExLjM5MjQxIHYgNjAuMDAwMDAgaCAtMTEuMzkyNDEgeiBNIDE2Ny4wODg2MSAwLjAwMDAwIGggMTUuMTg5ODcgdiA2MC4wMDAwMCBoIC0xNS4xODk4NyB6IE0gMTg2LjA3NTk1IDAuMDAwMDAgaCAzLjc5NzQ3IHYgNjAuMDAwMDAgaCAtMy43OTc0NyB6IE0gMTkzLjY3MDg5IDAuMDAwMDAgaCAzLjc5NzQ3IHYgNjAuMDAwMDAgaCAtMy43OTc0NyB6IE0gMjA4Ljg2MDc2IDAuMDAwMDAgaCA3LjU5NDk0IHYgNjAuMDAwMDAgaCAtNy41OTQ5NCB6IE0gMjIwLjI1MzE2IDAuMDAwMDAgaCAxMS4zOTI0MSB2IDYwLjAwMDAwIGggLTExLjM5MjQxIHogTSAyNDMuMDM3OTcgMC4wMDAwMCBoIDMuNzk3NDcgdiA2MC4wMDAwMCBoIC0zLjc5NzQ3IHogTSAyNTAuNjMyOTEgMC4wMDAwMCBoIDcuNTk0OTQgdiA2MC4wMDAwMCBoIC03LjU5NDk0IHogTSAyNjkuNjIwMjUgMC4wMDAwMCBoIDExLjM5MjQxIHYgNjAuMDAwMDAgaCAtMTEuMzkyNDEgeiBNIDI4NC44MTAxMyAwLjAwMDAwIGggMy43OTc0NyB2IDYwLjAwMDAwIGggLTMuNzk3NDcgeiBNIDI5Mi40MDUwNiAwLjAwMDAwIGggNy41OTQ5NCB2IDYwLjAwMDAwIGggLTcuNTk0OTQgeiAiIHN0eWxlPSJmaWxsOiAjMDAwMDAwIi8+PHRleHQgc3R5bGU9ImZpbGw6ICMwMDAwMDA7IGZvbnQtZmFtaWx5OiAmcXVvdDttb25vc3BhY2UmcXVvdDs7IGZvbnQtc2l6ZTogMTIuMDAwMDBweCIgeD0iMC4wMDAwMCIgeT0iMC4wMDAwMCI+PC90ZXh0Pjwvc3ZnPg==" /></div>
|
||||
</div>
|
||||
<div class="lbl-qr"><img alt="二维码" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPAAAADwCAYAAAA+VemSAAAABHNCSVQICAgIfAhkiAAAAAFzUkdCAK7OHOkAAAk0SURBVHic7d2xbtNXHMXxk8oREqqUSJHiR6hASHFHJpzdOy/ATp4AZWZiI6/QuQ9AOnXJYCYvDB3JhAMtqJEQXf4sAan3uP0599jfj5Tt6urm+n/aP9Ivx5L0hZ+Vf85VZ2qeZVq497vCOznv4HOM/fnBvGwAHSHAQDACDAQjwEAwAgwEI8BAMAIMBCPAQDACDAQbGWuXkl4XnqUXR5L2i/aeGHs/NPf+sWitJH00Pvu5ubeDZ/A7ehgb7Mmr0LHBmXGOmbn3E/P3rLoTnkFGKTfWn0VrJemNuR5rQoCBYAQYCEaAgWAEGAhGgIFgBBgIRoCBYAQYCEaAgWDOLLTrxTD7e9vmkk5u+xCDvWGet8WupB1J143rx8Y55pKOjfWPJZ0ae/dy3xv/DFYGeCLpUeH+ia5W+EOJu43rLo09l+Zc8WnoZ7nxzyCv0EAwAgwEI8BAMAIMBCPAQDACDAQjwEAwAgwEI8BAsMpJLHzLqVw9kPTAWO9Uxe6bI4YLY21lrSxuIMDr5czDziT9WnSOyVBd2up4iypdo/AK3S+3+tVdjw1AgIFgBBgIRoCBYAQYCEaAgWAEGAhGgIFgBBgIRoCBYIxSrpdTc7owq1+dGeRtqZXdeAR4vdya06r5422pld14vEIDwQgwEIwAA8EIMBCMAAPBCDAQjAADwQgwEIwAA8EqJ7F6qRft5Rwyq1+dtS63VvbQWFt5blcvn33ZOSoDzDxsv9xa2Q+FZ6m08c8gr9Dr5VS/9lQT+9lY29O5Nx4BBoIRYCAYAQaCEWAgGAEGghFgIBgBBoIRYCAYAQaCOaOUR+b4XSpnRti1NzRCtq51VFbWnhlr3XM7eAZvcAK8L2m62nkwuBrusXWto7Ky9q2knxrXuud28AzewCs0EIwAA8EIMBCMAAPBCDAQjAADwQgwEIwAA8EIMBBsJOm32z5EMLcu1KlcPZA0M0rinOrXyr3dWtleql+Bf3Uh6UvRz/tO9r647UveJrxCr1dl5apT/Vq5N7Wya0SAgWAEGAhGgIFgBBgIRoCBYAQYCEaAgWAEGAhGgIFgO0Y74dcZ19ZJmxNjznUy1KK2cvZ2OfWs7p28lHTZuPZY0tNhPLHFHUl/N679JOmZpDeN68fGuZfmZ1N533tGS+Z8eK5aVZ7ber6r5med+s9p4d6u89A7eWesdSplq1Xed+WddPGc8AoNBCPAQDACDAQjwEAwAgwEI8BAMAIMBCPAQDACDARzvuBbkl4b3zDfuu7rWqfedmxMq7ijfc7aA0kPjPVO5apbz7pbuHcl9yzOM+iMUi7MSbmFsdYdpXSe7+bxrrnx7fLVnDG2yrHBmTkiNyvc+y9jbU/Vr07VbuUzGDnO67xCL83/q24Dt0LVWe/ufV24dyXnLDyDN/BvYCAYAQaCEWAgGAEGghFgIBgBBoIRYCAYAQaCEWAg2MiYbLk3jL31UCv7UtJp41p3csepCz2U9MH4AuyxcY75UC3byq1+dVRX7VZ9lo6xuf+ZpLeNa907ue+c5darMVPnUIvvpCfbcCeVNb7uT/PevEIDwQgwEIwAA8EIMBCMAAPBCDAQjAADwQgwEIwAA8Eqa2WdasyH5jkqa1F7qZXdN8YXZY5SuqN9lRWqvZTUudXGTmWt+5w4FcHN411upWfl+J1Tz1qpsla2p9G+XsYdU5VVBKfWyvZSi1pZK4vNUVYRzL+BgWAEGAhGgIFgBBgIRoCBYAQYCEaAgWAEGAhGgIFgO8MIXos/JP1s7D0xRi+PJT0dxsNaXBZWejozrgtJvzSu1TCO2jrN1tMs9OOhVrjFfKgUbuVU1lbu7d6JU5vsfpZnTgVx63zmuXEAV09zv87elXfSE2eu3b2TXvbuaT68+dy8QgPBCDAQjAADwQgwEIwAA8EIMBCMAAPBCDAQjAADwUZGlaZTt+qqrPSsHKV078QZL10W3rk72ufUyrpndtYvzAmoQ/MsjsqKYOe+sUaVY4MOd3S1l1pZ99zvC0cpKyuCm++bV2hsss+Fe3dREUyAgWAEGAhGgIFgBBgIRoCBYAQYCEaAgWAEGAhGgIFgoy1qVqzg1pzuGbWy9yRdFNbhHjeulTmvPBnqXFs59axj80vmf5f0vHGtW23cXPu6wrmb9x5JemRsjP/myvhjBpnD+Etz76r/cO+bz5Rz5ktz/XPz93xmnsVZ6/6eTXiFBoIRYCAYAQaCEWAgGAEGghFgIBgBBoIRYCAYAQaCjYy1S0mvC8/SiyNzasbhjCS6dbj3jL0PhlbF1r3HxnTQQ+McMutZnbUyx0Urz+3WJjtjl13UnPbkVeidXBRWqL7rpJ51Zu697OTcZXiF3hxd1JyuwDm3+zu2/mHCKrq4bwIMBCPAQDACDAQjwEAwAgwEI8BAMAIMBCPAQDACDARzZqFdL4aK0dvmVr+mOjFmuN0KVaee1Z3hfizptHHtoaQPxhd33zHmincl7Ui6blzv1Mq6rOxUzf2eF86hOj/uuVNnoR1T8w6nhWepfE4qZ7i7uBNeoYFgBBgIRoCBYAQYCEaAgWAEGAhGgIFgBBgIRoCBYJWjlPjWxBh3XJo1tM7elRWqLud3PJD0wFj/0ahCdkdAx8Y0lru388XujFLeUDlK6dxJT/fdRYXqCrWyTwrPUnnf7xmlxP+piwrVFc7xpugc1Vr/WIN/AwPJCDAQjAADwQgwEIwAA8EIMBCMAAPBCDAQjAADwZiFXi+n+nVsjlO+VHs9a2X1a2WN73yoxG3lnNu9k/tGZe0nSc+MybCzxnVS6Gxu6iy0I7X6taeq3V4qa8uywys0EIwAA8EIMBCMAAPBCDAQjAADwQgwEIwAA8EIMBCscpTSqQut1Ms5tCXVrwtzKmws6bJxrVu120tlrfsMWusTR+Qq9VIr6/70Uv3qjoBWjiQ6eqqsbcYr9Obopfo1VWRlLQEGghFgIBgBBoIRYCAYAQaCEWAgGAEGghFgIBgBBoI5s9BHw5jhppsU7r1nVJHuStqRdN24fmycYyLphbF+T9JV49qFWf3qzEK7VbuV53bmld37Pmnd3wnwfnF16Ta4Mv6Y4au7jetaQ6DhDI+M9Uvz3FUzy9PQc7v33XxmXqGBYAQYCEaAgWAEGAhGgIFgBBgIRoCBYAQYCEaAgWD/ANlOLkkLzBmKAAAAAElFTkSuQmCC" /></div>
|
||||
</div>
|
||||
<div class="lbl-ftr"><span>岩美酒行</span><span>40×20mm</span></div>
|
||||
</div>
|
||||
<div class="lbl-note">40×20mm 热敏价签:二维码溯源 + 底部整幅一维条码(Code128,内容=商品编号,供扫码枪扫码出库)。原型为示意布局,实际由客户端热敏/PDF/Web 三路渲染。</div>
|
||||
</div>
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -126,17 +126,17 @@ const ITEMS=[
|
||||
const STATUS=['全部','在售','库存','已售'];
|
||||
// 成本价/总价列 = 成本口径,仅管理员可见(非管理员两列隐藏、列设置菜单不出现,2026-07-06);原型=管理员形态
|
||||
const COLS=[{key:'product',label:'商品',fixed:true},{key:'spec',label:'规格'},{key:'series',label:'系列'},{key:'prodDate',label:'生产日期',sort:true},{key:'qty',label:'库存',num:true,sort:true},{key:'cost',label:'成本价',num:true,sort:true},{key:'price',label:'总价',num:true,sort:true},{key:'batch',label:'批次号'},{key:'inDate',label:'入库时间',sort:true},{key:'supplier',label:'供应商'},{key:'status',label:'状态'},{key:'act',label:'操作',fixed:true}];
|
||||
const state={q:'',code:'',status:'全部',page:1,perPage:10,hidden:new Set(),sortBy:null,sortAsc:true,spec:new Set(),series:new Set(),wh:new Set()};
|
||||
const state={q:'',code:'',status:'全部',page:1,perPage:10,hidden:new Set(),sortBy:null,sortAsc:true,spec:'',series:'',wh:''};
|
||||
|
||||
function setActive(k){ document.querySelectorAll('.nav[data-k]').forEach(e=>e.classList.toggle('active',e.dataset.k===k)); }
|
||||
function navTo(k){ setActive(k); const inv=document.querySelector('[data-view="inventory"]'), ph=document.querySelector('[data-view="placeholder"]'); if(k==='inventory'){inv.classList.add('active');ph.classList.remove('active');return;} inv.classList.remove('active');ph.classList.add('active'); const nav=NAVS.find(n=>n.k===k); document.getElementById('phTitle').textContent=nav.label; document.getElementById('phIcon').innerHTML=navSvg(nav.d); }
|
||||
|
||||
function visibleCols(){ return COLS.filter(c=>!state.hidden.has(c.key)); }
|
||||
function filtered(){ const rows=ITEMS.filter(it=>{ if(state.status!=='全部'&&it.status!==state.status)return false; if(state.spec.size&&!state.spec.has(it.spec))return false; if(state.series.size&&!state.series.has(it.series))return false; if(state.wh.size&&!state.wh.has(it.supplier))return false; if(state.q){const q=state.q.toLowerCase(); if(!it.name.toLowerCase().includes(q)&&!it.code.toLowerCase().includes(q))return false;} return true; }); if(state.sortBy&&SORT_VAL[state.sortBy]){const v=SORT_VAL[state.sortBy];rows.sort((a,b)=>{const x=v(a),y=v(b);return (x<y?-1:x>y?1:0)*(state.sortAsc?1:-1);});} return rows; }
|
||||
function filtered(){ const rows=ITEMS.filter(it=>{ if(state.status!=='全部'&&it.status!==state.status)return false; if(state.spec&&it.spec!==state.spec)return false; if(state.series&&it.series!==state.series)return false; if(state.wh&&it.supplier!==state.wh)return false; if(state.q){const q=state.q.toLowerCase(); if(!it.name.toLowerCase().includes(q)&&!it.code.toLowerCase().includes(q))return false;} return true; }); if(state.sortBy&&SORT_VAL[state.sortBy]){const v=SORT_VAL[state.sortBy];rows.sort((a,b)=>{const x=v(a),y=v(b);return (x<y?-1:x>y?1:0)*(state.sortAsc?1:-1);});} return rows; }
|
||||
function render(){
|
||||
document.getElementById('statusVal').textContent=state.status==='全部'?'':state.status;
|
||||
// 多选摘要(镜像 DsChip value):1 项=值本身,多项=「N 项」
|
||||
[['spec','chipSpec','specVal'],['series','chipSeries','seriesVal'],['wh','chipWh','whVal']].forEach(([k,cid,vid])=>{ const s=state[k]; document.getElementById(vid).textContent=!s.size?'':(s.size===1?[...s][0]:`${s.size} 项`); document.getElementById(cid).classList.toggle('on',!!s.size); });
|
||||
// 单选摘要(镜像 DsChip value):选中值本身,未选为空
|
||||
[['spec','chipSpec','specVal'],['series','chipSeries','seriesVal'],['wh','chipWh','whVal']].forEach(([k,cid,vid])=>{ const s=state[k]; document.getElementById(vid).textContent=s||''; document.getElementById(cid).classList.toggle('on',!!s); });
|
||||
document.getElementById('chipStatus').classList.toggle('on',state.status!=='全部');
|
||||
const cols=visibleCols();
|
||||
document.getElementById('thead').innerHTML='<tr>'+cols.map(c=>{ const cls=(c.num?'num ':'')+(c.key==='act'?'act ':''); if(c.filter){const on=state.status!=='全部'; return `<th class="${cls}"><span class="fh ${on?'filtered':''}" onclick="openFilter('${c.filter}',this)">${c.label} <svg class="funnel" viewBox="0 0 24 24"><use href="#i-ic22"/></svg></span></th>`;} if(c.sort){const on=state.sortBy===c.key; const ic=!on?'i-chevron-down':(state.sortAsc?'i-ic51':'i-chevron-down'); return `<th class="${cls}"><span class="sh ${on?'on':''}" onclick="toggleSort('${c.key}')">${c.label} <svg class="sort-ic" viewBox="0 0 24 24"><use href="#${ic}"/></svg></span></th>`;} return `<th class="${cls}">${c.label}</th>`; }).join('')+'</tr>';
|
||||
@@ -155,14 +155,19 @@ function filterStatus(s){ state.status=s; state.page=1; navTo('inventory'); rend
|
||||
// 列头排序:无→升→降→无 循环(真实实现为服务端全局排序)
|
||||
function toggleSort(k){ if(state.sortBy!==k){state.sortBy=k;state.sortAsc=true;} else if(state.sortAsc){state.sortAsc=false;} else {state.sortBy=null;state.sortAsc=true;} state.page=1; render(); }
|
||||
const SORT_VAL={qty:it=>it.qty,cost:it=>parseFloat(String(it.cost).replace(/[¥,]/g,''))||0,price:it=>parseFloat(String(it.price).replace(/[¥,]/g,''))||0,prodDate:it=>it.prodDate||'',inDate:it=>it.inDate||''};
|
||||
function clearFilters(){ state.q='';state.status='全部';state.spec.clear();state.series.clear();state.wh.clear();state.page=1; document.getElementById('searchInput').value=''; render(); }
|
||||
function clearFilters(){ state.q='';state.status='全部';state.spec='';state.series='';state.wh='';state.page=1; document.getElementById('searchInput').value=''; render(); }
|
||||
|
||||
function openFilter(kind,t){ openMenu(t,STATUS.map(v=>({v,label:v,sel:v===state.status})),v=>{ state.status=v; state.page=1; closeMenus(); render(); }); }
|
||||
// 维度筛选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移):多选,点选即生效、菜单保持打开(镜像 showDsMultiMenu)
|
||||
// 维度筛选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移):均为单选(2026-08-28 用户口径)。
|
||||
// 规格/系列名录长 → 可搜索单选下拉(openSearchMenu,照 stock-out 客户筛选写法);
|
||||
// 仓库名录短 → 普通单选下拉(openMenu,无搜索框)。
|
||||
const DIM_KEY={spec:'spec',series:'series',wh:'supplier'};
|
||||
function openDimFilter(dim,t){ const key=DIM_KEY[dim]; const opts=[...new Set(ITEMS.map(it=>it[key]).filter(Boolean))]; openMenu(t,opts.map(v=>({v,label:v,sel:state[dim].has(v)})),v=>{ state[dim].has(v)?state[dim].delete(v):state[dim].add(v); state.page=1; render(); openDimFilter(dim,t); }); }
|
||||
const DIM_PH={spec:'搜索规格…',series:'搜索系列…'};
|
||||
function openDimFilter(dim,t){ const key=DIM_KEY[dim]; const opts=[...new Set(ITEMS.map(it=>it[key]).filter(Boolean))];
|
||||
if(dim==='wh'){ openMenu(t,opts.map(v=>({v,label:v,sel:v===state.wh})),v=>{ state.wh=v; state.page=1; closeMenus(); render(); }); return; }
|
||||
openSearchMenu(t,opts.map(v=>({v,label:v,sel:v===state[dim]})),v=>{ state[dim]=v; state.page=1; closeMenus(); render(); },{placeholder:DIM_PH[dim]}); }
|
||||
// chip 尾部 × 单清(镜像 DsChip onClear:只清本维度,不展开菜单)
|
||||
function clearDim(dim,e){ e.stopPropagation(); state[dim].clear(); state.page=1; render(); }
|
||||
function clearDim(dim,e){ e.stopPropagation(); state[dim]=''; state.page=1; render(); }
|
||||
function clearStatus(e){ e.stopPropagation(); state.status='全部'; state.page=1; render(); }
|
||||
document.getElementById('chipStatus').onclick=e=>openFilter('status',e.currentTarget);
|
||||
const COL_KEY='jiu-inv-hidden';
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="a">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>标签模板编辑器 · 岩美酒库</title>
|
||||
<link rel="stylesheet" href="../tokens.css">
|
||||
<link rel="stylesheet" href="../atoms.css">
|
||||
<style>
|
||||
body{margin:0; height:100vh; overflow:hidden; font-family:var(--font); color:var(--text); background:var(--bg); transition:background-color .28s;}
|
||||
.app{height:100vh; display:flex; flex-direction:column;}
|
||||
.main{padding:22px 26px; overflow:hidden; display:flex; flex-direction:column;}
|
||||
.head{display:flex; align-items:flex-end; gap:14px; margin-bottom:18px; flex:none;}
|
||||
.head h1{margin:0; font-size:var(--fs-h1); font-weight:700; color:var(--title); letter-spacing:.3px;}
|
||||
.head .sub{font-size:var(--fs-sm); color:var(--muted); margin-bottom:2px;}
|
||||
.head .actions{margin-left:auto; display:flex; gap:10px;}
|
||||
|
||||
/* ── 双栏:左预览 · 右属性 ── */
|
||||
.lt-wrap{flex:1; display:grid; grid-template-columns:minmax(0,1fr) 400px; gap:22px; min-height:0;}
|
||||
|
||||
/* 左 · 实时预览舞台 */
|
||||
.lt-stage{background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); box-shadow:var(--sh-1); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:18px; padding:24px; min-height:0;}
|
||||
.lt-stitle{align-self:flex-start; font-size:var(--fs-sm); font-weight:600; color:var(--muted); letter-spacing:.4px;}
|
||||
/* 舞台预留 100% 尺寸(320×160 @1.5=480×240);缩放靠 label 自身 transform */
|
||||
.lt-frame{width:480px; height:240px; display:flex; align-items:center; justify-content:center;}
|
||||
/* 价签纸面 320×160(40×20mm@203dpi):flex 栅格流,内容自动填满整纸,删字段自动回流补位 */
|
||||
.lt-label{width:320px; height:160px; background:#fff; border-radius:var(--r-sm); box-shadow:var(--sh-2); overflow:hidden; transform:scale(1.5); transform-origin:center; transition:transform .18s;
|
||||
display:flex; flex-direction:column; box-sizing:border-box;}
|
||||
/* 行①店名 band(flex:none,关掉后整体上填) */
|
||||
.lt-hd{flex:none; padding:4px 12px; text-align:center;
|
||||
background:#1f2a3a; /* ds-allow 价签抬头深蓝(物理印刷色,非主题) */ }
|
||||
.lt-shop{font-size:14px; font-weight:700; letter-spacing:.5px;
|
||||
color:#fff; /* ds-allow 抬头白字 */ }
|
||||
/* 行②主体(flex:1 撑满抬头以下全部高):左列(文字+条码) + 右列(整高二维码) */
|
||||
.lt-main{flex:1; min-height:0; display:flex; align-items:stretch; gap:10px; padding:8px 12px;}
|
||||
/* 左列:文字栏在上、条码在下,条码与文字同宽 */
|
||||
.lt-left{flex:1; min-width:0; display:flex; flex-direction:column; gap:6px;}
|
||||
.lt-txt{flex:1; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px; text-align:center;}
|
||||
.lt-name{font-size:18px; font-weight:700; line-height:1.15;
|
||||
color:#1a1a1a; /* ds-allow 价签品名墨色 */ }
|
||||
.lt-sub{font-size:14px; line-height:1.15;
|
||||
color:#3a3a3a; /* ds-allow 价签副字段灰 */ }
|
||||
/* 二维码:正方形,撑满整个主体高度(抬头以下一整列,横跨文字+条码) */
|
||||
.lt-qr{flex:none; align-self:stretch; aspect-ratio:1; border-radius:var(--r-sm);
|
||||
background:repeating-conic-gradient(#000 0 25%, #fff 0 50%) 0 0/12px 12px; /* ds-allow 二维码占位棋盘 */ }
|
||||
/* 条码 band(flex:none,宽度=左列宽=文字宽;关掉后左列上填) */
|
||||
.lt-foot{flex:none;}
|
||||
.lt-bars{width:100%; height:30px;
|
||||
background:repeating-linear-gradient(90deg, #000 0 2px, #fff 2px 5px, #000 5px 6px, #fff 6px 10px); /* ds-allow 一维条码占位 */ }
|
||||
.lt-off{display:none !important;}
|
||||
.lt-cap{font-size:var(--fs-xs); color:var(--muted); letter-spacing:.4px;}
|
||||
.lt-tools{display:flex; align-items:center; gap:12px;}
|
||||
.lt-tools .lbl{font-size:var(--fs-xs); color:var(--muted);}
|
||||
|
||||
/* 右 · 属性面板 */
|
||||
.lt-panel{overflow-y:auto; padding-right:4px; display:flex; flex-direction:column; gap:14px;}
|
||||
.lt-sec{background:var(--surface); border:1px solid var(--border); border-radius:var(--r-lg); padding:16px 16px 18px;}
|
||||
.lt-sectitle{font-size:var(--fs-sm); font-weight:700; color:var(--title); margin:0 0 12px; display:flex; align-items:center; gap:8px;}
|
||||
.lt-sectitle svg{width:16px; height:16px; stroke:currentColor; fill:none; stroke-width:2; color:var(--primary);}
|
||||
.lt-mgr{display:flex; flex-wrap:wrap; gap:8px; margin-top:10px;}
|
||||
/* 字段清单(每行内嵌样式,手风琴展开一行) */
|
||||
.lt-field{border-radius:var(--r-md);}
|
||||
.lt-field + .lt-field{margin-top:2px;}
|
||||
.lt-frow{display:flex; align-items:center; gap:10px; padding:9px 8px; border-radius:var(--r-md); cursor:pointer; transition:background .15s;}
|
||||
.lt-frow:hover{background:var(--row-hover);}
|
||||
.lt-field.open>.lt-frow{background:var(--accent-soft);}
|
||||
.lt-fname{flex:1; font-size:var(--fs-body); color:var(--text);}
|
||||
.lt-fmeta{font-size:var(--fs-xs); color:var(--muted);}
|
||||
.lt-frow .switch{flex:none;}
|
||||
.lt-frow .chev{width:16px; height:16px; flex:none; stroke:currentColor; fill:none; stroke-width:2; color:var(--muted); transition:transform .18s;}
|
||||
.lt-field.open>.lt-frow .chev{transform:rotate(180deg);}
|
||||
.lt-field.disabled .lt-fname{color:var(--muted); text-decoration:line-through;}
|
||||
/* 折叠体:默认收起,展开显字段样式 */
|
||||
.lt-fbody{display:none; padding:6px 8px 14px;}
|
||||
.lt-field.open>.lt-fbody{display:block;}
|
||||
.lt-srow{display:flex; align-items:center; gap:12px; margin-bottom:12px;}
|
||||
.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;}
|
||||
.lt-fontsel .ctrl{width:100%; box-sizing:border-box;}
|
||||
.lt-fontname{flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
|
||||
.lt-fontpop{position:absolute; left:0; top:calc(100% + 4px); width:100%; box-sizing:border-box; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-md); box-shadow:var(--sh-3); padding:4px; display:none; z-index:var(--z-pop);}
|
||||
.lt-fontpop.show{display:block;}
|
||||
.lt-fontitem{display:flex; align-items:center; padding:8px 10px; border-radius:var(--r-sm); font-size:var(--fs-body); color:var(--text); cursor:pointer;}
|
||||
.lt-fontitem:hover{background:var(--row-hover);}
|
||||
.lt-fontitem.on{color:var(--primary); font-weight:600;}
|
||||
/* 「打印默认」徽标 + 提示 */
|
||||
.lt-tag{margin-left:auto; font-size:var(--fs-xs); font-weight:600; color:var(--primary); background:var(--accent-soft); border-radius:var(--r-pill); padding:1px 8px;}
|
||||
.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;}
|
||||
.lt-adv>summary::-webkit-details-marker{display:none;}
|
||||
.lt-adv>summary .chev{margin-left:auto; width:16px; height:16px; stroke:currentColor; fill:none; stroke-width:2; color:var(--muted); transition:transform .18s;}
|
||||
.lt-adv[open]>summary .chev{transform:rotate(180deg);}
|
||||
.lt-adv .body{padding:0 16px 16px;}
|
||||
.lt-grid2{display:grid; grid-template-columns:1fr 1fr; gap:10px 14px;}
|
||||
|
||||
.ds-fab{position:fixed; right:22px; bottom:22px; width:44px; height:44px; border-radius:var(--r-pill); background:var(--surface); color:var(--muted); border:1px solid var(--border); display:flex; align-items:center; justify-content:center; box-shadow:var(--sh-2); z-index:var(--z-fab); opacity:.6; transition:opacity .18s,color .18s,border-color .18s,transform .18s,box-shadow .18s; text-decoration:none;}
|
||||
.ds-fab:hover{opacity:1; color:var(--primary); border-color:var(--primary); box-shadow:var(--sh-3); transform:translateY(-2px);}
|
||||
.ds-fab svg{width:20px; height:20px; stroke-width:2; fill:none; stroke:currentColor;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
<div class="top" data-shell-top data-active="devices"></div>
|
||||
<div class="body">
|
||||
<div class="side" id="side"></div>
|
||||
<div class="main">
|
||||
<div class="head">
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<div class="lt-wrap">
|
||||
<!-- 左 · 实时预览 -->
|
||||
<div class="lt-stage">
|
||||
<div class="lt-stitle">实时预览</div>
|
||||
<div class="lt-frame">
|
||||
<div class="lt-label" id="label">
|
||||
<div class="lt-hd" data-f="shop"><span class="lt-shop">鼎晟酒行</span></div>
|
||||
<div class="lt-main">
|
||||
<div class="lt-left">
|
||||
<div class="lt-txt">
|
||||
<div class="lt-name" data-f="name">贵州茅台酒</div>
|
||||
<div class="lt-sub"><span data-f="code">P1001</span> <span data-f="series">飞天53度</span></div>
|
||||
<div class="lt-sub"><span data-f="spec">500ml</span> <span data-f="date">2024-05-18</span></div>
|
||||
</div>
|
||||
<div class="lt-foot" data-f="barcode"><div class="lt-bars"></div></div>
|
||||
</div>
|
||||
<div class="lt-qr" data-f="qr"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-tools">
|
||||
<span class="lbl">缩放</span>
|
||||
<div class="seg" id="zoom">
|
||||
<button onclick="setZoom(1.125,this)">75%</button>
|
||||
<button class="on" onclick="setZoom(1.5,this)">100%</button>
|
||||
<button onclick="setZoom(1.875,this)">125%</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-cap">40 × 20 mm · 203 dpi · 示例数据</div>
|
||||
</div>
|
||||
|
||||
<!-- 右 · 属性面板 -->
|
||||
<div class="lt-panel">
|
||||
<!-- 模板管理 -->
|
||||
<div class="lt-sec">
|
||||
<div class="lt-sectitle"><svg viewBox="0 0 24 24"><use href="#i-ic12"/></svg>模板</div>
|
||||
<div class="field">
|
||||
<div class="lt-fontsel">
|
||||
<div class="ctrl" onclick="toggleTpl(event)"><span class="lt-fontname" id="tplName">默认版式</span><svg width="14" height="14" viewBox="0 0 24 24" stroke-width="2"><use href="#i-chevron-down"/></svg></div>
|
||||
<div class="lt-fontpop" id="tplPop"></div>
|
||||
</div>
|
||||
</div>
|
||||
<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 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 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>
|
||||
|
||||
<!-- 显示字段(样式内嵌每行,选中展开折叠) -->
|
||||
<div class="lt-sec">
|
||||
<div class="lt-sectitle"><svg viewBox="0 0 24 24"><use href="#i-eye"/></svg>显示字段<span class="lt-fmeta" style="margin-left:auto;font-weight:400">点字段名展开样式</span></div>
|
||||
<div id="fieldList"></div>
|
||||
</div>
|
||||
|
||||
<!-- 高级 · 区域位置 -->
|
||||
<details class="lt-adv">
|
||||
<summary><svg viewBox="0 0 24 24" style="width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2"><use href="#i-ic12"/></svg>高级 · 区域位置<svg class="chev" viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg></summary>
|
||||
<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="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">
|
||||
<div class="field"><label>X</label><input class="input" value="8"></div>
|
||||
<div class="field"><label>Y</label><input class="input" value="114"></div>
|
||||
<div class="field"><label>宽</label><input class="input" value="164"></div>
|
||||
<div class="field"><label>高</label><input class="input" value="40"></div>
|
||||
</div>
|
||||
<div class="lt-fmeta" style="margin:14px 0 10px">抬头</div>
|
||||
<div class="lt-grid2">
|
||||
<div class="field"><label>高度</label><input class="input" value="24"></div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 打印参数(常驻可见) -->
|
||||
<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>
|
||||
<div class="field"><label>纸张高 (mm)</label><input class="input" value="20"></div>
|
||||
<div class="field"><label>DPI</label><input class="input" value="203"></div>
|
||||
<div class="field"><label>间隔 (mm)</label><input class="input" value="2"></div>
|
||||
<div class="field"><label>打印密度</label><input class="input" value="10"></div>
|
||||
<div class="field"><label>打印速度</label><input class="input" value="2"></div>
|
||||
<div class="field"><label>份数</label><input class="input" value="1"></div>
|
||||
</div>
|
||||
<div class="lt-srow" style="margin-top:14px">
|
||||
<span class="k">打印方向</span>
|
||||
<div class="seg" id="dir">
|
||||
<button class="on" onclick="pickSeg(this)">正向</button>
|
||||
<button onclick="pickSeg(this)">反向</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重命名弹窗 -->
|
||||
<div class="overlay" id="ovRename"><div class="modal" style="width:420px">
|
||||
<div class="modal-head"><h3>重命名模板</h3><svg class="x" viewBox="0 0 24 24" stroke-width="2" onclick="closeRename()"><use href="#i-close"/></svg></div>
|
||||
<div class="modal-body">
|
||||
<div class="frow"><div class="field"><label>模板名称</label><div class="ctrl input-like"><input id="renameInput" value="默认版式"></div></div></div>
|
||||
</div>
|
||||
<div class="modal-foot"><div class="btn ghost" onclick="closeRename()">取消</div><div class="btn primary" onclick="doRename()">保存</div></div>
|
||||
</div></div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="shell.js"></script>
|
||||
<script>
|
||||
/* ── 字段清单:样式内嵌每行,手风琴展开 ── */
|
||||
/* 1 期字体:仅系统字体 + 应用已内置思源黑体,不打包新字体(宋体已撤)。
|
||||
楷体/等宽蹭系统,缺失回退默认。 */
|
||||
// 字体不做(热敏单色 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, 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},
|
||||
];
|
||||
// 编号↔型号 同物理行、版本↔日期 同物理行:字号/加粗/对齐共用(Option A)
|
||||
const PAIR = {2:3, 3:2, 4:5, 5:4};
|
||||
|
||||
function metaText(fd){
|
||||
if(fd.type==='graphic') return '图形 · 位置见高级';
|
||||
const parts = [fd.auto ? '自动字号' : fd.size+'px'];
|
||||
if(fd.bold) parts.push('加粗');
|
||||
parts.push(ALIGN_LABEL[fd.align]);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
function fontBody(fd, i){
|
||||
const autoCtrl = fd.f==='name'
|
||||
? `<span class="lt-fmeta">自动</span>
|
||||
<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>
|
||||
<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>
|
||||
<div class="lt-srow"><span class="k">对齐</span>
|
||||
<div class="seg">
|
||||
<button class="${fd.align==='left'?'on':''}" onclick="setAlign(event,${i},'left')">左</button>
|
||||
<button class="${fd.align==='center'?'on':''}" onclick="setAlign(event,${i},'center')">居中</button>
|
||||
<button class="${fd.align==='right'?'on':''}" onclick="setAlign(event,${i},'right')">右</button>
|
||||
</div></div>
|
||||
</div>`;
|
||||
}
|
||||
function graphicBody(fd){
|
||||
return `<div class="lt-fbody"><p class="lt-gnote">图形字段,无文字样式。位置与尺寸在下方「高级 · 区域位置」中调整。</p></div>`;
|
||||
}
|
||||
function rowHtml(fd, i){
|
||||
return `<div class="lt-field${fd.on?'':' disabled'}" data-i="${i}">
|
||||
<div class="lt-frow" onclick="expandField(${i})">
|
||||
<button class="switch${fd.on?' on':''}" onclick="tglField(event,${i})"></button>
|
||||
<span class="lt-fname">${fd.label}</span>
|
||||
<span class="lt-fmeta">${metaText(fd)}</span>
|
||||
<svg class="chev" viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg>
|
||||
</div>
|
||||
${fd.type==='graphic' ? graphicBody(fd) : fontBody(fd, i)}
|
||||
</div>`;
|
||||
}
|
||||
function renderFields(){
|
||||
document.getElementById('fieldList').innerHTML = FIELDS.map(rowHtml).join('');
|
||||
FIELDS.forEach((_,i)=>applyStyle(i));
|
||||
}
|
||||
function fieldEl(i){ return document.querySelector('.lt-field[data-i="'+i+'"]'); }
|
||||
function previewEls(i){ return document.querySelectorAll('.lt-label [data-f="'+FIELDS[i].f+'"]'); }
|
||||
function refreshMeta(i){ fieldEl(i).querySelector('.lt-fmeta').textContent = metaText(FIELDS[i]); }
|
||||
|
||||
function applyStyle(i){
|
||||
const fd = FIELDS[i];
|
||||
previewEls(i).forEach(el=>{
|
||||
el.classList.toggle('lt-off', !fd.on);
|
||||
if(fd.type==='text'){
|
||||
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');
|
||||
document.querySelectorAll('.lt-field.open').forEach(f=>f.classList.remove('open'));
|
||||
if(!wasOpen) el.classList.add('open');
|
||||
}
|
||||
function tglField(e, i){
|
||||
e.stopPropagation();
|
||||
FIELDS[i].on = !FIELDS[i].on;
|
||||
e.currentTarget.classList.toggle('on', FIELDS[i].on);
|
||||
fieldEl(i).classList.toggle('disabled', !FIELDS[i].on);
|
||||
applyStyle(i);
|
||||
}
|
||||
function setSize(i, v){
|
||||
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){
|
||||
e.stopPropagation();
|
||||
FIELDS[i].bold = !FIELDS[i].bold;
|
||||
e.currentTarget.classList.toggle('on', FIELDS[i].bold);
|
||||
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); mirror(i);
|
||||
}
|
||||
|
||||
function pickSeg(btn){
|
||||
btn.parentElement.querySelectorAll('button').forEach(b=>b.classList.remove('on'));
|
||||
btn.classList.add('on');
|
||||
}
|
||||
function setZoom(z, btn){
|
||||
btn.parentElement.querySelectorAll('button').forEach(b=>b.classList.remove('on'));
|
||||
btn.classList.add('on');
|
||||
document.getElementById('label').style.transform = 'scale('+z+')';
|
||||
}
|
||||
function resetTpl(){ toast('已重置为默认版式'); }
|
||||
/* 打印当前预览:真实端弹出签打印弹窗(选打印机/份数),此处仅提示 */
|
||||
function printTpl(){ toast('打开打印预览…'); }
|
||||
/* 模板:下拉选=正在编辑;「设默认」=打印实际使用的那个(二者可不同) */
|
||||
const TPLS = ['默认版式', '大字号简约', '双语抬头'];
|
||||
let tplEditing = '默认版式'; // 正在编辑
|
||||
let tplActive = '默认版式'; // 出签打印默认
|
||||
function renderTplPop(){
|
||||
document.getElementById('tplPop').innerHTML = TPLS.map(n=>
|
||||
`<div class="lt-fontitem${n===tplEditing?' on':''}" onclick="pickTpl(event,'${n}')">${n}${n===tplActive?'<span class="lt-tag">打印默认</span>':''}</div>`
|
||||
).join('');
|
||||
}
|
||||
function toggleTpl(e){
|
||||
e.stopPropagation();
|
||||
const p = document.getElementById('tplPop'), open = p.classList.contains('show');
|
||||
document.querySelectorAll('.lt-fontpop.show').forEach(x=>x.classList.remove('show'));
|
||||
if(!open) p.classList.add('show');
|
||||
}
|
||||
function pickTpl(e, name){
|
||||
e.stopPropagation();
|
||||
tplEditing = name;
|
||||
document.getElementById('tplName').textContent = name;
|
||||
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;
|
||||
renderTplPop();
|
||||
toast('已设为打印默认:'+tplActive);
|
||||
}
|
||||
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('已重命名 ✓'); }
|
||||
document.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeRename(); closeMenus(); } });
|
||||
</script>
|
||||
<a class="ds-fab" href="../index.html" title="返回设计系统" aria-label="返回设计系统"><svg viewBox="0 0 24 24"><use href="#i-arrow-left"/></svg></a>
|
||||
<script src="fab.js"></script>
|
||||
<script src="notify.js"></script>
|
||||
<script src="statusbar.js"></script>
|
||||
<script src="icons.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+1
-1
@@ -56,7 +56,7 @@
|
||||
|
||||
// 可搜索菜单:视觉承袭 .combo-pop(顶部 .cp-search 显式搜索框 + 边打边过滤 + 结果统计),
|
||||
// 供列表工具栏「供应商 / 客户」等长名录筛选复用(复用 atoms.css .combo-pop 族,零新增样式)。
|
||||
// items: [{ v, label, sel, small? }];opts: { placeholder, minW, cls }。
|
||||
// items: [{ v, label, sel, small? }];opts: { placeholder, minW, cls }。单选:点选后由 onPick 内自行 closeMenus。
|
||||
function openSearchMenu(t, items, onPick, opts) {
|
||||
closeMenus();
|
||||
opts = opts || {};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>标签模板编辑器 设计方案 — 酒库管理系统</title>
|
||||
<style>
|
||||
:root{
|
||||
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
|
||||
--success:#2E8B57; --warn:#B45309; --accent:#8B2331;
|
||||
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
|
||||
}
|
||||
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
|
||||
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.65;}
|
||||
h1{font-size:20px;margin:0 0 4px;}
|
||||
h2{font-size:16px;margin:26px 0 8px;color:var(--primary-dark);border-left:4px solid var(--primary);padding-left:10px;}
|
||||
h3{font-size:14px;margin:18px 0 6px;color:var(--accent);}
|
||||
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
|
||||
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;max-width:1000px;margin-bottom:16px;}
|
||||
p{font-size:14px;margin:6px 0;}
|
||||
code{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
|
||||
ol,ul{font-size:14px;margin:6px 0;padding-left:22px;}
|
||||
li{margin:5px 0;}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px;max-width:1000px;margin:8px 0;}
|
||||
th{background:var(--head);color:var(--primary-dark);font-weight:600;font-size:12px;text-align:left;padding:9px 10px;border:1px solid var(--border);}
|
||||
td{padding:9px 10px;border:1px solid #EEF1F5;vertical-align:top;}
|
||||
.tag{font-size:11px;padding:2px 8px;border-radius:10px;display:inline-block;}
|
||||
.tag.ok{background:#E6F3EC;color:var(--success);}
|
||||
.tag.warn{background:#FFF4E5;color:var(--warn);}
|
||||
.tag.no{background:var(--danger-bg);color:var(--danger);}
|
||||
.tag.rec{background:#E7F0FB;color:var(--primary-dark);}
|
||||
.lead{font-size:14px;background:#F0F6FF;border-left:3px solid var(--primary);padding:10px 14px;border-radius:4px;max-width:1000px;}
|
||||
.flow{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;background:#1d2430;color:#e6edf6;padding:14px 18px;border-radius:8px;max-width:1000px;overflow:auto;line-height:1.7;}
|
||||
.decision{background:#FFF9EC;border:1px solid #F0DDB0;border-radius:10px;padding:14px 18px;max-width:1000px;margin-bottom:16px;}
|
||||
.decision h3{color:var(--warn);margin-top:0;}
|
||||
.rec-box{background:#EEF6F0;border:1px solid #BFE0CB;border-radius:8px;padding:8px 12px;margin:8px 0;font-size:13px;}
|
||||
.rec-box b{color:var(--success);}
|
||||
.fix{background:#FDECEC;border:1px solid #F3C6C6;border-radius:8px;padding:8px 12px;margin:8px 0;font-size:13px;}
|
||||
.fix b{color:var(--danger);}
|
||||
.wire{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#fbfcfe;border:1px solid var(--border);border-radius:8px;padding:14px 16px;white-space:pre;overflow:auto;max-width:1000px;color:#333;line-height:1.5;}
|
||||
.fl{color:var(--muted);font-size:11.5px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>标签模板编辑器 · 设计方案(代码实证版 v2)</h1>
|
||||
<div class="sub">给「设置 → 打印模板 · 商品价签」的「编辑」按钮做可视化模板编辑器 · 2026-08-28 · 本版所有结论已逐行核对源码(不采信二手调研)· 方案评审(未实现)</div>
|
||||
|
||||
<div class="lead">
|
||||
目标不变:店家自定义价签版式(选字段 / 调字号字体位置 / 实时预览 / 多样式命名 / 打印参数进高级)。
|
||||
<b>本版把上一版两处关键判断按源码订正了</b>:① 二维码走的是<b>位图</b>不是原生指令;
|
||||
② PDF / Web 两条路径用的是 <b>flexbox 流式布局、根本没有绝对坐标</b>——这直接改变了「统一渲染」的做法。
|
||||
结论:真正要数据驱动的只有<b>画布(预览) + 热敏(实打)</b>这一条主线,PDF/Web 用「贴位图」收口即可,
|
||||
<b>比上一版设想的工作量更小、风险更低</b>。
|
||||
</div>
|
||||
|
||||
<h2>1. 现状(逐行核对源码)</h2>
|
||||
<div class="card">
|
||||
<p>标签有<b>四条输出路径</b>,各自布局机制如下(已核对,非调研二手):</p>
|
||||
<table>
|
||||
<tr><th>路径</th><th>位置</th><th>何时走</th><th>布局机制</th><th>条码 / 二维码</th></tr>
|
||||
<tr><td><b>画布</b></td><td><code>print_util_stub.dart:246</code> <code>_renderLabelBitmap</code></td><td>预览弹窗 + 热敏文字光栅(同一函数,<code>drawQr/drawBarcode=false</code> 供热敏)</td><td><b>绝对坐标</b>(逻辑 320×160,4× 超采样)</td><td>条码 <code>_drawBarcode</code> 画黑条(:315);QR <code>drawImageRect</code>(:295)</td></tr>
|
||||
<tr><td><b>热敏 TSPL</b></td><td>同文件 <code>_printFlatLabelThermal:351</code></td><td>检测到 DL-888B 等热敏机(真实主力)</td><td>文字=画布光栅 <code>BITMAP</code>(:394);<b>绝对坐标</b></td><td>条码=原生 <code>BARCODE</code>(:451);QR=<b>位图</b> <code>BITMAP</code>(:427)</td></tr>
|
||||
<tr><td>PDF 回退</td><td>同文件 <code>printProductLabelImpl:463</code></td><td><b>只在检测不到热敏机时</b>(:491-499)</td><td><b>flexbox 流式</b>(<code>pw.Column/Row/Expanded</code>),<b>无绝对坐标</b></td><td>条码 <code>pw.BarcodeWidget</code> 矢量(:578);QR <code>pw.Image</code>(:593)</td></tr>
|
||||
<tr><td>Web HTML</td><td><code>print_util_web.dart:21</code></td><td>Web 构建(<code>window.print()</code>)</td><td><b>CSS flexbox 流式</b>(:82-124),<b>无绝对坐标</b></td><td>条码 <code>toSvg</code> 矢量(:64);QR base64 img(:143)</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="fix"><b>订正 ①(我上一版说错的)</b>:热敏路径里二维码是 <code>BITMAP $qrX,$qrY,...</code>(第 427 行)——<b>位图,不是原生 QRCODE 指令</b>。它「相对清晰」的原因是<b>独立渲染</b>:把 QR PNG 按目标尺寸 130×130 单独解码 + 1:1 逐像素 128 阈值二值化(:422-436),<b>不经过文字那条 4×4 超采样降采样链路</b>。只有<b>条码</b>是真·原生 TSPL <code>BARCODE</code> 指令(:451)。</div>
|
||||
|
||||
<div class="fix"><b>订正 ②(影响架构)</b>:PDF 和 Web <b>不是绝对坐标布局,是 flexbox 流式</b>(PDF 用 <code>pw.Column/Row/Expanded</code>,Web 用 CSS flex)。这意味着——如果编辑器让用户「把字段拖到某个 x/y」,这套坐标<b>天然只对得上画布/热敏,对不上 PDF/Web 的 flex 流</b>。要让 PDF/Web 认坐标,就得把它们的 flex 布局整个换掉。这正是下一节改方案的原因。</div>
|
||||
|
||||
<h3>1.1 画布布局其实是「自适应」的,不是死坐标</h3>
|
||||
<p>关键细节(<code>_renderLabelBitmap:319-340</code>):品名字号 <code>_fitFont</code> 按宽度<b>自适应</b> clamp(12,18);文字块(品名 + 两行副字段)在剩余竖直空间里<b>自动均分留白居中</b>;副字段 <code>_labelSubFields:222</code> 把「编号+系列」拼一行、「规格+日期」拼一行,<b>空字段自动跳过、整行塌缩</b>。也就是说当前版式<b>不是一组固定像素位置</b>,而是「几个区域 + 自动排版」。这点直接决定版式模型该怎么建(见 §3)。</p>
|
||||
|
||||
<h3>1.2 实际渲染 / 未渲染的字段</h3>
|
||||
<p>参与渲染:<code>shopName / name / code / series / spec / productionDate / qr / barcode</code>。<b>已在 <code>LabelData</code> 但从不渲染</b>:<code>batchNo / remark / shopAddress / shopPhone</code>(<code>label_data.dart:15-20</code>) → 编辑器可直接把它们做成可选字段,<b>数据层零改动</b>。</p>
|
||||
|
||||
<h3>1.3 入口与存储(已核对,利好)</h3>
|
||||
<ul>
|
||||
<li><b>「编辑」是空实现</b>:<code>device_management_screen.dart:991</code> → <code>_snack('模板编辑即将上线')</code>;「预览」<code>_previewLabelTemplate:1002</code> 造示例 <code>LabelData</code> 弹 <code>LabelPreviewDialog</code>。<b>打印模板卡仅桌面渲染</b> → 编辑器只做桌面。</li>
|
||||
<li><b>打印入口是扁平参数</b>:<code>print_util.dart:15</code> <code>printProductLabel(...)</code> 和 <code>:45</code> <code>renderLabelPreview(label)</code> 都不带模板概念 → 要加一个 <code>template</code> 参数贯穿下去(改动集中在 facade + 两个 Impl)。</li>
|
||||
<li><b>后端 custom_fields 已支持增量 merge</b>:<code>shop.go:68-79</code> 读现有 custom_fields → 合并新键 → 保留旧键、店隔离,还有钉死契约的测试 <code>shop_test.go:31</code>。前端 <code>_savePeripherals:1031</code> 就是这么存外设的。<b>→ 模板存 custom_fields 零后端改动、且不会冲掉外设等已有键。</b></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>2. 渲染架构(按源码修订:画布为唯一权威 + PDF/Web 贴位图)</h2>
|
||||
<div class="card">
|
||||
<p>上一版我提「抽一层绘制指令 IR 给四路径各写 adapter」。读完代码后<b>这是过度设计</b>——因为 PDF/Web 是流式布局、且只是回退/次要面。更省的做法:</p>
|
||||
<div class="rec-box"><b>让「画布」成为唯一的几何权威。</b> 模板 = 画布上的绝对布局;画布同时驱动<b>预览</b>和<b>热敏文字光栅</b>(热敏文字本就是画布栅格化的)。PDF/Web 直接<b>嵌入画布渲染出的位图</b>,不再各自摆版。四套手工对齐的布局 → 收敛成<b>一套画布渲染器 + 两个「贴位图」消费者 + 热敏既有的条码/QR 特判</b>。</div>
|
||||
<table>
|
||||
<tr><th>面</th><th>改法</th><th>条码/QR 清晰度</th></tr>
|
||||
<tr><td>预览</td><td>画布 → PNG(现状即是)</td><td>屏幕显示,无所谓</td></tr>
|
||||
<tr><td><b>热敏(主力)</b></td><td>文字=画布光栅(现状);条码=原生 <code>BARCODE</code>、QR=独立 <code>BITMAP</code>(<b>都保留</b>);<b>唯一改动</b>:它们的坐标从写死的 <code>const</code> 改成<b>由模板算出</b></td><td><b>不降级</b>:条码原生矢量、QR 独立 1:1 阈值,扫码可靠性照旧</td></tr>
|
||||
<tr><td>PDF 回退</td><td><b>整个 <code>pw.*</code> 组件树换成单张 <code>pw.Image(画布位图)</code></b> 满页铺</td><td>回退面向办公激打/喷墨(≥300dpi),画布 1280px≈812dpi 栅格化,条码可扫</td></tr>
|
||||
<tr><td>Web</td><td><b>HTML flex 换成 <code><img></code> 画布 PNG</b>(40×20mm)</td><td>同上,Web 打印走高 dpi,无虞</td></tr>
|
||||
</table>
|
||||
<p class="fl">注:条码/QR 光栅化「扫不出」的风险<b>只发生在热敏 203dpi</b>——所以热敏那条<b>坚决保留</b>原生条码 + 独立 QR,不动。PDF/Web 是高 dpi 面,贴位图完全够用。</p>
|
||||
<div class="rec-box">附带红利:因为 PDF/Web 只是回退/次要面,<b>它们的模板落地可以延后</b>——先做画布+热敏(覆盖真实打印 + 预览),PDF/Web 后面「贴位图」一句话的事。<b>比上一版少写两个 adapter。</b></div>
|
||||
</div>
|
||||
|
||||
<h2>3. 版式模型:区域 + 自动栈(贴合现有自适应布局)</h2>
|
||||
<div class="card">
|
||||
<p>因为 §1.1 里现状是「区域 + 自动排版」而非死坐标,模型<b>不宜做成纯自由画布</b>(否则丢掉品名自适应、竖直自动居中这些现有优点,且短名不再放大、隐藏字段不再回填空间 → 反而变差)。建议<b>区域(region) + 元素(element)</b> 混合模型:</p>
|
||||
<div class="wire">LabelTemplate {
|
||||
id, name // 可命名 / 重命名
|
||||
paper: { width_mm:40, height_mm:20, dpi:203 }
|
||||
print: { density:10, speed:2, gap_mm:2, direction:1, copies:1 } // 高级设置
|
||||
regions: {
|
||||
header: { visible, height_mm, bg, fields:[ text... ] } // 深蓝抬头
|
||||
textStack:{ x,y,w,h, align, fields:[ text... ] } // 自动竖直均分(保留现有算法)
|
||||
qr: { visible, x, y, size } // 右列二维码
|
||||
barcode: { visible, x, y, w, h, symbology:code128, showText } // 左下条码
|
||||
}
|
||||
}
|
||||
text field { binding, staticText?, fontSize(或 auto), bold, align, color }
|
||||
binding ∈ shopName|shopAddress|shopPhone|name|code|series|spec|
|
||||
batchNo|productionDate|remark|static</div>
|
||||
<ul>
|
||||
<li><b>字段开关</b>:勾选控制 field 是否进 textStack / 是否显示 QR/条码。</li>
|
||||
<li><b>调样式</b>:字号(或 auto 自适应)、粗体、对齐、颜色。</li>
|
||||
<li><b>调位置</b>:QR / 条码 / 抬头 = <b>可移动可缩放的区域</b>(覆盖「调位置」诉求最实用的部分);textStack 内字段 = <b>调顺序 + 可选手动微调</b>,默认仍走现有自动均分。</li>
|
||||
<li><b>零回归</b>:内置「默认模板」= 把现有 <code>const</code>(headerH=24 / qrSize=134 / barTop=114 …)原样编码;渲染器保留自动栈算法。用字体探针 / golden 位图对照守逐像素一致。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>4. 存储:shop.custom_fields(已被源码证实可行、零后端改动)</h2>
|
||||
<div class="card">
|
||||
<p>模板存 <code>shop.custom_fields.label_templates</code>(数组)+ <code>label_template_active</code>(默认 id)。依据:</p>
|
||||
<ul>
|
||||
<li>后端 <code>shop.go:68-79</code> 对 custom_fields 做<b>增量 merge</b> 并有测试钉死 → 写 <code>label_templates</code> 不碰 <code>peripherals</code> 等旧键,店隔离。<b>无需新表/迁移/接口。</b></li>
|
||||
<li>前端照抄 <code>_savePeripherals:1031</code>:读 <code>shopInfoProvider</code> → 改 <code>customFields</code> map → <code>shopRepositoryProvider.updateInfo({...})</code>。</li>
|
||||
<li>店级 → 跨设备/员工同步;打印机选择继续留本地 <code>label_printer</code>(<code>label_preview_dialog.dart:11</code>) 不动。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>5. 编辑器交互(桌面专属)</h2>
|
||||
<div class="card">
|
||||
<p>价签卡「编辑」→ 大对话框。左预览右属性,改动 debounce ~150ms 调 <code>renderLabelPreview(sample, template)</code> 重渲染(预览路径现成)。</p>
|
||||
<div class="wire">┌─ 标签模板编辑器 ──────────────────────────────────────────┐
|
||||
│ 模板:[商品价签(默认)▾] [新建][复制][重命名][删除][设默认] │
|
||||
├──────────────────────────┬──────────────────────────────┤
|
||||
│ 实时预览(真实渲染位图) │ 字段 │
|
||||
│ ┌──────────────────┐ │ ☑店名 ☑品名 ☑编号 ☑系列 │
|
||||
│ │ 岩美酒行旗舰店 │ │ ☑规格 ☑生产日期 ☐批次 ☐备注 │
|
||||
│ │ 茅台飞天五十三度 │▨ │ ☑二维码 ☑底部条码 │
|
||||
│ │ P1001 飞天 │▨ │ ──────────────────────────── │
|
||||
│ │ 53度 2024-06-01 │ │ 选中「品名」: │
|
||||
│ │ ▐▌▐ ▌▐▌▌▐ ▌▐▌▐ │ │ 字号[自适应▾] □粗体 │
|
||||
│ └──────────────────┘ │ 对齐[居中▾] 颜色[■] │
|
||||
│ [示例▾][用真实商品▾] │ 选中「二维码」: X[178]Y[25] │
|
||||
│ │ 尺寸[134] │
|
||||
│ │ ──────────────────────────── │
|
||||
│ │ ▸ 高级(纸张/DPI/密度/速度) │
|
||||
├──────────────────────────┴──────────────────────────────┤
|
||||
│ [取消] [保存] │
|
||||
└──────────────────────────────────────────────────────────┘</div>
|
||||
</div>
|
||||
|
||||
<h2>6. 两个需你定调的点(含源码约束)</h2>
|
||||
<div class="decision">
|
||||
<h3>决策 A:字段自由度 —— 纯自由画布会丢现有自适应</h3>
|
||||
<p>你要「可调位置」,但源码现状是<b>自适应排版</b>(品名按宽度放大、文字块自动竖直居中、空字段塌缩)。三档:</p>
|
||||
<ul>
|
||||
<li>① 槽位式:只开关+调样式不能移位。稳,但不满足「调位置」。</li>
|
||||
<li><b>② 区域+自动栈</b> <span class="tag rec">推荐</span>:QR/条码/抬头做成<b>可移动缩放的区域</b>(满足「调位置」最实用部分);文字块内保留自动均分。<b>既能调位置又不丢现有优点。</b></li>
|
||||
<li>③ 纯自由画布:每字段任意 x/y。最强,但<b>丢掉品名自适应/自动居中</b>,40×20mm 上还易重叠溢出。</li>
|
||||
</ul>
|
||||
<p>推荐 <b>②</b>。若你更想要 ③ 的「每个字段都能拖」,我按纯坐标做,但要接受品名不再自动放大、隐藏字段不再自动回填空间。</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<h3>决策 B:字体与颜色 —— 热敏是单色,有物理约束</h3>
|
||||
<ul>
|
||||
<li><b>颜色</b>:热敏机是<b>单色介质</b>,文字二值化后全是黑(抬头深蓝也退成黑)。所以「字段颜色」<b>只对预览/PDF/Web 有视觉意义,对热敏实打无效</b>。要不要暴露颜色,取决于你在不在乎屏幕/PDF 上的彩色观感。</li>
|
||||
<li><b>字体</b>:<code>_drawText:166</code> 写死 <code>fontFamily:'NotoSansSC'</code>,且热敏要求打包字体。支持「换字体」= 再打包字体(如宋体,<b>每款中文约 4–8MB</b> 增包体)+ 参数化 fontFamily。<b>MVP 建议只做字号/粗体</b>(零增包),多字体二期按需。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>7. 落地路线(design-first,主线先行)</h2>
|
||||
<div class="card">
|
||||
<table>
|
||||
<tr><th>期</th><th>内容</th><th>验收</th></tr>
|
||||
<tr><td>P0 建模</td><td>定义 <code>LabelTemplate</code>(区域+自动栈)+ 画布渲染器读模型;内置默认模板=现 const;facade/Impl 加 <code>template</code> 参数</td><td>字体探针 / golden:默认模板逐像素零回归</td></tr>
|
||||
<tr><td>P1 原型</td><td><code>design/prototype/</code> 新增编辑器桌面原型</td><td>5180 serve URL 评审,过了再写代码</td></tr>
|
||||
<tr><td>P2 编辑器</td><td>编辑器 UI + custom_fields 存储 + 预览联动 + 多模板/重命名;<b>热敏</b>条码/QR 坐标改模板驱动</td><td>桌面建/存/切模板,预览实时刷新,热敏按模板出签</td></tr>
|
||||
<tr><td>P3 收口(可延后)</td><td>PDF/Web 改「贴画布位图」→ 自定义模板四面一致</td><td>同模板热敏/PDF/Web 观感一致</td></tr>
|
||||
</table>
|
||||
<p class="tag warn" style="display:block;max-width:980px;padding:8px 12px;">按铁律:P1 原型过审前不写实现代码;前端改动原型与代码同提交。本方案即 P1 前评审。</p>
|
||||
</div>
|
||||
|
||||
<h2>8. 待你拍板</h2>
|
||||
<div class="card">
|
||||
<table>
|
||||
<tr><th>#</th><th>决策</th><th>推荐</th></tr>
|
||||
<tr><td>1</td><td>模板存哪</td><td><b>shop.custom_fields</b>(源码证实零后端改动、店级同步)</td></tr>
|
||||
<tr><td>2</td><td>渲染统一</td><td><b>画布为唯一权威 + PDF/Web 贴位图</b>;热敏保留原生条码/独立 QR,仅坐标受模板驱动</td></tr>
|
||||
<tr><td>3</td><td>字段自由度</td><td><b>② 区域+自动栈</b>(可调位置又不丢自适应)</td></tr>
|
||||
<tr><td>4</td><td>字体</td><td>MVP <b>只字号/粗体</b>,多字体二期(增包体)</td></tr>
|
||||
<tr><td>5</td><td>颜色</td><td>热敏无效 → 建议 MVP <b>不暴露颜色</b>(或仅作预览观感),你定</td></tr>
|
||||
</table>
|
||||
<p>给个方向(认同/改哪条),我就做 P0 建模并出 P1 编辑器原型。</p>
|
||||
</div>
|
||||
|
||||
<div class="sub" style="margin-top:24px;">核对源码:<code>print_util_stub.dart</code>(:158-617) / <code>print_util_web.dart</code> / <code>label_data.dart</code> / <code>print_util.dart</code> / <code>label_preview_dialog.dart</code> / <code>device_management_screen.dart</code>(:930-1035) / 后端 <code>shop.go</code>(:51-79)+<code>shop_test.go</code> · 本方案未改任何代码。</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -46,6 +46,7 @@
|
||||
<li><a href="design/scan-stock-out-design.html">扫码出库(扫码枪扫二维码建出库单)设计</a><span class="tag html">HTML</span> <span class="hint">— 扫码枪=HID键盘,解析已印二维码URL取public_id→新增鉴权接口映射本店product_id→装配明细行;含数量口径待决</span></li>
|
||||
<li><a href="design/scan-stock-out-plan.html">扫码出库 · 实现计划(出库表单扫码+1)</a><span class="tag html">HTML</span> <span class="hint">— 落到 stock_out_form_screen:扫码框承接URL→取?code=→本地整仓索引命中→明细+1;方案A纯前端(≤1000SKU)/B加后端精确查;design-first先改原型</span></li>
|
||||
<li><a href="design/inventory-filter-spec.md">库存筛选规格</a><span class="tag">MD</span></li>
|
||||
<li><a href="design/label-template-editor.html">标签模板编辑器(价签版式自定义)设计</a><span class="tag html">HTML</span> <span class="hint">— 现状四路径硬编码→抽声明式 LabelTemplate 单源;编辑器桌面专属左预览右属性;存 shop.custom_fields;含 5 项待拍板决策(2026-08-28 方案评审)</span></li>
|
||||
</ul>
|
||||
|
||||
<h2>📚 知识库 · 调研</h2>
|
||||
|
||||
@@ -102,11 +102,26 @@ echo "==> compile-ios: built ${IPA}"
|
||||
# TestFlight 是 App Store 独立分发渠道,上传本身易抖(重复 build 号 / ASC 临时错误);
|
||||
# 其失败【不应阻断】web/mac/win/android 的整体发版(见 CLAUDE.md「iOS ... 不阻塞」)。
|
||||
# IPA 已产出,上传失败仅告警,可稍后用 altool/Transporter 手动补传同一 IPA。
|
||||
echo "==> compile-ios: uploading to TestFlight"
|
||||
if xcrun altool --upload-app -f "$IPA" -t ios \
|
||||
# altool 上传是【静默】的:上传 IPA 到 Apple 期间数分钟无任何输出。网络到 Apple
|
||||
# 抖动/变慢时会长时间挂住 → 超过 runner 僵尸超时(10min)整个 job 被 SIGKILL 判
|
||||
# 失败,连下面的软失败分支都跑不到 → 阻断发版(1.1.13 就这么挂的)。
|
||||
# 本机无 timeout(1)/gtimeout(1)(无 coreutils),故用后台看门狗:超 UPLOAD_TIMEOUT
|
||||
# 先 TERM 再 KILL,保证上传永远在僵尸窗口内【干净收尾】,失败只告警不阻断。
|
||||
UPLOAD_TIMEOUT=420
|
||||
echo "==> compile-ios: uploading to TestFlight (超时保护 ${UPLOAD_TIMEOUT}s)"
|
||||
xcrun altool --upload-app -f "$IPA" -t ios \
|
||||
--apiKey "${KEY_ID}" \
|
||||
--apiIssuer "${ISSUER}"; then
|
||||
--apiIssuer "${ISSUER}" &
|
||||
altool_pid=$!
|
||||
( sleep "$UPLOAD_TIMEOUT"
|
||||
kill -TERM "$altool_pid" 2>/dev/null || true
|
||||
sleep 5
|
||||
kill -KILL "$altool_pid" 2>/dev/null || true ) &
|
||||
watchdog_pid=$!
|
||||
if wait "$altool_pid"; then
|
||||
echo "==> compile-ios: done — uploaded build ${BUILD} to TestFlight"
|
||||
else
|
||||
echo "!! compile-ios: TestFlight 上传失败(build ${BUILD}),不阻断发版;IPA=${IPA} 已产出,可稍后手动补传。" >&2
|
||||
echo "!! compile-ios: TestFlight 上传失败或超时(build ${BUILD}),不阻断发版;IPA=${IPA} 已产出,可稍后手动补传。" >&2
|
||||
fi
|
||||
kill "$watchdog_pid" 2>/dev/null || true
|
||||
wait "$watchdog_pid" 2>/dev/null || true
|
||||
|
||||
@@ -50,6 +50,8 @@ PYEOF
|
||||
}
|
||||
|
||||
# create_release <tag> <body_json_string> — sets global RELEASE_ID on success.
|
||||
# 幂等:若该 tag 的 Release 已存在(重跑发版时 Forgejo 回 409),不再硬失败,
|
||||
# 改为取回既有 Release 复用其 id 继续(配合 upload_asset 的覆盖上传,重跑安全)。
|
||||
create_release() {
|
||||
local tag="$1" body="$2" http_code resp
|
||||
echo "==> release: creating Forgejo Release ${tag}"
|
||||
@@ -60,6 +62,15 @@ create_release() {
|
||||
-d "{\"tag_name\":\"${tag}\",\"name\":\"Release ${tag}\",\"body\":${body},\"draft\":false,\"prerelease\":false}")
|
||||
resp=$(cat /tmp/release_resp.json)
|
||||
echo "==> release: API response (HTTP ${http_code}): ${resp}"
|
||||
if [ "$http_code" = "409" ]; then
|
||||
# Release 已存在(重跑)→ 幂等复用:按 tag 取回既有 Release。
|
||||
echo "==> release: 该 tag 的 Release 已存在,改为复用(幂等重跑)"
|
||||
http_code=$(curl -k -w "%{http_code}" -o /tmp/release_resp.json \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/tags/${tag}")
|
||||
resp=$(cat /tmp/release_resp.json)
|
||||
echo "==> release: 既有 Release 查询 (HTTP ${http_code}): ${resp}"
|
||||
fi
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "==> release: FAILED — HTTP ${http_code}" >&2
|
||||
return 1
|
||||
@@ -69,8 +80,18 @@ create_release() {
|
||||
}
|
||||
|
||||
# upload_asset <file> — requires RELEASE_ID.
|
||||
# 幂等:若同名 asset 已存在(重跑),先删旧再传新,避免 Forgejo 409 重名冲突。
|
||||
upload_asset() {
|
||||
local file="$1" code
|
||||
local file="$1" code name existing_id
|
||||
name=$(basename "$file")
|
||||
existing_id=$(curl -ks -H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||
| python3 -c "import sys,json; a=json.load(sys.stdin); print(next((str(x['id']) for x in a if x.get('name')==sys.argv[1]), ''))" "$name" 2>/dev/null || true)
|
||||
if [ -n "$existing_id" ]; then
|
||||
echo "==> release: asset ${name} 已存在(id=${existing_id}),先删旧再重传"
|
||||
curl -ks -X DELETE -H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
"${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets/${existing_id}" >/dev/null || true
|
||||
fi
|
||||
code=$(curl -k -w "%{http_code}" -o /tmp/upload_resp.json \
|
||||
-X POST "${FORGEJO_URL}/api/v1/repos/${GITEA_REPOSITORY}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
||||
@@ -94,15 +115,23 @@ ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
rel = json.load(open('/tmp/release-info.json'))
|
||||
token = os.environ['FORGEJO_TOKEN']
|
||||
url = os.environ['FORGEJO_URL']
|
||||
repo = os.environ['GITEA_REPOSITORY']
|
||||
rid = rel['id']
|
||||
# 用每个 asset 的 browser_download_url 拉真正的二进制。
|
||||
# 注意:Forgejo 的 /releases/{id}/assets/{asset_id} API 端点返回的是 JSON
|
||||
# 元数据(几百字节)而非文件本身——曾导致下载到 245B 的坏 web.tar.gz、
|
||||
# 部署时 tar 报 "Unrecognized archive format"。browser_download_url 是
|
||||
# http,会 301 到 https,urllib 默认跟随重定向;带上 token 以防私有仓 401。
|
||||
for a in rel.get('assets', []):
|
||||
u = f"{url}/api/v1/repos/{repo}/releases/{rid}/assets/{a['id']}"
|
||||
req = urllib.request.Request(u, headers={'Authorization': f'token {token}'})
|
||||
dl = a.get('browser_download_url')
|
||||
if not dl:
|
||||
raise SystemExit(f"asset {a.get('name')} 缺 browser_download_url")
|
||||
req = urllib.request.Request(dl, headers={'Authorization': f'token {token}'})
|
||||
with urllib.request.urlopen(req, context=ctx) as r, open(f"dist/{a['name']}", 'wb') as o:
|
||||
o.write(r.read())
|
||||
print('Downloaded:', a['name'])
|
||||
got = os.path.getsize(f"dist/{a['name']}")
|
||||
exp = a.get('size', 0)
|
||||
print(f"Downloaded: {a['name']} ({got} bytes)")
|
||||
if exp and got != exp:
|
||||
raise SystemExit(f"下载大小不符: {a['name']} 期望 {exp} 实得 {got}")
|
||||
PYEOF
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user