Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ed2c612a9 | |||
| 78e6a4c192 | |||
| 801b8528ec | |||
| a1a3118c88 | |||
| 2797c998f8 | |||
| 90e6b25cec | |||
| 1655769827 | |||
| 46302d9e4a | |||
| d299d4b0b0 |
@@ -5,6 +5,21 @@ 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.15] - 2026-08-28
|
||||
|
||||
### 改进
|
||||
- 库存管理「规格 / 系列」筛选改为可搜索单选下拉,长列表可直接打字过滤快速定位;「仓库」筛选为单选下拉,操作更顺手。
|
||||
- 热敏打印机(标签机)打印商品价签的中文字体更清晰锐利、字号整体调大,不再发虚模糊。
|
||||
|
||||
### 修复
|
||||
- 修复库存筛选下拉框过宽、列表过高溢出屏幕的问题:现在下拉宽度贴合筛选按钮,超长列表在框内滚动、底部结果计数常驻可见。
|
||||
|
||||
## [1.1.14] - 2026-08-28
|
||||
|
||||
### 改进
|
||||
- 商品价签(标签)版式优化:品名与下方字段在左栏居中排布,编号+度数一行、规格+日期一行,更整齐易读。
|
||||
- 去掉价签底部多余的米色页脚,一维条码与二维码放大填满纸面,扫码更清晰。
|
||||
|
||||
## [1.1.13] - 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(
|
||||
|
||||
@@ -47,8 +47,8 @@ String _fileTs() {
|
||||
|
||||
// ── 设计色彩 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 _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 抬头反白店名
|
||||
const _footnote = PdfColor(0.353, 0.306, 0.208); // #5A4E35 编号 / 三字段小字
|
||||
const _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text
|
||||
|
||||
// ── 热敏标签 TSPL 裸发(桌面端:dart:ui 画扁平标签位图 → BITMAP → lp -o raw)────────
|
||||
@@ -163,6 +163,7 @@ void _drawText(
|
||||
{
|
||||
if (s.isEmpty) return;
|
||||
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
|
||||
fontFamily: 'NotoSansSC', // 打包字体,笔画统一、比系统兜底字更实(热敏二值化后不糊)
|
||||
fontSize: size,
|
||||
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
|
||||
textAlign: center ? ui.TextAlign.center : ui.TextAlign.left,
|
||||
@@ -194,6 +195,7 @@ void _drawBarcode(
|
||||
/// 测量单行文本在指定字号下的宽度
|
||||
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,
|
||||
@@ -213,36 +215,45 @@ double _fitFont(String s, double maxW, double maxH, bool bold, double cap) {
|
||||
return size;
|
||||
}
|
||||
|
||||
/// 组装标签底部平铺的三字段:度数(型号) / 规格(版本) / 生产日期。
|
||||
/// 各值为空则跳过;日期截断到 10 字符(YYYY-MM-DD)。四路渲染共用,口径一致。
|
||||
/// 组装标签左列名下两行副字段(居中排布):
|
||||
/// 行1 = 编号 + 度数(型号) 行2 = 规格(版本) + 生产日期
|
||||
/// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、居中」。
|
||||
/// 各字段为空则跳过(该行可只剩一个字段或整行消失);日期截断到 10 字符。四路渲染共用。
|
||||
List<String> _labelSubFields(
|
||||
String? series, String? spec, String? productionDate) {
|
||||
String code, String? series, String? spec, String? productionDate) {
|
||||
final date = (productionDate ?? '').isNotEmpty
|
||||
? (productionDate!.length > 10
|
||||
? productionDate.substring(0, 10)
|
||||
: productionDate)
|
||||
: '';
|
||||
return [
|
||||
final line1 = [
|
||||
if (code.isNotEmpty) code,
|
||||
if ((series ?? '').isNotEmpty) series!,
|
||||
].join(' ');
|
||||
final line2 = [
|
||||
if ((spec ?? '').isNotEmpty) spec!,
|
||||
if (date.isNotEmpty) date,
|
||||
].join(' ');
|
||||
return [
|
||||
if (line1.isNotEmpty) line1,
|
||||
if (line2.isNotEmpty) line2,
|
||||
];
|
||||
}
|
||||
|
||||
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
|
||||
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
|
||||
/// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。
|
||||
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
|
||||
Future<ui.Image> _renderLabelBitmap({
|
||||
required String shop,
|
||||
required String name,
|
||||
required String code,
|
||||
required List<String> subs, // 底部平铺字段:度数 / 规格 / 日期(已过滤空)
|
||||
required List<String> subs, // 左列逐行副字段:型号规格 / 日期(已过滤空)
|
||||
required Uint8List qrBytes,
|
||||
bool drawBarcode = true, // 热敏光栅路径置 false:条码改用原生 TSPL BARCODE 保证扫码
|
||||
bool drawQr = true, // 热敏光栅路径置 false:二维码改用原生 TSPL BITMAP 保证扫码
|
||||
}) async {
|
||||
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
|
||||
const scale = 2; // 2× 超采样,内部以 640×320 绘制
|
||||
const scale = 4; // 4× 超采样,内部以 1280×640 绘制(子像素更密,二值化边缘更锐)
|
||||
const sw = w * scale, sh = h * scale;
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas =
|
||||
@@ -253,10 +264,9 @@ Future<ui.Image> _renderLabelBitmap({
|
||||
// 热敏机为单色介质,实际打印时头/脚颜色会退化为黑白(TSPL 路径另行处理)。
|
||||
const cWhite = ui.Color(0xFFFFFFFF); // ds-ignore
|
||||
const cNavy = ui.Color(0xFF1F2A3A); // ds-ignore 深蓝店名头
|
||||
const cCream = ui.Color(0xFFF4ECD8); // 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);
|
||||
@@ -265,28 +275,19 @@ Future<ui.Image> _renderLabelBitmap({
|
||||
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);
|
||||
_drawText(canvas, shop, 10, 4, 14, 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);
|
||||
// 页脚只留右侧尺寸标注:店名已在顶部抬头,页脚不再重复(2026-08-27 去冗余)
|
||||
final szW = _measureWidth('40×20mm', 8, false);
|
||||
_drawText(canvas, '40×20mm', w - 10 - szW, footerTop + 3.5, 8, szW,
|
||||
color: cFoot);
|
||||
|
||||
// ── 主体两列布局 ────────────────────────────────────────────
|
||||
// ── 主体两列布局(无页脚:主体直抵纸底,2026-08-27 删掉底部米色尺寸条)────────
|
||||
const bodyTop = headerH; // 24
|
||||
const bodyBot = footerTop; // 145
|
||||
const double bodyBot = 160.0; // 直抵纸底 h(原到页脚顶 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
|
||||
// 右列:二维码略放大,占主体近满高(正方形),2026-08-28 用户口径「二维码调大一点」
|
||||
const double qrPad = 1.0;
|
||||
const double qrSize = (bodyBot - bodyTop) - 2 * qrPad; // 134
|
||||
const double qrTop = bodyTop + qrPad; // 25
|
||||
const double qrLeft = w - padH - qrSize; // 178
|
||||
if (drawQr) {
|
||||
try {
|
||||
final codec = await ui.instantiateImageCodec(qrBytes);
|
||||
@@ -304,87 +305,46 @@ Future<ui.Image> _renderLabelBitmap({
|
||||
|
||||
// 左列几何
|
||||
const double leftX = padH; // 8
|
||||
const double leftRight = qrLeft - colGap; // 197
|
||||
const double leftW = leftRight - leftX; // 189
|
||||
const double leftRight = qrLeft - colGap; // 172
|
||||
const double leftW = leftRight - leftX; // 164
|
||||
|
||||
// 左列底部一维条码(Code128,内容=商品编号),供扫码枪扫码出库
|
||||
const double barH = 40.0;
|
||||
const double barBottom = bodyBot - padV; // 137
|
||||
const double barTop = barBottom - barH; // 97
|
||||
const double barBottom = bodyBot - padV; // 154
|
||||
const double barTop = barBottom - barH; // 114
|
||||
if (drawBarcode) _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
|
||||
// 左列文字块:品名 + 两行副字段(行1=编号+度数、行2=规格+日期),整体水平居中、
|
||||
// 竖直均分。2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。
|
||||
const double infoTop = bodyTop + padV; // 30
|
||||
const double infoBot = barTop - 4; // 110
|
||||
const double infoH = infoBot - infoTop; // 80
|
||||
|
||||
// 品名自适应字号(为编号留出约 28% 宽度),上限 17
|
||||
const double kSmallSize = 14.0; // 编号+度数 / 规格+日期(2026-08-28 调大一号 12→14)
|
||||
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);
|
||||
_fitFont(name, leftW, 1000, true, 18.0).clamp(12.0, 18.0);
|
||||
final rows = <(String, double, bool)>[
|
||||
if (name.isNotEmpty) (name, nameSize, true), // 品名粗体
|
||||
for (final s in subs) (s, kSmallSize, false), // 编号+度数 / 规格+日期
|
||||
];
|
||||
const double kLineH = 1.3; // 行高倍数
|
||||
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, center: true);
|
||||
yy += r.$2 * kLineH + gap;
|
||||
}
|
||||
|
||||
// 三字段平铺一行(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)
|
||||
return recorder.endRecording().toImage(sw, sh); // 返回 4× 图(1280×640)
|
||||
}
|
||||
|
||||
/// 桌面端把整张标签「光栅化」成单色位图裸发到热敏机(TSPL BITMAP),令热敏输出与
|
||||
/// 预览画布 100% 一致——彻底消除内置点阵字(TSS16/24)与预览 TrueType 度量不一致
|
||||
/// 导致的字体重叠 / 失真。文字/抬头/页脚走光栅;条码(Code128)与二维码仍走原生
|
||||
/// 导致的字体重叠 / 失真。文字/抬头走光栅;条码(Code128)与二维码仍走原生
|
||||
/// BARCODE / BITMAP 命令保证扫码可靠(光栅化后细条/密码易糊)。
|
||||
/// 检测不到热敏机 -> 返回 false(交系统打印);检测到则强制 TSPL,失败抛异常。
|
||||
/// [printerName] 非空时跳过自动检测直接使用,为空则走 _findThermalPrinter()。
|
||||
@@ -404,8 +364,10 @@ Future<bool> _printFlatLabelThermal({
|
||||
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
|
||||
const wBytes = (w + 7) >> 3; // 40 bytes/row
|
||||
|
||||
// ── 文字/抬头/页脚整幅光栅:复用预览画布(跳过条码+QR,二者改原生命令)─────────
|
||||
// 画布内部 2× 超采样(640×320),此处 2×2 盒式降采样 + 亮度 128 阈值二值化,边缘更干净。
|
||||
// ── 文字/抬头整幅光栅:复用预览画布(跳过条码+QR,二者改原生命令)─────────
|
||||
// 画布内部 4× 超采样(1280×640),此处按覆盖率投票降采样二值化:
|
||||
// 每个墨点看其 4×4=16 个子像素中"是墨"的占比,≥40% 即打黑。
|
||||
// 替代旧的"盒式平均+128 阈值"——后者先把边缘抹成灰再一刀切,正是糊字的根因。
|
||||
final img = await _renderLabelBitmap(
|
||||
shop: shop,
|
||||
name: name,
|
||||
@@ -417,7 +379,9 @@ Future<bool> _printFlatLabelThermal({
|
||||
);
|
||||
final bd = await img.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
final rgba = bd!.buffer.asUint8List();
|
||||
final iw = img.width; // 640
|
||||
final iw = img.width; // 1280(4× 超采样)
|
||||
const int ss = 4; // 超采样倍率,须与 _renderLabelBitmap 的 scale 一致
|
||||
const int subTotal = ss * ss; // 16 子像素/墨点
|
||||
|
||||
final buf = BytesBuilder();
|
||||
buf.add(ascii.encode(
|
||||
@@ -425,34 +389,35 @@ Future<bool> _printFlatLabelThermal({
|
||||
'REFERENCE 0,0\r\nDENSITY 10\r\nSPEED 2\r\nCLS\r\n',
|
||||
));
|
||||
|
||||
// 整幅文字位图(mode 0 覆写整版):深蓝抬头→黑底反白、米色页脚→白底黑字,
|
||||
// 整幅文字位图(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 lum = 0;
|
||||
for (int dy = 0; dy < 2; dy++) {
|
||||
for (int dx = 0; dx < 2; dx++) {
|
||||
final i = ((y * 2 + dy) * iw + (x * 2 + dx)) * 4;
|
||||
final a = rgba[i + 3];
|
||||
lum += a < 128
|
||||
? 255
|
||||
: (0.299 * rgba[i] + 0.587 * rgba[i + 1] + 0.114 * rgba[i + 2])
|
||||
.round();
|
||||
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++; // 暗且不透明 = 墨
|
||||
}
|
||||
}
|
||||
if (lum ~/ 4 < 128) row[x >> 3] &= ~(0x80 >> (x & 7));
|
||||
// 覆盖率投票:≥40% 子像素是墨即打黑点。阈值偏低让细笔画不断(热敏易掉墨),
|
||||
// 且不引入盒式平均的灰边模糊。
|
||||
if (ink * 5 >= subTotal * 2) row[x >> 3] &= ~(0x80 >> (x & 7));
|
||||
}
|
||||
buf.add(row);
|
||||
}
|
||||
buf.add(ascii.encode('\r\n'));
|
||||
|
||||
// ── 右列二维码(原生 BITMAP,避免光栅降采样糊码):占主体整高、右对齐 ──
|
||||
const qrW = 104, qrH = 104;
|
||||
const qrX = 320 - 8 - qrW; // 208
|
||||
const bodyTop = 26, bodyBot = 134;
|
||||
const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 28
|
||||
const qrW = 130, qrH = 130; // 2026-08-28 放大,对齐画布 QR(134)
|
||||
const qrX = 320 - 8 - qrW; // 182
|
||||
const bodyTop = 24, bodyBot = 160; // 无页脚:主体直抵纸底
|
||||
const qrY = bodyTop + ((bodyBot - bodyTop) - qrH) ~/ 2; // 27
|
||||
const qrWBytes = (qrW + 7) >> 3; // 13
|
||||
final qrCodec = await ui.instantiateImageCodec(qrBytes,
|
||||
targetWidth: qrW, targetHeight: qrH);
|
||||
@@ -475,9 +440,9 @@ Future<bool> _printFlatLabelThermal({
|
||||
buf.add(ascii.encode('\r\n'));
|
||||
|
||||
// ── 左列底部整幅一维条码(原生 BARCODE,Code128,内容=商品编号),narrow 自适应铺满居中 ──
|
||||
const leftX = 8, leftRight = qrX - 6, leftW = leftRight - leftX; // 8..202, 194
|
||||
const kBarH = 28;
|
||||
const kBarY = bodyBot - 4 - kBarH; // 102
|
||||
const leftX = 8, leftRight = qrX - 6, leftW = leftRight - leftX; // 8..188, 180
|
||||
const kBarH = 34;
|
||||
const kBarY = bodyBot - 8 - kBarH; // 118
|
||||
if (code.isNotEmpty) {
|
||||
final estModules = 11 * code.length + 35; // Code128B 估算模块数(含起止校验)
|
||||
final narrow = (leftW / estModules).floor().clamp(2, 4);
|
||||
@@ -518,10 +483,9 @@ Future<void> printProductLabelImpl({
|
||||
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);
|
||||
final subs = _labelSubFields(code, series, spec, productionDate);
|
||||
|
||||
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
|
||||
if (await _printFlatLabelThermal(
|
||||
@@ -575,55 +539,34 @@ Future<void> printProductLabelImpl({
|
||||
child: pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 文字块(品名+编号 居中 / 三字段平铺),条码上方居中
|
||||
// 文字块:品名 + 两行副字段(行1=编号+度数、行2=规格+日期),整体居中。
|
||||
// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、居中」。
|
||||
pw.Expanded(
|
||||
child: pw.Column(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.center,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
pw.Text(name,
|
||||
textAlign: pw.TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: pw.TextOverflow.clip,
|
||||
style: pw.TextStyle(
|
||||
font: font,
|
||||
fontSize: nameFs,
|
||||
fontWeight: pw.FontWeight.bold,
|
||||
color: _ink,
|
||||
letterSpacing: 0.3)),
|
||||
for (final s in subs) ...[
|
||||
pw.SizedBox(height: 2),
|
||||
pw.Text(s,
|
||||
textAlign: pw.TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: pw.TextOverflow.clip,
|
||||
style: pw.TextStyle(
|
||||
font: font,
|
||||
fontSize: 5,
|
||||
color: _footnote)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -653,22 +596,6 @@ Future<void> printProductLabelImpl({
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Footer:米色页脚(仅尺寸;店名已在抬头,不再重复)─────────────────
|
||||
pw.Container(
|
||||
height: footerH,
|
||||
color: _cream,
|
||||
padding: const pw.EdgeInsets.symmetric(horizontal: 5),
|
||||
child: pw.Row(
|
||||
mainAxisAlignment: pw.MainAxisAlignment.end,
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.center,
|
||||
children: [
|
||||
pw.Text('40×20mm',
|
||||
style: pw.TextStyle(
|
||||
font: font, fontSize: 3.5, color: _footnote)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
@@ -696,7 +623,8 @@ Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
|
||||
shop: label.shopName,
|
||||
name: label.name,
|
||||
code: label.code,
|
||||
subs: _labelSubFields(label.series, label.spec, label.productionDate),
|
||||
subs: _labelSubFields(
|
||||
label.code, label.series, label.spec, label.productionDate),
|
||||
qrBytes: label.qrBytes!,
|
||||
);
|
||||
final bd = await img.toByteData(format: ui.ImageByteFormat.png);
|
||||
|
||||
@@ -41,13 +41,21 @@ Future<void> printProductLabelImpl({
|
||||
? productionDate.substring(0, 10)
|
||||
: productionDate)
|
||||
: '';
|
||||
// 底部平铺三字段:度数(型号) / 规格(版本) / 日期(口径与预览、PDF、TSPL 一致)
|
||||
final subs = <String>[
|
||||
// 左列名下两行副字段(居中):行1=编号+度数、行2=规格+日期(口径与预览、PDF、TSPL 一致)
|
||||
// 2026-08-28 用户口径「编号和度数放一行、规格和日期放一行、字体在空白处居中」。
|
||||
final line1 = [
|
||||
if (code.isNotEmpty) code,
|
||||
if (seriesVal.isNotEmpty) seriesVal,
|
||||
].join(' ');
|
||||
final line2 = [
|
||||
if (specVal.isNotEmpty) specVal,
|
||||
if (dateVal.isNotEmpty) dateVal,
|
||||
].join(' ');
|
||||
final subs = <String>[
|
||||
if (line1.isNotEmpty) line1,
|
||||
if (line2.isNotEmpty) line2,
|
||||
];
|
||||
final subSpans = subs.map((s) => '<span>$s</span>').join();
|
||||
final subLines = subs.map((s) => '<div class="sub">$s</div>').join();
|
||||
// 商品名过长时缩小字号
|
||||
final nameLen = name.runes.length;
|
||||
final nameFontPt = nameLen > 10 ? 7.0 : (nameLen > 7 ? 8.0 : 9.0);
|
||||
@@ -98,32 +106,22 @@ body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
|
||||
.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;
|
||||
justify-content: center; align-items: center; gap: 0.6mm; overflow: hidden;
|
||||
}
|
||||
.pname {
|
||||
font-weight: 700; text-align: center; line-height: 1.1;
|
||||
font-weight: 700; text-align: center; line-height: 1.1; max-width: 100%;
|
||||
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;
|
||||
font-size: 5pt; color: #333; line-height: 1.15; text-align: center;
|
||||
max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -136,8 +134,8 @@ body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
|
||||
<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 class="pname" style="font-size:${nameFontPt}pt">$name</div>
|
||||
$subLines
|
||||
</div>
|
||||
<div class="bc">$barcodeSvg</div>
|
||||
</div>
|
||||
@@ -146,8 +144,6 @@ body { width: 40mm; height: 20mm; overflow: hidden; background: #fff; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ftr"><span></span><span>40×20mm</span></div>
|
||||
|
||||
</div>
|
||||
<script>
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: ' 条'),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;}
|
||||
@@ -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,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" /></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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
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 || {};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user