Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 633dadf84f | |||
| fdab62b3bb | |||
| d951549934 | |||
| a996593b1c | |||
| b3c9d98eae | |||
| c7de728c18 | |||
| 24123eb93c | |||
| e0f721117f | |||
| c010d8b416 | |||
| 71909a76d7 | |||
| a79a4d4468 |
@@ -5,6 +5,14 @@ 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.1.2] - 2026-07-03
|
||||
|
||||
### 新功能
|
||||
- 系统设置 → 授权管理新增「在线购买 / 续费」:管理员可直接跳转支付宝付款,
|
||||
支付成功后自动到账续期(时长在现有到期时间上叠加)。
|
||||
- 购买弹窗新增「🔥 首月特惠 ¥1」套餐(新店专享,30 天标准版全部功能,每店限购一次),
|
||||
已享受过的门店自动置灰。
|
||||
|
||||
## [1.1.1] - 2026-07-03
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
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.1.3] - 2026-07-03
|
||||
|
||||
### 新功能
|
||||
- 新增「¥1 首月特惠」套餐(新店专享,30 天标准版全部功能),每个门店限购一次:
|
||||
下单前服务端校验,已享受过的门店无法重复购买;并发或绕过前端产生的第二笔特惠单
|
||||
也不会重复叠加时长(回调入账时二次校验兜底)。
|
||||
- 新增特惠资格查询接口,官网与 App 据此自动置灰已享受过的门店入口。
|
||||
|
||||
## [1.1.2] - 2026-07-03
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -6,6 +6,41 @@
|
||||
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.1.2] - 2026-07-03
|
||||
|
||||
### 新功能
|
||||
- 首页价格区与订单页新增「🔥 首月特惠 ¥1」套餐(新店专享,30 天标准版全部功能,
|
||||
每店限购一次);已享受过的门店入口自动置灰。
|
||||
- 导航「进入系统」按钮改为下拉菜单:个人信息 / 进入系统 / 退出登录。
|
||||
- 新增「个人信息」页:查看账号、门店与授权状态(含到期时间、设备数),可直达购买/续费。
|
||||
- 新增「退出登录」页:安全撤销会话并清除本机登录状态。
|
||||
|
||||
### 改进
|
||||
- 网页端登录后默认进入「个人信息」页,不再直接跳转进销存系统。
|
||||
- 帮助文档补充在线购买/续费说明。
|
||||
|
||||
## [1.1.1] - 2026-07-03
|
||||
|
||||
### 修复
|
||||
- 登录超过 1 小时后点「提交订单」被踢回登录页:官网现在会自动续签登录态(7 天内免重登)。
|
||||
- 浏览器缓存旧版脚本导致页面右上角出现错位的旧用户面板、用户名显示多余引号。
|
||||
|
||||
### 改进
|
||||
- 重新登录时自动回填上次的门店编号与账号。
|
||||
|
||||
## [1.1.0] - 2026-07-03
|
||||
|
||||
### 新功能
|
||||
- 注册与登录直接在官网完成,登录后无缝进入 Web 版系统,不再跳转 App 页面。
|
||||
- 价格方案改为四档:免费版 / 标准版 ¥299 / 高级版 ¥599 / 定制版;登录后可直接
|
||||
在线购买或续费(支付宝),新增确认订单页与支付结果页。
|
||||
- 帮助中心改版:与 App 同步的十章使用手册。
|
||||
|
||||
### 改进
|
||||
- 全站视觉重建,与 App 界面同一套设计体系(配色/组件/图标),首页展示真实产品截图。
|
||||
- 下载页接入真实下载链接与动态更新日志;页面零第三方外链,大陆访问更快。
|
||||
- 商品二维码定位更正:扫码看详情、可分享(非防伪)。
|
||||
|
||||
## [1.0.55] - 2026-06-17
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -43,6 +43,8 @@ func (h *PayHandler) Purchase(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrUnknownPlan):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrPromoUsed):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
log.Printf("[pay] purchase failed shop=%d biz_code=%s: %v", middleware.GetShopID(c), req.BizCode, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "下单失败,请稍后重试"})
|
||||
@@ -66,6 +68,16 @@ func (h *PayHandler) PurchaseStatus(c *gin.Context) {
|
||||
util.RespondSuccess(c, st)
|
||||
}
|
||||
|
||||
// PromoStatus GET /api/v1/license/promo-status — 本店首月特惠是否已享用(前端据此置灰特惠档)。
|
||||
func (h *PayHandler) PromoStatus(c *gin.Context) {
|
||||
used, err := h.svc.PromoUsed(middleware.GetShopID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, gin.H{"used": used})
|
||||
}
|
||||
|
||||
// Callback POST /api/v1/pay/callback — pay 支付成功 webhook(公开路由,HMAC 验签)。
|
||||
// 契约:验签失败回 401;受理成功回 200 + {"code":"SUCCESS"},否则 pay 每 60s 重试 24h。
|
||||
func (h *PayHandler) Callback(c *gin.Context) {
|
||||
|
||||
@@ -113,6 +113,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
// 在线购买/续费(走 pay 收款中枢;仅管理员,handler 内判权)
|
||||
license.POST("/purchase", payH.Purchase)
|
||||
license.GET("/purchase/:out_trade_no", payH.PurchaseStatus)
|
||||
license.GET("/promo-status", payH.PromoStatus)
|
||||
}
|
||||
|
||||
// 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作)
|
||||
|
||||
@@ -40,8 +40,12 @@ var (
|
||||
ErrPaySignature = errors.New("签名校验失败")
|
||||
ErrPayAmount = errors.New("回调金额与订单不符")
|
||||
ErrPurchaseNotFound = errors.New("购买记录不存在")
|
||||
ErrPromoUsed = errors.New("首月特惠每个门店限购一次,本店已享受过")
|
||||
)
|
||||
|
||||
// PromoBizCode 新店首月特惠(¥1/30 天标准版),每个门店仅可购买一次。
|
||||
const PromoBizCode = "promo_first_month"
|
||||
|
||||
// payPlan biz_code → 权益映射(与 pay 侧 seed 的套餐一一对应,金额权威在 pay,此处 price 仅作前端展示核对)。
|
||||
type payPlan struct {
|
||||
Days int
|
||||
@@ -52,6 +56,8 @@ type payPlan struct {
|
||||
}
|
||||
|
||||
var payPlans = map[string]payPlan{
|
||||
PromoBizCode: {Days: 30, Tier: "standard", Type: "monthly", MaxDevices: 2,
|
||||
Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}},
|
||||
"monthly_standard": {Days: 30, Tier: "standard", Type: "monthly", MaxDevices: 2,
|
||||
Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}},
|
||||
"annual_standard": {Days: 365, Tier: "standard", Type: "annual", MaxDevices: 2,
|
||||
@@ -91,6 +97,15 @@ func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode string) (*Pur
|
||||
if _, ok := payPlans[bizCode]; !ok {
|
||||
return nil, ErrUnknownPlan
|
||||
}
|
||||
if bizCode == PromoBizCode {
|
||||
used, err := s.PromoUsed(shopID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if used {
|
||||
return nil, ErrPromoUsed
|
||||
}
|
||||
}
|
||||
|
||||
productID, err := s.productID(bizCode)
|
||||
if err != nil {
|
||||
@@ -264,8 +279,26 @@ func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string
|
||||
if !ok {
|
||||
return ErrUnknownPlan
|
||||
}
|
||||
if err := entitle(tx, p.ShopID, plan); err != nil {
|
||||
return err
|
||||
// 特惠限次 webhook 兜底(防绕过前端/并发双买):入账前事务内再查一次,
|
||||
// 本店已有另一笔已支付特惠单 → 本单只标 paid 不叠加时长,记告警(契约 INTEGRATION-BOARD 要求)
|
||||
entitleOK := true
|
||||
if p.ProductBizCode == PromoBizCode {
|
||||
var dup int64
|
||||
if err := tx.Model(&model.LicensePurchase{}).
|
||||
Where("shop_id = ? AND product_biz_code = ? AND status = ? AND id <> ?",
|
||||
p.ShopID, PromoBizCode, "paid", p.ID).
|
||||
Count(&dup).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if dup > 0 {
|
||||
entitleOK = false
|
||||
log.Printf("[pay] ALERT promo double-claim out_trade_no=%s shop=%d:本店已享受过首月特惠,本单不叠加时长", outTradeNo, p.ShopID)
|
||||
}
|
||||
}
|
||||
if entitleOK {
|
||||
if err := entitle(tx, p.ShopID, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
shopID = p.ShopID
|
||||
return tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(map[string]any{
|
||||
@@ -394,6 +427,15 @@ func (s *PayService) Status(shopID uint64, outTradeNo string) (*PurchaseStatus,
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// PromoUsed 返回本店是否已享受过首月特惠(已支付的特惠单存在即视为已用)。
|
||||
func (s *PayService) PromoUsed(shopID uint64) (bool, error) {
|
||||
var count int64
|
||||
err := s.db.Model(&model.LicensePurchase{}).
|
||||
Where("shop_id = ? AND product_biz_code = ? AND status = ?", shopID, PromoBizCode, "paid").
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// ---------- ④ 查单兜底 ----------
|
||||
|
||||
// StartPayReconcile 后台每 60s 对 pending 超 5 分钟的购买单主动查 pay 对账,
|
||||
|
||||
@@ -275,6 +275,83 @@ func TestCreatePurchase_UnknownPlanAndUnconfigured(t *testing.T) {
|
||||
assert.ErrorIs(t, err, ErrPayNotConfigured)
|
||||
}
|
||||
|
||||
// ---------- 首月特惠限购 ----------
|
||||
|
||||
func TestCreatePurchase_PromoOncePerShop(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "PAY009")
|
||||
other := testutil.CreateTestShop(db, "PAY010")
|
||||
svc := newTestPaySvc(db, "http://pay.invalid")
|
||||
|
||||
// 未买过:不触发限购(pay 地址无效会走到下单失败,但不是 ErrPromoUsed)
|
||||
used, err := svc.PromoUsed(shop.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, used)
|
||||
_, err = svc.CreatePurchase(shop.ID, 1, PromoBizCode)
|
||||
assert.NotErrorIs(t, err, ErrPromoUsed)
|
||||
|
||||
// pending 单不算已享用(可能弃单),仍可重新下单
|
||||
createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-0")
|
||||
used, err = svc.PromoUsed(shop.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, used, "pending 不算已享用")
|
||||
|
||||
// 已支付的特惠单存在 → 已享用,再购直接拒绝
|
||||
p := createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-1")
|
||||
require.NoError(t, db.Model(p).Update("status", "paid").Error)
|
||||
used, err = svc.PromoUsed(shop.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, used)
|
||||
_, err = svc.CreatePurchase(shop.ID, 1, PromoBizCode)
|
||||
assert.ErrorIs(t, err, ErrPromoUsed)
|
||||
|
||||
// 多租户隔离:别家买过不影响本店
|
||||
used, err = svc.PromoUsed(other.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, used)
|
||||
}
|
||||
|
||||
// webhook 兜底(契约 INTEGRATION-BOARD):并发/绕过前端产生第二笔特惠单时,
|
||||
// 回调仍回 SUCCESS 并标 paid(钱已收),但不叠加时长,防止绕过购买接口的限购。
|
||||
func TestHandleCallback_PromoDoubleClaimNoEntitle(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "PAY011")
|
||||
svc := newTestPaySvc(db, "http://pay.invalid")
|
||||
|
||||
// 并发双买:两笔 pending 特惠单同时存在,先后收到回调
|
||||
createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-a")
|
||||
createPendingPurchase(t, db, shop.ID, PromoBizCode, "1.00", "yanmei-promo-b")
|
||||
|
||||
body := callbackBody("yanmei-promo-a", PromoBizCode, "1.00")
|
||||
ts, nonce, sign := signedCallbackArgs(body)
|
||||
require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
|
||||
|
||||
var lic model.License
|
||||
require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
|
||||
expires1 := *lic.ExpiresAt
|
||||
assert.InDelta(t, 30, daysFromNow(lic.ExpiresAt), 1, "第一笔正常续期 30 天")
|
||||
|
||||
// 第二笔回调:标 paid 但不再叠加
|
||||
body2 := callbackBody("yanmei-promo-b", PromoBizCode, "1.00")
|
||||
ts2, nonce2, sign2 := signedCallbackArgs(body2)
|
||||
require.NoError(t, svc.HandleCallback(body2, ts2, nonce2, sign2), "回调须回 SUCCESS,pay 停止重试")
|
||||
|
||||
var p2 model.LicensePurchase
|
||||
require.NoError(t, db.Where("out_trade_no = ?", "yanmei-promo-b").First(&p2).Error)
|
||||
assert.Equal(t, "paid", p2.Status, "第二笔仍标 paid(钱已收,退款人工处理)")
|
||||
|
||||
require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
|
||||
assert.True(t, lic.ExpiresAt.Equal(expires1), "第二笔特惠单不得叠加时长")
|
||||
|
||||
// 兜底只限特惠:同店正常套餐单不受影响
|
||||
createPendingPurchase(t, db, shop.ID, "monthly_standard", "299.00", "yanmei-normal-c")
|
||||
body3 := callbackBody("yanmei-normal-c", "monthly_standard", "299.00")
|
||||
ts3, nonce3, sign3 := signedCallbackArgs(body3)
|
||||
require.NoError(t, svc.HandleCallback(body3, ts3, nonce3, sign3))
|
||||
require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
|
||||
assert.InDelta(t, 60, daysFromNow(lic.ExpiresAt), 1, "正常套餐照常叠加")
|
||||
}
|
||||
|
||||
// ---------- Status ----------
|
||||
|
||||
func TestStatus_ScopedToShop(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/// 在线购买套餐目录(App 内购买/续费弹窗用)。
|
||||
///
|
||||
/// 价格仅前端展示,实付金额以 pay 侧套餐表为准(下单响应回传 amount);
|
||||
/// biz_code 与后端 payPlans、官网 checkout 页三方一致,改动需同步。
|
||||
class LicensePlan {
|
||||
final String bizCode;
|
||||
final int price; // 元(仅展示)
|
||||
final int days;
|
||||
const LicensePlan(this.bizCode, this.price, this.days);
|
||||
}
|
||||
|
||||
class LicensePlanGroup {
|
||||
final String name;
|
||||
final List<String> feats;
|
||||
final LicensePlan monthly;
|
||||
final LicensePlan annual;
|
||||
const LicensePlanGroup({
|
||||
required this.name,
|
||||
required this.feats,
|
||||
required this.monthly,
|
||||
required this.annual,
|
||||
});
|
||||
}
|
||||
|
||||
class LicensePlans {
|
||||
/// 新店首月特惠:¥1 体验 30 天标准版全部功能,每个门店限购一次
|
||||
/// (服务端强校验,前端据 GET /license/promo-status 置灰)。
|
||||
static const promo = LicensePlan('promo_first_month', 1, 30);
|
||||
static const promoOriginalPrice = 299; // 划线原价(=标准版月付)
|
||||
static const promoFeats = [
|
||||
'标准版全部功能,一分不少',
|
||||
'单门店 · 单仓库 · 2 台客户端',
|
||||
'1,000 张商品图片分享',
|
||||
'每个门店限购一次',
|
||||
];
|
||||
|
||||
static const standard = LicensePlanGroup(
|
||||
name: '标准版',
|
||||
feats: ['单门店 · 单仓库', '2 台客户端同时使用', '1,000 张商品图片分享'],
|
||||
monthly: LicensePlan('monthly_standard', 299, 30),
|
||||
annual: LicensePlan('annual_standard', 2999, 365),
|
||||
);
|
||||
|
||||
static const pro = LicensePlanGroup(
|
||||
name: '高级版',
|
||||
feats: [
|
||||
'单门店 · 多仓库',
|
||||
'5 台客户端同时使用',
|
||||
'10,000 张商品图片分享',
|
||||
'免费 AI 周度 / 月度商业数据分析',
|
||||
],
|
||||
monthly: LicensePlan('monthly_pro', 599, 30),
|
||||
annual: LicensePlan('annual_pro', 5999, 365),
|
||||
);
|
||||
|
||||
static const groups = [standard, pro];
|
||||
}
|
||||
@@ -64,3 +64,57 @@ class LicenseInfo {
|
||||
bool get needsAttention =>
|
||||
phase == 'grace' || phase == 'readonly' || phase == 'locked';
|
||||
}
|
||||
|
||||
/// POST /license/purchase 响应:pay 收银台跳转信息。
|
||||
class PurchaseOrder {
|
||||
final String payUrl;
|
||||
final String outTradeNo;
|
||||
final String amount; // 实付金额(pay 侧权威价,如 "2999.00")
|
||||
final String subject;
|
||||
|
||||
const PurchaseOrder({
|
||||
required this.payUrl,
|
||||
required this.outTradeNo,
|
||||
required this.amount,
|
||||
required this.subject,
|
||||
});
|
||||
|
||||
factory PurchaseOrder.fromJson(Map<String, dynamic> json) => PurchaseOrder(
|
||||
payUrl: json['pay_url'] as String? ?? '',
|
||||
outTradeNo: json['out_trade_no'] as String? ?? '',
|
||||
amount: json['amount'] as String? ?? '',
|
||||
subject: json['subject'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// GET /license/purchase/:otn 响应:购买单状态(到账轮询用)。
|
||||
class PurchaseStatusInfo {
|
||||
final String outTradeNo;
|
||||
final String status; // pending | paid | failed
|
||||
final String amount;
|
||||
final DateTime? paidAt;
|
||||
final DateTime? expiresAt; // 续期后的门店授权到期时间(仅 paid 时有值)
|
||||
|
||||
const PurchaseStatusInfo({
|
||||
required this.outTradeNo,
|
||||
required this.status,
|
||||
required this.amount,
|
||||
this.paidAt,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
bool get isPaid => status == 'paid';
|
||||
|
||||
factory PurchaseStatusInfo.fromJson(Map<String, dynamic> json) =>
|
||||
PurchaseStatusInfo(
|
||||
outTradeNo: json['out_trade_no'] as String? ?? '',
|
||||
status: json['status'] as String? ?? 'pending',
|
||||
amount: json['amount'] as String? ?? '',
|
||||
paidAt: json['paid_at'] != null
|
||||
? DateTime.tryParse(json['paid_at'] as String)
|
||||
: null,
|
||||
expiresAt: json['expires_at'] != null
|
||||
? DateTime.tryParse(json['expires_at'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,46 @@ class LicenseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// 在线购买/续费下单(仅管理员),返回 pay 收银台跳转信息。
|
||||
Future<PurchaseOrder> createPurchase(String bizCode) async {
|
||||
try {
|
||||
final resp =
|
||||
await _client.post('/license/purchase', data: {'biz_code': bizCode});
|
||||
return PurchaseOrder.fromJson(resp.data['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '下单失败,请稍后重试',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询购买单状态(到账轮询)。到账(paid)后返回续期后的授权到期时间。
|
||||
Future<PurchaseStatusInfo> purchaseStatus(String outTradeNo) async {
|
||||
try {
|
||||
final resp = await _client.get('/license/purchase/$outTradeNo');
|
||||
return PurchaseStatusInfo.fromJson(
|
||||
resp.data['data'] as Map<String, dynamic>);
|
||||
} on DioException catch (e) {
|
||||
throw AppException(
|
||||
e.response?.data?['error'] as String? ?? '查询订单状态失败',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 本店首月特惠是否已享用(购买弹窗据此置灰特惠档)。
|
||||
Future<bool> promoUsed() async {
|
||||
try {
|
||||
final resp = await _client.get('/license/promo-status');
|
||||
final data = resp.data['data'] as Map<String, dynamic>?;
|
||||
return data?['used'] as bool? ?? false;
|
||||
} on DioException {
|
||||
// 查询失败不阻塞弹窗:按未享用展示,下单时服务端仍会强校验
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Deactivate (unbind) this device from its license.
|
||||
Future<void> deactivate() async {
|
||||
final deviceId = await DeviceId.get();
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
// screens/settings/purchase_dialog.dart — App 内在线购买/续费授权。
|
||||
// 流程:套餐选择(标准/高级 × 月付/年付)→ POST /license/purchase 下单 →
|
||||
// 外部浏览器打开 pay 收银台(移动端由系统拉起支付宝 App)→ 本弹窗轮询
|
||||
// GET /license/purchase/:otn,webhook 续期到账后自动刷新授权并展示新到期日。
|
||||
// 价格仅展示,实付以 pay 侧套餐表为准(下单响应回传 amount)。
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../core/config/license_plans.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
import '../../core/theme/app_dims.g.dart';
|
||||
import '../../core/theme/app_fonts.dart';
|
||||
import '../../core/theme/app_tokens.dart';
|
||||
import '../../core/theme/context_tokens.dart';
|
||||
import '../../core/utils/dialog_util.dart';
|
||||
import '../../models/license.dart';
|
||||
import '../../providers/license_provider.dart';
|
||||
import '../../widgets/ds/ds_atoms.dart';
|
||||
import '../../widgets/ds/ds_toast.dart';
|
||||
|
||||
Future<void> showPurchaseDialog(BuildContext context) {
|
||||
return showAppDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const _PurchaseDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
enum _Phase { pick, waiting, success }
|
||||
|
||||
class _PurchaseDialog extends ConsumerStatefulWidget {
|
||||
const _PurchaseDialog();
|
||||
|
||||
@override
|
||||
ConsumerState<_PurchaseDialog> createState() => _PurchaseDialogState();
|
||||
}
|
||||
|
||||
class _PurchaseDialogState extends ConsumerState<_PurchaseDialog> {
|
||||
_Phase _phase = _Phase.pick;
|
||||
int _tabIdx = 0; // 0=标准版 1=首月特惠 2=高级版
|
||||
int _cycleIdx = 0; // 0=年付 1=月付
|
||||
bool _submitting = false;
|
||||
bool _checking = false;
|
||||
bool _promoUsed = false; // 首月特惠每店限一次,已享用则置灰(服务端仍强校验)
|
||||
|
||||
PurchaseOrder? _order;
|
||||
DateTime? _newExpiresAt;
|
||||
Timer? _pollTimer;
|
||||
|
||||
bool get _isPromo => _tabIdx == 1;
|
||||
LicensePlanGroup get _group =>
|
||||
_tabIdx == 0 ? LicensePlans.standard : LicensePlans.pro;
|
||||
LicensePlan get _plan => _isPromo
|
||||
? LicensePlans.promo
|
||||
: (_cycleIdx == 0 ? _group.annual : _group.monthly);
|
||||
String get _cycleLabel => _cycleIdx == 0 ? '年付' : '月付';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ref.read(licenseRepositoryProvider).promoUsed().then((used) {
|
||||
if (mounted && used) setState(() => _promoUsed = true);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final order =
|
||||
await ref.read(licenseRepositoryProvider).createPurchase(_plan.bizCode);
|
||||
// 外部浏览器打开收银台:桌面端进网页支付,移动端由系统浏览器拉起支付宝
|
||||
await launchUrl(Uri.parse(order.payUrl),
|
||||
mode: LaunchMode.externalApplication);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_order = order;
|
||||
_phase = _Phase.waiting;
|
||||
_submitting = false;
|
||||
});
|
||||
_pollTimer =
|
||||
Timer.periodic(const Duration(seconds: 3), (_) => _poll());
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _submitting = false);
|
||||
showDsToast(context, '下单失败:$e', bg: context.tokens.danger);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _poll({bool manual = false}) async {
|
||||
final order = _order;
|
||||
if (order == null || _checking) return;
|
||||
_checking = true;
|
||||
try {
|
||||
final st =
|
||||
await ref.read(licenseRepositoryProvider).purchaseStatus(order.outTradeNo);
|
||||
if (!mounted) return;
|
||||
if (st.isPaid) {
|
||||
_pollTimer?.cancel();
|
||||
ref.invalidate(licenseProvider);
|
||||
setState(() {
|
||||
_newExpiresAt = st.expiresAt;
|
||||
_phase = _Phase.success;
|
||||
});
|
||||
} else if (manual) {
|
||||
showDsToast(context, '尚未确认到账,支付完成后通常数秒内到账');
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted && manual) {
|
||||
showDsToast(context, '查询失败:$e', bg: context.tokens.danger);
|
||||
}
|
||||
} finally {
|
||||
_checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
return AlertDialog(
|
||||
title: Text(switch (_phase) {
|
||||
_Phase.pick => '在线购买 / 续费',
|
||||
_Phase.waiting => '等待支付',
|
||||
_Phase.success => '支付成功',
|
||||
}),
|
||||
content: SizedBox(
|
||||
width: context.dialogWidth(460),
|
||||
child: switch (_phase) {
|
||||
_Phase.pick => _buildPick(t),
|
||||
_Phase.waiting => _buildWaiting(t),
|
||||
_Phase.success => _buildSuccess(t),
|
||||
},
|
||||
),
|
||||
actions: switch (_phase) {
|
||||
_Phase.pick => [
|
||||
DsButton('取消',
|
||||
onPressed:
|
||||
_submitting ? null : () => Navigator.of(context).pop()),
|
||||
DsButton(
|
||||
_submitting
|
||||
? '正在创建订单…'
|
||||
: _isPromo && _promoUsed
|
||||
? '本店已享受过'
|
||||
: '提交订单 · ¥${_fmt(_plan.price)}',
|
||||
variant: DsBtnVariant.primary,
|
||||
onPressed:
|
||||
_submitting || (_isPromo && _promoUsed) ? null : _submit),
|
||||
],
|
||||
_Phase.waiting => [
|
||||
DsButton('稍后再说', onPressed: () => Navigator.of(context).pop()),
|
||||
DsButton('我已完成支付',
|
||||
variant: DsBtnVariant.primary,
|
||||
onPressed: () => _poll(manual: true)),
|
||||
],
|
||||
_Phase.success => [
|
||||
DsButton('完成',
|
||||
variant: DsBtnVariant.primary,
|
||||
onPressed: () => Navigator.of(context).pop()),
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPick(AppTokens t) {
|
||||
final promoGrayed = _isPromo && _promoUsed;
|
||||
final feats = _isPromo ? LicensePlans.promoFeats : _group.feats;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(spacing: 10, runSpacing: 8, children: [
|
||||
DsSeg(
|
||||
items: [
|
||||
'标准版',
|
||||
_promoUsed ? '首月特惠(已享受)' : '🔥 首月特惠 ¥1',
|
||||
'高级版',
|
||||
],
|
||||
index: _tabIdx,
|
||||
onChanged: (i) => setState(() => _tabIdx = i),
|
||||
),
|
||||
if (!_isPromo)
|
||||
DsSeg(
|
||||
items: const ['年付', '月付'],
|
||||
index: _cycleIdx,
|
||||
onChanged: (i) => setState(() => _cycleIdx = i),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 14),
|
||||
if (_isPromo) ...[
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: promoGrayed ? t.bg : t.warnBg,
|
||||
border: Border.all(color: promoGrayed ? t.border : t.warn),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(promoGrayed ? '本店已享受过首月特惠' : '新店专享 · 仅 ¥1 体验 30 天',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: promoGrayed ? t.faint : t.warn)),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
promoGrayed
|
||||
? '首月特惠每个门店限购一次,可选择标准版或高级版继续续费。'
|
||||
: '解锁标准版全部功能,原价 ¥299,每个门店限购一次——试过才知道多省心。',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: promoGrayed ? t.faint : t.text)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: t.bg,
|
||||
border: Border.all(color: t.border),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final f in feats)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(children: [
|
||||
Icon(LucideIcons.check,
|
||||
size: 14, color: promoGrayed ? t.faint : t.success),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(f,
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: promoGrayed ? t.faint : t.text))),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_isPromo
|
||||
? '首月特惠 · ${_plan.days} 天授权'
|
||||
: '${_group.name} · $_cycleLabel · ${_plan.days} 天授权',
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
),
|
||||
if (_isPromo) ...[
|
||||
Text('¥${_fmt(LicensePlans.promoOriginalPrice)}',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
color: t.faint,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text('¥${_fmt(_plan.price)}',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: promoGrayed
|
||||
? t.faint
|
||||
: _isPromo
|
||||
? t.warn
|
||||
: t.heading,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text('到期时间自动叠加,未到期续费不吃亏 · 支付宝支付',
|
||||
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaiting(AppTokens t) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: t.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text('已在浏览器打开支付宝收银台,完成支付后本窗口会自动确认到账。',
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: t.bg,
|
||||
border: Border.all(color: t.borderSubtle),
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('订单号', style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_order?.outTradeNo ?? '—',
|
||||
style: const TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
fontFamily: AppFonts.mono,
|
||||
fontFamilyFallback: AppFonts.monoFallback)),
|
||||
const SizedBox(height: 8),
|
||||
Text('金额 ¥${_order?.amount ?? '—'}',
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.text)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text('关闭本窗口不影响到账:支付成功后授权自动续期,可稍后回本页刷新查看。',
|
||||
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSuccess(AppTokens t) {
|
||||
final expires = _newExpiresAt != null
|
||||
? DateFormat('yyyy-MM-dd').format(_newExpiresAt!)
|
||||
: null;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: t.okSoft,
|
||||
borderRadius: BorderRadius.circular(AppDims.rMd),
|
||||
),
|
||||
child: Icon(LucideIcons.check, size: 20, color: t.success),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
expires != null ? '门店授权已续期至 $expires。' : '门店授权已续期。',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsTitle,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: t.heading)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
Text('订单号 ${_order?.outTradeNo ?? '—'}',
|
||||
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static String _fmt(int n) =>
|
||||
n.toString().replaceAllMapped(RegExp(r'(\d)(?=(\d{3})+$)'), (m) => '${m[1]},');
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import '../../widgets/ds/ds_atoms.dart';
|
||||
import '../../widgets/ds/ds_menu.dart';
|
||||
import '../../widgets/ds/ds_table.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
import 'purchase_dialog.dart';
|
||||
import 'users_screen.dart' show userRoleBadge, userStatusBadge;
|
||||
import '../../core/theme/app_fonts.dart';
|
||||
import '../../widgets/ds/ds_toast.dart';
|
||||
@@ -685,6 +686,8 @@ class _LicensePanelState extends ConsumerState<_LicensePanel> {
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.tokens;
|
||||
final lic = ref.watch(licenseProvider).valueOrNull;
|
||||
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
|
||||
final isAdmin = role == 'admin' || role == 'superadmin';
|
||||
final expires = lic?.expiresAt != null
|
||||
? DateFormat('yyyy-MM-dd').format(lic!.expiresAt!)
|
||||
: '—';
|
||||
@@ -759,14 +762,21 @@ class _LicensePanelState extends ConsumerState<_LicensePanel> {
|
||||
Row(children: [
|
||||
Text('没有兑换券?',
|
||||
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
|
||||
InkWell(
|
||||
onTap: () => launchUrl(Uri.parse('${AppInfo.website}/#pricing')),
|
||||
child: Text('购买授权时长 →',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: t.primary)),
|
||||
),
|
||||
// 在线购买仅管理员(后端 purchase 接口 admin only);其他角色引导看官网价格页
|
||||
if (isAdmin)
|
||||
DsButton('在线购买 / 续费',
|
||||
variant: DsBtnVariant.accent,
|
||||
onPressed: () => showPurchaseDialog(context))
|
||||
else
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
launchUrl(Uri.parse('${AppInfo.website}/#pricing')),
|
||||
child: Text('查看套餐价格 →',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: t.primary)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
// 过期降级说明(现有能力保留)
|
||||
|
||||
@@ -413,7 +413,7 @@
|
||||
<tr><td><b>门店信息</b></td><td>店名、地址、电话、负责人,管理员可编辑;门店编号不可改。这些信息会印在标签和单据上。</td></tr>
|
||||
<tr><td><b>用户管理</b></td><td>新增用户(账号+初始密码+角色)、编辑、重置密码、启用/停用。四级角色见第 2 章。仅管理员可操作。</td></tr>
|
||||
<tr><td><b>编号规则</b></td><td>入库单、出库单等单号的前缀与序号。<b>不要把序号调小</b>到已用过的范围,会撞号。</td></tr>
|
||||
<tr><td><b>授权兑换券</b></td><td>本系统按「时长兑换券」授权:输入形如 <code>JIUKU-XXXX-XXXX</code> 的短码点「兑换续期」,时长直接<b>叠加</b>到现有到期日上,无需订阅。新门店自带 30 天试用。</td></tr>
|
||||
<tr><td><b>授权兑换券</b></td><td>本系统按「时长兑换券」授权:输入形如 <code>JIUKU-XXXX-XXXX</code> 的短码点「兑换续期」,时长直接<b>叠加</b>到现有到期日上,无需订阅。新门店自带 30 天试用。管理员也可点「在线购买 / 续费」选套餐后支付宝支付,到账自动续期。</td></tr>
|
||||
<tr><td><b>偏好设置</b></td><td>三套主题切换 + <b>默认仓库</b>(新建单据时自动选中的仓库)。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -447,7 +447,7 @@
|
||||
<p class="faq-a">早期版本的扫码报错(502 / 页面不存在)已修复。请确认手机有网络;很旧的标签可以在库存页重打一张新标签。</p>
|
||||
|
||||
<p class="faq-q">Q7:系统提示授权过期、变成只读了,怎么续?</p>
|
||||
<p class="faq-a">「系统设置 → 授权兑换券」输入 <code>JIUKU-</code> 开头的兑换码点「兑换续期」,立即恢复。没有兑换码请联系客服购买。过期 15 天内数据都还在,别慌。</p>
|
||||
<p class="faq-a">「系统设置 → 授权兑换券」输入 <code>JIUKU-</code> 开头的兑换码点「兑换续期」,或管理员点「在线购买 / 续费」支付宝支付,到账立即恢复。过期 15 天内数据都还在,别慌。</p>
|
||||
|
||||
<p class="faq-q">Q8:进货时价格还没谈好,能先入库吗?</p>
|
||||
<p class="faq-a">能。进价留空提交即可(显示「待定价」),价格定了以后在入库单详情点「确认进价」补上,成本和应付自动补齐。出库同理,售价留空、事后「确认售价」。</p>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/* 构建期资产版本号:每次 build 变化,模板里作 ?v= 后缀破浏览器缓存,
|
||||
避免旧版 JS/CSS(同 URL)被缓存后在新版页面上执行。 */
|
||||
module.exports = { rev: Date.now().toString(36) };
|
||||
@@ -8,10 +8,12 @@
|
||||
{% if description %}<meta name="description" content="{{ description }}" />{% endif %}
|
||||
{# 真相源:tokens.css = App 原型 tokens.css 主题A 拷贝;site.css 镜像 atoms.css。
|
||||
全站零外链(无 Google Fonts / unpkg):字体走系统栈(与 App 真机一致),图标走内联雪碧图。 #}
|
||||
<link rel="stylesheet" href="/assets/tokens.css" />
|
||||
<link rel="stylesheet" href="/assets/site.css" />
|
||||
<link rel="stylesheet" href="/assets/tokens.css?v={{ build.rev }}" />
|
||||
<link rel="stylesheet" href="/assets/site.css?v={{ build.rev }}" />
|
||||
{% if pageExtraCss %}<link rel="stylesheet" href="{{ pageExtraCss }}" />{% endif %}
|
||||
<link rel="icon" href="/assets/logo-mark.svg" type="image/svg+xml" />
|
||||
{# auth.js 放 head 同步加载:页面内联脚本(checkout/登录态判断)在 body 中就要用 jiuAuth #}
|
||||
<script src="/assets/auth.js?v={{ build.rev }}"></script>
|
||||
{% if pageStyle %}<style>{{ pageStyle | safe }}</style>{% endif %}
|
||||
</head>
|
||||
<body>
|
||||
@@ -22,6 +24,6 @@
|
||||
|
||||
{% include "footer.njk" %}
|
||||
|
||||
<script src="/assets/nav.js"></script>
|
||||
<script src="/assets/nav.js?v={{ build.rev }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<a href="/#pricing">价格</a>
|
||||
<a href="/download/">下载</a>
|
||||
<a href="/docs/">帮助</a>
|
||||
<a href="/register/">注册门店</a>
|
||||
<a href="/login/">登录</a>
|
||||
<a href="/register/" data-auth>注册门店</a>
|
||||
<a href="/login/" data-auth>登录</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* 官网共享登录态助手:与 Web 版 App(shared_preferences_web)共享
|
||||
localStorage['flutter.*'](值为 JSON 编码)。access token 60 分钟过期,
|
||||
官网页面不常驻刷新,所以请求 401 时用 refresh_token 自动续签并重试一次,
|
||||
仍失败才交回调用方跳登录。零依赖。 */
|
||||
(function () {
|
||||
function get(key) {
|
||||
var raw = localStorage.getItem('flutter.' + key);
|
||||
if (raw === null) return null;
|
||||
try { return JSON.parse(raw); } catch (e) { return raw; }
|
||||
}
|
||||
function set(key, val) { localStorage.setItem('flutter.' + key, JSON.stringify(val)); }
|
||||
|
||||
function refresh(apiBase) {
|
||||
var rt = get('refresh_token');
|
||||
if (!rt) return Promise.resolve(false);
|
||||
return fetch(apiBase + '/api/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: rt }),
|
||||
})
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) {
|
||||
var d = j && j.data;
|
||||
if (!d || !d.access_token) return false;
|
||||
set('access_token', d.access_token);
|
||||
if (d.refresh_token) set('refresh_token', d.refresh_token); // refresh token 会轮换
|
||||
return true;
|
||||
})
|
||||
.catch(function () { return false; });
|
||||
}
|
||||
|
||||
function authFetch(apiBase, path, opts) {
|
||||
opts = opts || {};
|
||||
function doFetch() {
|
||||
var headers = Object.assign({}, opts.headers, { Authorization: 'Bearer ' + get('access_token') });
|
||||
return fetch(apiBase + path, Object.assign({}, opts, { headers: headers }));
|
||||
}
|
||||
return doFetch().then(function (r) {
|
||||
if (r.status !== 401) return r;
|
||||
return refresh(apiBase).then(function (ok) { return ok ? doFetch() : r; });
|
||||
});
|
||||
}
|
||||
|
||||
/* 是否可视为登录:access 过期但 refresh 还在(7 天),authFetch 会自动续 */
|
||||
function loggedIn() { return !!(get('access_token') || get('refresh_token')); }
|
||||
|
||||
/* 退出登录:清凭证与身份,保留 shop_no/username 供登录页回填 */
|
||||
function clear() {
|
||||
['access_token', 'refresh_token', 'user_id', 'real_name', 'role', 'shop_id'].forEach(function (k) {
|
||||
localStorage.removeItem('flutter.' + k);
|
||||
});
|
||||
}
|
||||
|
||||
window.jiuAuth = { get: get, set: set, refresh: refresh, authFetch: authFetch, loggedIn: loggedIn, clear: clear };
|
||||
})();
|
||||
+61
-13
@@ -1,14 +1,15 @@
|
||||
/* 官网导航交互:汉堡菜单 + 登录态感知(Web 版 App 与官网同域时,
|
||||
localStorage['flutter.*'] 有登录信息 → CTA 变「进入系统」)。零依赖。 */
|
||||
localStorage['flutter.*'] 有登录信息 → CTA 变「进入系统 · 姓名」下拉菜单)。
|
||||
renderAuth 可重复调用(暴露为 window.jiuNavRender,退出登录页清凭证后刷新导航)。零依赖。 */
|
||||
(function () {
|
||||
function getAuthUser() {
|
||||
var token = localStorage.getItem('flutter.access_token');
|
||||
var username = localStorage.getItem('flutter.username');
|
||||
if (!token || !username) return null;
|
||||
if (!window.jiuAuth || !jiuAuth.loggedIn()) return null;
|
||||
var username = jiuAuth.get('username');
|
||||
if (!username) return null;
|
||||
return {
|
||||
username: username,
|
||||
realName: localStorage.getItem('flutter.real_name') || '',
|
||||
shopNo: localStorage.getItem('flutter.shop_no') || '',
|
||||
realName: jiuAuth.get('real_name') || '',
|
||||
shopNo: jiuAuth.get('shop_no') || '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,15 +29,62 @@
|
||||
});
|
||||
}
|
||||
|
||||
// 登录态:CTA 换成「进入系统」
|
||||
var user = getAuthUser();
|
||||
if (user) {
|
||||
// 登录态渲染:桌面 CTA + 移动菜单(data-auth 标记的项按状态重建)
|
||||
var MENU_ITEMS =
|
||||
'<a href="/profile/">个人信息</a>' +
|
||||
'<a href="/app/">进入系统</a>' +
|
||||
'<div class="sep"></div>' +
|
||||
'<a class="danger" href="/logout/">退出登录</a>';
|
||||
|
||||
function renderAuth() {
|
||||
var user = getAuthUser();
|
||||
var cta = document.getElementById('nav-cta');
|
||||
if (cta) {
|
||||
cta.innerHTML =
|
||||
'<a class="btn primary" href="/app/">进入系统 · ' +
|
||||
(user.realName || user.username).replace(/[<>&"]/g, '') +
|
||||
'</a>';
|
||||
if (user) {
|
||||
cta.innerHTML =
|
||||
'<div class="nav-user" id="nav-user">' +
|
||||
'<button class="btn primary nav-user-btn" id="nav-user-btn" aria-haspopup="true" aria-expanded="false">' +
|
||||
'进入系统 · ' + (user.realName || user.username).replace(/[<>&"]/g, '') +
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>' +
|
||||
'</button>' +
|
||||
'<div class="nav-menu">' + MENU_ITEMS + '</div>' +
|
||||
'</div>';
|
||||
} else {
|
||||
cta.innerHTML =
|
||||
'<a class="btn ghost" href="/login/">登录</a>' +
|
||||
'<a class="btn primary" href="/register/">注册门店</a>';
|
||||
}
|
||||
}
|
||||
if (mobile) {
|
||||
Array.prototype.slice.call(mobile.querySelectorAll('[data-auth]')).forEach(function (el) { el.remove(); });
|
||||
var frag = document.createElement('div');
|
||||
frag.innerHTML = user
|
||||
? MENU_ITEMS
|
||||
: '<a href="/register/">注册门店</a><a href="/login/">登录</a>';
|
||||
Array.prototype.slice.call(frag.children).forEach(function (el) {
|
||||
if (el.tagName === 'A') { el.setAttribute('data-auth', ''); mobile.appendChild(el); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉开合(事件委托,renderAuth 重建 DOM 后依然有效)
|
||||
document.addEventListener('click', function (e) {
|
||||
var wrap = document.getElementById('nav-user');
|
||||
if (!wrap) return;
|
||||
var btn = document.getElementById('nav-user-btn');
|
||||
if (btn && btn.contains(e.target)) {
|
||||
var open = wrap.classList.toggle('open');
|
||||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
} else if (!wrap.contains(e.target)) {
|
||||
wrap.classList.remove('open');
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape') return;
|
||||
var wrap = document.getElementById('nav-user');
|
||||
if (wrap) wrap.classList.remove('open');
|
||||
});
|
||||
|
||||
renderAuth();
|
||||
window.jiuNavRender = renderAuth;
|
||||
})();
|
||||
|
||||
+37
-2
@@ -64,6 +64,16 @@ h1,h2,h3{color:var(--heading); line-height:1.3; margin:0;}
|
||||
.nav-mobile{display:none; border-top:1px solid var(--border); background:var(--surface); padding:10px 24px 16px;}
|
||||
.nav-mobile a{display:block; padding:10px 0; color:var(--text); font-size:15px; border-bottom:1px solid var(--border-subtle); text-decoration:none !important;}
|
||||
.nav-mobile.open{display:block;}
|
||||
.nav-user{position:relative;}
|
||||
.nav-user-btn{display:inline-flex; align-items:center; gap:7px; cursor:pointer;}
|
||||
.nav-user-btn svg{width:13px; height:13px; transition:transform .15s;}
|
||||
.nav-user.open .nav-user-btn svg{transform:rotate(180deg);}
|
||||
.nav-menu{display:none; position:absolute; right:0; top:calc(100% + 8px); min-width:158px; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-md); box-shadow:var(--sh-2); padding:6px; z-index:50;}
|
||||
.nav-user.open .nav-menu{display:block;}
|
||||
.nav-menu a{display:block; padding:9px 12px; border-radius:var(--r-sm); font-size:14px; color:var(--text); text-decoration:none !important;}
|
||||
.nav-menu a:hover{background:var(--bg);}
|
||||
.nav-menu a.danger{color:var(--danger);}
|
||||
.nav-menu .sep{height:1px; background:var(--border-subtle); margin:5px 4px;}
|
||||
|
||||
/* ---------- Hero(品牌渐变 = 登录页品牌面板同源) ---------- */
|
||||
.hero{background:var(--mk-hero-grad); color:var(--on-accent); padding:84px 0 0; overflow:hidden;}
|
||||
@@ -129,6 +139,14 @@ table.roles .n{color:var(--faint);}
|
||||
.plans.p4{grid-template-columns:repeat(4,1fr); gap:16px;}
|
||||
.plans.p4 .plan{padding:26px 22px;}
|
||||
.plans.p4 .plan .price{font-size:30px;}
|
||||
.plans.p5{grid-template-columns:repeat(5,1fr); gap:14px;}
|
||||
.plans.p5 .plan{padding:24px 18px;}
|
||||
.plans.p5 .plan .price{font-size:28px;}
|
||||
.plan.promo{border-color:var(--warn); box-shadow:var(--sh-2); position:relative; background:linear-gradient(180deg,var(--warn-bg),var(--surface) 55%);}
|
||||
.plan.promo::before{content:'🔥 新店专享'; position:absolute; top:-11px; left:50%; transform:translateX(-50%); background:var(--warn); color:#fff; font-size:var(--fs-xs); font-weight:700; padding:3px 12px; border-radius:var(--r-pill); white-space:nowrap;}
|
||||
.plan.promo .price{color:var(--warn);}
|
||||
.plan .price .was{font-size:14px; color:var(--faint); font-weight:400; text-decoration:line-through; font-family:var(--font); margin-left:6px;}
|
||||
.plan .btn.disabled{pointer-events:none; opacity:.55;}
|
||||
.plan{background:var(--surface); border:1px solid var(--border); border-radius:var(--r-xl); padding:28px 26px; display:flex; flex-direction:column;}
|
||||
.plan.hot{border-color:var(--primary); box-shadow:var(--sh-2); position:relative;}
|
||||
.plan.hot::before{content:'推荐'; position:absolute; top:-11px; left:50%; transform:translateX(-50%); background:var(--primary); color:var(--on-primary); font-size:var(--fs-xs); font-weight:700; padding:3px 12px; border-radius:var(--r-pill);}
|
||||
@@ -169,12 +187,12 @@ table.roles .n{color:var(--faint);}
|
||||
/* ---------- 响应式 ---------- */
|
||||
@media (max-width:1024px){
|
||||
.grid.c4{grid-template-columns:repeat(2,1fr);}
|
||||
.plans,.plans.p4{grid-template-columns:repeat(2,1fr);}
|
||||
.plans,.plans.p4,.plans.p5{grid-template-columns:repeat(2,1fr);}
|
||||
.split{grid-template-columns:1fr;}
|
||||
.footer-grid{grid-template-columns:1fr 1fr;}
|
||||
}
|
||||
@media (max-width:640px){
|
||||
.plans,.plans.p4{grid-template-columns:1fr; max-width:440px; margin:0 auto;}
|
||||
.plans,.plans.p4,.plans.p5{grid-template-columns:1fr; max-width:440px; margin:0 auto;}
|
||||
}
|
||||
@media (max-width:720px){
|
||||
.nav-links,.nav-cta .btn.ghost{display:none;}
|
||||
@@ -248,6 +266,11 @@ textarea.input{height:auto; min-height:64px; padding:9px 11px; resize:vertical;}
|
||||
.seg-btn{border:none; background:transparent; padding:8px 18px; border-radius:calc(var(--r-md) - 3px); font-size:var(--fs-body); color:var(--muted); cursor:pointer; font-family:var(--font); display:inline-flex; align-items:center; gap:7px;}
|
||||
.seg-btn.on{background:var(--surface); color:var(--heading); font-weight:600; box-shadow:var(--sh-1);}
|
||||
.seg-tip{font-size:var(--fs-xs); color:var(--success); background:var(--ok-soft); padding:1px 8px; border-radius:var(--r-pill); font-weight:600;}
|
||||
.seg-btn.promo{color:var(--warn); font-weight:700;}
|
||||
.seg-btn.promo.on{color:var(--warn);}
|
||||
.seg-btn.used,.seg-btn:disabled{color:var(--faint); font-weight:400; cursor:not-allowed; opacity:.7;}
|
||||
.promo-banner{background:var(--warn-bg); border:1px solid var(--warn); border-radius:var(--r-md); padding:12px 14px; font-size:var(--fs-sm); color:var(--text); margin:10px 0 4px; line-height:1.7;}
|
||||
.promo-banner b{display:block; color:var(--warn); font-size:var(--fs-body);}
|
||||
.seg-label{font-size:var(--fs-sm); color:var(--muted); margin:14px 0 8px;}
|
||||
.co-feats{list-style:none; padding:0; margin:12px 0 4px; display:flex; flex-direction:column; gap:8px; font-size:14px; color:var(--text);}
|
||||
.co-feats li{display:flex; gap:8px; align-items:flex-start;}
|
||||
@@ -281,6 +304,18 @@ textarea.input{height:auto; min-height:64px; padding:9px 11px; resize:vertical;}
|
||||
.sum-total .val{font-size:var(--fs-display); font-weight:800; color:var(--primary); font-family:var(--font-mono); letter-spacing:-.5px;}
|
||||
.co-summary .fine{font-size:var(--fs-xs); color:var(--faint); text-align:center; margin-top:12px; line-height:1.6;}
|
||||
|
||||
/* ---------- 个人信息(/profile/) ---------- */
|
||||
.pf-row{display:flex; justify-content:space-between; gap:16px; padding:9px 0; font-size:var(--fs-body); border-bottom:1px solid var(--border-subtle);}
|
||||
.pf-row:last-child{border-bottom:none; padding-bottom:2px;}
|
||||
.pf-row .k{color:var(--muted); flex:none;}
|
||||
.pf-row .v{color:var(--heading); font-weight:500; text-align:right; word-break:break-all;}
|
||||
.pf-row .v.mono{font-family:var(--font-mono);}
|
||||
.pf-badge{display:inline-flex; padding:2px 10px; border-radius:var(--r-pill); font-size:var(--fs-sm); font-weight:600;}
|
||||
.pf-badge.ok{background:var(--ok-soft); color:var(--success);}
|
||||
.pf-badge.warn{background:var(--warn-bg); color:var(--warn);}
|
||||
.pf-badge.bad{background:var(--danger-bg); color:var(--danger);}
|
||||
.pf-acts{display:flex; gap:10px; flex-wrap:wrap; margin-top:4px;}
|
||||
|
||||
/* ---------- 支付结果页 ---------- */
|
||||
.result{text-align:center; max-width:520px; margin:0 auto; padding:24px 16px;}
|
||||
.result .ring{width:72px; height:72px; border-radius:50%; display:flex; align-items:center; justify-content:center; margin:0 auto 18px;}
|
||||
|
||||
+46
-17
@@ -16,10 +16,15 @@ permalink: /checkout/
|
||||
<h2>选择套餐</h2>
|
||||
<div class="seg" id="planSeg">
|
||||
<button class="seg-btn on" data-v="standard">标准版</button>
|
||||
<button class="seg-btn promo" data-v="promo" id="promoSegBtn">🔥 首月特惠 ¥1</button>
|
||||
<button class="seg-btn" data-v="pro">高级版</button>
|
||||
</div>
|
||||
<div class="promo-banner" id="promoBanner" style="display:none">
|
||||
<b>新店专享 · 仅 ¥1 体验 30 天</b>
|
||||
解锁标准版全部功能,原价 ¥299,每个门店限购一次——试过才知道多省心。
|
||||
</div>
|
||||
<ul class="co-feats" id="planFeats"></ul>
|
||||
<div class="seg-label">计费周期</div>
|
||||
<div class="seg-label" id="cycleLabel">计费周期</div>
|
||||
<div class="seg" id="cycleSeg">
|
||||
<button class="seg-btn on" data-v="annual">年付 <span class="seg-tip">省两个月</span></button>
|
||||
<button class="seg-btn" data-v="monthly">月付</button>
|
||||
@@ -54,7 +59,7 @@ permalink: /checkout/
|
||||
<div class="co-card co-summary">
|
||||
<h2>费用汇总</h2>
|
||||
<div class="sum-row"><span id="sumName">标准版 · 年付</span><span id="sumBase">¥2,999</span></div>
|
||||
<div class="sum-row muted"><span>优惠</span><span>− ¥0</span></div>
|
||||
<div class="sum-row muted"><span>优惠</span><span id="sumOff">− ¥0</span></div>
|
||||
<div class="sum-div"></div>
|
||||
<div class="sum-total"><span class="lab">应付总额</span><span class="val" id="sumTotal">¥2,999</span></div>
|
||||
<div class="auth-alert" id="coErr" style="margin-top:12px"></div>
|
||||
@@ -68,13 +73,9 @@ permalink: /checkout/
|
||||
(function () {
|
||||
var API = '{{ site.appBaseUrl }}';
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function token() {
|
||||
var raw = localStorage.getItem('flutter.access_token');
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch (e) { return raw; }
|
||||
}
|
||||
function gotoLogin() { location.href = '/login/?next=' + encodeURIComponent('/checkout/' + location.search); }
|
||||
if (!token()) { gotoLogin(); return; }
|
||||
// access token 只有 60 分钟,只要 refresh token(7 天)还在就算登录,请求时自动续签
|
||||
if (!jiuAuth.loggedIn()) { gotoLogin(); return; }
|
||||
|
||||
/* 套餐目录:价格仅前端展示,实付金额以 pay 套餐表为准(下单响应回传) */
|
||||
var PLANS = {
|
||||
@@ -84,6 +85,11 @@ permalink: /checkout/
|
||||
monthly: { biz: 'monthly_standard', price: 299 },
|
||||
annual: { biz: 'annual_standard', price: 2999 },
|
||||
},
|
||||
promo: {
|
||||
name: '首月特惠',
|
||||
feats: ['标准版全部功能,一分不少', '单门店 · 单仓库 · 2 台客户端', '1,000 张商品图片分享', '每个门店限购一次'],
|
||||
single: { biz: 'promo_first_month', price: 1, orig: 299 },
|
||||
},
|
||||
pro: {
|
||||
name: '高级版',
|
||||
feats: ['单门店 · 多仓库', '5 台客户端同时使用', '10,000 张商品图片分享', '免费 AI 周度 / 月度商业数据分析'],
|
||||
@@ -96,39 +102,62 @@ permalink: /checkout/
|
||||
var cycle = 'annual';
|
||||
|
||||
function fmt(n) { return '¥' + n.toLocaleString('zh-CN'); }
|
||||
function selected() { return plan === 'promo' ? PLANS.promo.single : PLANS[plan][cycle]; }
|
||||
function render() {
|
||||
var p = PLANS[plan], c = p[cycle];
|
||||
var days = cycle === 'annual' ? 365 : 30;
|
||||
var p = PLANS[plan], c = selected(), isPromo = plan === 'promo';
|
||||
var days = isPromo || cycle === 'monthly' ? 30 : 365;
|
||||
var cyc = isPromo ? '仅 ¥1 · 30 天' : (cycle === 'annual' ? '年付' : '月付');
|
||||
$('promoBanner').style.display = isPromo ? 'block' : 'none';
|
||||
$('cycleLabel').style.display = isPromo ? 'none' : '';
|
||||
$('cycleSeg').style.display = isPromo ? 'none' : '';
|
||||
$('planFeats').innerHTML = p.feats.map(function (f) {
|
||||
return '<li><svg class="ic"><use href="#i-check"/></svg>' + f + '</li>';
|
||||
}).join('');
|
||||
$('itName').textContent = p.name + '授权 · ' + (cycle === 'annual' ? '年付' : '月付');
|
||||
$('itName').textContent = p.name + '授权 · ' + cyc;
|
||||
$('itDur').textContent = days + ' 天授权 · 到期时间自动叠加';
|
||||
$('itAmt').textContent = fmt(c.price);
|
||||
$('sumName').textContent = p.name + ' · ' + (cycle === 'annual' ? '年付' : '月付');
|
||||
$('sumBase').textContent = fmt(c.price);
|
||||
$('sumName').textContent = p.name + (isPromo ? '(原价 ¥299/月)' : ' · ' + cyc);
|
||||
$('sumBase').textContent = fmt(isPromo ? c.orig : c.price);
|
||||
$('sumOff').textContent = '− ' + fmt(isPromo ? c.orig - c.price : 0);
|
||||
$('sumTotal').textContent = fmt(c.price);
|
||||
document.querySelectorAll('#planSeg .seg-btn').forEach(function (b) { b.classList.toggle('on', b.dataset.v === plan); });
|
||||
document.querySelectorAll('#cycleSeg .seg-btn').forEach(function (b) { b.classList.toggle('on', b.dataset.v === cycle); });
|
||||
}
|
||||
document.querySelectorAll('#planSeg .seg-btn').forEach(function (b) {
|
||||
b.addEventListener('click', function () { plan = b.dataset.v; render(); });
|
||||
b.addEventListener('click', function () {
|
||||
if (b.disabled) return;
|
||||
plan = b.dataset.v; render();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('#cycleSeg .seg-btn').forEach(function (b) {
|
||||
b.addEventListener('click', function () { cycle = b.dataset.v; render(); });
|
||||
});
|
||||
render();
|
||||
|
||||
// 首月特惠每店限一次:已享用则该档置灰不可选
|
||||
jiuAuth.authFetch(API, '/api/v1/license/promo-status')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) {
|
||||
if (!j || !j.data || !j.data.used) return;
|
||||
var b = $('promoSegBtn');
|
||||
b.disabled = true;
|
||||
b.classList.add('used');
|
||||
b.innerHTML = '首月特惠(已享受)';
|
||||
if (plan === 'promo') { plan = 'standard'; render(); }
|
||||
})
|
||||
.catch(function () {});
|
||||
|
||||
function showErr(msg) { var el = $('coErr'); el.textContent = msg; el.classList.add('show'); }
|
||||
$('coBtn').addEventListener('click', function () {
|
||||
$('coErr').classList.remove('show');
|
||||
var btn = $('coBtn'); btn.disabled = true; btn.textContent = '正在创建订单…';
|
||||
fetch(API + '/api/v1/license/purchase', {
|
||||
jiuAuth.authFetch(API, '/api/v1/license/purchase', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token() },
|
||||
body: JSON.stringify({ biz_code: PLANS[plan][cycle].biz }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ biz_code: selected().biz }),
|
||||
})
|
||||
.then(function (r) {
|
||||
// authFetch 已自动用 refresh_token 续签重试过,仍 401 才是真过期
|
||||
if (r.status === 401) { gotoLogin(); throw new Error('登录已过期'); }
|
||||
return r.json().then(function (j) { return { ok: r.ok, j: j }; });
|
||||
})
|
||||
|
||||
+2
-2
@@ -133,7 +133,7 @@
|
||||
| 门店信息 | 店名/地址/电话/负责人,管理员可编辑,编号不可改 |
|
||||
| 用户管理 | 新增/编辑/重置密码/启停用户,仅管理员 |
|
||||
| 编号规则 | 单号前缀与序号;序号切勿调小,会撞号 |
|
||||
| 授权兑换券 | 输入 `JIUKU-XXXX-XXXX` 短码兑换续期,时长叠加;新店 30 天试用 |
|
||||
| 授权兑换券 | 输入 `JIUKU-XXXX-XXXX` 短码兑换续期,时长叠加;新店 30 天试用;管理员也可点「在线购买 / 续费」直接支付宝购买,到账自动续期 |
|
||||
| 偏好设置 | 主题切换 + 默认仓库 |
|
||||
|
||||
授权过期:7 天内宽限照常用,7–15 天只读,满 15 天锁定;兑换新券立即恢复,数据不丢。
|
||||
@@ -156,7 +156,7 @@
|
||||
|
||||
**扫码打不开商品页?** 早期版本的扫码报错已修复;确认手机联网,很旧的标签可重打。
|
||||
|
||||
**授权到期变只读?** 「系统设置 → 授权兑换券」输入 JIUKU 兑换码立即恢复;过期 15 天内数据都在。
|
||||
**授权到期变只读?** 「系统设置 → 授权兑换券」输入 JIUKU 兑换码,或管理员点「在线购买 / 续费」支付宝支付,到账立即恢复;过期 15 天内数据都在。
|
||||
|
||||
**进价/售价还没谈好?** 价格留空即「待定价」,事后在单据详情「确认进价 / 确认售价」补填,账自动补齐。
|
||||
|
||||
|
||||
+25
-2
@@ -190,7 +190,7 @@ description: 岩美酒库管理系统——每一瓶酒一个编号,审核驱
|
||||
<h2>按门店付费,明码标价</h2>
|
||||
<p>先免费试用,再按需选档;时长兑换券续期,无隐藏费用、无自动扣款。</p>
|
||||
</div>
|
||||
<div class="plans p4">
|
||||
<div class="plans p5">
|
||||
<div class="plan">
|
||||
<div class="pname">免费版</div>
|
||||
<div class="price">¥0<small> / 30 天</small></div>
|
||||
@@ -202,6 +202,17 @@ description: 岩美酒库管理系统——每一瓶酒一个编号,审核驱
|
||||
</ul>
|
||||
<a class="btn ghost" data-plan="free" href="/register/">立即试用</a>
|
||||
</div>
|
||||
<div class="plan promo">
|
||||
<div class="pname">首月特惠</div>
|
||||
<div class="price">¥1<span class="was">¥299</span><small> / 30 天</small></div>
|
||||
<div class="psub">一块钱用一个月,每店限一次</div>
|
||||
<ul>
|
||||
<li><svg class="ic"><use href="#i-check"/></svg>标准版全部功能,一分不少</li>
|
||||
<li><svg class="ic"><use href="#i-check"/></svg>2 台客户端 · 1,000 张图片</li>
|
||||
<li><svg class="ic"><use href="#i-check"/></svg>试过才知道多省心</li>
|
||||
</ul>
|
||||
<a class="btn primary" data-plan="promo" id="promoPlanBtn" href="/register/">¥1 立即体验</a>
|
||||
</div>
|
||||
<div class="plan hot">
|
||||
<div class="pname">标准版</div>
|
||||
<div class="price">¥299<small> / 月</small></div>
|
||||
@@ -276,16 +287,28 @@ description: 岩美酒库管理系统——每一瓶酒一个编号,审核驱
|
||||
/* 价格区登录态:已登录(与 Web 版 App 共享 localStorage flutter.*)时,
|
||||
标准/高级按钮 → 「购买 / 续费」进 /checkout/;免费版按钮 → 「进入系统」。 */
|
||||
(function () {
|
||||
if (!localStorage.getItem('flutter.access_token')) return;
|
||||
if (!window.jiuAuth || !jiuAuth.loggedIn()) return;
|
||||
var API = '{{ site.appBaseUrl }}';
|
||||
document.querySelectorAll('[data-plan]').forEach(function (a) {
|
||||
var plan = a.dataset.plan;
|
||||
if (plan === 'free') {
|
||||
a.textContent = '进入系统';
|
||||
a.href = '/app/';
|
||||
} else if (plan === 'promo') {
|
||||
a.href = '/checkout/?plan=promo';
|
||||
} else {
|
||||
a.textContent = '购买 / 续费';
|
||||
a.href = '/checkout/?plan=' + plan;
|
||||
}
|
||||
});
|
||||
// 首月特惠每店限一次:已享用则置灰
|
||||
jiuAuth.authFetch(API, '/api/v1/license/promo-status')
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) {
|
||||
if (!j || !j.data || !j.data.used) return;
|
||||
var b = document.getElementById('promoPlanBtn');
|
||||
if (b) { b.textContent = '已享受过'; b.classList.remove('primary'); b.classList.add('ghost', 'disabled'); b.removeAttribute('href'); }
|
||||
})
|
||||
.catch(function () {});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -38,20 +38,13 @@ permalink: /license/result/
|
||||
(function () {
|
||||
var API = '{{ site.appBaseUrl }}';
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function token() {
|
||||
var raw = localStorage.getItem('flutter.access_token');
|
||||
if (!raw) return null;
|
||||
try { return JSON.parse(raw); } catch (e) { return raw; }
|
||||
}
|
||||
var otn = new URLSearchParams(location.search).get('out_trade_no');
|
||||
if (!otn || !token()) { $('waiting').style.display = 'none'; $('slow').style.display = 'block'; return; }
|
||||
if (!otn || !jiuAuth.loggedIn()) { $('waiting').style.display = 'none'; $('slow').style.display = 'block'; return; }
|
||||
|
||||
var tries = 0, maxTries = 30; // 2s × 30 = 60s
|
||||
function poll() {
|
||||
tries++;
|
||||
fetch(API + '/api/v1/license/purchase/' + encodeURIComponent(otn), {
|
||||
headers: { 'Authorization': 'Bearer ' + token() },
|
||||
})
|
||||
jiuAuth.authFetch(API, '/api/v1/license/purchase/' + encodeURIComponent(otn), {})
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (j) {
|
||||
var d = j && j.data;
|
||||
|
||||
+10
-3
@@ -6,7 +6,7 @@ permalink: /login/
|
||||
---
|
||||
{# 官网内登录(不跳 App):POST /api/v1/auth/login,成功后把凭证写入与
|
||||
Web 版 App(shared_preferences_web)共享的 localStorage['flutter.*']
|
||||
(值为 JSON 编码,与插件格式一致),再进入 /app/ 即已登录。 #}
|
||||
(值为 JSON 编码,与插件格式一致),默认落地 /profile/;后续打开 /app/ 即已登录。 #}
|
||||
<div class="auth-wrap">
|
||||
<div class="auth">
|
||||
<div class="auth-brand">
|
||||
@@ -67,15 +67,22 @@ permalink: /login/
|
||||
.then(function (res) {
|
||||
if (!res.ok) throw new Error((res.j && res.j.error) || '登录失败,请检查账号信息');
|
||||
persist(res.j.data || {}, shop);
|
||||
// ?next= 仅允许站内路径(防开放跳转)
|
||||
// ?next= 仅允许站内路径(防开放跳转);默认留在官网(个人信息页),不跳 App
|
||||
var next = new URLSearchParams(location.search).get('next') || '';
|
||||
location.href = (next.charAt(0) === '/' && next.indexOf('//') !== 0) ? next : '/app/';
|
||||
location.href = (next.charAt(0) === '/' && next.indexOf('//') !== 0) ? next : '/profile/';
|
||||
})
|
||||
.catch(function (e) {
|
||||
showErr(e && e.message ? e.message : '无法连接服务器,请稍后重试');
|
||||
btn.disabled = false; btn.textContent = '登 录';
|
||||
});
|
||||
}
|
||||
// 登录过的浏览器回填门店编号/账号(token 过期重登时少敲两栏)
|
||||
if (window.jiuAuth) {
|
||||
var savedShop = jiuAuth.get('shop_no'), savedUser = jiuAuth.get('username');
|
||||
if (savedShop && !$('shop').value) $('shop').value = savedShop;
|
||||
if (savedUser && !$('user').value) $('user').value = savedUser;
|
||||
}
|
||||
|
||||
$('loginBtn').addEventListener('click', doLogin);
|
||||
['shop', 'user', 'pass'].forEach(function (id) {
|
||||
$(id).addEventListener('keydown', function (e) { if (e.key === 'Enter') doLogin(); });
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: 退出登录
|
||||
description: 退出岩美酒库管理系统的官网登录状态。
|
||||
permalink: /logout/
|
||||
---
|
||||
{# 退出登录:POST /api/v1/auth/logout 撤销服务端会话(尽力而为),
|
||||
再清 localStorage 凭证(保留门店编号/账号供下次登录回填),刷新导航登录态。 #}
|
||||
<div class="container" style="padding:64px 24px 88px">
|
||||
<div class="result">
|
||||
<div class="ring wait" id="loRing"><svg class="ic"><use href="#i-ic23"/></svg></div>
|
||||
<h2 id="loTitle">正在退出…</h2>
|
||||
<p id="loDesc">正在撤销当前登录会话,请稍候。</p>
|
||||
<div class="acts" id="loActs" style="display:none">
|
||||
<a class="btn primary" href="/login/">重新登录</a>
|
||||
<a class="btn ghost" href="/">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var API = '{{ site.appBaseUrl }}';
|
||||
function done() {
|
||||
if (window.jiuAuth) jiuAuth.clear();
|
||||
if (window.jiuNavRender) jiuNavRender();
|
||||
document.getElementById('loRing').className = 'ring good';
|
||||
document.getElementById('loTitle').textContent = '已退出登录';
|
||||
document.getElementById('loDesc').textContent = '本设备的登录状态已清除,感谢使用岩美酒库。';
|
||||
document.getElementById('loActs').style.display = 'flex';
|
||||
}
|
||||
if (window.jiuAuth && jiuAuth.loggedIn()) {
|
||||
// 撤销服务端会话失败也照常清本地(token 可能已过期)
|
||||
jiuAuth.authFetch(API, '/api/v1/auth/logout', { method: 'POST' }).then(done, done);
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
layout: base.njk
|
||||
title: 个人信息
|
||||
description: 查看当前登录账号、门店与授权状态。
|
||||
permalink: /profile/
|
||||
---
|
||||
{# 个人信息(需登录):账号信息来自 localStorage;门店与授权实时拉
|
||||
GET /api/v1/shop/info(原样 JSON)与 GET /api/v1/license/info({data}包裹)。 #}
|
||||
<div class="container" style="padding:40px 24px 72px; max-width:720px">
|
||||
<div class="crumb"><a href="/">首页</a><span>/</span><span>个人信息</span></div>
|
||||
<h1 class="co-title">个人信息</h1>
|
||||
|
||||
<div class="co-card">
|
||||
<h2>账号</h2>
|
||||
<div class="pf-row"><span class="k">姓名</span><span class="v" id="pfName">—</span></div>
|
||||
<div class="pf-row"><span class="k">登录账号</span><span class="v mono" id="pfUser">—</span></div>
|
||||
<div class="pf-row"><span class="k">角色</span><span class="v" id="pfRole">—</span></div>
|
||||
<div class="pf-row"><span class="k">门店编号</span><span class="v mono" id="pfShopNo">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="co-card">
|
||||
<h2>门店</h2>
|
||||
<div class="pf-row"><span class="k">门店名称</span><span class="v" id="pfShopName">加载中…</span></div>
|
||||
<div class="pf-row"><span class="k">负责人</span><span class="v" id="pfManager">—</span></div>
|
||||
<div class="pf-row"><span class="k">联系电话</span><span class="v mono" id="pfPhone">—</span></div>
|
||||
<div class="pf-row"><span class="k">门店地址</span><span class="v" id="pfAddr">—</span></div>
|
||||
</div>
|
||||
|
||||
<div class="co-card">
|
||||
<h2>授权</h2>
|
||||
<div class="pf-row"><span class="k">状态</span><span class="v" id="pfPhase">加载中…</span></div>
|
||||
<div class="pf-row"><span class="k">授权类型</span><span class="v" id="pfType">—</span></div>
|
||||
<div class="pf-row"><span class="k">到期时间</span><span class="v mono" id="pfExpire">—</span></div>
|
||||
<div class="pf-row"><span class="k">已绑定设备</span><span class="v mono" id="pfDev">—</span></div>
|
||||
<div class="pf-acts" style="margin-top:14px">
|
||||
<a class="btn primary" href="/checkout/">购买 / 续费</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pf-acts">
|
||||
<a class="btn primary" href="/app/">进入系统</a>
|
||||
<a class="btn ghost" href="/logout/">退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var API = '{{ site.appBaseUrl }}';
|
||||
function $(id) { return document.getElementById(id); }
|
||||
function gotoLogin() { location.href = '/login/?next=/profile/'; }
|
||||
if (!window.jiuAuth || !jiuAuth.loggedIn()) { gotoLogin(); return; }
|
||||
|
||||
var ROLE = { superadmin: '超级管理员', super_admin: '超级管理员', admin: '管理员', operator: '操作员', readonly: '只读' };
|
||||
var TYPE = { trial: '试用', monthly: '包月', annual: '包年', lifetime: '永久' };
|
||||
|
||||
$('pfName').textContent = jiuAuth.get('real_name') || '—';
|
||||
$('pfUser').textContent = jiuAuth.get('username') || '—';
|
||||
var role = jiuAuth.get('role') || '';
|
||||
$('pfRole').textContent = ROLE[role] || role || '—';
|
||||
$('pfShopNo').textContent = jiuAuth.get('shop_no') || '—';
|
||||
|
||||
function guard(r) {
|
||||
if (r.status === 401) { gotoLogin(); return null; }
|
||||
return r.ok ? r.json() : null;
|
||||
}
|
||||
|
||||
// 门店信息(GetInfo 直接返回 shop JSON,无 {data} 包裹)
|
||||
jiuAuth.authFetch(API, '/api/v1/shop/info').then(guard).then(function (s) {
|
||||
if (!s) return;
|
||||
$('pfShopName').textContent = s.name || '—';
|
||||
$('pfManager').textContent = s.manager_name || '—';
|
||||
$('pfPhone').textContent = s.phone || '—';
|
||||
$('pfAddr').textContent = s.address || '—';
|
||||
}).catch(function () { $('pfShopName').textContent = '加载失败'; });
|
||||
|
||||
// 授权信息({data} 包裹;data:null = 无有效授权)
|
||||
jiuAuth.authFetch(API, '/api/v1/license/info').then(guard).then(function (j) {
|
||||
if (!j) return;
|
||||
var d = j.data;
|
||||
if (!d) { $('pfPhase').innerHTML = '<span class="pf-badge bad">无有效授权</span>'; return; }
|
||||
var badge =
|
||||
d.phase === 'normal' ? '<span class="pf-badge ok">正常</span>' :
|
||||
d.phase === 'grace' ? '<span class="pf-badge warn">已到期 · 宽限期内</span>' :
|
||||
d.phase === 'readonly' ? '<span class="pf-badge warn">已到期 · 只读</span>' :
|
||||
'<span class="pf-badge bad">已锁定</span>';
|
||||
$('pfPhase').innerHTML = badge;
|
||||
$('pfType').textContent = TYPE[d.type] || d.type || '—';
|
||||
$('pfExpire').textContent = d.expires_at ? String(d.expires_at).slice(0, 10) : '—';
|
||||
$('pfDev').textContent = (d.device_count != null ? d.device_count : '—') + ' / ' + (d.max_devices || '—');
|
||||
}).catch(function () { $('pfPhase').textContent = '加载失败'; });
|
||||
})();
|
||||
</script>
|
||||
Reference in New Issue
Block a user