feat: 扫码商品详情页重设计 + 宣传主页 + nginx 路由完善

- public_product_screen: 1:1 复刻设计稿,新增商品介绍、批次防伪(自定义圆形印章)、完整商品参数表、门店营业时间、页脚链接
- app_theme: 完整品牌色系(brand/gray/accent/semantic)
- shop model: 新增 business_hours 字段,public 接口同步返回
- nginx: 新增 /product/ → Flutter app 扫码入口,修复 /docs /download html 后缀兼容
- web/: 新增宣传主页、功能页、文档页、扫码 HTML 及设计资源
- app_shell: 界面优化

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-05-23 18:58:04 +08:00
parent 1b4ca0c675
commit 30a8e791a3
22 changed files with 4280 additions and 364 deletions
+7 -6
View File
@@ -37,16 +37,17 @@ class _RouterNotifier extends ChangeNotifier {
String? redirect(BuildContext context, GoRouterState state) {
final authState = _ref.read(authStateProvider);
final isLoggedIn = authState.isLoggedIn;
final isLoginRoute = state.matchedLocation == '/login';
final isPublicRoute = state.matchedLocation.startsWith('/product/');
final loc = state.matchedLocation;
final isPublicRoute = loc == '/login' ||
loc.startsWith('/product/');
final result = !authState.initialized
? null
: (!isLoggedIn && !isLoginRoute && !isPublicRoute)
: (!isLoggedIn && !isPublicRoute)
? '/login'
: (isLoggedIn && isLoginRoute)
: (isLoggedIn && loc == '/login')
? '/stock-in'
: null;
debugPrint('[Router] redirect: location=${state.matchedLocation}'
debugPrint('[Router] redirect: location=$loc'
' initialized=${authState.initialized}'
' isLoggedIn=$isLoggedIn'
'${result ?? "null (no redirect)"}');
@@ -71,7 +72,7 @@ final appRouterProvider = Provider<GoRouter>((ref) {
refreshListenable: notifier,
redirect: notifier.redirect,
routes: [
// Public route — no auth, no shell nav bar
// Public product scan — no auth, no shell nav bar
GoRoute(
path: '/product/:public_id',
builder: (context, state) =>
+226 -39
View File
@@ -1,17 +1,110 @@
import 'package:flutter/material.dart';
class AppTheme {
static const Color primary = Color(0xFF1565C0);
static const Color primaryDark = Color(0xFF0D47A1);
static const Color primaryLight = Color(0xFF1976D2);
static const Color accent = Color(0xFFFF6F00);
static const Color success = Color(0xFF2E7D32);
static const Color danger = Color(0xFFC62828);
static const Color background = Color(0xFFF5F5F5);
// ─────────────────────────────────────────────────────────────────────────
// LEGACY API — field names preserved, values updated to 岩美 brand.
// ─────────────────────────────────────────────────────────────────────────
static const Color primary = Color(0xFF2563AC);
static const Color primaryDark = Color(0xFF154072);
static const Color primaryLight = Color(0xFF4F86C6);
/// Bordeaux accent — wine context cue. Was warm orange #FF6F00.
static const Color accent = Color(0xFF8B2331);
static const Color success = Color(0xFF2E8B57);
static const Color danger = Color(0xFFD14343);
static const Color background = Color(0xFFF5F7FA);
static const Color surface = Color(0xFFFFFFFF);
static const Color border = Color(0xFFE0E0E0);
static const Color textPrimary = Color(0xFF212121);
static const Color textSecondary = Color(0xFF757575);
static const Color border = Color(0xFFDCE2EB);
static const Color textPrimary = Color(0xFF232934);
static const Color textSecondary = Color(0xFF6E7888);
// ─────────────────────────────────────────────────────────────────────────
// Full brand scale (50..900)
// ─────────────────────────────────────────────────────────────────────────
static const Color brand50 = Color(0xFFEEF4FB);
static const Color brand100 = Color(0xFFD6E5F5);
static const Color brand200 = Color(0xFFADC9EA);
static const Color brand300 = Color(0xFF7FA8DA);
static const Color brand400 = Color(0xFF4F86C6);
static const Color brand500 = Color(0xFF2563AC);
static const Color brand600 = Color(0xFF1B4F8E);
static const Color brand700 = Color(0xFF154072);
static const Color brand800 = Color(0xFF0F3057);
static const Color brand900 = Color(0xFF0A1F3B);
// ─────────────────────────────────────────────────────────────────────────
// Full neutral (gray) scale
// ─────────────────────────────────────────────────────────────────────────
static const Color gray0 = Color(0xFFFFFFFF);
static const Color gray25 = Color(0xFFFBFCFD);
static const Color gray50 = Color(0xFFF5F7FA);
static const Color gray100 = Color(0xFFECEFF4);
static const Color gray200 = Color(0xFFDCE2EB);
static const Color gray300 = Color(0xFFC2CAD6);
static const Color gray400 = Color(0xFF99A3B3);
static const Color gray500 = Color(0xFF6E7888);
static const Color gray600 = Color(0xFF4F5867);
static const Color gray700 = Color(0xFF353C48);
static const Color gray800 = Color(0xFF232934);
static const Color gray900 = Color(0xFF141821);
// ─────────────────────────────────────────────────────────────────────────
// Accent (bordeaux) scale
// ─────────────────────────────────────────────────────────────────────────
static const Color accent50 = Color(0xFFFAEEF0);
static const Color accent100 = Color(0xFFF1D2D7);
static const Color accent500 = Color(0xFF8B2331);
static const Color accent700 = Color(0xFF5F1621);
// ─────────────────────────────────────────────────────────────────────────
// Semantic scale
// ─────────────────────────────────────────────────────────────────────────
static const Color success50 = Color(0xFFE8F5EE);
static const Color success500 = Color(0xFF2E8B57);
static const Color success700 = Color(0xFF1F6B41);
static const Color warning50 = Color(0xFFFFF4DB);
static const Color warning500 = Color(0xFFE08E00);
static const Color warning700 = Color(0xFFA66700);
static const Color danger50 = Color(0xFFFDECEC);
static const Color danger500 = Color(0xFFD14343);
static const Color danger700 = Color(0xFF9E2A2A);
static const Color info50 = Color(0xFFE5F1FB);
static const Color info500 = Color(0xFF2F7BD0);
static const Color info700 = Color(0xFF1F5C9F);
// ─────────────────────────────────────────────────────────────────────────
// State colors
// ─────────────────────────────────────────────────────────────────────────
static const Color rowHover = Color(0xFFEEF4FB);
static const Color tableHeader = Color(0xFFF5F7FA);
static const Color borderSubtle = Color(0xFFECEFF4);
// ─────────────────────────────────────────────────────────────────────────
// Radii
// ─────────────────────────────────────────────────────────────────────────
static const double radiusSm = 4.0;
static const double radiusMd = 6.0;
static const double radiusLg = 10.0;
// ─────────────────────────────────────────────────────────────────────────
// Spacing (4px base)
// ─────────────────────────────────────────────────────────────────────────
static const double space1 = 4.0;
static const double space2 = 8.0;
static const double space3 = 12.0;
static const double space4 = 16.0;
static const double space5 = 20.0;
static const double space6 = 24.0;
static const double space8 = 32.0;
// ─────────────────────────────────────────────────────────────────────────
// ThemeData
// ─────────────────────────────────────────────────────────────────────────
static ThemeData light() {
return ThemeData(
@@ -21,78 +114,172 @@ class AppTheme {
brightness: Brightness.light,
).copyWith(
primary: primary,
surface: surface,
onPrimary: Colors.white,
secondary: brand400,
surface: surface,
onSurface: textPrimary,
error: danger,
outline: border,
outlineVariant: borderSubtle,
),
scaffoldBackgroundColor: background,
appBarTheme: const AppBarTheme(
backgroundColor: primary,
foregroundColor: Colors.white,
elevation: 0,
centerTitle: false,
toolbarHeight: 56,
),
cardTheme: CardThemeData(
color: surface,
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
side: const BorderSide(color: border, width: 0.5),
titleTextStyle: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
letterSpacing: 0.4,
),
),
cardTheme: CardThemeData(
color: surface,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg),
side: const BorderSide(color: border, width: 1),
),
margin: const EdgeInsets.all(0),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primary,
foregroundColor: Colors.white,
minimumSize: const Size(0, 36),
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(horizontal: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
letterSpacing: 0.2,
),
elevation: 0,
).copyWith(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.pressed)) return primaryDark;
if (states.contains(WidgetState.hovered)) return brand600;
return primary;
}),
overlayColor:
WidgetStateProperty.all(Colors.white.withValues(alpha: 0.08)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: primary,
minimumSize: const Size(0, 36),
padding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
side: const BorderSide(color: primary),
foregroundColor: brand700,
minimumSize: const Size(0, 40),
padding: const EdgeInsets.symmetric(horizontal: 18),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusMd),
),
side: const BorderSide(color: border, width: 1),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
).copyWith(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.pressed)) return gray100;
if (states.contains(WidgetState.hovered)) return gray50;
return surface;
}),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: primary,
minimumSize: const Size(0, 36),
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusSm),
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(4),
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: primary, width: 1.5),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: danger),
),
hoverColor: gray50,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
isDense: true,
filled: true,
fillColor: surface,
labelStyle: const TextStyle(fontSize: 13, color: textSecondary),
hintStyle: TextStyle(fontSize: 13, color: gray400),
),
dividerTheme: const DividerThemeData(color: border, thickness: 0.5),
dividerTheme: const DividerThemeData(
color: borderSubtle,
thickness: 1,
space: 1,
),
chipTheme: ChipThemeData(
backgroundColor: gray100,
labelStyle: const TextStyle(fontSize: 12, color: textPrimary),
side: BorderSide.none,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(999),
),
),
tooltipTheme: TooltipThemeData(
decoration: BoxDecoration(
color: gray900,
borderRadius: BorderRadius.circular(radiusSm),
),
textStyle: const TextStyle(color: Colors.white, fontSize: 12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
),
textTheme: const TextTheme(
bodyLarge: TextStyle(fontSize: 14, color: textPrimary),
bodyMedium: TextStyle(fontSize: 14, color: textPrimary),
bodySmall: TextStyle(fontSize: 12, color: textSecondary),
titleMedium: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: textPrimary),
labelMedium: TextStyle(fontSize: 12, color: textSecondary),
displayLarge: TextStyle(fontSize: 36, fontWeight: FontWeight.w600, color: brand900, height: 1.2, letterSpacing: 0.4),
displayMedium: TextStyle(fontSize: 28, fontWeight: FontWeight.w600, color: brand900, height: 1.2, letterSpacing: 0.4),
headlineSmall: TextStyle(fontSize: 22, fontWeight: FontWeight.w600, color: gray900, height: 1.3),
titleLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: gray900, height: 1.35),
titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: textPrimary, height: 1.4),
titleSmall: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: textPrimary, height: 1.4),
bodyLarge: TextStyle(fontSize: 14, color: textPrimary, height: 1.55),
bodyMedium: TextStyle(fontSize: 14, color: textPrimary, height: 1.55),
bodySmall: TextStyle(fontSize: 12, color: textSecondary, height: 1.5),
labelLarge: TextStyle(fontSize: 13, color: textPrimary, fontWeight: FontWeight.w500),
labelMedium: TextStyle(fontSize: 12, color: textSecondary, letterSpacing: 0.4),
labelSmall: TextStyle(fontSize: 11, color: textSecondary, letterSpacing: 0.4),
),
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.windows: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.macOS: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.linux: FadeUpwardsPageTransitionsBuilder(),
},
),
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ class _JiuAppState extends ConsumerState<JiuApp> {
Widget build(BuildContext context) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: '酒库管理系统',
title: '岩美',
theme: AppTheme.light(),
routerConfig: router,
debugShowCheckedModeBanner: false,
File diff suppressed because it is too large Load Diff
+67 -3
View File
@@ -591,10 +591,10 @@ class _ShopButton extends StatelessWidget {
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.wine_bar, color: Colors.white, size: 22),
SizedBox(width: 8),
_YanmeiMark(size: 28),
SizedBox(width: 10),
Text(
'酒库管理系统',
'岩美',
style: TextStyle(
color: Colors.white,
fontSize: 18,
@@ -608,6 +608,70 @@ class _ShopButton extends StatelessWidget {
}
}
/// Brand mark widget — approximates the 岩美 logo SVG without flutter_svg.
/// Dark blue rounded rect, white mountain/wave strokes, bordeaux dot.
class _YanmeiMark extends StatelessWidget {
final double size;
const _YanmeiMark({this.size = 32});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: const Color(0xFF0F3057),
borderRadius: BorderRadius.circular(size * 0.19),
),
child: CustomPaint(
painter: _YanmeiMarkPainter(),
),
);
}
}
class _YanmeiMarkPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final h = size.height;
final paint = Paint()
..color = Colors.white
..strokeWidth = w * 0.055
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
// Mountain/wave path: M14 38 L22 22 L32 32 L42 22 L50 38 (on 64px grid)
final path = Path();
path.moveTo(w * 0.219, h * 0.594);
path.lineTo(w * 0.344, h * 0.344);
path.lineTo(w * 0.500, h * 0.500);
path.lineTo(w * 0.656, h * 0.344);
path.lineTo(w * 0.781, h * 0.594);
canvas.drawPath(path, paint);
// Horizontal baseline: M12 46 L52 46 (on 64px grid)
canvas.drawLine(
Offset(w * 0.1875, h * 0.719),
Offset(w * 0.8125, h * 0.719),
paint,
);
// Bordeaux dot: circle cx=32 cy=52 r=2.4 (on 64px grid)
canvas.drawCircle(
Offset(w * 0.500, h * 0.859),
w * 0.042,
Paint()
..color = const Color(0xFFC97B86)
..style = PaintingStyle.fill,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _InfoRow extends StatelessWidget {
final IconData icon;
final String label;
+3 -3
View File
@@ -18,18 +18,18 @@
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<meta name="description" content="岩美酒库管理系统 — 酒店饮品库存与采购管理平台">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="jiu_client">
<meta name="apple-mobile-web-app-title" content="岩美">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>jiu_client</title>
<title>岩美</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
+5 -5
View File
@@ -1,11 +1,11 @@
{
"name": "jiu_client",
"short_name": "jiu_client",
"name": "岩美",
"short_name": "岩美",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"background_color": "#2563AC",
"theme_color": "#2563AC",
"description": "岩美酒库管理系统 — 酒店饮品库存与采购管理平台",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [