chore: release v1.0.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+7
-3
@@ -5,14 +5,18 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.3] - 2026-05-26
|
||||
## [1.0.3] - 2026-05-28
|
||||
|
||||
### 新功能
|
||||
- 新增用户自助注册功能,在官网 /register/ 填写门店信息即可完成注册,系统自动生成门店编码(S000001 格式)
|
||||
- 支持 Windows 桌面客户端构建与发布(需配置 Windows CI Runner)
|
||||
- 注册账号默认设为超级管理员权限
|
||||
- 支持 Windows 桌面客户端构建与发布
|
||||
|
||||
### 修复
|
||||
- 商品标签打印改为竖向 20mm×38mm 布局,修复标签内容旋转 90° 问题
|
||||
- 修复新门店首次登录时库存、仓库、用户等列表加载崩溃的问题
|
||||
- 修复库存管理页在无数据时页面崩溃(RangeError)的问题
|
||||
- 商品标签打印改为横向 38mm×20mm 布局,修复标签内容错位问题
|
||||
- 修复数据导入中移除不必要的"商品编码"导入项
|
||||
|
||||
## [1.0.2] - 2026-05-25
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ func (h *FinanceHandler) ListRecords(c *gin.Context) {
|
||||
var total int64
|
||||
base.Count(&total)
|
||||
|
||||
var records []model.FinanceRecord
|
||||
records := make([]model.FinanceRecord, 0)
|
||||
offset := (q.Page - 1) * q.PageSize
|
||||
base.Preload("Partner").Order("record_date DESC, id DESC").Offset(offset).Limit(q.PageSize).Find(&records)
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ func (h *InventoryHandler) Logs(c *gin.Context) {
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var logs []model.InventoryLog
|
||||
logs := make([]model.InventoryLog, 0)
|
||||
query.Preload("Product").Preload("Warehouse").Offset((page-1)*pageSize).Limit(pageSize).Order("id DESC").Find(&logs)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": logs, "total": total, "page": page, "page_size": pageSize})
|
||||
|
||||
@@ -32,7 +32,7 @@ var defaultRules = []struct {
|
||||
// List GET /api/v1/number-rules
|
||||
func (h *NumberRuleHandler) List(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var rules []model.NumberRule
|
||||
rules := make([]model.NumberRule, 0)
|
||||
h.db.Where("shop_id = ?", shopID).Order("id").Find(&rules)
|
||||
|
||||
if len(rules) == 0 {
|
||||
|
||||
@@ -38,7 +38,7 @@ func (h *PartnerHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var partners []model.Partner
|
||||
partners := make([]model.Partner, 0)
|
||||
query.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&partners)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": partners, "total": total, "page": page, "page_size": pageSize})
|
||||
|
||||
@@ -48,7 +48,7 @@ func (h *ProductHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var products []model.Product
|
||||
products := make([]model.Product, 0)
|
||||
offset := (page - 1) * pageSize
|
||||
query.Preload("Category").Offset(offset).Limit(pageSize).
|
||||
Order("id DESC").Find(&products)
|
||||
|
||||
@@ -22,7 +22,7 @@ func NewProductOptionHandler(db *gorm.DB) *ProductOptionHandler {
|
||||
|
||||
func (h *ProductOptionHandler) ListNames(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var items []model.ProductNameOption
|
||||
items := make([]model.ProductNameOption, 0)
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (h *ProductOptionHandler) DeleteName(c *gin.Context) {
|
||||
|
||||
func (h *ProductOptionHandler) ListSeries(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var items []model.ProductSeriesOption
|
||||
items := make([]model.ProductSeriesOption, 0)
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func (h *ProductOptionHandler) DeleteSeries(c *gin.Context) {
|
||||
|
||||
func (h *ProductOptionHandler) ListSpecs(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var items []model.ProductSpecOption
|
||||
items := make([]model.ProductSpecOption, 0)
|
||||
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ func (h *StockInHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var orders []model.StockInOrder
|
||||
orders := make([]model.StockInOrder, 0)
|
||||
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Order("id DESC").Find(&orders)
|
||||
|
||||
@@ -44,7 +44,7 @@ func (h *StockOutHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var orders []model.StockOutOrder
|
||||
orders := make([]model.StockOutOrder, 0)
|
||||
query.Preload("Warehouse").Preload("Partner").Preload("Operator").Preload("Reviewer").
|
||||
Offset((page - 1) * pageSize).Limit(pageSize).
|
||||
Order("id DESC").Find(&orders)
|
||||
|
||||
@@ -22,7 +22,7 @@ func NewUserHandler(db *gorm.DB) *UserHandler {
|
||||
// List GET /api/v1/users
|
||||
func (h *UserHandler) List(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var users []model.User
|
||||
users := make([]model.User, 0)
|
||||
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).
|
||||
Order("id ASC").Find(&users)
|
||||
c.JSON(http.StatusOK, gin.H{"data": users})
|
||||
|
||||
@@ -20,7 +20,7 @@ func NewWarehouseHandler(db *gorm.DB) *WarehouseHandler {
|
||||
|
||||
func (h *WarehouseHandler) List(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
var warehouses []model.Warehouse
|
||||
warehouses := make([]model.Warehouse, 0)
|
||||
h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID).Find(&warehouses)
|
||||
c.JSON(http.StatusOK, gin.H{"data": warehouses})
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func JWT() gin.HandlerFunc {
|
||||
func AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get(CtxRole)
|
||||
if role != "admin" {
|
||||
if role != "admin" && role != "superadmin" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin only"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ func (s *AuthService) Register(in RegisterInput) (*RegisterResult, error) {
|
||||
PasswordHash: string(hash),
|
||||
RealName: in.ManagerName,
|
||||
Phone: in.Phone,
|
||||
Role: "admin",
|
||||
Role: "superadmin",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
|
||||
@@ -16,7 +16,7 @@ class PageResult<T> {
|
||||
T Function(Map<String, dynamic>) fromJson,
|
||||
) {
|
||||
return PageResult(
|
||||
data: (json['data'] as List)
|
||||
data: ((json['data'] as List?) ?? [])
|
||||
.map((e) => fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: json['total'] as int,
|
||||
|
||||
@@ -46,72 +46,76 @@ Future<void> printProductLabelImpl({
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=Noto+Serif+SC:wght@700&family=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&family=IBM+Plex+Mono:wght@400&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* 标签尺寸:20mm × 38mm,竖向 */
|
||||
@page { size: 20mm 38mm; margin: 0; }
|
||||
/* 标签尺寸:38mm × 20mm,横向 */
|
||||
@page { size: 38mm 20mm; margin: 0; }
|
||||
* { box-sizing: border-box; margin: 0; padding: 0;
|
||||
-webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
body { width: 20mm; height: 38mm; overflow: hidden; background: #fff; }
|
||||
body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
|
||||
|
||||
.label {
|
||||
width: 20mm; height: 38mm;
|
||||
width: 38mm; height: 20mm;
|
||||
display: flex; flex-direction: column;
|
||||
font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* ── Header:酒行名称 5.5mm ── */
|
||||
/* ── Header:酒行名称 3.5mm ── */
|
||||
.hdr {
|
||||
background: #1f2a3a; color: #f4ecd8;
|
||||
height: 5.5mm; flex-shrink: 0;
|
||||
height: 3.5mm; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 1mm; overflow: hidden;
|
||||
padding: 0 1.5mm; overflow: hidden;
|
||||
}
|
||||
.hdr-name {
|
||||
font-size: 6pt; font-weight: 700; letter-spacing: 0.06em;
|
||||
font-size: 5.5pt; font-weight: 700; letter-spacing: 0.08em;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── 商品名 7mm ── */
|
||||
.product-name {
|
||||
height: 7mm; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 1mm;
|
||||
font-size: 7pt; font-weight: 700; letter-spacing: 0.03em;
|
||||
text-align: center; line-height: 1.2;
|
||||
/* ── 中部:左侧商品信息 + 右侧二维码 ── */
|
||||
.middle {
|
||||
flex: 1; display: flex; flex-direction: row; overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左:商品名 + 规格批次 */
|
||||
.info {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
justify-content: center; padding: 0.8mm 1mm 0.5mm 1.5mm;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── QR 码区 15mm ── */
|
||||
.qr-wrap {
|
||||
height: 15mm; flex-shrink: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 0.5mm;
|
||||
.product-name {
|
||||
font-size: 6.5pt; font-weight: 700; line-height: 1.2;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
margin-bottom: 0.8mm;
|
||||
}
|
||||
.qr-wrap img { width: 13.5mm; height: 13.5mm; object-fit: contain; }
|
||||
.qr-cap { font-size: 4pt; color: #888; letter-spacing: 0.12em; }
|
||||
|
||||
/* ── 规格/批次 7.5mm ── */
|
||||
.specs {
|
||||
flex: 1; padding: 0.5mm 1.5mm;
|
||||
display: flex; flex-direction: column; justify-content: center; gap: 0.8mm;
|
||||
display: flex; flex-direction: column; gap: 0.5mm;
|
||||
}
|
||||
.spec-row {
|
||||
display: flex; gap: 1mm; align-items: baseline;
|
||||
font-size: 5pt; white-space: nowrap; overflow: hidden;
|
||||
display: flex; gap: 0.8mm; align-items: baseline;
|
||||
font-size: 4pt; white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
.lbl { color: #888; flex-shrink: 0; }
|
||||
.lbl { color: #999; flex-shrink: 0; }
|
||||
.val { font-family: "IBM Plex Mono", monospace;
|
||||
overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* 右:二维码 */
|
||||
.qr-wrap {
|
||||
width: 14mm; flex-shrink: 0;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 0.3mm;
|
||||
padding: 0.5mm 0.5mm 0.5mm 0;
|
||||
}
|
||||
.qr-wrap img { width: 12mm; height: 12mm; object-fit: contain; }
|
||||
.qr-cap { font-size: 3pt; color: #aaa; letter-spacing: 0.1em; }
|
||||
|
||||
/* ── Footer:联系方式 3mm ── */
|
||||
.ftr {
|
||||
background: #f4ecd8; height: 3mm; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 0 1mm;
|
||||
font-size: 4pt; color: #5a4e35; overflow: hidden; white-space: nowrap;
|
||||
padding: 0 1.5mm;
|
||||
font-size: 3.5pt; color: #5a4e35; overflow: hidden; white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -123,17 +127,19 @@ body { width: 20mm; height: 38mm; overflow: hidden; background: #fff; }
|
||||
<span class="hdr-name">$shopName</span>
|
||||
</div>
|
||||
|
||||
<div class="product-name">$name</div>
|
||||
|
||||
<div class="qr-wrap">
|
||||
<img src="data:image/png;base64,$base64Img" />
|
||||
<div class="qr-cap">扫码溯源</div>
|
||||
</div>
|
||||
|
||||
<div class="specs">
|
||||
<div class="spec-row"><span class="lbl">规</span><span class="val">$specVal</span></div>
|
||||
<div class="spec-row"><span class="lbl">批</span><span class="val">$batchVal</span></div>
|
||||
<div class="spec-row"><span class="lbl">产</span><span class="val">$dateVal</span></div>
|
||||
<div class="middle">
|
||||
<div class="info">
|
||||
<div class="product-name">$name</div>
|
||||
<div class="specs">
|
||||
<div class="spec-row"><span class="lbl">规</span><span class="val">$specVal</span></div>
|
||||
<div class="spec-row"><span class="lbl">批</span><span class="val">$batchVal</span></div>
|
||||
<div class="spec-row"><span class="lbl">产</span><span class="val">$dateVal</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qr-wrap">
|
||||
<img src="data:image/png;base64,$base64Img" />
|
||||
<div class="qr-cap">扫码溯源</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ftr">${contactLine.isNotEmpty ? contactLine : shopName}</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ class NumberRuleRepository {
|
||||
Future<List<NumberRule>> list() async {
|
||||
try {
|
||||
final resp = await _client.get('/number-rules');
|
||||
final data = resp.data['data'] as List;
|
||||
final data = (resp.data['data'] as List?) ?? [];
|
||||
return data
|
||||
.map((e) => NumberRule.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
@@ -7,7 +7,7 @@ class UserRepository {
|
||||
|
||||
Future<List<AppUser>> list() async {
|
||||
final resp = await _client.get('/users');
|
||||
final data = resp.data['data'] as List;
|
||||
final data = (resp.data['data'] as List?) ?? [];
|
||||
return data.map((e) => AppUser.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class WarehouseRepository {
|
||||
try {
|
||||
final resp = await _client.get('/warehouses');
|
||||
final body = resp.data as Map<String, dynamic>;
|
||||
final raw = body['data'] as List;
|
||||
final raw = (body['data'] as List?) ?? [];
|
||||
return raw
|
||||
.map((e) => Warehouse.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/storage/login_history.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
@@ -488,13 +489,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
const SizedBox(height: 12),
|
||||
TextButton(
|
||||
onPressed: () => launchUrl(
|
||||
Uri.parse('https://jiu.51yanmei.com/register/'),
|
||||
Uri.parse('${AppConfig.publicBaseUrl}/register/'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
child: const Text(
|
||||
'还没有门店账号?前往官网注册',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontSize: 15,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -386,7 +386,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: filteredItems
|
||||
|
||||
@@ -1578,8 +1578,6 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> {
|
||||
'格式:选项编号 | 选项名称 | 备注'),
|
||||
_ImportSlot('商品规格', '/import/product-specs',
|
||||
'格式:选项编号 | 选项名称 | 单品数量 | 备注'),
|
||||
_ImportSlot('商品编码', '/import/product-codes',
|
||||
'格式:商品编码:xxxx'),
|
||||
_ImportSlot('库存', '/import/inventory',
|
||||
'格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'),
|
||||
];
|
||||
|
||||
+12
-28
@@ -69,10 +69,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
|
||||
sha256: "67cf6d84013f9c601e42a6f8a6b74c4c0d9dc1a1619d775f2b28b732d3551b85"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
version: "1.2.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -205,14 +205,6 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
go_router:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -225,10 +217,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
|
||||
sha256: a41af4e8fc687cd6d33de9751eb936c8c0204ebe2bcb6c15ecf707504bf47f31
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "2.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -373,22 +365,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.17.6"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.3.0"
|
||||
version: "9.4.1"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -694,10 +678,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_android
|
||||
sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572"
|
||||
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.29"
|
||||
version: "6.3.30"
|
||||
url_launcher_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -734,10 +718,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
|
||||
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.2"
|
||||
version: "2.4.3"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -758,10 +742,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
version: "15.2.0"
|
||||
web:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -29,10 +29,17 @@ flutter build web --release \
|
||||
"--dart-define=APP_VERSION=${TAG}"
|
||||
cd ..
|
||||
|
||||
# Build marketing site
|
||||
cd web
|
||||
npm ci --prefer-offline
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# Package artifacts
|
||||
mkdir -p dist
|
||||
mv backend/jiu-server dist/jiu-server
|
||||
tar -czf dist/web.tar.gz -C client/build web
|
||||
tar -czf dist/marketing.tar.gz -C web/dist .
|
||||
|
||||
echo "==> compile: done — dist/ contents:"
|
||||
ls -lh dist/
|
||||
|
||||
@@ -10,7 +10,7 @@ TAG="$1"
|
||||
echo "==> deploy: tag=${TAG}"
|
||||
|
||||
# Download from Forgejo if dist/ was not built in this job
|
||||
if [ ! -f dist/jiu-server ] || [ ! -f dist/web.tar.gz ] || [ ! -f dist/configs.tar.gz ]; then
|
||||
if [ ! -f dist/jiu-server ] || [ ! -f dist/web.tar.gz ] || [ ! -f dist/configs.tar.gz ] || [ ! -f dist/marketing.tar.gz ]; then
|
||||
echo "==> deploy: dist/ incomplete — downloading assets for ${TAG} from Forgejo"
|
||||
mkdir -p dist
|
||||
|
||||
@@ -57,6 +57,11 @@ rm -rf /tmp/jiu-web-new
|
||||
mkdir -p /tmp/jiu-web-new
|
||||
tar -xzf dist/web.tar.gz -C /tmp/jiu-web-new --strip-components=1
|
||||
|
||||
# Extract marketing site
|
||||
rm -rf /tmp/jiu-marketing-new
|
||||
mkdir -p /tmp/jiu-marketing-new
|
||||
tar -xzf dist/marketing.tar.gz -C /tmp/jiu-marketing-new
|
||||
|
||||
# Setup SSH
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s' "${EC2_SSH_KEY}" > ~/.ssh/ec2_deploy.pem
|
||||
@@ -81,9 +86,9 @@ ${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/ngin
|
||||
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
|
||||
/tmp/jiu-web-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/"
|
||||
|
||||
# Upload marketing site (from repo checkout)
|
||||
# Upload marketing site (built by compile.sh)
|
||||
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
|
||||
web/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/"
|
||||
/tmp/jiu-marketing-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/"
|
||||
|
||||
echo "==> deploy: running remote deploy"
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ upload_asset() {
|
||||
|
||||
upload_asset dist/jiu-server
|
||||
upload_asset dist/web.tar.gz
|
||||
upload_asset dist/marketing.tar.gz
|
||||
upload_asset dist/configs.tar.gz
|
||||
upload_asset dist/jiu-windows-x64.zip
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
// dev-proxy.js — 本地开发反向代理
|
||||
// localhost:3000 → 营销站 :3001 / Flutter Web :8888
|
||||
// /app/* 路由到 Flutter,其余路由到 Eleventy
|
||||
// 自动把 Flutter index.html 里的 base href="/" 改写为 /app/,使静态资源请求正确走代理
|
||||
|
||||
const http = require('http');
|
||||
const net = require('net');
|
||||
|
||||
const PROXY_PORT = parseInt(process.env.PROXY_PORT || '3000', 10);
|
||||
const ELEVENTY_PORT = parseInt(process.env.ELEVENTY_PORT || '3001', 10);
|
||||
const FLUTTER_PORT = parseInt(process.env.FLUTTER_PORT || '9090', 10);
|
||||
const BACKEND_PORT = parseInt(process.env.BACKEND_PORT || '8080', 10);
|
||||
|
||||
function isFlutter(url) {
|
||||
return url === '/app' || url.startsWith('/app/');
|
||||
}
|
||||
|
||||
function isBackend(url) {
|
||||
return url.startsWith('/api/') || url === '/health' || url.startsWith('/images/');
|
||||
}
|
||||
|
||||
function stripApp(url) {
|
||||
return url.slice(4) || '/';
|
||||
}
|
||||
|
||||
function proxyHttp(req, res, port, path) {
|
||||
const opts = {
|
||||
hostname: 'localhost',
|
||||
port,
|
||||
path,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: `localhost:${port}` },
|
||||
};
|
||||
|
||||
const pr = http.request(opts, (upstream) => {
|
||||
const ct = upstream.headers['content-type'] || '';
|
||||
|
||||
if (port === FLUTTER_PORT && ct.includes('text/html')) {
|
||||
let body = '';
|
||||
upstream.setEncoding('utf8');
|
||||
upstream.on('data', (c) => { body += c; });
|
||||
upstream.on('end', () => {
|
||||
// rewrite base href so Flutter asset requests stay under /app/
|
||||
body = body.replace(/(<base\s+href=")[^"]*(")/i, '$1/app/$2');
|
||||
const headers = { ...upstream.headers };
|
||||
headers['content-length'] = Buffer.byteLength(body);
|
||||
delete headers['transfer-encoding'];
|
||||
res.writeHead(upstream.statusCode, headers);
|
||||
res.end(body);
|
||||
});
|
||||
} else {
|
||||
res.writeHead(upstream.statusCode, upstream.headers);
|
||||
upstream.pipe(res, { end: true });
|
||||
}
|
||||
});
|
||||
|
||||
pr.on('error', () => res.writeHead(502).end('upstream error'));
|
||||
req.pipe(pr, { end: true });
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
if (isFlutter(req.url)) {
|
||||
proxyHttp(req, res, FLUTTER_PORT, stripApp(req.url));
|
||||
} else if (isBackend(req.url)) {
|
||||
proxyHttp(req, res, BACKEND_PORT, req.url);
|
||||
} else {
|
||||
proxyHttp(req, res, ELEVENTY_PORT, req.url);
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket 透传(Eleventy 热重载 / Flutter DevTools)
|
||||
server.on('upgrade', (req, socket, head) => {
|
||||
const port = isFlutter(req.url) ? FLUTTER_PORT
|
||||
: isBackend(req.url) ? BACKEND_PORT
|
||||
: ELEVENTY_PORT;
|
||||
const path = isFlutter(req.url) ? stripApp(req.url) : req.url;
|
||||
|
||||
const conn = net.createConnection(port, 'localhost', () => {
|
||||
conn.write(`${req.method} ${path} HTTP/1.1\r\n`);
|
||||
for (const [k, v] of Object.entries(req.headers)) {
|
||||
conn.write(`${k}: ${v}\r\n`);
|
||||
}
|
||||
conn.write('\r\n');
|
||||
if (head && head.length) conn.write(head);
|
||||
socket.pipe(conn);
|
||||
conn.pipe(socket);
|
||||
});
|
||||
|
||||
conn.on('error', () => socket.destroy());
|
||||
socket.on('error', () => conn.destroy());
|
||||
});
|
||||
|
||||
server.listen(PROXY_PORT, () => {
|
||||
console.log(`[proxy] http://localhost:${PROXY_PORT}`);
|
||||
console.log(` / → Eleventy :${ELEVENTY_PORT}`);
|
||||
console.log(` /app/ → Flutter Web :${FLUTTER_PORT}`);
|
||||
});
|
||||
+93
-7
@@ -32,6 +32,9 @@ BACKEND_PID_FILE="$LOG_DIR/backend.pid"
|
||||
BACKEND_HASH_FILE="$LOG_DIR/backend.hash"
|
||||
WEB_LOG="$LOG_DIR/web.log"
|
||||
WEB_PID_FILE="$LOG_DIR/web.pid"
|
||||
ELEVENTY_LOG="$LOG_DIR/eleventy.log"
|
||||
ELEVENTY_PID_FILE="$LOG_DIR/eleventy.pid"
|
||||
PROXY_PID_FILE="$LOG_DIR/proxy.pid"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
@@ -115,6 +118,79 @@ RUN_BACKEND=true
|
||||
RUN_FRONTEND=true # macOS
|
||||
RUN_WEB=false
|
||||
|
||||
cmd_marketing() {
|
||||
local WEB_DIR="$ROOT/web"
|
||||
local FLUTTER_WEB_PORT=9090
|
||||
local ELEVENTY_PORT=3001
|
||||
local PROXY_PORT=3000
|
||||
|
||||
# 检查依赖
|
||||
if ! command -v node &>/dev/null; then
|
||||
error "未找到 node,请安装 Node.js"
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v flutter &>/dev/null; then
|
||||
error "未找到 flutter"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 清理旧进程
|
||||
for pid_file in "$ELEVENTY_PID_FILE" "$PROXY_PID_FILE" "$WEB_PID_FILE"; do
|
||||
if [ -f "$pid_file" ]; then
|
||||
pid=$(cat "$pid_file")
|
||||
kill "$pid" 2>/dev/null || true
|
||||
rm -f "$pid_file"
|
||||
fi
|
||||
done
|
||||
for port in $ELEVENTY_PORT $FLUTTER_WEB_PORT $PROXY_PORT; do
|
||||
pids=$(lsof -ti:"$port" 2>/dev/null || true)
|
||||
[ -n "$pids" ] && echo "$pids" | xargs kill -9 2>/dev/null || true
|
||||
done
|
||||
sleep 0.5
|
||||
|
||||
# 启动后端(如已运行则跳过)
|
||||
if ! curl -s -o /dev/null http://localhost:8080/health 2>/dev/null; then
|
||||
info "启动后端..."
|
||||
cd "$BACKEND_DIR"
|
||||
go run main.go >>"$BACKEND_LOG" 2>&1 &
|
||||
local bp=$!
|
||||
echo "$bp" > "$BACKEND_PID_FILE"
|
||||
wait_backend
|
||||
cd "$ROOT"
|
||||
else
|
||||
success "后端已在运行 → http://localhost:8080"
|
||||
fi
|
||||
|
||||
# 启动 Eleventy(宣传站)在后台,3001 口
|
||||
info "启动 Eleventy(宣传站后台)→ :${ELEVENTY_PORT}..."
|
||||
cd "$WEB_DIR"
|
||||
npx eleventy --serve --port=$ELEVENTY_PORT >>"$ELEVENTY_LOG" 2>&1 &
|
||||
echo "$!" > "$ELEVENTY_PID_FILE"
|
||||
cd "$ROOT"
|
||||
|
||||
# 启动代理(后台)
|
||||
info "启动代理(后台)→ http://localhost:${PROXY_PORT}..."
|
||||
PROXY_PORT=$PROXY_PORT ELEVENTY_PORT=$ELEVENTY_PORT FLUTTER_PORT=$FLUTTER_WEB_PORT \
|
||||
node "$ROOT/scripts/dev-proxy.js" >>"$LOG_DIR/proxy.log" 2>&1 &
|
||||
echo "$!" > "$PROXY_PID_FILE"
|
||||
sleep 1
|
||||
|
||||
echo ""
|
||||
success "代理已就绪:http://localhost:${PROXY_PORT}(宣传站)/ http://localhost:${PROXY_PORT}/app/(Flutter)"
|
||||
echo -e " ${CYAN}Eleventy 日志:tail -f $ELEVENTY_LOG${NC}"
|
||||
echo ""
|
||||
info "在前台启动 Flutter Web(支持热重载,按 r 重载,按 q 退出)..."
|
||||
echo ""
|
||||
|
||||
# Flutter 在前台运行,支持交互热重载
|
||||
cd "$CLIENT_DIR"
|
||||
flutter pub get
|
||||
flutter run -d chrome --web-port "$FLUTTER_WEB_PORT" \
|
||||
"--dart-define=BASE_URL=http://localhost:8080" \
|
||||
"--dart-define=PUBLIC_URL=http://localhost:${PROXY_PORT}" \
|
||||
"--dart-define=APP_VERSION=dev"
|
||||
}
|
||||
|
||||
show_help() {
|
||||
echo "用法: sh scripts/dev.sh <命令> [选项]"
|
||||
echo ""
|
||||
@@ -126,6 +202,7 @@ show_help() {
|
||||
echo " --backend-only 仅启动后端"
|
||||
echo " --frontend-only 仅启动前端(macOS)"
|
||||
echo " --web-only 仅启动前端(Web)"
|
||||
echo " marketing 宣传站 + Flutter Web + 代理(localhost:3000,/app/ 路由到 Flutter)"
|
||||
echo ""
|
||||
echo "数据库命令:"
|
||||
echo " seed <shop_code> 写入指定门店测试数据(如 S001)"
|
||||
@@ -139,6 +216,8 @@ show_help() {
|
||||
}
|
||||
|
||||
case "$COMMAND" in
|
||||
marketing)
|
||||
;; # 在底部所有函数定义完后执行
|
||||
run)
|
||||
case "${2:-}" in
|
||||
--force) FORCE=true ;;
|
||||
@@ -223,12 +302,13 @@ esac
|
||||
cleanup() {
|
||||
echo ""
|
||||
info "正在关闭服务..."
|
||||
if [ -f "$WEB_PID_FILE" ]; then
|
||||
pid=$(cat "$WEB_PID_FILE")
|
||||
kill "$pid" 2>/dev/null || true
|
||||
rm -f "$WEB_PID_FILE"
|
||||
info "Web 服务已停止"
|
||||
fi
|
||||
for pid_file in "$WEB_PID_FILE" "$ELEVENTY_PID_FILE" "$PROXY_PID_FILE"; do
|
||||
if [ -f "$pid_file" ]; then
|
||||
pid=$(cat "$pid_file")
|
||||
kill "$pid" 2>/dev/null || true
|
||||
rm -f "$pid_file"
|
||||
fi
|
||||
done
|
||||
info "后端继续在后台运行。如需停止:sh scripts/dev.sh stop"
|
||||
exit 0
|
||||
}
|
||||
@@ -284,7 +364,7 @@ wait_backend() {
|
||||
info "等待后端启动..."
|
||||
local i=0
|
||||
while [ $i -lt 30 ]; do
|
||||
if curl -s -o /dev/null http://localhost:8080/api/v1/auth/login 2>/dev/null; then
|
||||
if curl -s -o /dev/null http://localhost:8080/health 2>/dev/null; then
|
||||
success "后端已就绪 → http://localhost:8080"
|
||||
return 0
|
||||
fi
|
||||
@@ -356,6 +436,12 @@ start_web_bg() {
|
||||
}
|
||||
|
||||
# ════════════════════════════════════════════════════════
|
||||
|
||||
if [ "$COMMAND" = "marketing" ]; then
|
||||
cmd_marketing
|
||||
exit 0
|
||||
fi
|
||||
|
||||
check_deps
|
||||
|
||||
if [ "$RUN_BACKEND" = true ]; then
|
||||
|
||||
+3
-3
@@ -184,7 +184,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
|
||||
<h1>从入库到出库,<br/><em>一套系统</em>管到底。</h1>
|
||||
<p class="lead">岩美酒库管理系统,为酒行与酒店设计的库存、审核、财务一体化平台。审核驱动业务流,库存与账款自动同步,五端无缝接入。</p>
|
||||
<div class="d-flex gap-12 mb-28">
|
||||
<a href="/app/" class="btn btn-primary btn-lg">免费试用 30 天<i data-lucide="arrow-right" class="icon"></i></a>
|
||||
<a href="/register/" class="btn btn-primary btn-lg">免费试用 30 天<i data-lucide="arrow-right" class="icon"></i></a>
|
||||
<a href="/app/" class="btn btn-secondary btn-lg"><i data-lucide="play-circle" class="icon"></i>观看演示</a>
|
||||
</div>
|
||||
<div class="d-flex items-center gap-18 text-muted fs-sm">
|
||||
@@ -537,7 +537,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
|
||||
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>Web 端 + 移动端</li>
|
||||
<li class="d-flex items-start gap-8 fs-sm text-gray-7"><i data-lucide="check" class="icon-sm text-brand flex-shrink-0 mt-2"></i>邮件技术支持</li>
|
||||
</ul>
|
||||
<a href="/app/" class="btn btn-secondary w-full justify-center mt-auto">免费开通</a>
|
||||
<a href="/register/" class="btn btn-secondary w-full justify-center mt-auto">免费开通</a>
|
||||
</div>
|
||||
<div class="price-card featured card d-flex flex-col" style="padding:32px 28px">
|
||||
<div class="fs-sm text-muted fw-5 mb-4">标准方案</div>
|
||||
@@ -618,7 +618,7 @@ details[open] .faq-q .icon { transform: rotate(45deg); }
|
||||
<p>30 天免费试用,无需绑卡。任何时间可取消,已录入数据保留 90 天。</p>
|
||||
</div>
|
||||
<div class="d-flex flex-col gap-12 items-start">
|
||||
<a href="/app/" class="btn btn-primary btn-lg">立即开通试用<i data-lucide="arrow-right" class="icon"></i></a>
|
||||
<a href="/register/" class="btn btn-primary btn-lg">立即开通试用<i data-lucide="arrow-right" class="icon"></i></a>
|
||||
<a href="/app/" class="btn btn-secondary btn-lg"><i data-lucide="phone" class="icon"></i>预约 1 对 1 演示</a>
|
||||
<span class="fs-sm" style="color:#99A3B3">销售热线 {{ site.support.phone }} · 工作日 9:00-18:00</span>
|
||||
</div>
|
||||
|
||||
+85
-38
@@ -14,10 +14,13 @@ pageStyle: |
|
||||
.form-row { margin-bottom: var(--space-5); }
|
||||
.form-label { display: block; font-size: var(--text-sm); font-weight: 500; color: var(--neutral-700); margin-bottom: var(--space-1-5); }
|
||||
.form-label .req { color: var(--error-500); margin-left: 2px; }
|
||||
.form-input, .form-textarea { width: 100%; padding: var(--space-2-5) var(--space-3); border: 1px solid var(--neutral-300); border-radius: var(--radius-md); font-size: var(--text-md); font-family: inherit; background: var(--neutral-0); color: var(--neutral-900); transition: border-color .15s, box-shadow .15s; box-sizing: border-box; }
|
||||
.form-input, .form-textarea { width: 100%; padding: var(--space-2-5) var(--space-3); border: 1.5px solid var(--neutral-300); border-radius: var(--radius-md); font-size: var(--text-md); font-family: inherit; background: var(--neutral-0); color: var(--neutral-900); transition: border-color .15s, box-shadow .15s; box-sizing: border-box; }
|
||||
.form-input:focus, .form-textarea:focus { outline: none; border-color: var(--brand-500); box-shadow: 0 0 0 3px var(--brand-100); }
|
||||
.form-input.error, .form-textarea.error { border-color: var(--error-500); box-shadow: 0 0 0 3px rgba(239,68,68,.12); }
|
||||
.form-textarea { resize: vertical; min-height: 80px; }
|
||||
.form-helper { font-size: var(--text-xs); color: var(--neutral-400); margin-top: var(--space-1); }
|
||||
.field-error { font-size: var(--text-xs); color: var(--error-600); margin-top: 4px; display: none; }
|
||||
.field-error.visible { display: block; }
|
||||
.reg-submit { width: 100%; padding: var(--space-3) var(--space-4); font-size: var(--text-md); font-weight: 600; margin-top: var(--space-2); }
|
||||
.reg-login-hint { text-align: center; margin-top: var(--space-4); font-size: var(--text-sm); color: var(--neutral-500); }
|
||||
.reg-login-hint a { color: var(--brand-600); }
|
||||
@@ -61,24 +64,28 @@ pageStyle: |
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="shop_name">店铺名称<span class="req">*</span></label>
|
||||
<input class="form-input" type="text" id="shop_name" placeholder="例:强朋友名酒行" autocomplete="organization" />
|
||||
<div class="field-error" id="err-shop_name"></div>
|
||||
<div class="form-helper">您的酒行正式名称,将显示在所有单据和商品标签上</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="address">门店地址<span class="req">*</span></label>
|
||||
<input class="form-input" type="text" id="address" placeholder="例:贵州省贵阳市云岩区中华北路 88 号" autocomplete="street-address" />
|
||||
<div class="field-error" id="err-address"></div>
|
||||
<div class="form-helper">实际经营地址,打印标签和单据时展示</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="manager_name">负责人姓名<span class="req">*</span></label>
|
||||
<input class="form-input" type="text" id="manager_name" placeholder="例:张三" autocomplete="name" />
|
||||
<div class="field-error" id="err-manager_name"></div>
|
||||
<div class="form-helper">主要负责人真实姓名,也是首个管理员账号的姓名</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="phone">联系电话<span class="req">*</span></label>
|
||||
<input class="form-input" type="tel" id="phone" placeholder="例:0851-12345678" autocomplete="tel" />
|
||||
<div class="field-error" id="err-phone"></div>
|
||||
<div class="form-helper">对外联系电话,打印商品标签时会展示</div>
|
||||
</div>
|
||||
|
||||
@@ -96,18 +103,21 @@ pageStyle: |
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="username">用户名<span class="req">*</span></label>
|
||||
<input class="form-input" type="text" id="username" placeholder="例:admin" autocomplete="username" />
|
||||
<div class="field-error" id="err-username"></div>
|
||||
<div class="form-helper">登录账号(3–30 位,字母/数字/下划线),创建后不可修改</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="password">密码<span class="req">*</span></label>
|
||||
<input class="form-input" type="password" id="password" placeholder="至少 6 位" autocomplete="new-password" />
|
||||
<div class="field-error" id="err-password"></div>
|
||||
<div class="form-helper">至少 6 位,建议字母数字组合</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label class="form-label" for="password_confirm">确认密码<span class="req">*</span></label>
|
||||
<input class="form-input" type="password" id="password_confirm" placeholder="再次输入密码" autocomplete="new-password" />
|
||||
<div class="field-error" id="err-password_confirm"></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -115,7 +125,7 @@ pageStyle: |
|
||||
<i data-lucide="store" class="icon"></i>
|
||||
立即注册
|
||||
</button>
|
||||
<p class="reg-login-hint">已有账号?<a href="https://jiu.51yanmei.com/app/">直接登录</a></p>
|
||||
<p class="reg-login-hint">已有账号?<a href="/app/">直接登录</a></p>
|
||||
</div>
|
||||
|
||||
<!-- 注册成功面板 -->
|
||||
@@ -152,7 +162,7 @@ pageStyle: |
|
||||
</div>
|
||||
|
||||
<p class="success-note">如忘记门店编码,登录后可在「系统设置 → 关于」中查看。</p>
|
||||
<a href="https://jiu.51yanmei.com/app/" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:8px;">
|
||||
<a href="/app/" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:8px;">
|
||||
<i data-lucide="log-in" class="icon"></i>前往登录
|
||||
</a>
|
||||
</div>
|
||||
@@ -162,49 +172,86 @@ pageStyle: |
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var API_URL = 'https://jiu.51yanmei.com/api/v1/public/register';
|
||||
var API_URL = window.location.origin + '/api/v1/public/register';
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function showError(msg) {
|
||||
function getVal(id) { return $(id).value.trim(); }
|
||||
|
||||
function setFieldError(id, msg) {
|
||||
var input = $(id);
|
||||
var errEl = $('err-' + id);
|
||||
if (msg) {
|
||||
input.classList.add('error');
|
||||
if (errEl) { errEl.textContent = msg; errEl.classList.add('visible'); }
|
||||
} else {
|
||||
input.classList.remove('error');
|
||||
if (errEl) { errEl.textContent = ''; errEl.classList.remove('visible'); }
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllErrors() {
|
||||
['shop_name','address','manager_name','phone','username','password','password_confirm'].forEach(function(id) {
|
||||
setFieldError(id, '');
|
||||
});
|
||||
}
|
||||
|
||||
// 用户修改字段后清除该字段的错误提示
|
||||
['shop_name','address','manager_name','phone','username','password','password_confirm'].forEach(function(id) {
|
||||
var el = $(id);
|
||||
if (el) el.addEventListener('input', function() { setFieldError(id, ''); });
|
||||
});
|
||||
|
||||
function validate() {
|
||||
clearAllErrors();
|
||||
var ok = true;
|
||||
var firstErr = null;
|
||||
|
||||
function require(id, msg) {
|
||||
if (!getVal(id)) { setFieldError(id, msg); if (!firstErr) firstErr = id; ok = false; }
|
||||
}
|
||||
|
||||
require('shop_name', '请填写店铺名称');
|
||||
require('address', '请填写门店地址');
|
||||
require('manager_name', '请填写负责人姓名');
|
||||
require('phone', '请填写联系电话');
|
||||
|
||||
var username = getVal('username');
|
||||
if (!username) {
|
||||
setFieldError('username', '请填写用户名'); if (!firstErr) firstErr = 'username'; ok = false;
|
||||
} else if (username.length < 3 || username.length > 30) {
|
||||
setFieldError('username', '用户名长度须在 3–30 位之间'); if (!firstErr) firstErr = 'username'; ok = false;
|
||||
} else if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
||||
setFieldError('username', '用户名只能包含字母、数字和下划线'); if (!firstErr) firstErr = 'username'; ok = false;
|
||||
}
|
||||
|
||||
var password = $('password').value;
|
||||
if (!password) {
|
||||
setFieldError('password', '请填写密码'); if (!firstErr) firstErr = 'password'; ok = false;
|
||||
} else if (password.length < 6) {
|
||||
setFieldError('password', '密码至少 6 位'); if (!firstErr) firstErr = 'password'; ok = false;
|
||||
}
|
||||
|
||||
var confirm = $('password_confirm').value;
|
||||
if (!confirm) {
|
||||
setFieldError('password_confirm', '请再次输入密码'); if (!firstErr) firstErr = 'password_confirm'; ok = false;
|
||||
} else if (password && password !== confirm) {
|
||||
setFieldError('password_confirm', '两次输入的密码不一致'); if (!firstErr) firstErr = 'password_confirm'; ok = false;
|
||||
}
|
||||
|
||||
if (firstErr) { $(firstErr).focus(); $(firstErr).scrollIntoView({ behavior: 'smooth', block: 'center' }); }
|
||||
return ok;
|
||||
}
|
||||
|
||||
function showTopError(msg) {
|
||||
var el = $('reg-error');
|
||||
el.textContent = msg;
|
||||
el.classList.add('visible');
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
var el = $('reg-error');
|
||||
el.textContent = '';
|
||||
el.classList.remove('visible');
|
||||
}
|
||||
|
||||
function getVal(id) { return $(id).value.trim(); }
|
||||
|
||||
function validate() {
|
||||
var shopName = getVal('shop_name');
|
||||
var address = getVal('address');
|
||||
var manager = getVal('manager_name');
|
||||
var phone = getVal('phone');
|
||||
var username = getVal('username');
|
||||
var password = getVal('password');
|
||||
var confirm = getVal('password_confirm');
|
||||
|
||||
if (!shopName) { showError('请填写店铺名称'); $('shop_name').focus(); return false; }
|
||||
if (!address) { showError('请填写门店地址'); $('address').focus(); return false; }
|
||||
if (!manager) { showError('请填写负责人姓名'); $('manager_name').focus(); return false; }
|
||||
if (!phone) { showError('请填写联系电话'); $('phone').focus(); return false; }
|
||||
if (!username) { showError('请填写用户名'); $('username').focus(); return false; }
|
||||
if (username.length < 3 || username.length > 30) { showError('用户名长度须在 3–30 位之间'); $('username').focus(); return false; }
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(username)) { showError('用户名只能包含字母、数字和下划线'); $('username').focus(); return false; }
|
||||
if (!password) { showError('请填写密码'); $('password').focus(); return false; }
|
||||
if (password.length < 6) { showError('密码至少 6 位'); $('password').focus(); return false; }
|
||||
if (password !== confirm) { showError('两次输入的密码不一致'); $('password_confirm').focus(); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
$('reg-btn').addEventListener('click', function() {
|
||||
clearError();
|
||||
$('reg-error').classList.remove('visible');
|
||||
if (!validate()) return;
|
||||
|
||||
var btn = $('reg-btn');
|
||||
@@ -234,7 +281,7 @@ pageStyle: |
|
||||
if (msg.indexOf('Duplicate') !== -1 || msg.indexOf('duplicate') !== -1) {
|
||||
msg = '该用户名在此门店已存在,请换一个用户名';
|
||||
}
|
||||
showError(msg);
|
||||
showTopError(msg);
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('btn-loading');
|
||||
btn.innerHTML = '<i data-lucide="store" class="icon"></i>立即注册';
|
||||
@@ -252,7 +299,7 @@ pageStyle: |
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
})
|
||||
.catch(function() {
|
||||
showError('网络异常,请检查网络连接后重试');
|
||||
showTopError('网络异常,请检查网络连接后重试');
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('btn-loading');
|
||||
btn.innerHTML = '<i data-lucide="store" class="icon"></i>立即注册';
|
||||
|
||||
Reference in New Issue
Block a user