Compare commits

..

5 Commits

Author SHA1 Message Date
wangjia 4ed2c612a9 chore: release client-v1.1.15
Deploy Client / build-windows (push) Successful in 2m1s
Deploy Client / build-client-web (push) Successful in 1m3s
Deploy Client / build-macos (push) Successful in 2m41s
Deploy Client / build-android (push) Successful in 1m41s
Deploy Client / build-ios (push) Successful in 3m35s
Deploy Client / release-deploy-client (push) Successful in 3m31s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 17:31:04 +08:00
wangjia 78e6a4c192 fix(client): 热敏打印价签中文字体清晰化(NotoSansSC+4x超采样+覆盖率投票二值化,字号上调)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 17:31:00 +08:00
wangjia 801b8528ec feat(client): 库存规格/系列筛选改可搜索单选下拉、仓库单选,修复下拉过宽过高溢出
原型与代码同提交(design-first):下拉宽度锚定触发器宽 max(宽,220)、列表 264 封顶超出滚动、底部计数常驻。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 17:30:55 +08:00
wangjia a1a3118c88 fix(ci): download_release_assets 改用 browser_download_url + 下载后大小校验
Forgejo 的 /releases/{id}/assets/{asset_id} API 端点返回 JSON 元数据(约245字节)
而非二进制,导致下载到坏 web.tar.gz、部署时 tar 报 Unrecognized archive format。
改用 asset.browser_download_url(http→https 跟随重定向 + token)拉真正的文件,
并在下载后校验字节数与声明 size 一致,不符即当场失败。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 12:48:17 +08:00
wangjia 2797c998f8 fix(ci): 发版 create_release/upload_asset 改幂等,重跑不再 409 崩
根因:release-client.sh 建 Release 非幂等——首跑已建好 Release+传齐产物但
deploy 未跑完时,retry 再 POST 同 tag 的 Release,Forgejo 回 409
("Release is has no Tag"),整个 release-deploy 在部署前就硬失败,发版卡死。

- create_release:撞 409 时改为按 tag 取回既有 Release 复用其 id 继续。
- upload_asset:同名 asset 已存在时先删旧再重传,避免重名 409。

重跑发版从此安全;免重编上线用 Manual Deploy(manual.yml)拉既有 Release 产物部署。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bupi8Kdqkfx2N5acFsHTx5
2026-08-28 12:21:06 +08:00
7 changed files with 353 additions and 77 deletions
+9
View File
@@ -5,6 +5,15 @@ 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
### 改进
+25 -18
View File
@@ -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,
@@ -239,7 +241,7 @@ List<String> _labelSubFields(
}
/// 用 dart:ui 把一张标签绘制成位图(热敏标签实际尺寸 40×20mm @203dpi)。
/// 内部以 2× 超采样(640×320)绘制,转 TSPL 时再降采样,消除反锯齿模糊。
/// 内部以 4× 超采样(1280×640)绘制,转 TSPL 时按覆盖率投票降采样二值化,边缘锐利不糊。
/// 供 TSPL 裸发和预览渲染共用同一套画布逻辑,确保预览即实际输出。
Future<ui.Image> _renderLabelBitmap({
required String shop,
@@ -251,7 +253,7 @@ Future<ui.Image> _renderLabelBitmap({
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 =
@@ -273,7 +275,7 @@ 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);
// ── 主体两列布局(无页脚:主体直抵纸底,2026-08-27 删掉底部米色尺寸条)────────
const bodyTop = headerH; // 24
@@ -318,9 +320,9 @@ Future<ui.Image> _renderLabelBitmap({
const double infoBot = barTop - 4; // 110
const double infoH = infoBot - infoTop; // 80
const double kSmallSize = 12.0; // 编号+度数 / 规格+日期
const double kSmallSize = 14.0; // 编号+度数 / 规格+日期2026-08-28 调大一号 12→14
final double nameSize =
_fitFont(name, leftW, 1000, true, 16.0).clamp(11.0, 16.0);
_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), // 编号+度数 / 规格+日期
@@ -337,7 +339,7 @@ Future<ui.Image> _renderLabelBitmap({
yy += r.$2 * kLineH + gap;
}
return recorder.endRecording().toImage(sw, sh); // 返回 2× 图(640×320
return recorder.endRecording().toImage(sw, sh); // 返回 4× 图(1280×640
}
/// 桌面端把整张标签「光栅化」成单色位图裸发到热敏机(TSPL BITMAP),令热敏输出与
@@ -363,7 +365,9 @@ Future<bool> _printFlatLabelThermal({
const wBytes = (w + 7) >> 3; // 40 bytes/row
// ── 文字/抬头整幅光栅:复用预览画布(跳过条码+QR,二者改原生命令)─────────
// 画布内部 2× 超采样(640×320),此处 2×2 盒式降采样 + 亮度 128 阈值二值化,边缘更干净。
// 画布内部 4× 超采样(1280×640),此处按覆盖率投票降采样二值化:
// 每个墨点看其 4×4=16 个子像素中"是墨"的占比,≥40% 即打黑。
// 替代旧的"盒式平均+128 阈值"——后者先把边缘抹成灰再一刀切,正是糊字的根因。
final img = await _renderLabelBitmap(
shop: shop,
name: name,
@@ -375,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; // 12804× 超采样)
const int ss = 4; // 超采样倍率,须与 _renderLabelBitmap 的 scale 一致
const int subTotal = ss * ss; // 16 子像素/墨点
final buf = BytesBuilder();
buf.add(ascii.encode(
@@ -389,18 +395,19 @@ Future<bool> _printFlatLabelThermal({
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);
}
@@ -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(
+243 -17
View File
@@ -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;
}
/// .menusurface / 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-listoverflow automax-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),
],
],
),
),
);
}
// 命中子串高亮(.hlprimary 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-footq 空 → 共 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: ''),
],
]),
);
}
}
+13 -8
View File
@@ -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';
+1 -1
View File
@@ -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 || {};
+36 -7
View File
@@ -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 到 httpsurllib 默认跟随重定向;带上 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
}