feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏

- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
This commit is contained in:
wangjia
2026-07-03 09:58:14 +08:00
parent ca7595b113
commit 6238b86dcb
286 changed files with 16831 additions and 11136 deletions
+6 -3
View File
@@ -157,8 +157,10 @@ class ApiClient {
return handler.next(e);
}
if (e.response?.statusCode == 401 && (_refreshToken ?? '').isNotEmpty) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
if (e.response?.statusCode == 401 &&
(_refreshToken ?? '').isNotEmpty) {
debugPrint(
'[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
final newToken = await _refreshAccessToken();
if (newToken == null) {
return handler.next(e);
@@ -172,7 +174,8 @@ class ApiClient {
return handler.next(retryErr is DioException ? retryErr : e);
}
} else if (e.response?.statusCode == 401) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
debugPrint(
'[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
}
return handler.next(e);
},
+2 -1
View File
@@ -101,7 +101,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
Future<void> login(AuthUser user) async {
debugPrint('[Auth] login() called, username=${user.username} shopId=${user.shopId}');
debugPrint(
'[Auth] login() called, username=${user.username} shopId=${user.shopId}');
final gen = ++_sessionGen;
await _runStorage(() async {
if (gen != _sessionGen) return; // 期间又发生 login/logout → 放弃这次写盘
+16 -9
View File
@@ -1,3 +1,4 @@
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/material.dart';
import '../../models/license.dart';
@@ -113,11 +114,14 @@ class LicenseCopy {
static Color bannerColor(String phase) {
switch (phase) {
case 'locked':
return const Color(0xFFD14343);
return const Color(
0xFFD14343); // ds-ignore: 授权紧迫度专用深色,主题无关(承白字,历史分叉已集中)
case 'readonly':
return const Color(0xFFB71C1C); // 深红,强调即将彻底锁定
return const Color(
0xFFB71C1C); // 深红,强调即将彻底锁定 // ds-ignore: 授权紧迫度专用深色,主题无关(承白字,历史分叉已集中)
case 'grace':
return const Color(0xFFE65100); // 深橙
return const Color(
0xFFE65100); // 深橙 // ds-ignore: 授权紧迫度专用深色,主题无关(承白字,历史分叉已集中)
default:
return Colors.transparent;
}
@@ -127,12 +131,15 @@ class LicenseCopy {
static Color phaseColor(String phase) {
switch (phase) {
case 'grace':
return const Color(0xFFE08E00);
return const Color(
0xFFE08E00); // ds-ignore: 授权紧迫度专用深色,主题无关(承白字,历史分叉已集中)
case 'readonly':
case 'locked':
return const Color(0xFFD14343);
return const Color(
0xFFD14343); // ds-ignore: 授权紧迫度专用深色,主题无关(承白字,历史分叉已集中)
default:
return const Color(0xFF2E8B57);
return const Color(
0xFF2E8B57); // ds-ignore: 授权紧迫度专用深色,主题无关(承白字,历史分叉已集中)
}
}
@@ -140,12 +147,12 @@ class LicenseCopy {
static IconData phaseIcon(String phase) {
switch (phase) {
case 'grace':
return Icons.warning_amber_rounded;
return LucideIcons.triangleAlert;
case 'readonly':
case 'locked':
return Icons.error_outline;
return LucideIcons.circleAlert;
default:
return Icons.verified_user_outlined;
return LucideIcons.shieldCheck;
}
}
+3 -1
View File
@@ -109,7 +109,9 @@ class ErrorReporter {
String? stack;
if (stackTrace != null) {
final raw = stackTrace.toString();
stack = raw.length > _maxStackLength ? raw.substring(0, _maxStackLength) : raw;
stack = raw.length > _maxStackLength
? raw.substring(0, _maxStackLength)
: raw;
}
await _client.post('/public/errors', data: {
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
// Keep the server socket alive for the lifetime of the process.
// The OS releases the port automatically when the process exits.
// ignore: unused_element — 只写不读是刻意的:持有引用防 GC 关闭端口锁
ServerSocket? _server;
// Port unique to this app; chosen from the private range 4915265535.
+12 -5
View File
@@ -17,6 +17,7 @@ import '../../screens/products/product_detail_screen.dart';
import '../../screens/public/public_product_screen.dart';
import '../../screens/public/public_shop_products_screen.dart';
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 '../auth/auth_state.dart';
@@ -163,7 +164,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
StatefulShellBranch(routes: [
GoRoute(
path: '/products',
pageBuilder: (_, __) => _noTransition(const ProductsScreen())),
// ?tab=intro → 商品介绍 tab(对齐原型 products.html?tab=intro
pageBuilder: (_, state) => _noTransition(ProductsScreen(
initialTab:
state.uri.queryParameters['tab'] == 'intro' ? 1 : 0))),
GoRoute(
path: '/products/:id',
pageBuilder: (_, state) => _noTransition(ProductDetailScreen(
@@ -176,7 +180,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
pageBuilder: (_, __) =>
_noTransition(const DeviceManagementScreen())),
]),
// 7 系统设置
// 7 系统设置(面板:门店信息/用户管理/编号规则/授权兑换券/偏好设置)
StatefulShellBranch(routes: [
GoRoute(
path: '/settings',
@@ -185,13 +189,16 @@ final appRouterProvider = Provider<GoRouter>((ref) {
'shop': 0,
'users': 1,
'number': 2,
'system': 3,
'license': 4,
'import': 5,
'license': 3,
'pref': 4,
};
final tab = tabIndex[state.uri.queryParameters['tab']] ?? 0;
return _noTransition(SettingsScreen(initialTab: tab));
}),
// 用户管理独立页(照原型 users.html
GoRoute(
path: '/settings/users',
pageBuilder: (_, __) => _noTransition(const UsersScreen())),
]),
// 8 关于我们
StatefulShellBranch(routes: [
@@ -7,6 +7,18 @@ class LoginHistoryStorage {
static const int _maxItems = 5;
static const String _hotelKey = 'login_history_hotels';
static const String _usernameKey = 'login_history_usernames';
static const String _rememberKey = 'login_remember';
/// 「记住我」偏好(登录页复选框,默认开)。关闭时登录成功不记录账号历史。
static Future<bool> getRemember() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_rememberKey) ?? true;
}
static Future<void> setRemember(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_rememberKey, value);
}
static Future<List<String>> getHotelCodes() async {
final prefs = await SharedPreferences.getInstance();
+19
View File
@@ -0,0 +1,19 @@
/// 字体真相源。
///
/// 等宽字体镜像原型 tokens.css `--font-mono` 首选 **JetBrains Mono**
/// 以资产打包(pubspec `JetBrainsMono` 400/700)——原型截图、golden 测试、
/// 三端真机渲染完全同源。历史教训:曾写 CSS 泛型名 `'monospace'`Flutter
/// **不解析**并静默回退(真机无衬线、golden 里 tofu 方块),勿走回头路。
/// CJK 字形不在 JetBrains Mono 内,回退链兜底系统等宽/默认字体。
class AppFonts {
const AppFonts._();
static const String mono = 'JetBrainsMono';
static const List<String> monoFallback = [
'Menlo',
'SF Mono',
'Consolas',
'Roboto Mono',
'monospace',
];
}
+221 -60
View File
@@ -2,61 +2,193 @@ import 'package:flutter/material.dart';
@immutable
class AppTokens extends ThemeExtension<AppTokens> {
final Color page, bg, surface, border, borderSubtle, text, heading, title, muted, faint;
final Color page,
bg,
surface,
border,
borderSubtle,
text,
heading,
title,
muted,
faint;
final Color primary, primaryDark, onPrimary, brand400, brand50, accent;
final Color success, danger, warn, infoSoft, okSoft, accentSoft, successBg, dangerBg, warnBg;
final Color success,
danger,
warn,
infoSoft,
okSoft,
accentSoft,
successBg,
dangerBg,
warnBg;
final Color thBg, rowHover, shadow;
final Color topBg, topFg, topFaint, topBorder, topControl, topMarkBg, topMarkFg;
final Color sideBg, sideFg, sideMuted, sideActiveBg, sideActiveFg, sideBorder, sideHover;
final Color topBg,
topFg,
topFaint,
topBorder,
topControl,
topMarkBg,
topMarkFg;
final Color sideBg,
sideFg,
sideMuted,
sideActiveBg,
sideActiveFg,
sideBorder,
sideHover;
final Color menuUserBg, menuUserFg, menuUserHover;
final Color toastBg; // 原型 --toast-bg(基础 :root 共用,主题不变)
final bool isDark;
const AppTokens({
required this.page, required this.bg, required this.surface, required this.border,
required this.borderSubtle, required this.text, required this.heading, required this.title,
required this.muted, required this.faint, required this.primary, required this.primaryDark,
required this.onPrimary, required this.brand400, required this.brand50, required this.accent,
required this.success, required this.danger, required this.warn, required this.infoSoft,
required this.okSoft, required this.accentSoft, required this.successBg, required this.dangerBg,
required this.warnBg, required this.thBg, required this.rowHover, required this.shadow,
required this.topBg, required this.topFg, required this.topFaint, required this.topBorder,
required this.topControl, required this.topMarkBg, required this.topMarkFg, required this.sideBg,
required this.sideFg, required this.sideMuted, required this.sideActiveBg, required this.sideActiveFg,
required this.sideBorder, required this.sideHover, required this.menuUserBg, required this.menuUserFg,
required this.menuUserHover, required this.isDark,
required this.page,
required this.bg,
required this.surface,
required this.border,
required this.borderSubtle,
required this.text,
required this.heading,
required this.title,
required this.muted,
required this.faint,
required this.primary,
required this.primaryDark,
required this.onPrimary,
required this.brand400,
required this.brand50,
required this.accent,
required this.success,
required this.danger,
required this.warn,
required this.infoSoft,
required this.okSoft,
required this.accentSoft,
required this.successBg,
required this.dangerBg,
required this.warnBg,
required this.thBg,
required this.rowHover,
required this.shadow,
required this.topBg,
required this.topFg,
required this.topFaint,
required this.topBorder,
required this.topControl,
required this.topMarkBg,
required this.topMarkFg,
required this.sideBg,
required this.sideFg,
required this.sideMuted,
required this.sideActiveBg,
required this.sideActiveFg,
required this.sideBorder,
required this.sideHover,
required this.menuUserBg,
required this.menuUserFg,
required this.menuUserHover,
required this.toastBg,
required this.isDark,
});
@override
AppTokens copyWith({
Color? page, Color? bg, Color? surface, Color? border, Color? borderSubtle, Color? text,
Color? heading, Color? title, Color? muted, Color? faint, Color? primary, Color? primaryDark,
Color? onPrimary, Color? brand400, Color? brand50, Color? accent, Color? success, Color? danger,
Color? warn, Color? infoSoft, Color? okSoft, Color? accentSoft, Color? successBg, Color? dangerBg,
Color? warnBg, Color? thBg, Color? rowHover, Color? shadow, Color? topBg, Color? topFg,
Color? topFaint, Color? topBorder, Color? topControl, Color? topMarkBg, Color? topMarkFg,
Color? sideBg, Color? sideFg, Color? sideMuted, Color? sideActiveBg, Color? sideActiveFg,
Color? sideBorder, Color? sideHover, Color? menuUserBg, Color? menuUserFg, Color? menuUserHover,
Color? page,
Color? bg,
Color? surface,
Color? border,
Color? borderSubtle,
Color? text,
Color? heading,
Color? title,
Color? muted,
Color? faint,
Color? primary,
Color? primaryDark,
Color? onPrimary,
Color? brand400,
Color? brand50,
Color? accent,
Color? success,
Color? danger,
Color? warn,
Color? infoSoft,
Color? okSoft,
Color? accentSoft,
Color? successBg,
Color? dangerBg,
Color? warnBg,
Color? thBg,
Color? rowHover,
Color? shadow,
Color? topBg,
Color? topFg,
Color? topFaint,
Color? topBorder,
Color? topControl,
Color? topMarkBg,
Color? topMarkFg,
Color? sideBg,
Color? sideFg,
Color? sideMuted,
Color? sideActiveBg,
Color? sideActiveFg,
Color? sideBorder,
Color? sideHover,
Color? menuUserBg,
Color? menuUserFg,
Color? menuUserHover,
Color? toastBg,
bool? isDark,
}) {
return AppTokens(
page: page ?? this.page, bg: bg ?? this.bg, surface: surface ?? this.surface,
border: border ?? this.border, borderSubtle: borderSubtle ?? this.borderSubtle,
text: text ?? this.text, heading: heading ?? this.heading, title: title ?? this.title,
muted: muted ?? this.muted, faint: faint ?? this.faint, primary: primary ?? this.primary,
primaryDark: primaryDark ?? this.primaryDark, onPrimary: onPrimary ?? this.onPrimary,
brand400: brand400 ?? this.brand400, brand50: brand50 ?? this.brand50, accent: accent ?? this.accent,
success: success ?? this.success, danger: danger ?? this.danger, warn: warn ?? this.warn,
infoSoft: infoSoft ?? this.infoSoft, okSoft: okSoft ?? this.okSoft, accentSoft: accentSoft ?? this.accentSoft,
successBg: successBg ?? this.successBg, dangerBg: dangerBg ?? this.dangerBg, warnBg: warnBg ?? this.warnBg,
thBg: thBg ?? this.thBg, rowHover: rowHover ?? this.rowHover, shadow: shadow ?? this.shadow,
topBg: topBg ?? this.topBg, topFg: topFg ?? this.topFg, topFaint: topFaint ?? this.topFaint,
topBorder: topBorder ?? this.topBorder, topControl: topControl ?? this.topControl,
topMarkBg: topMarkBg ?? this.topMarkBg, topMarkFg: topMarkFg ?? this.topMarkFg,
sideBg: sideBg ?? this.sideBg, sideFg: sideFg ?? this.sideFg, sideMuted: sideMuted ?? this.sideMuted,
sideActiveBg: sideActiveBg ?? this.sideActiveBg, sideActiveFg: sideActiveFg ?? this.sideActiveFg,
sideBorder: sideBorder ?? this.sideBorder, sideHover: sideHover ?? this.sideHover,
menuUserBg: menuUserBg ?? this.menuUserBg, menuUserFg: menuUserFg ?? this.menuUserFg,
menuUserHover: menuUserHover ?? this.menuUserHover, isDark: isDark ?? this.isDark,
page: page ?? this.page,
bg: bg ?? this.bg,
surface: surface ?? this.surface,
border: border ?? this.border,
borderSubtle: borderSubtle ?? this.borderSubtle,
text: text ?? this.text,
heading: heading ?? this.heading,
title: title ?? this.title,
muted: muted ?? this.muted,
faint: faint ?? this.faint,
primary: primary ?? this.primary,
primaryDark: primaryDark ?? this.primaryDark,
onPrimary: onPrimary ?? this.onPrimary,
brand400: brand400 ?? this.brand400,
brand50: brand50 ?? this.brand50,
accent: accent ?? this.accent,
success: success ?? this.success,
danger: danger ?? this.danger,
warn: warn ?? this.warn,
infoSoft: infoSoft ?? this.infoSoft,
okSoft: okSoft ?? this.okSoft,
accentSoft: accentSoft ?? this.accentSoft,
successBg: successBg ?? this.successBg,
dangerBg: dangerBg ?? this.dangerBg,
warnBg: warnBg ?? this.warnBg,
thBg: thBg ?? this.thBg,
rowHover: rowHover ?? this.rowHover,
shadow: shadow ?? this.shadow,
topBg: topBg ?? this.topBg,
topFg: topFg ?? this.topFg,
topFaint: topFaint ?? this.topFaint,
topBorder: topBorder ?? this.topBorder,
topControl: topControl ?? this.topControl,
topMarkBg: topMarkBg ?? this.topMarkBg,
topMarkFg: topMarkFg ?? this.topMarkFg,
sideBg: sideBg ?? this.sideBg,
sideFg: sideFg ?? this.sideFg,
sideMuted: sideMuted ?? this.sideMuted,
sideActiveBg: sideActiveBg ?? this.sideActiveBg,
sideActiveFg: sideActiveFg ?? this.sideActiveFg,
sideBorder: sideBorder ?? this.sideBorder,
sideHover: sideHover ?? this.sideHover,
menuUserBg: menuUserBg ?? this.menuUserBg,
menuUserFg: menuUserFg ?? this.menuUserFg,
menuUserHover: menuUserHover ?? this.menuUserHover,
toastBg: toastBg ?? this.toastBg,
isDark: isDark ?? this.isDark,
);
}
@@ -65,24 +197,53 @@ class AppTokens extends ThemeExtension<AppTokens> {
if (other is! AppTokens) return this;
Color c(Color a, Color b) => Color.lerp(a, b, t)!;
return AppTokens(
page: c(page, other.page), bg: c(bg, other.bg), surface: c(surface, other.surface),
border: c(border, other.border), borderSubtle: c(borderSubtle, other.borderSubtle),
text: c(text, other.text), heading: c(heading, other.heading), title: c(title, other.title),
muted: c(muted, other.muted), faint: c(faint, other.faint), primary: c(primary, other.primary),
primaryDark: c(primaryDark, other.primaryDark), onPrimary: c(onPrimary, other.onPrimary),
brand400: c(brand400, other.brand400), brand50: c(brand50, other.brand50), accent: c(accent, other.accent),
success: c(success, other.success), danger: c(danger, other.danger), warn: c(warn, other.warn),
infoSoft: c(infoSoft, other.infoSoft), okSoft: c(okSoft, other.okSoft), accentSoft: c(accentSoft, other.accentSoft),
successBg: c(successBg, other.successBg), dangerBg: c(dangerBg, other.dangerBg), warnBg: c(warnBg, other.warnBg),
thBg: c(thBg, other.thBg), rowHover: c(rowHover, other.rowHover), shadow: c(shadow, other.shadow),
topBg: c(topBg, other.topBg), topFg: c(topFg, other.topFg), topFaint: c(topFaint, other.topFaint),
topBorder: c(topBorder, other.topBorder), topControl: c(topControl, other.topControl),
topMarkBg: c(topMarkBg, other.topMarkBg), topMarkFg: c(topMarkFg, other.topMarkFg),
sideBg: c(sideBg, other.sideBg), sideFg: c(sideFg, other.sideFg), sideMuted: c(sideMuted, other.sideMuted),
sideActiveBg: c(sideActiveBg, other.sideActiveBg), sideActiveFg: c(sideActiveFg, other.sideActiveFg),
sideBorder: c(sideBorder, other.sideBorder), sideHover: c(sideHover, other.sideHover),
menuUserBg: c(menuUserBg, other.menuUserBg), menuUserFg: c(menuUserFg, other.menuUserFg),
menuUserHover: c(menuUserHover, other.menuUserHover), isDark: t < 0.5 ? isDark : other.isDark,
page: c(page, other.page),
bg: c(bg, other.bg),
surface: c(surface, other.surface),
border: c(border, other.border),
borderSubtle: c(borderSubtle, other.borderSubtle),
text: c(text, other.text),
heading: c(heading, other.heading),
title: c(title, other.title),
muted: c(muted, other.muted),
faint: c(faint, other.faint),
primary: c(primary, other.primary),
primaryDark: c(primaryDark, other.primaryDark),
onPrimary: c(onPrimary, other.onPrimary),
brand400: c(brand400, other.brand400),
brand50: c(brand50, other.brand50),
accent: c(accent, other.accent),
success: c(success, other.success),
danger: c(danger, other.danger),
warn: c(warn, other.warn),
infoSoft: c(infoSoft, other.infoSoft),
okSoft: c(okSoft, other.okSoft),
accentSoft: c(accentSoft, other.accentSoft),
successBg: c(successBg, other.successBg),
dangerBg: c(dangerBg, other.dangerBg),
warnBg: c(warnBg, other.warnBg),
thBg: c(thBg, other.thBg),
rowHover: c(rowHover, other.rowHover),
shadow: c(shadow, other.shadow),
topBg: c(topBg, other.topBg),
topFg: c(topFg, other.topFg),
topFaint: c(topFaint, other.topFaint),
topBorder: c(topBorder, other.topBorder),
topControl: c(topControl, other.topControl),
topMarkBg: c(topMarkBg, other.topMarkBg),
topMarkFg: c(topMarkFg, other.topMarkFg),
sideBg: c(sideBg, other.sideBg),
sideFg: c(sideFg, other.sideFg),
sideMuted: c(sideMuted, other.sideMuted),
sideActiveBg: c(sideActiveBg, other.sideActiveBg),
sideActiveFg: c(sideActiveFg, other.sideActiveFg),
sideBorder: c(sideBorder, other.sideBorder),
sideHover: c(sideHover, other.sideHover),
menuUserBg: c(menuUserBg, other.menuUserBg),
menuUserFg: c(menuUserFg, other.menuUserFg),
menuUserHover: c(menuUserHover, other.menuUserHover),
toastBg: c(toastBg, other.toastBg),
isDark: t < 0.5 ? isDark : other.isDark,
);
}
}
+9 -3
View File
@@ -49,6 +49,7 @@ const AppTokens kTokensA = AppTokens(
menuUserBg: Color(0xFF3C79C0),
menuUserFg: Color(0xFFFFFFFF),
menuUserHover: Color(0xFF4D88CC),
toastBg: Color(0xFF1F2330),
isDark: false,
);
@@ -98,6 +99,7 @@ const AppTokens kTokensB = AppTokens(
menuUserBg: Color(0xFF3A3120),
menuUserFg: Color(0xFFF0E7D6),
menuUserHover: Color(0xFF473B27),
toastBg: Color(0xFF1F2330),
isDark: true,
);
@@ -147,13 +149,17 @@ const AppTokens kTokensC = AppTokens(
menuUserBg: Color(0xFF7E2B3B),
menuUserFg: Color(0xFFF7E9D6),
menuUserHover: Color(0xFF8E3344),
toastBg: Color(0xFF1F2330),
isDark: false,
);
AppTokens appTokensOf(String key) {
switch (key) {
case 'b': return kTokensB;
case 'c': return kTokensC;
default: return kTokensA;
case 'b':
return kTokensB;
case 'c':
return kTokensC;
default:
return kTokensA;
}
}
+83
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'app_dims.g.dart';
import 'app_tokens.g.dart';
ThemeData buildTheme(String key) {
@@ -18,6 +19,88 @@ ThemeData buildTheme(String key) {
brightness: brightness,
colorScheme: scheme,
scaffoldBackgroundColor: t.bg,
// 桌面端默认 adaptivePlatformDensity=compact 会把输入框/按钮压矮 8px,
// 与原型 px 规格(.input h38 等)和 golden(测试环境=standard)不一致——
// 真机曾因此输入框只有 ~30 高。锁 standard,三端渲染与原型同几何。
visualDensity: VisualDensity.standard,
// ── 对话框/输入框/按钮的 Material 默认样式钉到 ds 真相源 ──
// 原型 .modalsurface / border / r-xl / 标题 fs-h2 heading
dialogTheme: DialogThemeData(
backgroundColor: t.surface,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppDims.rXl),
side: BorderSide(color: t.border),
),
titleTextStyle: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
color: t.heading),
contentTextStyle: TextStyle(fontSize: AppDims.fsBody, color: t.text),
),
// 原型 .inputh38 盒式(surface/1px border/r-md/pad 0 11),聚焦主色边。
// 全局兜底——未显式给 decoration 边框的 TextField/DropdownButtonFormField
// 一律盒式,禁止 Material 默认下划线再出现。
inputDecorationTheme: InputDecorationTheme(
isDense: true,
filled: true,
fillColor: t.surface,
hintStyle: TextStyle(fontSize: AppDims.fsBody, color: t.faint),
labelStyle: TextStyle(fontSize: AppDims.fsSm, color: t.muted),
contentPadding: const EdgeInsets.symmetric(horizontal: 11, vertical: 9),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDims.rMd),
borderSide: BorderSide(color: t.border),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDims.rMd),
borderSide: BorderSide(color: t.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDims.rMd),
borderSide: BorderSide(color: t.primary),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppDims.rMd),
borderSide: BorderSide(color: t.border),
),
),
// 原型 .btn.primaryprimary 底 / onPrimary 字 / h38 / r-md / w600 / 无阴影
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: t.primary,
foregroundColor: t.onPrimary,
elevation: 0,
minimumSize: const Size(0, 38),
padding: const EdgeInsets.symmetric(horizontal: 16),
textStyle: const TextStyle(
fontSize: AppDims.fsBody, fontWeight: FontWeight.w600),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppDims.rMd)),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: t.primary,
foregroundColor: t.onPrimary,
minimumSize: const Size(0, 38),
padding: const EdgeInsets.symmetric(horizontal: 16),
textStyle: const TextStyle(
fontSize: AppDims.fsBody, fontWeight: FontWeight.w600),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppDims.rMd)),
),
),
// TextButton 兼作行内链接,不加边框;只统一字号字重与圆角
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
minimumSize: const Size(0, 38),
textStyle: const TextStyle(
fontSize: AppDims.fsBody, fontWeight: FontWeight.w600),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppDims.rMd)),
),
),
// 字体走系统默认(macOS/iOS=PingFang SC,与原型 --font 一致);不强制 Noto。
// Noto 仅 golden 测试加载(CI 无中文字体),见 test/support/golden_harness.dart。
extensions: <ThemeExtension<dynamic>>[t],
+8 -13
View File
@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import '../../providers/update_provider.dart';
import 'app_updater_io.dart'
if (dart.library.js_interop) 'app_updater_web.dart' as impl;
import '../../widgets/ds/ds_atoms.dart';
import 'app_updater_io.dart' if (dart.library.js_interop) 'app_updater_web.dart'
as impl;
/// 应用内更新统一入口。
/// - Web:刷新页面。
@@ -65,17 +66,11 @@ Future<void> _runWithProgress(
title: const Text('更新失败'),
content: Text('$e\n\n可改用浏览器下载更新。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
launchUpdateUrl(info.downloadUrls);
},
child: const Text('浏览器下载'),
),
DsButton('取消', onPressed: () => Navigator.pop(ctx)),
DsButton('浏览器下载', variant: DsBtnVariant.primary, onPressed: () {
Navigator.pop(ctx);
launchUpdateUrl(info.downloadUrls);
}),
],
),
);
+2 -1
View File
@@ -47,7 +47,8 @@ Future<void> downloadAndInstall(
await Process.run('/bin/rm', ['-rf', extractDir]);
await Directory(extractDir).create(recursive: true);
// ditto 正确处理 .app 包(保留权限/符号链接)。
final unzip = await Process.run('/usr/bin/ditto', ['-x', '-k', zip, extractDir]);
final unzip =
await Process.run('/usr/bin/ditto', ['-x', '-k', zip, extractDir]);
if (unzip.exitCode != 0) {
throw Exception('解压失败:${unzip.stderr}');
}
+5
View File
@@ -0,0 +1,5 @@
/// 可注入的应用时钟:业务展示层的「当前时间」统一走 [appNow],
/// golden 测试可覆写 [appClock] 冻结时间,保证 相对日期(今天/昨天/剩余天数)确定化。
DateTime Function() appClock = () => DateTime.now();
DateTime appNow() => appClock();
+2 -1
View File
@@ -30,7 +30,8 @@ void exportExcel({
final blob = web.Blob(
<JSAny>[uint8Array].toJS,
web.BlobPropertyBag(
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
type:
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
);
final url = web.URL.createObjectURL(blob);
final anchor = web.document.createElement('a') as web.HTMLAnchorElement
+5 -4
View File
@@ -6,6 +6,8 @@ import '../errors/error_reporter.dart';
import 'label_data.dart';
export 'label_data.dart';
import 'order_print_meta.dart';
import '../theme/context_tokens.dart';
import '../../widgets/ds/ds_toast.dart';
export 'order_print_meta.dart';
import 'print_util_stub.dart'
if (dart.library.js_interop) 'print_util_web.dart';
@@ -56,7 +58,8 @@ Future<void> printStockOutOrder(StockOutOrder order, OrderPrintMeta meta) =>
printStockOutOrderImpl(order, meta);
/// 生成入库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。
Future<Uint8List> buildStockInOrderPdf(StockInOrder order, OrderPrintMeta meta) =>
Future<Uint8List> buildStockInOrderPdf(
StockInOrder order, OrderPrintMeta meta) =>
buildStockInOrderPdfImpl(order, meta);
/// 生成出库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。
@@ -72,9 +75,7 @@ Future<void> safePrint(BuildContext context, Future<void> Function() fn) async {
} catch (e, st) {
reportError(e, st);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('打印失败:$e'), backgroundColor: Colors.red),
);
showDsToast(context, '打印失败:$e', bg: context.tokens.danger);
}
}
}
+228 -186
View File
@@ -47,10 +47,10 @@ 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 _ink = PdfColor(0.067, 0.067, 0.067); // #111 body text
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。
@@ -156,8 +156,9 @@ 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}) {
void _drawText(
ui.Canvas canvas, String s, double x, double y, double size, double maxW,
{bool bold = false, bool center = false}) {
if (s.isEmpty) return;
final pb = ui.ParagraphBuilder(ui.ParagraphStyle(
fontSize: size,
@@ -166,7 +167,8 @@ void _drawText(ui.Canvas canvas, String s, double x, double y, double size,
maxLines: 1,
ellipsis: '',
))
..pushStyle(ui.TextStyle(color: const ui.Color(0xFF000000)))
..pushStyle(
ui.TextStyle(color: const ui.Color(0xFF000000))) // ds-ignore: 打印画布纸面固定色
..addText(s);
final p = pb.build()..layout(ui.ParagraphConstraints(width: maxW));
canvas.drawParagraph(p, ui.Offset(x, y));
@@ -178,7 +180,8 @@ double _measureWidth(String s, double size, bool bold) {
fontSize: size,
fontWeight: bold ? ui.FontWeight.bold : ui.FontWeight.normal,
maxLines: 1,
))..addText(s);
))
..addText(s);
final p = pb.build()..layout(const ui.ParagraphConstraints(width: 100000));
return p.maxIntrinsicWidth;
}
@@ -205,14 +208,14 @@ Future<ui.Image> _renderLabelBitmap({
required Uint8List qrBytes,
}) async {
const w = 320, h = 160; // 40×20mm @203dpi(逻辑尺寸)
const scale = 2; // 2× 超采样,内部以 640×320 绘制
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()));
final canvas =
ui.Canvas(recorder, ui.Rect.fromLTWH(0, 0, sw.toDouble(), sh.toDouble()));
canvas.scale(scale.toDouble(), scale.toDouble()); // 坐标系放大,后续坐标不变
canvas.drawRect(ui.Rect.fromLTWH(0, 0, w.toDouble(), h.toDouble()),
ui.Paint()..color = const ui.Color(0xFFFFFFFF));
ui.Paint()..color = const ui.Color(0xFFFFFFFF)); // ds-ignore: 打印画布纸面固定色
// 右侧二维码(解码后端 PNG 直接画上)
const qrS = 124.0;
@@ -237,27 +240,29 @@ Future<ui.Image> _renderLabelBitmap({
const textMaxW = qrLeft - lx - 6; // 164
// 字号固定:只有商品名按宽度自适应,其余行均固定,杜绝自动放大
const double kShopSize = 17.0; // 酒行名,粗体
const double kShopSize = 17.0; // 酒行名,粗体
const double kSmallSize = 13.0; // 编号 / 度数 / 日期,统一小字
// 商品名:仅按宽度适配,上限 26px,下限 14px
final double nameSize = _fitFont(name, textMaxW, 1000, true, 26.0).clamp(14.0, 26.0);
final double nameSize =
_fitFont(name, textMaxW, 1000, true, 26.0).clamp(14.0, 26.0);
final codeText = code.isNotEmpty ? '编号:$code' : '';
final rows = <(String, double, bool)>[
if (shop.isNotEmpty) (shop, kShopSize, false), // 店名细体,突出商品名
if (name.isNotEmpty) (name, nameSize, true), // 商品名粗体
if (shop.isNotEmpty) (shop, kShopSize, false), // 店名细体,突出商品名
if (name.isNotEmpty) (name, nameSize, true), // 商品名粗体
if (codeText.isNotEmpty) (codeText, kSmallSize, false),
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
if (date.isNotEmpty) (date, kSmallSize, false),
if (degSpec.isNotEmpty) (degSpec, kSmallSize, false),
if (date.isNotEmpty) (date, kSmallSize, false),
];
// 文字区域:整体略低于二维码顶部,底部延伸至扫码溯源文字下方
const double kTextTop = 16.0; // 比二维码顶部(8)低一些,整体下移
const double kTextBot = 150.0; // 延伸至底部,减少留白
const double kLineH = 1.35; // 行高倍数(字号 × 行高 = 该行占用高度)
const double kLineH = 1.35; // 行高倍数(字号 × 行高 = 该行占用高度)
final double totalTextH = rows.fold(0.0, (s, r) => s + r.$2 * kLineH);
final int n = rows.length;
final double rawGap = n > 0 ? (kTextBot - kTextTop - totalTextH) / (n + 1) : 4.0;
final double rawGap =
n > 0 ? (kTextBot - kTextTop - totalTextH) / (n + 1) : 4.0;
final double gap = rawGap < 1.5 ? 1.5 : rawGap;
double yy = kTextTop + gap;
@@ -277,11 +282,13 @@ Uint8List _encodeGbk(String text) {
if (Platform.isWindows) {
final wideStr = text.toNativeUtf16();
try {
final needed = WideCharToMultiByte(936, 0, wideStr, -1, nullptr, 0, nullptr, nullptr);
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);
WideCharToMultiByte(
936, 0, wideStr, -1, buf.cast(), needed, nullptr, nullptr);
return Uint8List.fromList(buf.asTypedList(needed - 1)); // 不含 null 终止符
} finally {
calloc.free(buf);
@@ -337,7 +344,8 @@ Future<bool> _printFlatLabelThermal({
const qrWBytes = (qrW + 7) >> 3; // 16 bytes/row
// 将后端 256×256 QR PNG 缩放到 124×124 并转 1-bit
final codec = await ui.instantiateImageCodec(qrBytes, targetWidth: qrW, targetHeight: qrH);
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();
@@ -370,24 +378,24 @@ Future<bool> _printFlatLabelThermal({
// 左侧文字(x=4, 宽度至 x=186
// TSS24.BF2: 24×24 点阵; TSS16.BF2: 16×16 点阵
const kFontL = 'TSS16.BF2'; // 小号(编号/度数/日期)
const kFontL = 'TSS16.BF2'; // 小号(编号/度数/日期)
const kFontXL = 'TSS24.BF2'; // 大号(商品名,≤7字时使用)
final nameFont = name.runes.length <= 7 ? kFontXL : kFontL;
final nameH = nameFont == kFontXL ? 24 : 16;
final nameH = nameFont == kFontXL ? 24 : 16;
final codeText = code.isNotEmpty ? '编号:$code' : '';
// (文本, 字体, 字符高度点数)
final rows = <(String, String, int)>[];
if (shop.isNotEmpty) rows.add((shop, kFontL, 16));
if (name.isNotEmpty) rows.add((name, nameFont, nameH));
if (codeText.isNotEmpty) rows.add((codeText, kFontL, 16));
if (degSpec.isNotEmpty) rows.add((degSpec, kFontL, 16));
if (date.isNotEmpty) rows.add((date, kFontL, 16));
if (shop.isNotEmpty) rows.add((shop, kFontL, 16));
if (name.isNotEmpty) rows.add((name, nameFont, nameH));
if (codeText.isNotEmpty) rows.add((codeText, kFontL, 16));
if (degSpec.isNotEmpty) rows.add((degSpec, kFontL, 16));
if (date.isNotEmpty) rows.add((date, kFontL, 16));
// 在 y=8..150 区间均匀分布各行
const kTextTop = 8;
const kTextBot = 150;
const kTextX = 4;
const kTextX = 4;
final totalH = rows.fold(0, (s, r) => s + r.$3);
final n = rows.length;
final gapSize = n > 0
@@ -423,154 +431,168 @@ Future<void> printProductLabelImpl({
String? printerName,
}) async {
try {
final font = await _loadFont();
final doc = pw.Document();
final qrImage = pw.MemoryImage(qrBytes);
final font = await _loadFont();
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;
// 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;
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
// 度数(系列) + 规格 同一行
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
: '';
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
// 度数(系列) + 规格 同一行
final degSpecVal =
[seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10
? productionDate.substring(0, 10)
: productionDate)
: '';
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
if (await _printFlatLabelThermal(
shop: shopName,
name: name,
code: code,
degSpec: degSpecVal,
date: dateVal == '' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
qrBytes: qrBytes,
printerName: printerName)) {
return;
}
// 桌面端热敏机:直接画扁平标签位图 + TSPL 裸发(不走会多走纸的系统打印)
if (await _printFlatLabelThermal(
shop: shopName,
name: name,
code: code,
degSpec: degSpecVal,
date: dateVal == '' ? '' : dateVal, // 无生产日期(商品详情)则不显示该行
qrBytes: qrBytes,
printerName: printerName)) {
return;
}
final contact = [
if (shopAddress.isNotEmpty) shopAddress,
if (shopPhone.isNotEmpty) shopPhone,
].join(' · ');
final footerLeft = contact.isNotEmpty ? contact : shopName;
final contact = [
if (shopAddress.isNotEmpty) shopAddress,
if (shopPhone.isNotEmpty) shopPhone,
].join(' · ');
final footerLeft = contact.isNotEmpty ? contact : shopName;
// 标签生成时间
final now = DateTime.now();
final genTime =
'${now.year}-${now.month.toString().padLeft(2,'0')}-${now.day.toString().padLeft(2,'0')}'
' ${now.hour.toString().padLeft(2,'0')}:${now.minute.toString().padLeft(2,'0')}';
// 标签生成时间
final now = DateTime.now();
final genTime =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'
' ${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
doc.addPage(pw.Page(
pageFormat: const PdfPageFormat(labelW, labelH),
margin: pw.EdgeInsets.zero,
build: (_) => pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.stretch,
children: [
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: 4),
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,
)),
),
// ── Header:酒行名称 ─────────────────────────────────────────────────
pw.Container(
height: headerH,
color: _navy,
padding: const pw.EdgeInsets.symmetric(horizontal: 4),
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, 2, 4, 2),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Expanded(
child: pw.Column(
// ── Body ─────────────────────────────────────────────────────────────
pw.Expanded(
child: pw.Container(
color: PdfColors.white,
padding: const pw.EdgeInsets.fromLTRB(4, 2, 4, 2),
child: pw.Row(
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Expanded(
child: pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
// 商品名(过长自动缩小并最多两行)
pw.Text(name,
maxLines: 2,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font,
fontSize: name.runes.length > 10
? 5.5
: (name.runes.length > 7 ? 6.5 : 7.5),
fontWeight: pw.FontWeight.bold,
color: _ink,
letterSpacing: 0.3)),
pw.SizedBox(height: 2.5),
// 商品编号(度数上方)
if (code.isNotEmpty) ...[
pw.Text('编号:$code',
style: pw.TextStyle(
font: font, fontSize: 5.5, color: _ink)),
pw.SizedBox(height: 1.5),
],
// 度数(系列) + 规格 同一行, 无字段名标签
pw.Text(degSpecVal,
style: pw.TextStyle(
font: font, fontSize: 5.5, color: _ink)),
pw.SizedBox(height: 1.5),
// 生产日期(只显示日期,无标签)
pw.Text(dateVal,
style: pw.TextStyle(
font: font, fontSize: 5.5, color: _ink)),
],
),
),
pw.SizedBox(width: 4),
// QR
pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
// 商品名(过长自动缩小并最多两行)
pw.Text(name,
maxLines: 2,
overflow: pw.TextOverflow.clip,
pw.SizedBox(
width: 38,
height: 38,
child: pw.Image(qrImage, fit: pw.BoxFit.contain),
),
pw.SizedBox(height: 1),
pw.Text('扫码溯源',
style: pw.TextStyle(
font: font,
fontSize: name.runes.length > 10
? 5.5
: (name.runes.length > 7 ? 6.5 : 7.5),
fontWeight: pw.FontWeight.bold,
color: _ink, letterSpacing: 0.3)),
pw.SizedBox(height: 2.5),
// 商品编号(度数上方)
if (code.isNotEmpty) ...[
pw.Text('编号:$code',
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
pw.SizedBox(height: 1.5),
],
// 度数(系列) + 规格 同一行, 无字段名标签
pw.Text(degSpecVal,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
pw.SizedBox(height: 1.5),
// 生产日期(只显示日期,无标签)
pw.Text(dateVal,
style: pw.TextStyle(font: font, fontSize: 5.5, color: _ink)),
fontSize: 3.5,
color: PdfColors.grey600)),
],
),
],
),
),
),
// ── Footer ───────────────────────────────────────────────────────────
pw.Container(
height: footerH,
color: _cream,
padding: const pw.EdgeInsets.symmetric(horizontal: 4),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Flexible(
child: pw.Text(footerLeft,
maxLines: 1,
overflow: pw.TextOverflow.clip,
style: pw.TextStyle(
font: font, fontSize: 3, color: _footnote)),
),
pw.SizedBox(width: 4),
// QR
pw.Column(
mainAxisAlignment: pw.MainAxisAlignment.center,
children: [
pw.SizedBox(
width: 38, height: 38,
child: pw.Image(qrImage, fit: pw.BoxFit.contain),
),
pw.SizedBox(height: 1),
pw.Text('扫码溯源',
style: pw.TextStyle(
font: font, fontSize: 3.5,
color: PdfColors.grey600)),
],
),
pw.Text(genTime,
style: pw.TextStyle(
font: font, fontSize: 3, color: _footnote)),
],
),
),
),
// ── Footer ───────────────────────────────────────────────────────────
pw.Container(
height: footerH,
color: _cream,
padding: const pw.EdgeInsets.symmetric(horizontal: 4),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Flexible(
child: pw.Text(footerLeft,
maxLines: 1, overflow: pw.TextOverflow.clip,
style: pw.TextStyle(font: font, fontSize: 3, color: _footnote)),
),
pw.Text(genTime,
style: pw.TextStyle(font: font, fontSize: 3, color: _footnote)),
],
),
),
],
),
));
],
),
));
// 预生成 PDF 字节,避免 macOS NSPrintPanel 打开后再调 doc.save() 导致主线程卡住。
final bytes = await doc.save();
@@ -593,7 +615,8 @@ Future<void> printProductLabelImpl({
Future<Uint8List> renderLabelPreviewImpl(LabelData label) async {
final specVal = (label.spec ?? '').isNotEmpty ? label.spec! : '';
final seriesVal = (label.series ?? '').isNotEmpty ? label.series! : '';
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final degSpecVal =
[seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (label.productionDate ?? '').isNotEmpty
? (label.productionDate!.length > 10
? label.productionDate!.substring(0, 10)
@@ -658,15 +681,33 @@ String _qtyStr(double q) =>
/// 单据明细列(入库/出库统一):商品编号→备注。
const _orderHeaders = [
'商品编号', '商品名称', '系列', '规格', '批次号',
'生产日期', '数量', '单价', '金额', '备注',
'商品编号',
'商品名称',
'系列',
'规格',
'批次号',
'生产日期',
'数量',
'单价',
'金额',
'备注',
];
// 针打版式列宽:日期 / 单价 / 金额 加宽,避免大字号下数字、日期断行。
const _orderColFlex = <double>[1.4, 1.8, 1.0, 1.2, 1.6, 1.45, 0.7, 1.45, 1.45, 0.7];
const _orderColFlex = <double>[
1.4,
1.8,
1.0,
1.2,
1.6,
1.45,
0.7,
1.45,
1.45,
0.7
];
/// 温馨提示文案(入库/出库统一)。
const _kTipsText =
'温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,'
const _kTipsText = '温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,'
'如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。';
/// 各明细列对齐:系列/规格/批次号/生产日期居中,数量/单价/金额右对齐,其余左对齐。
@@ -691,20 +732,20 @@ pw.TextAlign _colAlign(int i) {
List<pw.Widget> _buildOrderDoc({
required pw.Font font,
required pw.Font bold,
required String shopName, // 公司名称(顶部抬头)
required String title, // 入 库 单 / 出 库 单
required String shopName, // 公司名称(顶部抬头)
required String title, // 入 库 单 / 出 库 单
required String orderNo,
required String noPrefix, // 'NO' / 'NO.'
required String partnerLabel, // 单位名称 / 客户名称
required String noPrefix, // 'NO' / 'NO.'
required String partnerLabel, // 单位名称 / 客户名称
required String? partnerName,
required String dateLabel, // 入库日期 / 出库日期
required String dateLabel, // 入库日期 / 出库日期
required String? orderDate,
required String addressLabel, // 单位地址 / 送货地址
required String addressLabel, // 单位地址 / 送货地址
required String? warehouseName,
required String signerLabel, // 出货人(签字)/ 收货人(签字)
required String signerLabel, // 出货人(签字)/ 收货人(签字)
required String operatorRoleLabel, // 采购员 / 销售员(= 经办人)
required String? operatorName,
required String? makerName, // 制单人 = 打印人
required String? makerName, // 制单人 = 打印人
required String? remark,
required bool showTips,
required List<List<String>> rows,
@@ -717,13 +758,13 @@ List<pw.Widget> _buildOrderDoc({
// 数字等宽(内置 CourierASCII 数字适用)。
final mono = pw.Font.courierBold();
final monoBold = pw.Font.courierBold();
final cellStyle = pw.TextStyle(
font: bold, fontSize: 9, fontWeight: pw.FontWeight.bold);
final cellStyle =
pw.TextStyle(font: bold, fontSize: 9, fontWeight: pw.FontWeight.bold);
final numStyle = pw.TextStyle(font: mono, fontSize: 9);
final headStyle =
pw.TextStyle(font: bold, fontSize: 9.5, fontWeight: pw.FontWeight.bold);
final metaStyle = pw.TextStyle(
font: bold, fontSize: 10, fontWeight: pw.FontWeight.bold);
final metaStyle =
pw.TextStyle(font: bold, fontSize: 10, fontWeight: pw.FontWeight.bold);
final metaBold =
pw.TextStyle(font: bold, fontSize: 10.5, fontWeight: pw.FontWeight.bold);
@@ -801,8 +842,8 @@ List<pw.Widget> _buildOrderDoc({
partnerLabel, partnerName ?? '', pw.TextAlign.left)),
pw.Expanded(
flex: 4,
child:
metaText(dateLabel, _d10(orderDate), pw.TextAlign.left)),
child: metaText(
dateLabel, _d10(orderDate), pw.TextAlign.left)),
pw.Expanded(
flex: 3,
child: pw.Text('$noPrefix$orderNo',
@@ -813,8 +854,7 @@ List<pw.Widget> _buildOrderDoc({
pw.Row(
children: [
pw.Expanded(
flex: 5,
child: metaText('联系电话', '', pw.TextAlign.left)),
flex: 5, child: metaText('联系电话', '', pw.TextAlign.left)),
pw.Expanded(
flex: 4,
child: metaText(addressLabel, '', pw.TextAlign.left)),
@@ -1021,7 +1061,8 @@ Future<Uint8List> buildStockInOrderPdfImpl(
}
return _buildOrderPdf(() => _buildOrderDoc(
font: font, bold: bold,
font: font,
bold: bold,
shopName: meta.shopName,
title: '入 库 单',
orderNo: order.orderNo,
@@ -1091,7 +1132,8 @@ Future<Uint8List> buildStockOutOrderPdfImpl(
}
return _buildOrderPdf(() => _buildOrderDoc(
font: font, bold: bold,
font: font,
bold: bold,
shopName: meta.shopName,
title: '出 库 单',
orderNo: order.orderNo,
+5 -3
View File
@@ -33,12 +33,14 @@ Future<void> printProductLabelImpl({
}) async {
final base64Img = base64Encode(qrBytes);
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final specVal = (spec ?? '').isNotEmpty ? spec! : '';
final seriesVal = (series ?? '').isNotEmpty ? series! : '';
// 度数(系列) + 规格 同一行
final degSpecVal = [seriesVal, specVal].where((s) => s.isNotEmpty).join(' ');
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10 ? productionDate.substring(0, 10) : productionDate)
final dateVal = (productionDate ?? '').isNotEmpty
? (productionDate!.length > 10
? productionDate.substring(0, 10)
: productionDate)
: '';
// 商品名过长时缩小字号(适当缩小,配合两行折行)
final nameLen = name.runes.length;