chore: release v1.0.3
Deploy / build-windows (push) Failing after 9s
Deploy / build-linux-web (push) Successful in 51s
Deploy / release-deploy (push) Has been skipped

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-05-28 22:39:04 +08:00
parent ec7596e272
commit 75e3b934bc
29 changed files with 386 additions and 150 deletions
+7 -3
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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})
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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})
+1 -1
View File
@@ -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)
+3 -3
View File
@@ -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})
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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})
+1 -1
View File
@@ -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})
}
+1 -1
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,
+51 -45
View File
@@ -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();
+1 -1
View File
@@ -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();
+3 -2
View File
@@ -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
View File
@@ -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:
+7
View File
@@ -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/
+8 -3
View File
@@ -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"
+1
View File
@@ -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
+98
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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', '用户名长度须在 330 位之间'); 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('用户名长度须在 330 位之间'); $('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>立即注册';