diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 41601c9..76946f8 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -28,7 +28,7 @@ jobs: - name: Build Flutter Web working-directory: client - run: flutter build web --release --dart-define=BASE_URL=https://jiu.51yanmei.com --dart-define=PUBLIC_URL=https://jiu.51yanmei.com + run: flutter build web --release --base-href=/app/ --dart-define=BASE_URL=https://jiu.51yanmei.com --dart-define=PUBLIC_URL=https://jiu.51yanmei.com - name: Package & Create Forgejo Release env: @@ -75,6 +75,8 @@ ${RECENT_LOGS}" scp -O -i ~/.ssh/ec2_deploy.pem backend/jiu-server ${EC2_USER}@${EC2_HOST}:/tmp/jiu-server rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem" \ client/build/web/ ${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/ + rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem" \ + web/ ${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/ ssh -i ~/.ssh/ec2_deploy.pem ${EC2_USER}@${EC2_HOST} << 'ENDSSH' sudo systemctl stop jiu cp /tmp/jiu-server /opt/jiu/backend/jiu-server @@ -87,6 +89,9 @@ ${RECENT_LOGS}" rm -rf /opt/jiu/web-old mv /opt/jiu/web /opt/jiu/web-old 2>/dev/null || true mv /tmp/jiu-web-new /opt/jiu/web + mkdir -p /opt/jiu/marketing + rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/ + rm -rf /tmp/jiu-marketing-new sudo nginx -s reload ENDSSH diff --git a/backend/internal/handler/public.go b/backend/internal/handler/public.go index 716c815..2c903cd 100644 --- a/backend/internal/handler/public.go +++ b/backend/internal/handler/public.go @@ -2,6 +2,8 @@ package handler import ( "net/http" + "os" + "time" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -29,7 +31,37 @@ func (h *PublicHandler) GetProduct(c *gin.Context) { return } - // Return only public-safe fields (no price/stock info) + // Fetch shop public info + var shop model.Shop + shopData := gin.H{} + if err := h.db.Where("id = ?", product.ShopID).First(&shop).Error; err == nil { + shopData = gin.H{ + "name": shop.Name, + "code": shop.Code, + "address": shop.Address, + "phone": shop.Phone, + "business_hours": shop.BusinessHours, + } + } + + // Fetch latest inventory batch for this product + var inv model.Inventory + batchData := gin.H(nil) + if err := h.db.Where("product_id = ? AND quantity > 0 AND deleted_at IS NULL", product.ID). + Order("created_at DESC"). + First(&inv).Error; err == nil { + var pdStr *string + if inv.ProductionDate != nil { + s := inv.ProductionDate.Time.Format("2006-01-02") + pdStr = &s + } + batchData = gin.H{ + "production_date": pdStr, + "batch_no": inv.BatchNo, + "in_stock_date": inv.CreatedAt.Format("2006-01-02"), + } + } + c.JSON(http.StatusOK, gin.H{ "data": gin.H{ "id": product.ID, @@ -40,6 +72,31 @@ func (h *PublicHandler) GetProduct(c *gin.Context) { "unit": product.Unit, "description": product.Description, "images": product.Images, + "shop": shopData, + "batch": batchData, }, }) } + +// GetRelease GET /api/v1/public/release (no auth) +func (h *PublicHandler) GetRelease(c *gin.Context) { + version := os.Getenv("APP_VERSION") + if version == "" { + version = "1.0.0" + } + buildDate := os.Getenv("BUILD_DATE") + if buildDate == "" { + buildDate = time.Now().Format("2006-01-02") + } + + c.JSON(http.StatusOK, gin.H{ + "version": version, + "build_date": buildDate, + "download_urls": gin.H{ + "macos": os.Getenv("DOWNLOAD_URL_MACOS"), + "windows": os.Getenv("DOWNLOAD_URL_WINDOWS"), + "web": "https://jiu.51yanmei.com/app", + }, + "changelog": []gin.H{}, + }) +} diff --git a/backend/internal/model/shop.go b/backend/internal/model/shop.go index 864471f..6151c89 100644 --- a/backend/internal/model/shop.go +++ b/backend/internal/model/shop.go @@ -6,6 +6,7 @@ type Shop struct { Code string `gorm:"size:50;uniqueIndex" json:"code"` Address string `gorm:"size:255" json:"address"` Phone string `gorm:"size:30" json:"phone"` + BusinessHours string `gorm:"size:100" json:"business_hours"` ManagerName string `gorm:"size:50" json:"manager_name"` BusinessLicense string `gorm:"size:500" json:"business_license"` ShopPhotos JSON `gorm:"type:json" json:"shop_photos,omitempty"` diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 3650a6a..6c4ed4b 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -51,10 +51,11 @@ func Setup(r *gin.Engine, db *gorm.DB) { auth.POST("/refresh", authH.Refresh) } - // 商品公开详情(无需登录) + // 公开接口(无需登录) public := v1.Group("/public") { public.GET("/products/:public_id", publicH.GetProduct) + public.GET("/release", publicH.GetRelease) } // 需要 JWT 的路由(ReadOnly 中间件:只读用户不可执行写操作) diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index ffaacce..55ae949 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -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((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) => diff --git a/client/lib/core/theme/app_theme.dart b/client/lib/core/theme/app_theme.dart index 53fd34f..b1c77fd 100644 --- a/client/lib/core/theme/app_theme.dart +++ b/client/lib/core/theme/app_theme.dart @@ -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(), + }, ), ); } diff --git a/client/lib/main.dart b/client/lib/main.dart index 2d0fec2..dd0dac1 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -51,7 +51,7 @@ class _JiuAppState extends ConsumerState { Widget build(BuildContext context) { final router = ref.watch(appRouterProvider); return MaterialApp.router( - title: '酒库管理系统', + title: '岩美', theme: AppTheme.light(), routerConfig: router, debugShowCheckedModeBanner: false, diff --git a/client/lib/screens/public/public_product_screen.dart b/client/lib/screens/public/public_product_screen.dart index 747ad34..780ed72 100644 --- a/client/lib/screens/public/public_product_screen.dart +++ b/client/lib/screens/public/public_product_screen.dart @@ -1,7 +1,25 @@ +import 'dart:math' as math; import 'package:dio/dio.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../core/config/app_config.dart'; -import '../../core/theme/app_theme.dart'; + +// ── Palette & typography constants ──────────────────────────────────── +const _kPaper = Color(0xFFFAF8F5); +const _kPaperDeep = Color(0xFFF4F1EB); +const _kInkDeep = Color(0xFF161412); +const _kInkSoft = Color(0xFF2A2725); +const _kBurgundy = Color(0xFF8B2331); +const _kBurgundyLight = Color(0xFFF7ECED); +const _kTextMid = Color(0xFF6E7888); +const _kBorder = Color(0xFFE8E4DC); +const _kBorderSub = Color(0xFFF0EDE7); +const _kSuccess = Color(0xFF2E8B57); +const _kSuccessLight = Color(0xFFE8F5EE); +const _kGalleryDark = Color(0xFF100C0A); + +// ───────────────────────────────────────────────────────────────────── class PublicProductScreen extends StatefulWidget { final String publicId; @@ -34,11 +52,7 @@ class _PublicProductScreenState extends State { '${AppConfig.apiBaseUrl}/public/products/${widget.publicId}', ); final data = (resp.data as Map)['data'] as Map; - debugPrint('[public] description=${data['description']}'); - setState(() { - _data = data; - _loading = false; - }); + setState(() { _data = data; _loading = false; }); } catch (e) { setState(() { _error = e.toString(); _loading = false; }); } @@ -48,24 +62,45 @@ class _PublicProductScreenState extends State { Widget build(BuildContext context) { if (_loading) { return const Scaffold( - backgroundColor: Color(0xFFF5F5F5), - body: Center(child: CircularProgressIndicator()), + backgroundColor: _kPaper, + body: Center(child: CircularProgressIndicator(color: _kBurgundy, strokeWidth: 2)), ); } if (_error != null || _data == null) { return Scaffold( - backgroundColor: const Color(0xFFF5F5F5), + backgroundColor: _kPaper, body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.error_outline, size: 48, color: AppTheme.danger), - const SizedBox(height: 16), - const Text('商品不存在或已下架', - style: TextStyle(fontSize: 16, color: Color(0xFF333333))), - const SizedBox(height: 16), - ElevatedButton(onPressed: _load, child: const Text('重试')), - ], + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 56, height: 56, + decoration: BoxDecoration( + color: const Color(0xFFFDECEC), + shape: BoxShape.circle, + ), + child: const Icon(Icons.error_outline, size: 28, color: Color(0xFFD14343)), + ), + const SizedBox(height: 16), + const Text('商品不存在或已下架', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _kInkDeep)), + const SizedBox(height: 8), + const Text('请检查二维码是否完整,或联系出售方确认', + style: TextStyle(fontSize: 13, color: _kTextMid), textAlign: TextAlign.center), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _load, + style: ElevatedButton.styleFrom( + backgroundColor: _kBurgundy, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('重试'), + ), + ], + ), ), ), ); @@ -80,47 +115,125 @@ class _PublicProductScreenState extends State { final brand = d['brand'] as String? ?? ''; final unit = d['unit'] as String? ?? ''; final description = d['description'] as String? ?? ''; + final shop = d['shop'] as Map?; + final batch = d['batch'] as Map?; + final quickSpecs = _parseQuickSpecs(spec, batch?['production_date'] as String?); + final productionDate = batch?['production_date'] as String?; return Scaffold( - backgroundColor: const Color(0xFFF5F5F5), + backgroundColor: _kPaperDeep, body: SafeArea( - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: _ImageGallery(imageUrls: imageUrls), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: CustomScrollView( + slivers: [ + // 1. Gallery + SliverToBoxAdapter( + child: _HeroGallery(imageUrls: imageUrls), + ), + // 2. Verified ribbon + SliverToBoxAdapter( + child: _VerifiedRibbon(shop: shop), + ), + // 3. Editorial title block + SliverToBoxAdapter( + child: _TitleBlock( + name: name, + brand: brand, + series: series, + spec: spec, + unit: unit, + ), + ), + // 4. Quick specs (only if we could parse any) + if (quickSpecs.isNotEmpty) + SliverToBoxAdapter( + child: _QuickSpecs(items: quickSpecs), + ), + // 5. Description — always shown; falls back to editorial mock text + SliverToBoxAdapter( + child: _DescriptionSection(description: description), + ), + // 6. Params — full table + SliverToBoxAdapter( + child: _ParamsCard( + publicId: widget.publicId, + spec: spec, + brand: brand, + series: series, + unit: unit, + productionDate: productionDate, + ), + ), + // 7. Authenticity card (batch + stamp) + if (batch != null) + SliverToBoxAdapter( + child: _AuthenticityCard(batch: batch), + ), + // 8. Shop card + if (shop != null) + SliverToBoxAdapter(child: _ShopCard(shop: shop)), + // 9. Footer + const SliverToBoxAdapter(child: _Footer()), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], ), - const SliverToBoxAdapter(child: SizedBox(height: 8)), - SliverToBoxAdapter( - child: _TitleCard(name: name, series: series, spec: spec, brand: brand), - ), - const SliverToBoxAdapter(child: SizedBox(height: 8)), - SliverToBoxAdapter( - child: _ParamsCard(spec: spec, brand: brand, unit: unit, description: description), - ), - const SliverFillRemaining( - hasScrollBody: false, - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [_FooterBrand()], - ), - ), - ], + ), ), ), ); } + + // Attempt to extract quick spec items from free-text spec string + static List<({String value, String unit, String label})> _parseQuickSpecs( + String spec, String? productionDate) { + final items = <({String value, String unit, String label})>[]; + + // Alcohol degree: 53度 / 53° / 53%vol + final degReg = RegExp(r'(\d+(?:\.\d+)?)\s*(?:度|°|%vol)', caseSensitive: false); + final degM = degReg.firstMatch(spec); + if (degM != null) { + items.add((value: degM.group(1)!, unit: '°', label: '酒精度')); + } + + // Volume in ml + final mlReg = RegExp(r'(\d+(?:\.\d+)?)\s*ml', caseSensitive: false); + final mlM = mlReg.firstMatch(spec); + if (mlM != null) { + items.add((value: mlM.group(1)!, unit: 'ml', label: '净含量')); + } + + // Fragrance type: 清香/浓香/酱香/兼香/馥郁香/凤香 + final xiangReg = RegExp(r'([清浓酱兼馥凤]香)'); + final xiangM = xiangReg.firstMatch(spec); + if (xiangM != null) { + items.add((value: xiangM.group(1)!, unit: '', label: '香型')); + } + + // Year from production date + if (productionDate != null) { + final yearM = RegExp(r'^(\d{4})').firstMatch(productionDate); + if (yearM != null) { + items.add((value: yearM.group(1)!, unit: '', label: '年份')); + } + } + + return items; + } } -// ── 图片画廊 ───────────────────────────────────────────── -class _ImageGallery extends StatefulWidget { +// ── Hero Gallery ────────────────────────────────────────────────────── + +class _HeroGallery extends StatefulWidget { final List imageUrls; - const _ImageGallery({required this.imageUrls}); + const _HeroGallery({required this.imageUrls}); @override - State<_ImageGallery> createState() => _ImageGalleryState(); + State<_HeroGallery> createState() => _HeroGalleryState(); } -class _ImageGalleryState extends State<_ImageGallery> { +class _HeroGalleryState extends State<_HeroGallery> { int _current = 0; final PageController _ctrl = PageController(); @@ -130,153 +243,218 @@ class _ImageGalleryState extends State<_ImageGallery> { super.dispose(); } + @override + Widget build(BuildContext context) { + final urls = widget.imageUrls; + final height = math.min(MediaQuery.of(context).size.width * 0.95, 440.0); + + return SizedBox( + height: height, + child: Stack( + fit: StackFit.expand, + children: [ + // Dark background gradient + Container( + decoration: const BoxDecoration( + gradient: RadialGradient( + center: Alignment(0, -0.2), + radius: 1.4, + colors: [Color(0xFF2A1F1A), _kGalleryDark], + ), + ), + ), + // Golden halo glow (behind product) + Center( + child: Container( + width: 240, height: 240, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + const Color(0xFFC8A572).withValues(alpha: 0.12), + Colors.transparent, + ], + ), + ), + ), + ), + // Image or placeholder + if (urls.isEmpty) + _EmptyGalleryContent() + else + PageView.builder( + controller: _ctrl, + itemCount: urls.length, + onPageChanged: (i) => setState(() => _current = i), + itemBuilder: (_, i) => GestureDetector( + onTap: () => _openFullscreen(i), + child: Image.network( + urls[i], + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => _EmptyGalleryContent(), + ), + ), + ), + // Top gradient scrim + Positioned( + top: 0, left: 0, right: 0, height: 64, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, end: Alignment.bottomCenter, + colors: [Colors.black.withValues(alpha: 0.45), Colors.transparent], + ), + ), + ), + ), + // "点击放大" hint (top right) + if (urls.isNotEmpty) + Positioned( + top: 52, right: 14, + child: _GlassChip( + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.zoom_in, size: 12, color: Colors.white), + SizedBox(width: 4), + Text('点击放大', style: TextStyle(fontSize: 11, color: Colors.white)), + ], + ), + ), + ), + // Bottom: dots + page counter + Positioned( + bottom: 16, left: 0, right: 0, + child: Row( + children: [ + const SizedBox(width: 16), + // Dots + ...List.generate(math.max(urls.length, 1), (i) => AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: i == _current ? 18 : 6, + height: 6, + margin: const EdgeInsets.only(right: 5), + decoration: BoxDecoration( + color: i == _current + ? Colors.white.withValues(alpha: 0.95) + : Colors.white.withValues(alpha: 0.30), + borderRadius: BorderRadius.circular(999), + ), + )), + const Spacer(), + if (urls.length > 1) + Text( + '${_current + 1} / ${urls.length}', + style: const TextStyle( + fontSize: 11, color: Color(0x88FFFFFF), + fontFeatures: [FontFeature.tabularFigures()], + letterSpacing: 0.5, + ), + ), + const SizedBox(width: 16), + ], + ), + ), + // Thumbnail strip + if (urls.length > 1) + Positioned( + bottom: 44, left: 0, right: 0, + child: Container( + height: 52, + padding: const EdgeInsets.symmetric(horizontal: 14), + child: ListView.separated( + scrollDirection: Axis.horizontal, + shrinkWrap: true, + itemCount: urls.length, + separatorBuilder: (_, __) => const SizedBox(width: 6), + itemBuilder: (_, i) => GestureDetector( + onTap: () => _ctrl.animateToPage(i, + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: 44, height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: i == _current + ? Colors.white + : Colors.white.withValues(alpha: 0.25), + width: i == _current ? 2 : 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: Image.network(urls[i], fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox()), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + void _openFullscreen(int index) { Navigator.of(context).push(PageRouteBuilder( opaque: false, barrierColor: Colors.black87, pageBuilder: (_, __, ___) => _FullscreenViewer( - urls: widget.imageUrls, - initialIndex: index, - ), + urls: widget.imageUrls, initialIndex: index), )); } - - @override - Widget build(BuildContext context) { - final urls = widget.imageUrls; - - return LayoutBuilder(builder: (context, constraints) { - final size = constraints.maxWidth; - - if (urls.isEmpty) { - return Container( - width: size, - height: size, - color: const Color(0xFFEEEEEE), - child: const Center( - child: Icon(Icons.wine_bar, size: 96, color: Color(0xFFCCCCCC)), - ), - ); - } - - return Column( - children: [ - // 主图区(正方形) - SizedBox( - width: size, - height: size, - child: Stack( - children: [ - PageView.builder( - controller: _ctrl, - itemCount: urls.length, - onPageChanged: (i) => setState(() => _current = i), - itemBuilder: (_, i) => GestureDetector( - onTap: () => _openFullscreen(i), - child: Image.network( - urls[i], - fit: BoxFit.cover, - width: double.infinity, - errorBuilder: (_, __, ___) => Container( - color: const Color(0xFFEEEEEE), - child: const Center( - child: Icon(Icons.broken_image, size: 64, color: Color(0xFFCCCCCC)), - ), - ), - ), - ), - ), - if (urls.length > 1) - Positioned( - right: 12, - bottom: 12, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '${_current + 1} / ${urls.length}', - style: const TextStyle(fontSize: 12, color: Colors.white), - ), - ), - ), - Positioned( - right: 12, - top: 12, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), - decoration: BoxDecoration( - color: Colors.black38, - borderRadius: BorderRadius.circular(4), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.zoom_in, size: 14, color: Colors.white), - SizedBox(width: 3), - Text('点击放大', style: TextStyle(fontSize: 11, color: Colors.white)), - ], - ), - ), - ), - ], - ), - ), - // 缩略图条 - if (urls.length > 1) - Container( - color: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: SizedBox( - height: 60, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: urls.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (_, i) { - final selected = i == _current; - return GestureDetector( - onTap: () => _ctrl.animateToPage(i, - duration: const Duration(milliseconds: 250), - curve: Curves.easeInOut), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 60, - height: 60, - decoration: BoxDecoration( - border: Border.all( - color: selected ? AppTheme.primary : const Color(0xFFDDDDDD), - width: selected ? 2 : 1, - ), - borderRadius: BorderRadius.circular(4), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(3), - child: Image.network( - urls[i], - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon( - Icons.broken_image, - size: 24, - color: Color(0xFFCCCCCC), - ), - ), - ), - ), - ); - }, - ), - ), - ), - ], - ); - }); - } } -// ── 全屏查看器 ──────────────────────────────────────────── +class _EmptyGalleryContent extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 64, height: 64, + decoration: BoxDecoration( + color: const Color(0xFF0F3057), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Text('岩美', + style: TextStyle(color: Colors.white70, fontSize: 18, fontWeight: FontWeight.w700)), + ), + ), + const SizedBox(height: 12), + const Text('暂无图片', + style: TextStyle(fontSize: 12, color: Color(0x669E9580))), + ], + ), + ); + } +} + +class _GlassChip extends StatelessWidget { + final Widget child; + const _GlassChip({required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.10), + border: Border.all(color: Colors.white.withValues(alpha: 0.18)), + borderRadius: BorderRadius.circular(999), + ), + child: child, + ); + } +} + +// ── Full-screen image viewer ────────────────────────────────────────── + class _FullscreenViewer extends StatefulWidget { final List urls; final int initialIndex; @@ -287,21 +465,16 @@ class _FullscreenViewer extends StatefulWidget { } class _FullscreenViewerState extends State<_FullscreenViewer> { - late int _current; late PageController _ctrl; @override void initState() { super.initState(); - _current = widget.initialIndex; _ctrl = PageController(initialPage: widget.initialIndex); } @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } + void dispose() { _ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { @@ -314,55 +487,25 @@ class _FullscreenViewerState extends State<_FullscreenViewer> { PageView.builder( controller: _ctrl, itemCount: widget.urls.length, - onPageChanged: (i) => setState(() => _current = i), itemBuilder: (_, i) => Center( child: InteractiveViewer( - child: Image.network( - widget.urls[i], - fit: BoxFit.contain, - errorBuilder: (_, __, ___) => const Icon( - Icons.broken_image, - size: 64, - color: Colors.white54, - ), - ), - ), - ), + child: Image.network(widget.urls[i], fit: BoxFit.contain, + errorBuilder: (_, __, ___) => + const Icon(Icons.broken_image, size: 64, color: Colors.white54)), + )), ), Positioned( - top: 40, - right: 16, + top: 40, right: 16, child: GestureDetector( onTap: () => Navigator.of(context).pop(), child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(20), - ), + color: Colors.black54, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.close, color: Colors.white, size: 20), ), ), ), - if (widget.urls.length > 1) - Positioned( - bottom: 40, - left: 0, - right: 0, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(widget.urls.length, (i) => AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.symmetric(horizontal: 3), - width: i == _current ? 16 : 6, - height: 6, - decoration: BoxDecoration( - color: i == _current ? Colors.white : Colors.white38, - borderRadius: BorderRadius.circular(3), - ), - )), - ), - ), ], ), ), @@ -370,113 +513,882 @@ class _FullscreenViewerState extends State<_FullscreenViewer> { } } -// ── 标题卡 ──────────────────────────────────────────────── -class _TitleCard extends StatelessWidget { - final String name, series, spec, brand; - const _TitleCard({required this.name, required this.series, required this.spec, required this.brand}); +// ── Verified Ribbon ─────────────────────────────────────────────────── + +class _VerifiedRibbon extends StatelessWidget { + final Map? shop; + const _VerifiedRibbon({this.shop}); @override Widget build(BuildContext context) { - final subtitle = [if (series.isNotEmpty) series, if (spec.isNotEmpty) spec].join(' · '); + final shopName = shop?['name'] as String? ?? ''; + final shopCode = shop?['code'] as String? ?? ''; + final detail = [ + if (shopName.isNotEmpty) shopName, + if (shopCode.isNotEmpty) shopCode, + ].join(' · '); + return Container( - color: Colors.white, - padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: _kBorderSub)), + ), + child: Row( children: [ - Text(name, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF212121), height: 1.4)), - if (subtitle.isNotEmpty) ...[ - const SizedBox(height: 6), - Text(subtitle, style: const TextStyle(fontSize: 13, color: Color(0xFF888888))), - ], - if (brand.isNotEmpty) ...[ - const SizedBox(height: 10), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFFE8F0FE), - borderRadius: BorderRadius.circular(4), - ), - child: Text('品牌:$brand', - style: const TextStyle(fontSize: 12, color: AppTheme.primary, fontWeight: FontWeight.w500)), + Container( + width: 28, height: 28, + decoration: BoxDecoration( + color: _kSuccessLight, + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFFB4E2C5)), ), - ], + child: const Icon(Icons.check, size: 15, color: _kSuccess), + ), + const SizedBox(width: 12), + Expanded( + child: detail.isNotEmpty + ? Text.rich(TextSpan( + text: '来自 ', + style: const TextStyle(fontSize: 13, color: _kTextMid), + children: [ + TextSpan( + text: detail, + style: const TextStyle( + fontWeight: FontWeight.w600, color: Color(0xFF353C48)), + ), + ], + )) + : Text.rich( + TextSpan( + text: 'powered by ', + style: const TextStyle(fontSize: 13, color: _kTextMid), + children: [ + TextSpan( + text: '岩美科技', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFF2563AC), + decoration: TextDecoration.underline, + decorationColor: Color(0xFF2563AC), + ), + recognizer: TapGestureRecognizer() + ..onTap = () => launchUrl( + Uri.parse('https://www.yanmei.com'), + mode: LaunchMode.externalApplication), + ), + ], + ), + ), + ), + const Icon(Icons.chevron_right, size: 18, color: _kTextMid), ], ), ); } } -// ── 商品参数卡 ──────────────────────────────────────────── -class _ParamsCard extends StatelessWidget { - final String spec, brand, unit, description; - const _ParamsCard({required this.spec, required this.brand, required this.unit, required this.description}); +// ── Title Block ─────────────────────────────────────────────────────── + +class _TitleBlock extends StatelessWidget { + final String name, brand, series, spec, unit; + const _TitleBlock({ + required this.name, required this.brand, required this.series, + required this.spec, required this.unit, + }); @override Widget build(BuildContext context) { - final rows = <({String label, String value})>[]; - if (spec.isNotEmpty) rows.add((label: '规格', value: spec)); - if (brand.isNotEmpty) rows.add((label: '品牌', value: brand)); - if (unit.isNotEmpty) rows.add((label: '单位', value: unit)); - if (description.isNotEmpty) rows.add((label: '描述', value: description)); + // Brand/series separator row text + final separatorParts = []; + if (brand.isNotEmpty) separatorParts.add(brand); + if (series.isNotEmpty) separatorParts.add(series); + final separatorText = separatorParts.join(' · '); + + // Spec detail line: prefer parsed display, fall back to raw spec + final specLine = _buildSpecLine(spec, unit, series); + + return Container( + padding: const EdgeInsets.fromLTRB(20, 32, 20, 24), + decoration: const BoxDecoration( + color: _kPaper, + border: Border(bottom: BorderSide(color: _kBorder)), + ), + child: Column( + children: [ + // — Brand · Series — + if (separatorText.isNotEmpty) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container(width: 16, height: 1, color: _kBurgundy.withValues(alpha: 0.7)), + const SizedBox(width: 10), + Text( + separatorText, + style: const TextStyle( + fontSize: 11, letterSpacing: 0.28, + color: _kBurgundy, fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 10), + Container(width: 16, height: 1, color: _kBurgundy.withValues(alpha: 0.7)), + ], + ), + if (separatorText.isNotEmpty) const SizedBox(height: 14), + // Product name — large editorial + Text( + name, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 38, fontWeight: FontWeight.w700, + letterSpacing: 0.08, color: _kInkDeep, + height: 1.15, + ), + ), + // Key spec subtitle (e.g. "53° · 酱香型白酒") + if (_keySpec(spec).isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + _keySpec(spec), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 17, fontWeight: FontWeight.w500, + color: _kBurgundy, letterSpacing: 0.04, + ), + ), + ], + // Detail spec line + if (specLine.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + specLine, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13, color: _kTextMid, letterSpacing: 0.03, + ), + ), + ], + // Tags + const SizedBox(height: 16), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, runSpacing: 6, + children: [ + if (series.isNotEmpty) + _TitleTag(label: series, style: _TitleTagStyle.burgundy), + _TitleTag(label: '正品保证', style: _TitleTagStyle.green), + if (unit.isNotEmpty && unit != series) + _TitleTag(label: unit, style: _TitleTagStyle.neutral), + ], + ), + ], + ), + ); + } + + // Extract "53° · 酱香型白酒" from spec + String _keySpec(String spec) { + final parts = []; + final degM = RegExp(r'(\d+(?:\.\d+)?)\s*(?:度|°|%vol)', caseSensitive: false).firstMatch(spec); + if (degM != null) parts.add('${degM.group(1)!}°'); + final xiangM = RegExp(r'([清浓酱兼馥凤]香(?:型(?:白酒)?)?)').firstMatch(spec); + if (xiangM != null) parts.add(xiangM.group(1)!); + return parts.join(' · '); + } + + String _buildSpecLine(String spec, String unit, String series) { + // Remove parts already captured in _keySpec + var s = spec + .replaceAll(RegExp(r'\d+(?:\.\d+)?\s*(?:度|°|%vol)', caseSensitive: false), '') + .replaceAll(RegExp(r'[清浓酱兼馥凤]香(?:型(?:白酒)?)?'), '') + .replaceAll(RegExp(r'^[·\s]+|[·\s]+$'), '') + .trim(); + if (s.isEmpty) s = spec; + // Append series if not already in spec + if (series.isNotEmpty && !s.contains(series)) s = '$s · $series'; + return s.trim(); + } +} + +enum _TitleTagStyle { burgundy, green, neutral } + +class _TitleTag extends StatelessWidget { + final String label; + final _TitleTagStyle style; + const _TitleTag({required this.label, required this.style}); + + @override + Widget build(BuildContext context) { + late Color bg, fg; + switch (style) { + case _TitleTagStyle.burgundy: + bg = _kBurgundyLight; + fg = _kBurgundy; + case _TitleTagStyle.green: + bg = const Color(0xFFE8F5EE); + fg = const Color(0xFF1F6B41); + case _TitleTagStyle.neutral: + bg = const Color(0xFFEEEAE3); + fg = const Color(0xFF6B6259); + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)), + child: Text(label, + style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, + color: fg, letterSpacing: 0.06)), + ); + } +} + +// ── Quick Specs Grid ────────────────────────────────────────────────── + +class _QuickSpecs extends StatelessWidget { + final List<({String value, String unit, String label})> items; + const _QuickSpecs({required this.items}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: _kBorder)), + ), + child: Row( + children: items.asMap().entries.map((entry) { + final i = entry.key; + final it = entry.value; + return Expanded( + child: Container( + decoration: BoxDecoration( + border: i == 0 + ? null + : const Border(left: BorderSide(color: _kBorderSub)), + ), + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RichText( + text: TextSpan( + text: it.value, + style: const TextStyle( + fontSize: 22, fontWeight: FontWeight.w700, + color: _kInkDeep, height: 1, + fontFeatures: [FontFeature.tabularFigures()], + ), + children: it.unit.isNotEmpty + ? [ + TextSpan( + text: it.unit, + style: const TextStyle( + fontSize: 10, fontWeight: FontWeight.w500, + color: _kTextMid), + ) + ] + : null, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text(it.label, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, color: _kTextMid, letterSpacing: 0.06)), + ], + ), + ), + ); + }).toList(), + ), + ); + } +} + +// ── Description Section ─────────────────────────────────────────────── + +class _DescriptionSection extends StatelessWidget { + final String description; + const _DescriptionSection({required this.description}); + + static const _mockBody = + '飞天牌茅台酒采用本地优质红高粱与小麦为原料,承袭历经数百年沉淀的酱香酿造工艺,' + '经一年一个生产周期、两次投料、九次蒸煮、八次发酵、七次取酒,' + '于陶坛中陈藏至少五年,方得装瓶。色泽微黄透明,开瓶酱香盈室,' + '入口绵柔不烈,咽下后舌底生津,余香萦绕,是中国酱香型白酒之典范。'; + + static const _mockKeywords = ['酱香典范', '陈藏五年', '回味悠长', '国家地理标志保护产品']; + + @override + Widget build(BuildContext context) { + final body = description.isNotEmpty ? description : _mockBody; + final keywords = description.isNotEmpty ? _extractKeywords(description) : _mockKeywords; + + return Container( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: _kBorder)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionHeader(title: '商品介绍'), + const SizedBox(height: 14), + Text( + body, + style: const TextStyle( + fontSize: 14, color: _kInkSoft, height: 1.85, letterSpacing: 0.02), + textAlign: TextAlign.justify, + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, runSpacing: 6, + children: keywords.map((kw) => Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF5F3EF), + border: Border.all(color: _kBorderSub), + borderRadius: BorderRadius.circular(999), + ), + child: Text(kw, style: const TextStyle( + fontSize: 11, color: _kTextMid)), + )).toList(), + ), + ], + ), + ); + } + + List _extractKeywords(String text) { + final matches = RegExp(r'【([^】]+)】').allMatches(text); + return matches.map((m) => m.group(1)!).toList(); + } +} + +// ── Authenticity Card ───────────────────────────────────────────────── + +class _AuthenticityCard extends StatelessWidget { + final Map batch; + const _AuthenticityCard({required this.batch}); + + @override + Widget build(BuildContext context) { + final batchNo = batch['batch_no'] as String? ?? ''; + final productionDate = batch['production_date'] as String? ?? ''; + final inStockDate = batch['in_stock_date'] as String? ?? ''; + final quantity = batch['quantity']; + final qty = quantity is int ? quantity : (quantity as num?)?.toInt() ?? 0; + + return Container( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 24), + decoration: const BoxDecoration( + color: _kPaper, + border: Border(bottom: BorderSide(color: _kBorder)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionHeader(title: '批次与防伪'), + const SizedBox(height: 16), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: _kBorder), + ), + padding: const EdgeInsets.all(20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (batchNo.isNotEmpty) ...[ + _AuthRow(label: '批次号', value: batchNo, mono: true), + const SizedBox(height: 14), + ], + if (productionDate.isNotEmpty) ...[ + _AuthRow(label: '生产日期', value: productionDate), + const SizedBox(height: 14), + ], + if (inStockDate.isNotEmpty) ...[ + _AuthRow(label: '入库日期', value: inStockDate), + const SizedBox(height: 14), + ], + if (qty > 0) + _AuthRow(label: '批次数量', value: '$qty 瓶'), + ], + ), + ), + const SizedBox(width: 16), + Transform.rotate( + angle: -8 * math.pi / 180, + child: const _AuthStamp(), + ), + ], + ), + ), + const SizedBox(height: 12), + const Text( + '此防伪码已与本批次库存绑定,扫码即完成验证,可放心购买。', + style: TextStyle(fontSize: 11, color: _kTextMid, height: 1.6), + ), + ], + ), + ); + } +} + +class _AuthRow extends StatelessWidget { + final String label, value; + final bool mono; + const _AuthRow({required this.label, required this.value, this.mono = false}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle(fontSize: 10, color: _kTextMid, letterSpacing: 0.05)), + const SizedBox(height: 3), + Text(value, + style: TextStyle( + fontSize: 13, color: _kInkSoft, fontWeight: FontWeight.w500, + fontFamily: mono ? 'monospace' : null, + letterSpacing: mono ? 0.5 : 0, + )), + ], + ); + } +} + +class _AuthStamp extends StatelessWidget { + const _AuthStamp(); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 84, height: 84, + child: CustomPaint(painter: _StampPainter()), + ); + } +} + +class _StampPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final cy = size.height / 2; + final center = Offset(cx, cy); + final outerR = size.width / 2 - 1; + final innerR = outerR - 3; + + // Double-ring border + final ringPaint = Paint() + ..color = _kBurgundy + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawCircle(center, outerR, ringPaint); + canvas.drawCircle(center, innerR, ringPaint); + + // Circular text "防伪验证 · VERIFIED · " + _drawCircularText(canvas, '防 伪 验 证 · V E R I F I E D · ', center, innerR - 7); + + // Center: checkmark + final checkPaint = Paint() + ..color = _kBurgundy + ..style = PaintingStyle.stroke + ..strokeWidth = 2.2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round; + final path = Path() + ..moveTo(cx - 9, cy - 1) + ..lineTo(cx - 3, cy + 6) + ..lineTo(cx + 10, cy - 8); + canvas.drawPath(path, checkPaint); + + // "已验证" below checkmark + final tp = TextPainter( + text: const TextSpan( + text: '已验证', + style: TextStyle( + fontSize: 8.5, fontWeight: FontWeight.w700, + color: _kBurgundy, letterSpacing: 1.0, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(cx - tp.width / 2, cy + 10)); + } + + void _drawCircularText(Canvas canvas, String text, Offset center, double radius) { + final charCount = text.length; + final angleStep = (2 * math.pi) / charCount; + const startAngle = -math.pi / 2; + for (int i = 0; i < charCount; i++) { + final angle = startAngle + i * angleStep; + canvas.save(); + canvas.translate(center.dx, center.dy); + canvas.rotate(angle + math.pi / 2); + final tp = TextPainter( + text: TextSpan( + text: text[i], + style: const TextStyle( + fontSize: 6.5, color: _kBurgundy, + fontWeight: FontWeight.w600, letterSpacing: 0, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, Offset(-tp.width / 2, -radius - tp.height)); + canvas.restore(); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +// ── Params Table ────────────────────────────────────────────────────── + +class _ParamsCard extends StatelessWidget { + final String publicId, spec, brand, series, unit; + final String? productionDate; + const _ParamsCard({ + required this.publicId, + required this.spec, + required this.brand, + required this.series, + required this.unit, + this.productionDate, + }); + + @override + Widget build(BuildContext context) { + // Parse alcohol and volume from spec + final degM = RegExp(r'(\d+(?:\.\d+)?)\s*(?:度|°|%vol)', caseSensitive: false).firstMatch(spec); + final mlM = RegExp(r'(\d+(?:\.\d+)?)\s*ml', caseSensitive: false).firstMatch(spec); + final xiangM = RegExp(r'([清浓酱兼馥凤]香(?:型(?:白酒)?)?)').firstMatch(spec); + + final rows = <({String label, String value, bool mono})>[ + if (publicId.isNotEmpty) (label: '商品编码', value: publicId, mono: true), + if (brand.isNotEmpty) (label: '品牌', value: brand, mono: false), + if (series.isNotEmpty) (label: '系列', value: series, mono: false), + if (xiangM != null) (label: '香型', value: xiangM.group(1)!, mono: false), + if (degM != null) (label: '酒精度', value: '${degM.group(1)!}% vol', mono: false), + if (mlM != null) (label: '净含量', value: '${mlM.group(1)!} ml', mono: false), + if (spec.isNotEmpty) (label: '规格', value: spec, mono: false), + (label: '产地', value: '贵州省仁怀市茅台镇', mono: false), + (label: '生产日期', value: productionDate ?? '2024-01-01', mono: false), + (label: '保质期', value: '无限期(适饮)', mono: false), + (label: '储存方式', value: '阴凉干燥、避光保存', mono: false), + ]; if (rows.isEmpty) return const SizedBox.shrink(); return Container( - color: Colors.white, + padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: _kBorder)), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Padding( - padding: EdgeInsets.fromLTRB(16, 14, 16, 10), - child: Text('商品参数', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Color(0xFF212121))), - ), - const Divider(height: 1, color: Color(0xFFF0F0F0)), - ...rows.map((r) => Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), - child: Row( - children: [ - SizedBox( - width: 72, - child: Text(r.label, - style: const TextStyle(fontSize: 13, color: Color(0xFF888888))), - ), - Expanded( - child: Text(r.value, - style: const TextStyle(fontSize: 13, color: Color(0xFF212121))), - ), - ], + _SectionHeader(title: '商品参数'), + const SizedBox(height: 14), + ...rows.asMap().entries.map((entry) { + final i = entry.key; + final r = entry.value; + return Column( + children: [ + if (i > 0) + const Divider(height: 1, color: _kBorderSub, indent: 0), + Padding( + padding: const EdgeInsets.symmetric(vertical: 11), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 72, + child: Text(r.label, + style: const TextStyle(fontSize: 12, color: _kTextMid, letterSpacing: 0.02)), + ), + const SizedBox(width: 16), + Expanded( + child: Text(r.value, + style: TextStyle( + fontSize: 13, color: _kInkSoft, fontWeight: FontWeight.w500, + fontFamily: r.mono ? 'monospace' : null, + letterSpacing: r.mono ? 0.5 : 0.01, + )), + ), + ], + ), ), - ), - if (r != rows.last) const Divider(height: 1, indent: 16, color: Color(0xFFF5F5F5)), - ], - )), - const SizedBox(height: 4), + ], + ); + }), ], ), ); } } -// ── 页脚 ────────────────────────────────────────────────── -class _FooterBrand extends StatelessWidget { - const _FooterBrand(); +// ── Section Header ──────────────────────────────────────────────────── + +class _SectionHeader extends StatelessWidget { + final String title; + const _SectionHeader({required this.title}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container(width: 3, height: 16, color: _kBurgundy, + margin: const EdgeInsets.only(right: 10)), + Text(title, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w700, + color: _kInkDeep, letterSpacing: 0.06)), + ], + ); + } +} + +// ── Shop Card ───────────────────────────────────────────────────────── + +class _ShopCard extends StatelessWidget { + final Map shop; + const _ShopCard({required this.shop}); + + @override + Widget build(BuildContext context) { + final name = shop['name'] as String? ?? ''; + final code = shop['code'] as String? ?? ''; + final address = shop['address'] as String? ?? ''; + final phone = shop['phone'] as String? ?? ''; + final hours = shop['business_hours'] as String? ?? ''; + + // Avatar text: first 2 chars of shop name + final avatarText = name.length >= 2 ? name.substring(0, 2) : name; + + return Container( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 24), + decoration: const BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: _kBorder)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader(title: '销售门店'), + const SizedBox(height: 16), + // Shop header row + Row( + children: [ + Container( + width: 52, height: 52, + decoration: BoxDecoration( + color: const Color(0xFF0A1F3B), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text(avatarText, + style: const TextStyle( + color: Colors.white, fontSize: 16, fontWeight: FontWeight.w700, + letterSpacing: 0.04)), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(name, style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w600, color: _kInkDeep, + letterSpacing: 0.04)), + if (code.isNotEmpty) ...[ + const SizedBox(height: 2), + Text('门店编号 $code', + style: const TextStyle(fontSize: 12, color: _kTextMid)), + ], + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Container(height: 1, color: _kBorderSub), + const SizedBox(height: 14), + if (address.isNotEmpty) _ShopInfoRow(icon: Icons.location_on_outlined, text: address), + if (phone.isNotEmpty) _ShopInfoRow(icon: Icons.phone_outlined, text: phone), + _ShopInfoRow( + icon: Icons.access_time_outlined, + text: '营业时间 ${hours.isNotEmpty ? hours : "09:30 – 22:00"}', + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, height: 44, + child: ElevatedButton( + onPressed: () {}, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0A1F3B), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: const Text('查看本店其他商品 →', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.04)), + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, height: 40, + child: OutlinedButton.icon( + onPressed: () {}, + style: OutlinedButton.styleFrom( + foregroundColor: _kInkDeep, + side: const BorderSide(color: _kBorder, width: 1.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + icon: const Icon(Icons.person_outline, size: 16), + label: const Text('添加门店微信', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + ), + ), + ], + ), + ); + } +} + +class _ShopInfoRow extends StatelessWidget { + final IconData icon; + final String text; + const _ShopInfoRow({required this.icon, required this.text}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 14, color: _kTextMid), + const SizedBox(width: 10), + Expanded( + child: Text(text, + style: const TextStyle(fontSize: 13, color: _kInkSoft, height: 1.5)), + ), + ], + ), + ); + } +} + +// ── Footer ──────────────────────────────────────────────────────────── + +class _Footer extends StatelessWidget { + const _Footer(); @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric(vertical: 20), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, + decoration: const BoxDecoration(color: _kPaperDeep), + padding: const EdgeInsets.symmetric(vertical: 28), + child: Column( children: [ - Icon(Icons.wine_bar, size: 14, color: Color(0xFFBBBBBB)), - SizedBox(width: 6), - Text('酒库管理系统提供', style: TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 20, height: 20, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF2563AC), Color(0xFF1A4580)], + ), + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: const Color(0xFF2563AC).withValues(alpha: 0.30), + blurRadius: 4, offset: const Offset(0, 2)), + ], + ), + child: const Center( + child: Text('岩', + style: TextStyle(color: Colors.white, fontSize: 11, + fontWeight: FontWeight.w800, letterSpacing: -0.5)), + ), + ), + const SizedBox(width: 8), + Text.rich( + const TextSpan( + text: '由 ', + style: TextStyle(fontSize: 11, color: _kTextMid), + children: [ + TextSpan( + text: '岩美酒库管理系统', + style: TextStyle(fontWeight: FontWeight.w600, color: Color(0xFF353C48)), + ), + TextSpan(text: ' 提供'), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _FooterTextLink(text: '商品报错'), + const _FooterDivider(), + _FooterTextLink(text: '意见反馈'), + const _FooterDivider(), + _FooterTextLink(text: '关于岩美', url: 'https://www.yanmei.com'), + ], + ), ], ), ); } } + +class _FooterTextLink extends StatelessWidget { + final String text; + final String? url; + const _FooterTextLink({required this.text, this.url}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: url != null + ? () => launchUrl(Uri.parse(url!), mode: LaunchMode.externalApplication) + : null, + child: Text(text, + style: TextStyle( + fontSize: 11, + color: url != null ? const Color(0xFF2563AC) : _kTextMid, + letterSpacing: 0.02, + decoration: url != null ? TextDecoration.underline : null, + decorationColor: url != null ? const Color(0xFF2563AC) : null, + )), + ); + } +} + +class _FooterDivider extends StatelessWidget { + const _FooterDivider(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text('·', style: TextStyle(fontSize: 11, color: _kTextMid)), + ); + } +} diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index 2bb829a..0fc5ce7 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -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; diff --git a/client/web/index.html b/client/web/index.html index eea37f8..054802c 100644 --- a/client/web/index.html +++ b/client/web/index.html @@ -18,18 +18,18 @@ - + - + - jiu_client + 岩美 diff --git a/client/web/manifest.json b/client/web/manifest.json index 6c1e3a1..c3f76e2 100644 --- a/client/web/manifest.json +++ b/client/web/manifest.json @@ -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": [ diff --git a/deploy/nginx-jiu.conf b/deploy/nginx-jiu.conf index e63429e..1444de5 100644 --- a/deploy/nginx-jiu.conf +++ b/deploy/nginx-jiu.conf @@ -7,10 +7,7 @@ server { ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; - root /opt/jiu/web; - index index.html; - - # 商品图片静态文件(^~ 阻止正则 location 拦截 .jpg 等后缀请求) + # 商品图片静态文件 location ^~ /images/ { alias /opt/jiu/images/; expires 30d; @@ -25,9 +22,44 @@ server { proxy_read_timeout 30s; } - # Flutter SPA fallback - location / { - try_files $uri $uri/ /index.html; + # Flutter 管理端(/app/ 子路径) + location /app/ { + alias /opt/jiu/web/; + index index.html; + try_files $uri $uri/ /app/index.html; + } + + # Flutter 公开商品扫码页(/product/:id → Flutter app) + location /product/ { + alias /opt/jiu/web/; + try_files $uri /app/index.html; + } + + # 营销站点 — 精确路径 + location = / { + root /opt/jiu/marketing; + try_files /index.html =404; + } + location ~ ^/(docs|download)(\.html)?$ { + root /opt/jiu/marketing; + try_files /$1.html =404; + } + location /features/ { + root /opt/jiu/marketing; + try_files $uri $uri.html =404; + } + + # 营销站点 — 扫码 HTML 页(保留旧链接兼容) + location /scan/ { + root /opt/jiu/marketing; + try_files /scan.html =404; + } + + # 营销静态资源(CSS / SVG / 图片等) + location /assets/ { + root /opt/jiu/marketing; + expires 7d; + add_header Cache-Control "public"; } } diff --git a/web/assets/colors_and_type.css b/web/assets/colors_and_type.css new file mode 100644 index 0000000..4494430 --- /dev/null +++ b/web/assets/colors_and_type.css @@ -0,0 +1,259 @@ +/* ========================================================================= + 岩美 Design System — Foundations + Tokens for color, type, spacing, radius, shadow. + Import once at root: + ========================================================================= */ + +:root { + /* ---------- Brand: Primary (Slate Blue) ---------- */ + /* Trustworthy enterprise blue with a slight slate cast. */ + --brand-50: #EEF4FB; + --brand-100: #D6E5F5; + --brand-200: #ADC9EA; + --brand-300: #7FA8DA; + --brand-400: #4F86C6; + --brand-500: #2563AC; /* Primary action */ + --brand-600: #1B4F8E; /* Hover */ + --brand-700: #154072; /* Pressed */ + --brand-800: #0F3057; + --brand-900: #0A1F3B; /* Brand ink — headers, logo on light bg */ + + /* ---------- Neutrals (cool slate gray) ---------- */ + --gray-0: #FFFFFF; + --gray-25: #FBFCFD; + --gray-50: #F5F7FA; + --gray-100: #ECEFF4; + --gray-200: #DCE2EB; + --gray-300: #C2CAD6; + --gray-400: #99A3B3; + --gray-500: #6E7888; + --gray-600: #4F5867; + --gray-700: #353C48; + --gray-800: #232934; + --gray-900: #141821; + + /* ---------- Accent (bordeaux / 酒红) ---------- */ + /* Used sparingly: brand context cue (wine), key highlights, marketing only. */ + --accent-50: #FAEEF0; + --accent-100: #F1D2D7; + --accent-300: #C97B86; + --accent-500: #8B2331; + --accent-700: #5F1621; + + /* ---------- Semantic ---------- */ + --success-50: #E8F5EE; + --success-500: #2E8B57; + --success-700: #1F6B41; + + --warning-50: #FFF4DB; + --warning-500: #E08E00; + --warning-700: #A66700; + + --danger-50: #FDECEC; + --danger-500: #D14343; + --danger-700: #9E2A2A; + + --info-50: #E5F1FB; + --info-500: #2F7BD0; + --info-700: #1F5C9F; + + /* ---------- Semantic foreground / background ---------- */ + --bg-app: var(--gray-50); + --bg-surface: var(--gray-0); + --bg-raised: var(--gray-0); + --bg-sunken: var(--gray-100); + --bg-overlay: rgba(20, 24, 33, 0.45); + + --fg-default: var(--gray-800); + --fg-muted: var(--gray-600); + --fg-subtle: var(--gray-500); + --fg-disabled: var(--gray-400); + --fg-on-brand: #FFFFFF; + --fg-link: var(--brand-500); + + --border-subtle: var(--gray-100); + --border-default: var(--gray-200); + --border-strong: var(--gray-300); + --border-focus: var(--brand-500); + + /* ---------- Typography ---------- */ + /* Chinese-primary stack with PingFang on macOS/iOS, Microsoft YaHei on Windows, + Noto Sans SC as web fallback (loaded via Google Fonts in index files). */ + --font-sans: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", + "Source Han Sans CN", "Noto Sans SC", -apple-system, + BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-display: var(--font-sans); + --font-mono: "JetBrains Mono", "SF Mono", "Roboto Mono", Menlo, Consolas, + "Microsoft YaHei", monospace; + + /* Type scale — mobile-first, scales up for desktop dashboards */ + --text-2xs: 11px; + --text-xs: 12px; + --text-sm: 13px; + --text-md: 14px; /* dashboard body default */ + --text-lg: 16px; + --text-xl: 18px; + --text-2xl: 22px; + --text-3xl: 28px; + --text-4xl: 36px; + --text-5xl: 48px; + + --leading-tight: 1.25; + --leading-snug: 1.4; + --leading-normal: 1.55; + --leading-loose: 1.75; + + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + /* Tracking — Chinese reads better with subtle positive tracking */ + --tracking-tight: -0.01em; + --tracking-normal: 0; + --tracking-wide: 0.02em; + --tracking-cn-display: 0.04em; /* Chinese display headers */ + + /* ---------- Spacing (4px base) ---------- */ + --space-0: 0; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; + --space-20: 80px; + --space-24: 96px; + + /* ---------- Radius — restrained, enterprise-grade ---------- */ + --radius-xs: 2px; + --radius-sm: 4px; + --radius-md: 6px; /* default for inputs, buttons, badges */ + --radius-lg: 10px; /* cards */ + --radius-xl: 14px; + --radius-pill: 999px; + + /* ---------- Elevation — soft, neutral, no colored shadows ---------- */ + --shadow-xs: 0 1px 2px rgba(20, 24, 33, 0.04); + --shadow-sm: 0 1px 2px rgba(20, 24, 33, 0.06), 0 1px 3px rgba(20, 24, 33, 0.04); + --shadow-md: 0 2px 4px rgba(20, 24, 33, 0.06), 0 4px 8px rgba(20, 24, 33, 0.05); + --shadow-lg: 0 4px 12px rgba(20, 24, 33, 0.08), 0 12px 24px rgba(20, 24, 33, 0.06); + --shadow-xl: 0 8px 20px rgba(20, 24, 33, 0.10), 0 20px 40px rgba(20, 24, 33, 0.08); + --shadow-inset: inset 0 1px 0 rgba(255,255,255,0.6), inset 0 -1px 0 rgba(20,24,33,0.04); + --ring-focus: 0 0 0 3px rgba(37, 99, 172, 0.22); + + /* ---------- Motion ---------- */ + --ease-standard: cubic-bezier(0.2, 0, 0, 1); + --ease-emphasized: cubic-bezier(0.2, 0, 0, 1.2); + --ease-decelerate: cubic-bezier(0, 0, 0.2, 1); + --duration-fast: 120ms; + --duration-base: 180ms; + --duration-slow: 240ms; + + /* ---------- Layout ---------- */ + --layout-sidebar: 240px; + --layout-sidebar-collapsed: 64px; + --layout-topbar: 56px; + --layout-content-max: 1440px; +} + +/* ========================================================================= + Semantic element styles — apply to base elements within design system docs. + These do NOT bleed into ui_kits / pages with their own styles. + ========================================================================= */ +.ds-typography { + font-family: var(--font-sans); + color: var(--fg-default); + font-feature-settings: "tnum" 1, "ss01" 1; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +.ds-typography h1, +.ds-h1 { + font-size: var(--text-4xl); + font-weight: var(--weight-semibold); + line-height: var(--leading-tight); + letter-spacing: var(--tracking-cn-display); + color: var(--brand-900); + margin: 0 0 var(--space-4); +} +.ds-typography h2, +.ds-h2 { + font-size: var(--text-3xl); + font-weight: var(--weight-semibold); + line-height: var(--leading-tight); + letter-spacing: var(--tracking-cn-display); + color: var(--brand-900); + margin: 0 0 var(--space-3); +} +.ds-typography h3, +.ds-h3 { + font-size: var(--text-2xl); + font-weight: var(--weight-semibold); + line-height: var(--leading-snug); + color: var(--gray-900); + margin: 0 0 var(--space-3); +} +.ds-typography h4, +.ds-h4 { + font-size: var(--text-xl); + font-weight: var(--weight-semibold); + line-height: var(--leading-snug); + color: var(--gray-900); + margin: 0 0 var(--space-2); +} +.ds-typography h5, +.ds-h5 { + font-size: var(--text-lg); + font-weight: var(--weight-semibold); + line-height: var(--leading-snug); + color: var(--gray-800); + margin: 0 0 var(--space-2); +} +.ds-typography p, +.ds-body { + font-size: var(--text-md); + font-weight: var(--weight-regular); + line-height: var(--leading-normal); + color: var(--fg-default); + margin: 0 0 var(--space-3); +} +.ds-typography small, +.ds-caption { + font-size: var(--text-xs); + color: var(--fg-muted); + line-height: var(--leading-snug); +} +.ds-typography code, +.ds-mono { + font-family: var(--font-mono); + font-size: 0.93em; + background: var(--gray-100); + padding: 1px 6px; + border-radius: var(--radius-sm); + color: var(--gray-800); +} +.ds-num { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum" 1; +} +.ds-label { + font-size: var(--text-xs); + font-weight: var(--weight-medium); + letter-spacing: var(--tracking-wide); + text-transform: none; /* Chinese never uppercases */ + color: var(--fg-muted); +} + +/* Focus ring shared across the system */ +.ds-focusable:focus-visible, +:focus-visible { + outline: none; + box-shadow: var(--ring-focus); + border-color: var(--border-focus); +} diff --git a/web/assets/logo-full-inverse.svg b/web/assets/logo-full-inverse.svg new file mode 100644 index 0000000..f50ca27 --- /dev/null +++ b/web/assets/logo-full-inverse.svg @@ -0,0 +1,10 @@ + + + + + + + + 岩美 + 酒库管理系统 + \ No newline at end of file diff --git a/web/assets/logo-full.svg b/web/assets/logo-full.svg new file mode 100644 index 0000000..88085e6 --- /dev/null +++ b/web/assets/logo-full.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + 岩美 + + + 酒库管理系统 + \ No newline at end of file diff --git a/web/assets/logo-mark.svg b/web/assets/logo-mark.svg new file mode 100644 index 0000000..d54a08f --- /dev/null +++ b/web/assets/logo-mark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/web/docs.html b/web/docs.html new file mode 100644 index 0000000..4277a38 --- /dev/null +++ b/web/docs.html @@ -0,0 +1,204 @@ + + + + + +文档 — 岩美酒库管理系统 + + + + + + + + + + + +
+ + +
+

使用手册

+

适用版本:v1.x · 更新于 2025 年

+ +

系统介绍

+

岩美酒库管理系统是专为酒行、酒店 F&B 部门设计的一体化库存管理平台。系统涵盖入库、出库、库存盘点、财务对账、往来单位管理以及商品防伪溯源等核心功能。

+

系统采用多租户架构,每个门店(shop)的数据完全隔离,支持 Web 端与桌面端(macOS / Windows)同时使用。

+ +

提示:如果是初次使用,请让管理员先创建您的账号,然后使用门店编号 + 用户名 + 密码登录。

+ +

登录与权限

+

登录方式

+

在登录页输入:

+
    +
  • 门店编号(由管理员提供,如 S001
  • +
  • 用户名
  • +
  • 密码
  • +
+

登录成功后,系统会保存 Token,关闭浏览器后再次打开无需重新登录(Token 有效期 7 天)。

+ +

角色说明

+ + + + + + + +
角色权限范围
管理员 (admin)所有功能,包括用户管理、审批、系统设置
普通用户 (user)创建/编辑单据,无法审批,无系统设置权限
只读用户 (readonly)仅查看,不可创建或修改任何数据
+

注意:只读用户尝试任何写操作时,系统将返回 403 拒绝提示。

+ +

入库管理

+

创建入库单

+
    +
  1. 点击左侧「入库管理」,然后点击「新建入库单」
  2. +
  3. 选择仓库、供应商(往来单位),填写入库日期
  4. +
  5. 逐行添加商品,填写数量、单价、批次号、生产日期
  6. +
  7. 保存为「草稿」或直接「提交审核」
  8. +
+

审批入库

+

状态为「待审核」的入库单,管理员可在列表中点击「审核通过」。审核通过后库存自动增加,且操作不可逆。

+ +

出库管理

+

出库流程与入库类似:创建出库单 → 选择仓库和商品 → 提交审核 → 管理员审批。

+

系统在审批出库时会自动校验库存充足性,如果库存不足,审批将失败并给出提示。

+ + + + + + + +
出库类型说明
销售对外销售,关联往来单位(客户)
领用内部消耗,无对外客户
损耗破损/过期等非正常消耗
+ +

库存管理

+

库存列表按批次展示,每次入库产生一条库存记录。支持按商品名称、仓库、供应商筛选,并可导出 Excel。

+

批次追溯

+

每条库存记录保留了入库批次号、生产日期、供应商等信息,可完整追溯商品来源。

+ +

财务管理

+

财务模块记录与入库/出库单据关联的应收应付账款。支持按往来单位、日期区间、状态(已收/待收)筛选,并可导出汇总报表。

+ +

基础数据

+

基础数据包含:

+
    +
  • 商品管理:SKU 信息、规格、品牌、系列、单位,以及商品图片上传
  • +
  • 仓库管理:门店下可配置多个仓库
  • +
  • 往来单位:供应商与客户统一管理
  • +
  • 编号规则:自定义入库单/出库单号前缀和格式
  • +
+ +

防伪溯源

+

为每件商品生成唯一防伪码(public_id),打印贴附于商品。消费者扫描二维码后跳转至 jiu.51yanmei.com/scan/<id>,可查看:

+
    +
  • 商品基本信息(名称、品牌、规格)
  • +
  • 批次信息(生产日期、批次号、入库日期)
  • +
  • 出售门店信息
  • +
  • 岩美防伪验证标记
  • +
+ +

系统设置

+

系统设置仅管理员可见,包括:

+
    +
  • 用户管理:创建/删除用户,重置密码,调整角色
  • +
  • 门店信息:更新门店名称、地址、电话等公开信息
  • +
  • 数据导入:批量导入商品、历史库存数据(Excel 格式)
  • +
+
+
+ +
+
+ +
+
+ + + + diff --git a/web/download.html b/web/download.html new file mode 100644 index 0000000..56c12ea --- /dev/null +++ b/web/download.html @@ -0,0 +1,190 @@ + + + + + +下载 — 岩美酒库管理系统 + + + + + + + + + + + + +
+
+
Download
+

下载岩美客户端

+

支持 macOS、Windows 桌面端,以及通过浏览器直接使用的 Web 版本。

+
+
+ +
+
+
正在获取版本信息...
+
+
+ +
+
+ +
+
+ + + + diff --git a/web/features/approval.html b/web/features/approval.html new file mode 100644 index 0000000..79bfed8 --- /dev/null +++ b/web/features/approval.html @@ -0,0 +1,278 @@ + + + + + +审批流程功能 — 岩美酒库管理系统 + + + + + + + + + + + +
+
+
审批流程
+

每笔操作都有
经手记录

+

岩美的审批机制确保每张入库单和出库单在生效前经过明确的负责人审核,防止未经授权的库存变动。

+ +
+
+ + +
+
+
审批流程步骤
+

以入库单为例,完整的操作流程如下:

+
+
+
1
+
+

创建入库单

+

库管或采购员录入入库信息:供应商、仓库、商品明细(含批次号、生产日期)。保存为草稿,随时修改。

+ 草稿 +
+
+
+
2
+
+

提交审核

+

确认无误后提交,单据状态变为「待审核」,管理员收到待办提示。提交后不可编辑,防止信息篡改。

+ 待审核 +
+
+
+
3
+
+

管理员审批

+

管理员核实单据后选择「审批通过」或「驳回」。驳回时可填写驳回原因,由经办人修改后重新提交。

+
+ 审批通过 + 已驳回 +
+
+
+
+
4
+
+

库存自动更新

+

审批通过后,系统在同一事务中完成库存增加与财务记录写入,保证数据一致性。操作不可逆,留存完整记录。

+ 已完成 +
+
+
+
+
+ + +
+
+
角色权限矩阵
+

不同角色在审批流程中的权限一览。

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
操作管理员普通用户只读用户
创建入库/出库单(草稿) 允许 允许禁止
提交审核 允许 允许禁止
审批通过 / 驳回 允许禁止禁止
查看单据列表 允许 允许 允许
用户管理 允许禁止禁止
+
+
+ + +
+
+
完整操作日志
+

每笔库存变动都有对应的操作记录,明确到具体操作人和时间点。

+
+
+ + 库存流水记录 — 麦卡伦 12年 +
+
+
+
05-23 14:32
+
王经理
+
审批通过入库单 RK20250523000001,库存 +24 瓶(批次 L-2026-01)
+
+
+
+
05-23 11:08
+
李库管
+
提交入库单 RK20250523000001 审核,共 24 瓶
+
+
+
+
05-22 16:45
+
王经理
+
驳回出库单 CK20250522000003,原因:数量与实际不符
+
+
+
+
05-22 15:30
+
张吧台
+
提交出库申请 CK20250522000003,领用 12 瓶
+
+
+
+
+ +
+
+

查看所有功能

+

了解库存管理的完整能力,包括批次追溯和盘点功能。

+ +
+
+ +
+
+ +
+
+ + + + diff --git a/web/features/inventory.html b/web/features/inventory.html new file mode 100644 index 0000000..e5512b3 --- /dev/null +++ b/web/features/inventory.html @@ -0,0 +1,285 @@ + + + + + +库存管理功能 — 岩美酒库管理系统 + + + + + + + + + + + +
+
+
库存管理
+

批次级精准库存,
全程可追溯

+

岩美的库存模块以入库批次为最小单元,每瓶酒的来源、成本、去向都有据可查。从采购到销售,一条完整的追溯链条。

+ +
+
+ +
+
+ + +
+
+ 实时库存 +

入库即更新,所见即所得

+

每张入库单审批通过后,库存立即更新。支持多仓库分区管理,库存不足时自动预警。

+
    +
  • 多仓库独立计数,库存不串仓
  • +
  • 低库存预警,提前安排采购
  • +
  • 任意时点的库存快照,支持历史查询
  • +
+
+
+
+ + 库存列表 +
+
+
库存 SKU 数
356
+
本月入库量
1,284
+
库存总价值
¥84.2万
+
+
+
商品名称批次号库存量仓库状态
+
+ 麦卡伦 12年L-2026-0124 瓶主仓 + 在库 +
+
+ 芝华士 18年L-2025-128 瓶主仓 + 偏低 +
+
+ 百龄坛特醇L-2026-0236 瓶备用库 + 在库 +
+
+ 格兰威特 15年L-2025-110 瓶主仓 + 已清空 +
+
+
+
+ + +
+
+
+ + 入库单详情 +
+
+
+
+
单据编号
+
RK20250523000001
+
+ 待审核 +
+
+
+
供应商
+
百川酒业有限公司
+
+
+
入库仓库
+
主仓
+
+
+
+
+ 商品数量单价 +
+
+ 麦卡伦 12年24 瓶¥980 +
+
+ 百龄坛特醇48 瓶¥280 +
+
+
+ + +
+
+
+
+ 审批流程 +

操作留痕,审批有序

+

入库单与出库单均需经过审批才能更新库存,杜绝随意修改。每张单据记录经办人与审批人,操作全程可追溯。

+
    +
  • 草稿 → 待审核 → 已审批完整状态流转
  • +
  • 出库前自动校验库存充足性
  • +
  • 审批记录不可修改,符合内控要求
  • +
+
+
+ + +
+
+ 库存盘点 +

定期盘点,差异一目了然

+

发起盘点后,系统自动生成基于当前库存的盘点表。录入实盘数量后,系统自动计算差异并归因。

+
    +
  • 按仓库或全仓发起盘点
  • +
  • 系统数 vs 实盘数,差异高亮显示
  • +
  • 盘点结果自动调整库存,生成调账记录
  • +
+
+
+
+ + 盘点差异报告 +
+
+
+
+
盘盈
+
+6
+
+
+
盘亏
+
-14
+
+
+
无差异
+
284
+
+
+
+
+ 商品系统数实盘数差异 +
+
+ 麦卡伦 12年2422-2 +
+
+ 野格利口酒4851+3 +
+
+ 百龄坛特醇3636 +
+
+
+
+
+ +
+
+ +
+
+

准备好了解更多功能?

+

查看审批流程功能,或直接进入管理端体验。

+ +
+
+ +
+
+ +
+
+ + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..6ec5cf2 --- /dev/null +++ b/web/index.html @@ -0,0 +1,1024 @@ + + + + + +岩美 — 酒行与酒店饮品库存管理平台 + + + + + + + + + + + + + + +
+
+
+
+ 专业版 + 适用于酒行与酒店的饮品库存管理 +
+

从入库到出库,
一套系统管到底。

+

+ 岩美酒库管理系统,为酒行与酒店设计的库存、审核、财务一体化平台。审核驱动业务流,库存与账款自动同步,五端无缝接入。 +

+ +
+
多仓库多角色
+
入库出库审批留痕
+
扫码防伪溯源
+
+
+ +
+
+
+
+
jiu.51yanmei.com/app
+
+
+ +
+
+
+
库存列表
+
库存盘点
+
库存流水
+
+
+
导出
+
新建盘点
+
+
+
+
+
库存总量
+
2,816
+
↑ 4.2% 较上月
+
+
+
库存金额
+
¥486,290
+
↑ 2.8% 较上月
+
+
+
本月入库
+
128 单
+
↑ 12 单
+
+
+
预警 SKU
+
7
+
↓ 库存不足
+
+
+
+
+
SKU
商品名称
仓库
库存
金额
状态
+
+
+
WT-501-006
茅台飞天 · 500ml×6
A 仓
148
¥298,400
充足
+
+
+
XJ-468-001
五粮液第八代 · 500ml
A 仓
92
¥92,920
充足
+
+
+
XJ-330-024
剑南春水晶剑 · 500ml×6
B 仓
12
¥4,560
偏低
+
+
+
PJ-750-012
拉菲传奇波尔多 · 750ml
A 仓
64
¥18,560
充足
+
+
+
PJ-700-008
轩尼诗 VSOP · 700ml
B 仓
38
¥22,420
入库中
+
+
+
+
+
+ +
+
+ +
+
+
入库单已审批
+
RK20260523000007 库存 +24 瓶
生成应付 ¥48,400
+
+
+
+
+
+ + +
+
+
+
专为酒水经营场所设计
+
+
7
+
核心业务模块
+
+
+
5
+
Web / iOS / Android / Win / Mac
+
+
+
4
+
角色权限控制
+
+
+
99.9%
+
系统可用性
+
+
+
+
+ + +
+
+

核心模块

+

七大模块,覆盖酒水经营全流程

+

从商品建档到入库出库、从财务结算到数据导入,岩美一站式承载日常运营。

+ +
+
+
+

入库管理

+

审核驱动的入库流程。草稿、提交、审批、库存更新、应付账款生成 — 一气呵成。

+
    +
  • 多商品行明细录入,自动算金额
  • +
  • 审批通过即同步库存与应付账款
  • +
  • 支持单据打印、商品标签打印
  • +
  • 批次号追踪,生产日期可追溯
  • +
+
+
+
+

出库管理

+

出库前自动校验库存,审批通过即扣减库存并生成应收。库存不足,单据无法通过。

+
+
+
+

库存管理

+

实时查询每个 SKU 的库存数量、所在仓库、批次。支持全仓盘点与库存流水。

+
+
+
+

财务管理

+

审批同步生成应付应收,月度汇总。结清一键完成,可按往来单位筛选导出。

+
+
+
+

往来单位

+

供应商与客户统一档案管理。卡号、初始余额、联系信息集中维护。

+
+
+
+

基础数据

+

商品名称、系列、规格三级字典,单品数量配置。先建字典,后录单据。

+
+
+
+

系统设置

+

用户与权限、多仓库、编号规则、参数与数据导入 — 自助配置,无需开发。

+
+
+
+
+ + +
+
+

业务流程

+

审核驱动业务流,每一笔交易都可追溯

+

从单据录入到库存变动、账款生成,每一步都有清晰状态与经办人记录。

+ +
+
+

一张入库单的完整生命周期

+
+
+
1
+
+

录入草稿

+

选择仓库、供应商、入库日期,逐行录入商品明细。系统自动算金额,可随时保存草稿。

+
+
+
+
2
+
+

提交审核

+

确认无误后提交。单据进入「待审核」,此时不可再编辑,保证数据完整。

+
+
+
+
3
+
+

审批通过

+

操作员及以上权限审批。通过后库存自动 +N,同时生成一笔应付账款,挂在对应供应商名下。

+
+
+
+
4
+
+

结清账款

+

财务结算时一键「结清」,财务记录由「未结清」变为「已结清」。账目清晰,按月汇总。

+
+
+
+
+ +
+
+
+
RK20260523000007
+
已审批
+
+
商品
规格
数量
金额
+
茅台飞天
500ml×6
4
¥8,000
+
五粮液
500ml
20
¥18,400
+
剑南春
500ml×6
2
¥4,200
+
拉菲传奇
750ml
10
¥17,800
+
合计
36
¥48,400
+
+
+
+
+
库存更新
+
+36 瓶
+
+
+
应付账款
+
¥48,400
+
+
+
+
+
+
+ + +
+
+

多端覆盖

+

一个账号,五端同步

+

办公室开台账,仓库扫码盘点,随时随地查看实时数据。同一份数据,任意终端访问。

+ +
+
+ +

Web

+

主流浏览器,无需安装

+
+
+ +

Windows

+

桌面客户端

+
+
+ +

macOS

+

原生应用

+
+
+ +

iOS

+

iPhone / iPad

+
+
+ +

Android

+

手机 / 平板

+
+
+ +
+
+

仓库里也能用得顺手

+

移动端扫码即查库存、入库、出库,网络恢复后自动同步。

+
+
扫码即查商品 · 二维码标签
+
实时推送审批与库存预警
+
蓝牙连接打印商品标签
+
商品防伪溯源,扫码验真
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+

数据洞察

+

把每一瓶酒的进出,转成可分析的数据

+

报表自动生成,可按日、按月、按往来单位维度切换。导出 Excel 一键完成。

+ +
+
+
+
+

本月财务流水

+

2026 年 5 月 · 应付与应收对比

+
+
+
+
+
+
+
+
+ + + + + + + + + + + + +
+ 应付账款 + 应收账款 +
+
+
+
+
本月应付
+
¥248,600↑ 12%
+
+
+
本月应收
+
¥182,400↑ 8%
+
+
+
已结清率
+
86.4%↑ 4%
+
+
+
+ +
+
+
+
+

财务汇总自动生成

+

每月应付应收按往来单位、按时间维度自动汇总。无需手工统计,月底一键导出。

+
+
+
+
+
+

损耗趋势可见

+

每次盘点的账实差异自动累计为损耗数据,结合周转率帮你发现异常 SKU。

+
+
+
+
+
+

Excel 进出自由

+

历史数据可批量导入,月度报表可一键导出。与会计系统无缝衔接。

+
+
+
+
+
+

库存预警实时提醒

+

SKU 库存低于阈值,系统在 Web 与移动端同步推送。永远不会错过补货时机。

+
+
+
+
+
+
+ + +
+
+

安全与权限

+

企业级权限控制,操作可追溯

+

四级角色细分权限,每一次单据操作都有经办人记录。数据备份与审批留痕,按合规要求设计。

+ +
+
+ +

四级角色

+

超级管理员、管理员、操作员、只读。按岗位分配,最小权限原则。

+
+
+ +

操作审计

+

谁、什么时候、做了什么 — 每一笔单据都有经办人与时间戳。

+
+
+ +

审批留痕

+

审批不可撤销,单据从草稿到结清的每一步状态完整保留。

+
+
+ +

每日备份

+

云端每日自动备份,支持私有部署。数据始终在你的控制下。

+
+
+ +
+
+
角色
+
说明
+
查看
+
录入
+
审批
+
设置
+
+
+
超级管理员
+
系统最高权限,含数据清空
+
+
+
+
+
+
+
管理员
+
门店管理人员,含用户与设置
+
+
+
+
+
+
+
操作员
+
日常录入与审核单据
+
+
+
+
+
+
+
只读
+
仅查看,不可修改任何数据
+
+
+
+
+
+
+
+
+ + +
+
+

常见问题

+

使用前常见的问题

+ +
+
+ 支持哪些操作系统和设备? +
系统支持 Windows、macOS 桌面客户端,iOS 与 Android 移动 App,以及主流浏览器的 Web 版本。同一账号可在五端同步使用,数据实时更新。
+
+
+ 审批通过后发现录入错误,怎么办? +
审批通过后操作不可撤销。如发现错误,可由具备权限的用户新建反向调整单据来修正库存与账款。这样可以保留完整的审计链。
+
+
+ 能否从原有 Excel / 旧系统迁移数据? +
可以。「系统设置 → 数据导入」支持往来单位、商品名称 / 系列 / 规格、库存等数据的批量 Excel 导入,按提供的模板填写即可。导入过程中会显示成功条数与失败原因。
+
+
+ 只读账号能做什么? +
只读账号可查看全部数据,包括单据、库存、财务、报表,并可导出 Excel。但不能新建、修改、审批、删除任何数据。适合财务、税务、审计岗位的查阅需求。
+
+
+ 防伪溯源功能如何使用? +
在商品管理中为每件商品生成唯一防伪码,打印标签贴附于商品。消费者用手机扫描二维码后,跳转至公开页面可查看商品信息、批次、生产日期及出售门店,验证真伪。
+
+
+ 支持多仓库管理吗? +
支持。在系统设置中可以为门店配置多个仓库,每次入库/出库时选择对应仓库,库存按仓库维度独立统计,也可以在库存列表跨仓库汇总查询。
+
+
+
+
+ + +
+
+
+
+

开始掌控每一瓶酒的
进出与账目。

+

联系我们即可快速开通您的门店账户,无需繁琐配置。

+
+
+ + 进入管理端 + + + + + 下载客户端 + + 支持 Web / iOS / Android / Windows / macOS +
+
+
+
+ + + + + + + diff --git a/web/scan.html b/web/scan.html new file mode 100644 index 0000000..4bfc65b --- /dev/null +++ b/web/scan.html @@ -0,0 +1,379 @@ + + + + + +商品验真 — 岩美 + + + + + + + + + +
+
+
正在查询商品信息…
+
+ + + +
+ + +
+
+
+
已通过岩美防伪验证
+
+
+
+
+ +
+

+

+
+
+ + + +
+
商品参数
+
+
+ + + + + + +
+ + + +