Compare commits

...

12 Commits

Author SHA1 Message Date
wangjia c6d95edf12 chore: release server-v1.1.5
Deploy Server / release-deploy-server (push) Successful in 54s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 22:00:49 +08:00
wangjia a773a33779 feat: App→网页免登录(一次性换票 SSO)——移动/桌面点链接直达已登录官网
- backend:POST /auth/web-ticket(需登录,签 60s 单次票据,每用户限频)+
  POST /auth/web-ticket/exchange(公开,原子即焚兑换正常登录态,响应同
  /auth/login 另带 shop_code);web_tickets 新表 + user_sessions.kind 列
  (sso 会话:web 平台、refresh 12h 硬到期、不占设备并发配额、设备管理可见
  可踢);清理任务顺带清过期票据;schema.sql/AutoMigrate/testutil 同步
- web:/sso 落地页——兑票写入与 Web 版共享的 localStorage 后跳 redirect;
  redirect 白名单只收本站相对路径(防 open redirect),票据即时从地址栏抹除
- client:launchAuthedWebPath(ref, path) 封装(签票→拼 /sso URL→launchUrl,
  失败降级未登录直开;桌面/移动同一套);授权面板「查看套餐价格」接入

test(backend): web_ticket_test 三用例(兑换+续期/单次即焚/过期拒绝/
sso 不占配额双向验证)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:30:08 +08:00
wangjia 11413df3db fix(client): 底部 sheet 打开时横滑改为关 sheet,不再切 tab
- showMSheet 登记「打开中 sheet」关闭器栈(任何方式关闭都注销)
- 壳层横滑手势先查栈:有 sheet 开着 → 从左往右滑关顶层 sheet,
  任何方向都不切 tab / 不退二级屏;无 sheet 才走原 tab 切换/返回逻辑

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:55:31 +08:00
wangjia f9d5bc79c7 fix(client): 关于页「检查更新」按钮与反馈按钮同规格对齐(38h 通栏)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:48:15 +08:00
wangjia 02cffcb9ba feat(client): 切页滑动过渡 + 二级屏返回手势方向修正 + 授权三格横排
- 窄屏切页加方向感知滑入过渡(240ms easeOutCubic + 渐显):tab 间按左右
  次序、进二级屏从右滑入、返回从左滑入;桌面无动画。控制器在 initState
  创建(惰性 late 到 dispose 才求值会在已卸载树上找 Ticker 崩溃)
- 二级屏返回手势方向修正:从左往右滑=退出(iOS 返回习惯),向左滑无响应
- 授权信息三格(状态/到期日/剩余天数)窄屏改横排:间距收窄 + FittedBox
  缩放值文本防溢出,桌面不变

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:39:31 +08:00
wangjia 9efd1177b7 feat(client): 移动端横滑手势——tab 根左右滑切 tab、二级屏左滑退出
- tab 根(库存/入库/出库/财务/我的):左滑下一个 tab、右滑上一个(不循环)
- 二级屏:左滑返回(同顶栏返回箭头语义),右滑无响应
- 仅窄屏生效;内容区横向滚动(分段 tab/表格横滚)在手势竞技场优先,不冲突

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 20:31:33 +08:00
wangjia 980e38e020 feat(client): 库存/出入库移动卡片与详情 sheet 逐一对齐原型结构
- 新增 MCard(原型 .m-card 双栏:mc-main 标题+副行 mono | mc-right 徽章行+
  金额 mono | › | mc-foot ⓘ脚注)+ DsIconBadge(.badge.ico 22px 纯图标)/
  DsTextIconBadge(.badge.bi 图标+文字) + statusToneOf 状态词配色表——
  替换原 MobileListCard「标签:值」布局与各屏本地 _icoBadge/_statusColors
- 出入库卡:单号 + 供应商/客户·N 项 + 状态/退单/待定价图标徽章 + 合计金额 +
  日期·经手人·审核 脚注;库存卡:品名 + 编码·规格 + 状态徽章 + 数量(缺货红/
  预警黄);移动卡不再有行内操作(对齐原型,操作统一在详情 sheet;打标签=
  打印,移动端裁剪)
- DsTable 窄屏卡片流去外层大卡容器(卡直接铺页面底色,对齐原型 m-scroll)——
  往来/基础数据等全部卡片流屏同步受益
- 出入库详情窄屏改 sheet 专属布局(原型 openOrder):drow 字段行 + 明细
  pe-card(头行 图标+「明细 · N 项」+ 行=品名+系列·×数量 | 金额/待定价徽章)+
  合计 drow + 等分按钮行(无组标签);桌面右滑抽屉布局不变
- 相关移动 golden 全量重生成(出入库/库存/往来/基础数据 ×3 主题),
  桌面 golden 零漂移;283 测试全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:30:40 +08:00
wangjia 145bf06ff8 devops: production.env 同步通道修复——render-env 切 ali + 发版漂移告警
2026-07-04 二维码 IP 事故复盘:env 不随发版同步(by design,密钥走
render-env 手动通道),但 render-env 还停在 EC2 时代且割接后没跑过,
线上 env 的 STORAGE_*/CORS 一直是割接临时 IP。三处修复:

- deploy/render-env.sh:目标改 ssh 别名 ali(可 JIU_DEPLOY_SSH 覆盖)、
  SECRET_KEYS 补 PAY_SECRET(旧模板缺此行,直接 deploy 会删掉线上支付
  密钥)、健康检查改 :8081、覆盖前先备份线上 env
- deploy/production.env.template:SERVER_PORT 8080→8081(对齐 ali nginx
  反代口径)、补 PAY_SECRET 占位
- scripts/ci/deploy-server.sh(ali 分支):部署后比对模板与线上 env 的
  非密钥项,漂移只告警不覆盖——手改线上配置不再长期无人发现

线上 env 已手工修正(STORAGE_BASE_URL/STORAGE_PUBLIC_URL/SERVER_CORS_ORIGIN
→ https://jiu.51yanmei.com,退役 LICENSE_* 三行清除,改前有备份)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:20:53 +08:00
wangjia cd477ce81a fix(client): 抽屉内操作不再收抽屉(原地刷新)+ 日期输入框垂直居中
- 出入库详情抽屉:通过/拒绝/撤回/提交/结清/退单/确认定价/打印 全部不收抽屉,
  操作完成后原地重拉单据刷新内容(StatefulBuilder+refresh);仅 遮罩/X/
  修改·删除(离开语义)关闭——对齐拍板「点抽屉外才收回」
- 往来抽屉「编辑」、财务往来抽屉「登记收款/付款」(完成后流水原地刷新)、
  商品编辑抽屉「保存」同规则;「取消」保持关闭语义
- 日期组件输入框 forceStrutHeight 锁行盒:修等宽字体 ascent/descent 不对称
  导致的文字垂直偏移

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 18:09:21 +08:00
wangjia fb236018f3 refactor(client): 日期选择统一为扁平组件——输入框+滚轮同宽居中入面板,桌面/移动全覆盖
- wheel_date_picker.dart 重构为统一组件:单日期 showDsDateDropdown(面板=
  可输入框+三列滚轮+今天/确定)、范围 showDateRangeDropdown(起始|结束双窗格
  并排+共用底栏);桌面锚定下拉(透明遮罩不冻结窗口)、窄屏底部 sheet 同内容
  (范围纵排)——同一套窗格两端复用
- 扁平化:输入框与滚轮同宽 248、文字水平垂直居中(mono)、去掉全部阴影/立体
  效果(滚轮外框仅 1px 边框,中心高亮带去描边)
- DsDateCell(录单日期格)与 DatePickerField(登记收支)改为触发器:点选/
  回车打开统一面板,键入在面板内完成;出库工具栏时间 chip 补切到新组件
- 旧 showWheelDatePicker/showWheelDateRange 两步弹窗删除;组件测试改写为
  新契约(触发器+面板键入+词法归一),表单 golden 随形态更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:49:17 +08:00
wangjia 06dc68245a feat(client): 日期范围改锚定下拉——起止并排双滚轮 + 可键入,不再冻结窗口
- 新增 showDateRangeDropdown:锚定触发控件下方(透明遮罩点外关闭,形态同
  DsMenu 下拉),起始/结束两列并排,各带可输入框(键入/回车/失焦归一
  yyyy-MM-dd)与滚轮,输入↔滚轮双向联动;共用底栏显示当前范围 + 确定,
  起止倒置自动交换;窄屏回落既有两步滚轮弹窗
- WheelDatePanel 加 showFooter/onChanged(滚动即回调),供范围面板复用
- 出入库列表 6 处调用切换:工具栏时间 chip「自定义…」、桌面详搜弹窗、
  窄屏详搜 sheet 的日期区间字段均锚定在各自控件下方

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:29:21 +08:00
wangjia 0425939d7e feat(client): 录单日期格支持直接键入(DsDateCell 可输入 + 滚轮并存)
- DsDateCell 由纯点选改为可键入:支持 2024 / 2024-5-12 / 20240512 等,
  失焦或回车归一为 yyyy-MM-dd;回车提交并推进下一格(键盘流保留 Tab/Esc);
  日历图标仍打开滚轮面板,滚轮确定回填输入框
- 归一词法抽为共享 normalizeYmd(core/utils/date_util.dart),
  DatePickerField 改为复用同一实现(登记收支等处词法一致)
- 出入库表单 golden 基准随日期格形态更新(桌面+移动 ×3 主题)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:20:03 +08:00
82 changed files with 2166 additions and 1010 deletions
+7
View File
@@ -5,6 +5,13 @@
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.5] - 2026-07-04
### 新功能
- App → 网页免登录:新增一次性登录票据接口,从 App 打开官网链接(如查看套餐、
在线购买)自动登录,无需再输一遍账号密码。票据 60 秒单次有效、用后即焚;
兑换出的网页会话 12 小时自动过期、不占登录设备名额,可在设备管理中查看和下线
## [1.1.4] - 2026-07-04
### 新功能
+74
View File
@@ -3,6 +3,8 @@ package handler
import (
"errors"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/internal/middleware"
@@ -139,3 +141,75 @@ func (h *AuthHandler) Ping(c *gin.Context) {
// view 可能为 nil(确无授权)→ license:null,与 /license/info 的 data:null 语义一致。
util.RespondSuccess(c, gin.H{"ok": true, "license": view})
}
// ═══════════════ App → 网页免登录(一次性换票)═══════════════
// webTicketLast 签票限频:每用户 1 秒至多 1 张(票据本就要求已登录,此为防刷兜底)。
var webTicketLast sync.Map // userID → time.Time
// WebTicket POST /api/v1/auth/web-ticket(需登录):签发一次性网页登录票据。
// App 拿到票后拼 https://<host>/sso?t=<ticket>&redirect=<路径> 打开系统浏览器。
func (h *AuthHandler) WebTicket(c *gin.Context) {
shopID := middleware.GetShopID(c)
userID := middleware.GetUserID(c)
if last, ok := webTicketLast.Load(userID); ok &&
time.Since(last.(time.Time)) < time.Second {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "请求过于频繁,请稍后再试"})
return
}
webTicketLast.Store(userID, time.Now())
ticket, ttl, err := h.svc.IssueWebTicket(shopID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "签发失败"})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{
"ticket": ticket,
"expires_in": ttl,
}})
}
// WebTicketExchange POST /api/v1/auth/web-ticket/exchange(公开):
// 网页端凭一次性票据兑换正常登录态(响应结构同 /auth/login,另带 shop_code
// 供官网 localStorage 回填)。票据单次可用,无效/过期/重放一律 401。
func (h *AuthHandler) WebTicketExchange(c *gin.Context) {
var req struct {
Ticket string `json:"ticket" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
dev := service.DeviceInfo{
Platform: "web",
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
}
pair, user, shop, err := h.svc.LoginWithWebTicket(req.Ticket, dev)
if err != nil {
if errors.Is(err, service.ErrTicketInvalid) ||
errors.Is(err, service.ErrInvalidCredentials) ||
errors.Is(err, service.ErrUserInactive) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "票据无效或已过期"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "兑换失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"access_token": pair.AccessToken,
"refresh_token": pair.RefreshToken,
"expires_in": pair.ExpiresIn,
"shop_id": pair.ShopID,
"shop_code": shop.Code,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"real_name": user.RealName,
"role": user.Role,
},
},
})
}
+4 -1
View File
@@ -24,7 +24,10 @@ type UserSession struct {
// 否则 revoke/cleanup 等任意 Updates 都会把已撤销会话误刷成「刚活跃」。
LastSeenAt time.Time `gorm:"autoCreateTime" json:"last_seen_at"`
RevokedAt *time.Time `gorm:"index" json:"revoked_at,omitempty"`
RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled|reuse|pwd_reset|relogin
RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled|reuse|pwd_reset|relogin
// Kind 会话种类:login=正常登录;sso=App 换票的网页短时会话
//(不计设备并发配额,refresh 短时效)。
Kind string `gorm:"size:10;default:'login'" json:"kind"`
RevokedBy *uint64 `gorm:"column:revoked_by" json:"revoked_by,omitempty"` // 吊销操作人 user_id;系统/自助吊销为 NULL
RefreshExpAt time.Time `json:"refresh_exp_at"`
}
+18
View File
@@ -0,0 +1,18 @@
package model
import "time"
// WebTicket App→网页免登录的一次性换票凭据(magic-link)。
// App 持 JWT 签票(TTL 60s),浏览器打开 /sso 后凭票兑换正常 web 会话;
// 单次可用(used_at 原子置位防重放),票据本身不含任何令牌信息。
type WebTicket struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
Ticket string `gorm:"size:64;not null;uniqueIndex" json:"-"`
ShopID uint64 `gorm:"not null" json:"shop_id"`
UserID uint64 `gorm:"not null" json:"user_id"`
ExpiresAt time.Time `gorm:"not null;index" json:"expires_at"`
UsedAt *time.Time `json:"used_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (WebTicket) TableName() string { return "web_tickets" }
+4
View File
@@ -70,6 +70,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
{
auth.POST("/login", loginIP, authH.Login)
auth.POST("/refresh", refreshIP, authH.Refresh)
// App→网页免登录:一次性票据兑换(公开;票据 60s 单次可用,复用 refresh 限流)
auth.POST("/web-ticket/exchange", refreshIP, authH.WebTicketExchange)
}
// 公开接口(无需登录):读接口防爬、写接口防刷
@@ -95,6 +97,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
{
api.POST("/auth/logout", authH.Logout)
api.POST("/auth/ping", authH.Ping)
// App→网页免登录:签发一次性票据(需登录)
api.POST("/auth/web-ticket", authH.WebTicket)
// 在线会话列表:所有登录用户只读
api.GET("/sessions", sessionH.List)
// 强制下线:仅 admin/superadmin
+100 -1
View File
@@ -1,6 +1,8 @@
package service
import (
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"log"
@@ -217,10 +219,12 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
// 统计该 user 全部平台的活跃会话(不分平台类);已达上限则拒绝。
// 仅计未过期会话:refresh 已过期的行等待 cleanup 物理删除,不再占配额
// (历史 bug:过期未清理的会话把坑占满,正常用户被锁在门外)。
// kind=sso(App→网页免登录的短时会话)不占设备配额。
var activeCount int64
if err := tx.Model(&model.UserSession{}).
Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND user_id = ? AND revoked_at IS NULL AND refresh_exp_at > ?",
Where("shop_id = ? AND user_id = ? AND revoked_at IS NULL AND refresh_exp_at > ?"+
" AND kind <> 'sso'",
shop.ID, user.ID, now).
Count(&activeCount).Error; err != nil {
return err
@@ -789,3 +793,98 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role, sid, refreshJTI s
ShopID: shopID,
}, nil
}
// ═══════════════ App → 网页免登录(一次性换票,magic-link)═══════════════
// App 持 JWT 调 IssueWebTicket 签票(TTL 60s、单次可用),浏览器打开 /sso
// 页后凭票 LoginWithWebTicket 兑换正常 web 会话(kind=sso:不占设备配额、
// refresh 12 小时硬到期——RefreshTokens 轮换不延长 refresh_exp_at)。
const (
webTicketTTL = 60 * time.Second
ssoRefreshExpire = 12 * time.Hour
)
// ErrTicketInvalid 票据不存在 / 已使用 / 已过期。
var ErrTicketInvalid = errors.New("票据无效或已过期")
// IssueWebTicket 为已登录用户签发一次性网页登录票据。
func (s *AuthService) IssueWebTicket(shopID, userID uint64) (string, int, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", 0, err
}
ticket := base64.RawURLEncoding.EncodeToString(buf)
wt := model.WebTicket{
Ticket: ticket,
ShopID: shopID,
UserID: userID,
ExpiresAt: time.Now().Add(webTicketTTL),
}
if err := s.db.Create(&wt).Error; err != nil {
return "", 0, err
}
return ticket, int(webTicketTTL.Seconds()), nil
}
// LoginWithWebTicket 凭一次性票据兑换 web 会话。票据消费为原子置位
// UPDATE … WHERE used_at IS NULL),并发重放必失败。
func (s *AuthService) LoginWithWebTicket(
ticket string, dev DeviceInfo,
) (*TokenPair, *model.User, *model.Shop, error) {
now := time.Now()
res := s.db.Model(&model.WebTicket{}).
Where("ticket = ? AND used_at IS NULL AND expires_at > ?", ticket, now).
Update("used_at", now)
if res.Error != nil {
return nil, nil, nil, res.Error
}
if res.RowsAffected != 1 {
return nil, nil, nil, ErrTicketInvalid
}
var wt model.WebTicket
if err := s.db.Where("ticket = ?", ticket).First(&wt).Error; err != nil {
return nil, nil, nil, ErrTicketInvalid
}
var user model.User
if err := s.db.Where("id = ? AND shop_id = ?", wt.UserID, wt.ShopID).
First(&user).Error; err != nil {
return nil, nil, nil, ErrInvalidCredentials
}
if !user.IsActive {
return nil, nil, nil, ErrUserInactive
}
var shop model.Shop
if err := s.db.First(&shop, wt.ShopID).Error; err != nil {
return nil, nil, nil, err
}
// sso 会话:web 平台、短时效、不占配额(平台闸门不拦——票据由已登录
// 会话签出,权限等同签票用户本人)。
sid := uuid.New().String()
jti := uuid.New().String()
sess := model.UserSession{
ShopID: shop.ID,
UserID: user.ID,
SID: sid,
DeviceID: dev.DeviceID,
DeviceName: "网页免登(App 跳转)",
Platform: "web",
PlatformClass: "web",
Kind: "sso",
IP: dev.IP,
UserAgent: dev.UserAgent,
RefreshJTI: jti,
LastSeenAt: now,
RefreshExpAt: now.Add(ssoRefreshExpire),
}
if err := s.db.Create(&sess).Error; err != nil {
return nil, nil, nil, err
}
pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid, jti)
if err != nil {
return nil, nil, nil, err
}
return pair, &user, &shop, nil
}
@@ -47,6 +47,12 @@ func cleanupOnce(db *gorm.DB, retentionDays int) (sessions, attempts int64) {
log.Printf("[cleanup] purge login_attempts failed: %v", r2.Error)
}
// 一次性网页登录票据:过期即无用(TTL 60s),过期一天后物理清除
if err := db.Where("expires_at < ?", time.Now().AddDate(0, 0, -1)).
Delete(&model.WebTicket{}).Error; err != nil {
log.Printf("[cleanup] purge web_tickets failed: %v", err)
}
if r1.RowsAffected > 0 || r2.RowsAffected > 0 {
log.Printf("[cleanup] purged %d sessions, %d login_attempts (older than %dd)",
r1.RowsAffected, r2.RowsAffected, retentionDays)
@@ -0,0 +1,97 @@
package service
// App→网页免登录一次性换票的回归测试:签票/兑票/单次即焚/过期/sso 不占配额。
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
func TestWebTicket_ExchangeOnce(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SSO001")
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
ticket, ttl, err := svc.IssueWebTicket(shop.ID, user.ID)
require.NoError(t, err)
assert.NotEmpty(t, ticket)
assert.Equal(t, 60, ttl)
dev := DeviceInfo{Platform: "web", IP: "1.2.3.4", UserAgent: "test"}
pair, u, sh, err := svc.LoginWithWebTicket(ticket, dev)
require.NoError(t, err)
assert.NotEmpty(t, pair.AccessToken)
assert.Equal(t, user.ID, u.ID)
assert.Equal(t, "SSO001", sh.Code)
// 兑出的 refresh token 可正常续期(会话有效)
_, err = svc.RefreshTokens(pair.RefreshToken)
assert.NoError(t, err)
// 单次即焚:二次兑换必失败
_, _, _, err = svc.LoginWithWebTicket(ticket, dev)
assert.ErrorIs(t, err, ErrTicketInvalid)
}
func TestWebTicket_Expired(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SSO002")
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
ticket, _, err := svc.IssueWebTicket(shop.ID, user.ID)
require.NoError(t, err)
// 手动把票据改成已过期
require.NoError(t, db.Model(&model.WebTicket{}).
Where("ticket = ?", ticket).
Update("expires_at", time.Now().Add(-time.Minute)).Error)
_, _, _, err = svc.LoginWithWebTicket(ticket, DeviceInfo{Platform: "web"})
assert.ErrorIs(t, err, ErrTicketInvalid)
}
func TestWebTicket_SsoSessionNotCountedInQuota(t *testing.T) {
db := testutil.SetupTestDB()
old := config.C.Session.LimitTotal
config.C.Session.LimitTotal = 2
defer func() { config.C.Session.LimitTotal = old }()
shop := testutil.CreateTestShop(db, "SSO003")
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
// 占满 2 个正常配额
_, _, err := svc.Login("SSO003", "admin", "password123",
DeviceInfo{DeviceID: "d1", Platform: "macos"})
require.NoError(t, err)
_, _, err = svc.Login("SSO003", "admin", "password123",
DeviceInfo{DeviceID: "d2", Platform: "ios"})
require.NoError(t, err)
// 配额已满仍可换票登录(sso 不受配额限制)
ticket, _, err := svc.IssueWebTicket(shop.ID, user.ID)
require.NoError(t, err)
_, _, _, err = svc.LoginWithWebTicket(ticket, DeviceInfo{Platform: "web"})
require.NoError(t, err, "sso 会话不受设备配额限制")
// sso 会话短时效 + 打标记
var sess model.UserSession
require.NoError(t, db.Where("shop_id = ? AND kind = 'sso'", shop.ID).
First(&sess).Error)
assert.Equal(t, "web", sess.Platform)
assert.WithinDuration(t, time.Now().Add(12*time.Hour), sess.RefreshExpAt,
time.Minute)
// 反向:sso 会话在场时,正常登录的配额统计不把它算进去
_, _, err = svc.Login("SSO003", "admin", "password123",
DeviceInfo{DeviceID: "d1", Platform: "macos"}) // 同设备重登复用坑位
assert.NoError(t, err, "sso 会话不占配额,正常登录不受影响")
}
+1
View File
@@ -148,6 +148,7 @@ func autoMigrate(db *gorm.DB) {
&model.ProductImage{},
&model.ErrorReport{},
&model.Feedback{},
&model.WebTicket{},
)
if err != nil {
log.Fatalf("auto migrate failed: %v", err)
+18 -1
View File
@@ -71,9 +71,10 @@ CREATE TABLE IF NOT EXISTS `user_sessions` (
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`revoked_at` DATETIME DEFAULT NULL,
`revoked_reason` VARCHAR(30) DEFAULT NULL COMMENT 'kicked|logout|admin|disabled|reuse|pwd_reset',
`revoked_reason` VARCHAR(30) DEFAULT NULL COMMENT 'kicked|logout|admin|disabled|reuse|pwd_reset|relogin',
`revoked_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '吊销操作人 user_id;系统/自助吊销为 NULL',
`refresh_exp_at` DATETIME DEFAULT NULL,
`kind` VARCHAR(10) DEFAULT 'login' COMMENT 'login=正常登录;sso=App换票的网页短时会话(不计设备配额)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_session_sid` (`sid`),
KEY `idx_session_shop_user` (`shop_id`, `user_id`),
@@ -596,4 +597,20 @@ CREATE TABLE IF NOT EXISTS `feedbacks` (
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户意见反馈';
-- ------------------------------------------------------------
-- App→网页免登录一次性换票(magic-linkTTL 60s 单次可用,used_at 原子置位防重放)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `web_tickets` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`ticket` VARCHAR(64) NOT NULL,
`shop_id` BIGINT UNSIGNED NOT NULL,
`user_id` BIGINT UNSIGNED NOT NULL,
`expires_at` DATETIME NOT NULL,
`used_at` DATETIME DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_web_ticket` (`ticket`),
KEY `idx_web_ticket_expires` (`expires_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='App→网页免登录一次性票据';
SET FOREIGN_KEY_CHECKS = 1;
+11 -1
View File
@@ -106,7 +106,17 @@ func SetupTestDB() *gorm.DB {
revoked_at DATETIME,
revoked_reason TEXT,
revoked_by INTEGER,
refresh_exp_at DATETIME
refresh_exp_at DATETIME,
kind TEXT DEFAULT 'login'
)`,
`CREATE TABLE IF NOT EXISTS web_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket TEXT NOT NULL UNIQUE,
shop_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
expires_at DATETIME NOT NULL,
used_at DATETIME,
created_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+38
View File
@@ -20,3 +20,41 @@ String? composeYmd(int? year, int? month, int? day) {
final d = day > maxDay ? maxDay : day;
return formatYmd(DateTime(year, month, d));
}
/// 归一用户键入的日期为 `yyyy-MM-dd`。支持「2024」「2024-5」「2024-5-12」
/// 「20240512」「2024/05/12」等;缺月/日补 01(老酒只知年份,填 2024 即
/// 2024-01-01),超出当月的日 clamp 回当月最大值。无有效年(1900–2200)返回 null。
String? normalizeYmd(String raw) {
final s = raw.trim();
if (s.isEmpty) return null;
int? y, mo, d;
if (RegExp(r'^[0-9]+$').hasMatch(s)) {
// 纯数字串:yyyymmdd / yyyymm / yyyy
if (s.length >= 8) {
y = int.tryParse(s.substring(0, 4));
mo = int.tryParse(s.substring(4, 6));
d = int.tryParse(s.substring(6, 8));
} else if (s.length == 6) {
y = int.tryParse(s.substring(0, 4));
mo = int.tryParse(s.substring(4, 6));
} else {
y = int.tryParse(s);
}
} else {
final parts =
s.split(RegExp(r'[^0-9]+')).where((e) => e.isNotEmpty).toList();
if (parts.isNotEmpty) y = int.tryParse(parts[0]);
if (parts.length >= 2) mo = int.tryParse(parts[1]);
if (parts.length >= 3) d = int.tryParse(parts[2]);
}
if (y == null || y < 1900 || y > 2200) return null;
mo ??= 1;
d ??= 1;
if (mo < 1 || mo > 12) return null;
final maxDay = DateTime(y, mo + 1, 0).day;
if (d < 1) d = 1;
if (d > maxDay) d = maxDay;
return '${y.toString().padLeft(4, '0')}-'
'${mo.toString().padLeft(2, '0')}-'
'${d.toString().padLeft(2, '0')}';
}
+26
View File
@@ -0,0 +1,26 @@
// core/utils/web_launch.dart — App→官网免登录跳转(一次性换票 SSO)。
// 先向后端签一张 60s 单次票据,再打开 官网/sso?t=<票>&redirect=<路径>
// 落地页兑票写入登录态后跳目标页——桌面/移动同一套(launchUrl 跨端)。
// 签票失败(未登录/网络异常)时降级为直接打开目标链接(未登录态浏览)。
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../api/api_client.dart';
import '../config/app_config.dart';
/// 打开官网 [path](如 `/#pricing`、`/profile/`)并自动登录。
Future<void> launchAuthedWebPath(WidgetRef ref, String path) async {
final base = AppConfig.publicBaseUrl;
Uri target = Uri.parse('$base$path');
try {
final resp = await ref.read(apiClientProvider).post('/auth/web-ticket');
final ticket = resp.data?['data']?['ticket'] as String?;
if (ticket != null && ticket.isNotEmpty) {
target = Uri.parse(
'$base/sso/?t=$ticket&redirect=${Uri.encodeComponent(path)}');
}
} catch (_) {
// 签票失败降级:未登录态直开目标页
}
await launchUrl(target, mode: LaunchMode.externalApplication);
}
+8 -3
View File
@@ -189,10 +189,15 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
Text('酒水进销存管理系统',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
]),
const SizedBox(height: 12),
// 检查更新与下方反馈按钮同规格(38h 通栏,左右边缘对齐)
Row(children: [
Expanded(
child: DsButton('检查更新',
icon: LucideIcons.refreshCw, onPressed: _checkUpdate),
),
]),
const SizedBox(height: 10),
DsSmallGhostButton(
label: '检查更新', icon: LucideIcons.refreshCw, onTap: _checkUpdate),
const SizedBox(height: 18),
Row(children: [
Expanded(
child: DsButton('反馈 Bug',
@@ -8,26 +8,19 @@ import '../../core/theme/context_tokens.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/storage/column_prefs.dart';
import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../core/utils/dialog_util.dart';
import '../../models/inventory.dart';
import '../../providers/inventory_provider.dart';
import '../../providers/product_provider.dart';
import '../../providers/product_option_provider.dart';
import '../../providers/shop_provider.dart' show shopInfoProvider;
import '../../widgets/write_guard.dart';
import '../../widgets/kpi_card.dart' show StatusPill; // 移动卡片状态药丸
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_kpi.dart';
import '../../widgets/ds/ds_menu.dart';
import '../../widgets/ds/ds_table.dart';
import '../../widgets/ds/m_card.dart';
import '../../widgets/ds/m_kpi_grid.dart';
import '../../widgets/ds/m_search_row.dart';
import '../../widgets/ds/m_sheet.dart';
import '../../widgets/ds/status_icon_map.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/multi_select_dropdown.dart' show ColDef;
import '../../widgets/label_preview_dialog.dart';
import '../../widgets/product_editor_drawer.dart';
import '../../core/theme/app_fonts.dart';
import '../../widgets/ds/ds_toast.dart';
@@ -363,33 +356,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
}
}
Future<void> _printLabel(BuildContext context, Inventory item) async {
if (item.productId == null) return;
final qrBytes = await ref
.read(productRepositoryProvider)
.getQRCodeBytes(item.productId!);
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
if (!context.mounted) return;
final label = LabelData(
productId: item.productId,
qrBytes: qrBytes,
name: item.productName,
code: item.productCode,
series: item.series.isEmpty ? null : item.series,
spec: item.spec.isEmpty ? null : item.spec,
batchNo: item.batchNo.isEmpty ? null : item.batchNo,
productionDate: item.productionDate,
remark: item.remark.isEmpty ? null : item.remark,
shopName: shopInfo?.name ?? '',
shopAddress: shopInfo?.address ?? '',
shopPhone: shopInfo?.phone ?? '',
);
showAppDialog(
context: context,
builder: (_) => LabelPreviewDialog(labels: [label]),
);
}
void _exportInventory(List<Inventory> items) {
exportExcel(
filename: '库存查询',
@@ -551,41 +517,40 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
};
}
/// 库存查询:窄屏卡片
/// 库存查询:窄屏卡片(原型 m-inventory .m-card 双栏:
/// 品名+编码·规格 | 状态徽章+数量 | ›;操作统一收进详情 sheet,移动无打印)。
Widget _inventoryCard(Inventory item) {
return MobileListCard(
final t = context.tokens;
final status = _statusOf(item);
final qtyColor = switch (status) {
'缺货' => t.danger,
'预警' => t.warn,
_ => t.heading,
};
final sub = [
if (item.productCode.isNotEmpty) item.productCode,
if (item.spec.isNotEmpty) item.spec,
].join(' · ');
return MCard(
onTap: item.productId != null ? () => _openEditor(item) : null,
title: Text(item.productName.isEmpty ? '-' : item.productName),
subtitle: item.productCode.isEmpty ? null : Text(item.productCode),
trailing: _InventoryStatusBadge(item),
fields: [
if (item.spec.isNotEmpty) MobileCardField('规格', item.spec),
if (item.series.isNotEmpty) MobileCardField('系列', item.series),
if (item.batchNo.isNotEmpty) MobileCardField('批次', item.batchNo),
MobileCardField(
'仓库', item.warehouseName.isEmpty ? '-' : item.warehouseName),
MobileCardField(
'单价',
item.unitPrice != null
? '¥${item.unitPrice!.toStringAsFixed(2)}'
: '待定价'),
if (item.supplierName.isNotEmpty)
MobileCardField('供应商', item.supplierName),
],
actions: [
if (!WriteGuard.isReadonly(ref))
WriteGuard(
child: TextButton(
onPressed: () => _editRemark(context, item),
child: const Text('备注', style: TextStyle(fontSize: 13)),
),
),
if (item.productId != null)
TextButton(
onPressed: () => _printLabel(context, item),
child: const Text('打标签', style: TextStyle(fontSize: 13)),
),
],
nm: item.productName.isEmpty ? '-' : item.productName,
sub: sub.isEmpty ? null : sub,
badges: [DsTextIconBadge(status)],
amtWidget: Text.rich(
TextSpan(children: [
TextSpan(
text: item.quantity.toStringAsFixed(0),
style: TextStyle(color: qtyColor)),
TextSpan(
text: ' ${item.unit}'.trimRight(),
style: TextStyle(color: t.heading, fontWeight: FontWeight.w400)),
]),
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w700,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback),
),
);
}
@@ -678,46 +643,46 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
// 头部(原型 .head{margin-bottom:18px}):标题 + SKU 数 + 列设置/导出
// 窄屏隐藏(原型 m-inventory:标题在壳顶栏 m-top
if (!mobile)
Container(
width: double.infinity,
color: context.tokens.bg,
padding: const EdgeInsets.only(bottom: 18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('库存管理',
style: TextStyle(
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700,
color: context.tokens.heading)),
const SizedBox(width: AppDims.sp3),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${NumberFormat.decimalPattern().format(result.total)} 个 SKU · 数据实时',
Container(
width: double.infinity,
color: context.tokens.bg,
padding: const EdgeInsets.only(bottom: 18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('库存管理',
style: TextStyle(
fontSize: AppDims.fsSm,
color: context.tokens.muted)),
),
const Spacer(),
if (!context.isMobile) ...[
Builder(
builder: (bctx) => DsButton(
'列设置',
icon: LucideIcons.alignLeft,
onPressed: () => _openColMenu(bctx),
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700,
color: context.tokens.heading)),
const SizedBox(width: AppDims.sp3),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${NumberFormat.decimalPattern().format(result.total)} 个 SKU · 数据实时',
style: TextStyle(
fontSize: AppDims.fsSm,
color: context.tokens.muted)),
),
const Spacer(),
if (!context.isMobile) ...[
Builder(
builder: (bctx) => DsButton(
'列设置',
icon: LucideIcons.alignLeft,
onPressed: () => _openColMenu(bctx),
),
),
),
const SizedBox(width: AppDims.sp2),
DsButton(
'导出',
icon: LucideIcons.download,
onPressed: () => _exportInventory(filteredItems),
),
const SizedBox(width: AppDims.sp2),
DsButton(
'导出',
icon: LucideIcons.download,
onPressed: () => _exportInventory(filteredItems),
),
],
],
],
),
),
),
// KPI 卡(还原原型 .kpi):宽屏 4 等分,窄屏横向滚动
Builder(builder: (context) {
// KPI 走全店汇总接口(不随分页),环比对月初快照。
@@ -771,8 +736,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
delta: '需补货 ${summary?.warningCount ?? 0}',
deltaTone: MKpiDeltaTone.warn,
selected: _statusFilter == '缺货',
onTap: () =>
setState(() => _statusFilter = '缺货'),
onTap: () => setState(() => _statusFilter = '缺货'),
),
]),
);
@@ -1059,34 +1023,3 @@ class _RowActEyeState extends State<_RowActEye> {
);
}
}
class _InventoryStatusBadge extends StatelessWidget {
final Inventory item;
const _InventoryStatusBadge(this.item);
@override
Widget build(BuildContext context) {
final t = context.tokens;
// 状态派生 qty vs min_stock(在售 / 预警 / 缺货),软底 + 图标变体
//2026-07-04 拍板:圆点 → 代表图标,映射走 status_icon_map)。
if (item.quantity == 0) {
return StatusPill(
label: '缺货',
color: t.danger,
background: t.dangerBg,
icon: statusIcon('缺货'));
}
if (item.minStock != null && item.quantity < item.minStock!) {
return StatusPill(
label: '预警',
color: t.warn,
background: t.warnBg,
icon: statusIcon('预警'));
}
return StatusPill(
label: '在售',
color: t.success,
background: t.okSoft,
icon: statusIcon('在售'));
}
}
+27 -25
View File
@@ -4,11 +4,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/auth/auth_state.dart';
import '../../core/config/app_info.dart';
import '../../core/config/license_copy.dart';
import '../../core/utils/web_launch.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/app_fonts.dart';
@@ -86,22 +85,16 @@ class _LicensePanelState extends ConsumerState<LicensePanel> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── 卡1:授权信息(3 cell,自「关于我们」迁入)──
// ── 卡1:授权信息(3 cell 横排,窄屏同样横排、间距收窄)──
SettingsCard(
title: '授权信息',
desc: '当前门店的授权状态与有效期,续期时长在现有到期时间上叠加',
child: context.isMobile
? Column(children: [
for (final c in licCells)
Padding(
padding: const EdgeInsets.only(bottom: 10), child: c),
])
: Row(children: [
for (var i = 0; i < licCells.length; i++) ...[
if (i > 0) const SizedBox(width: 14),
Expanded(child: licCells[i]),
],
]),
child: Row(children: [
for (var i = 0; i < licCells.length; i++) ...[
if (i > 0) SizedBox(width: context.isMobile ? 8 : 14),
Expanded(child: licCells[i]),
],
]),
),
const SizedBox(height: 16),
// ── 卡2:在线购买 / 续费(后端 purchase 接口 admin only
@@ -120,8 +113,8 @@ class _LicensePanelState extends ConsumerState<LicensePanel> {
Text('在线购买请联系门店管理员 · ',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
InkWell(
onTap: () =>
launchUrl(Uri.parse('${AppInfo.website}/#pricing')),
// 免登跳官网(一次性换票 SSO):浏览器落地即已登录,可直接购买
onTap: () => launchAuthedWebPath(ref, '/#pricing'),
child: Text('查看套餐价格 →',
style: TextStyle(
fontSize: AppDims.fsSm,
@@ -189,7 +182,10 @@ class _LicensePanelState extends ConsumerState<LicensePanel> {
/// 原型 .lic-cell(与「关于我们」同款):lk 11 muted + lv 17/700/mono。
Widget _licCell(dynamic t, String label, String value, {Color? color}) =>
Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
// 窄屏三格横排时收窄内距,值文本按格宽缩放防溢出(如 2026-06-25
padding: context.isMobile
? const EdgeInsets.fromLTRB(10, 12, 10, 12)
: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.borderSubtle),
@@ -199,15 +195,21 @@ class _LicensePanelState extends ConsumerState<LicensePanel> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
const SizedBox(height: 6),
Text(value,
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: color ?? t.heading)),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(value,
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: color ?? t.heading)),
),
],
),
);
+71 -127
View File
@@ -760,7 +760,8 @@ class RowActions extends StatelessWidget {
);
}
/// 内联日期格(.datefield / .gci-date):点选或 Enter 打开日历,值等宽显示。
/// 内联日期格(.datefield / .gci-date):点选或 Enter 打开统一日期下拉
/// (面板内含可输入框 + 三列滚轮,见 wheel_date_picker.dart),值等宽显示。
class DsDateCell extends StatefulWidget {
final String? value; // yyyy-MM-dd
final ValueChanged<String?> onChanged;
@@ -787,143 +788,86 @@ class DsDateCell extends StatefulWidget {
}
class _DsDateCellState extends State<DsDateCell> {
// 浮层锚定到字段下方(对齐原型 datewheel.jspop.style.top = field.bottom + 4),
// 非居中弹窗,无遮罩——点字段外任意处收起(TapRegion.onTapOutside)。
final _link = LayerLink();
OverlayEntry? _entry;
bool get _open => _entry != null;
BuildContext? _anchorCtx; // 字段自身 ctx(Builder 捕获),下拉锚定于此
@override
void dispose() {
_removeOverlay();
super.dispose();
}
void _toggle() {
if (_open) {
_close();
} else {
_openOverlay();
Future<void> _pick() async {
final anchor = _anchorCtx;
if (!widget.enabled || anchor == null) return;
final d = await showDsDateDropdown(anchor, initial: parseYmd(widget.value));
if (d != null) {
widget.onChanged(formatYmd(d));
widget.onPicked?.call();
}
}
void _openOverlay() {
if (_open || !widget.enabled) return;
final init = parseYmd(widget.value) ?? DateTime.now();
_entry = OverlayEntry(
builder: (_) => Positioned(
width: 248,
child: CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: Alignment.bottomLeft,
followerAnchor: Alignment.topLeft,
offset: const Offset(0, 4),
child: TapRegion(
onTapOutside: (_) => _close(),
child: WheelDatePanel(
initial: init,
onCommit: (d) {
widget.onChanged(formatYmd(d));
_close();
widget.onPicked?.call();
},
),
),
),
),
);
Overlay.of(context).insert(_entry!);
setState(() {});
}
void _close() {
_removeOverlay();
if (mounted) setState(() {});
}
void _removeOverlay() {
_entry?.remove();
_entry = null;
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final has = widget.value != null && widget.value!.isNotEmpty;
final height = widget.cell ? 32.0 : 38.0;
return CompositedTransformTarget(
link: _link,
child: Focus(
focusNode: widget.focusNode,
onKeyEvent: (node, e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
final k = e.logicalKey;
if (k == LogicalKeyboardKey.enter ||
k == LogicalKeyboardKey.numpadEnter ||
k == LogicalKeyboardKey.space) {
_toggle();
return KeyEventResult.handled;
}
if (k == LogicalKeyboardKey.escape) {
if (_open) {
_close();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
if (k == LogicalKeyboardKey.tab) {
final back = HardwareKeyboard.instance.isShiftPressed;
if (_open) _close();
final handled = widget.onTab?.call(backward: back) ?? false;
return handled ? KeyEventResult.handled : KeyEventResult.ignored;
}
return KeyEventResult.ignored;
},
child: Builder(builder: (context) {
final active = Focus.of(context).hasFocus || _open;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.enabled
? () {
widget.focusNode?.requestFocus();
_toggle();
}
: null,
child: Container(
height: height,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: widget.cell
? (active ? t.surface : Colors.transparent)
: t.surface,
border: Border.all(
color: widget.hasError
? t.danger
: (active
? t.primary
: (widget.cell ? Colors.transparent : t.border)),
),
borderRadius:
BorderRadius.circular(widget.cell ? 5 : AppDims.rMd),
return Focus(
focusNode: widget.focusNode,
onKeyEvent: (node, e) {
if (e is! KeyDownEvent) return KeyEventResult.ignored;
final k = e.logicalKey;
if (k == LogicalKeyboardKey.enter ||
k == LogicalKeyboardKey.numpadEnter ||
k == LogicalKeyboardKey.space) {
_pick();
return KeyEventResult.handled;
}
if (k == LogicalKeyboardKey.tab) {
final back = HardwareKeyboard.instance.isShiftPressed;
final handled = widget.onTab?.call(backward: back) ?? false;
return handled ? KeyEventResult.handled : KeyEventResult.ignored;
}
return KeyEventResult.ignored;
},
child: Builder(builder: (context) {
_anchorCtx = context;
final active = Focus.of(context).hasFocus;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.enabled
? () {
widget.focusNode?.requestFocus();
_pick();
}
: null,
child: Container(
height: height,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: widget.cell
? (active ? t.surface : Colors.transparent)
: t.surface,
border: Border.all(
color: widget.hasError
? t.danger
: (active
? t.primary
: (widget.cell ? Colors.transparent : t.border)),
),
child: Row(children: [
Expanded(
child: Text(
has ? widget.value! : '选择日期',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsBody,
color: has ? t.text : t.faint,
fontFamily: has ? 'monospace' : null),
),
),
Icon(LucideIcons.calendar, size: 15, color: t.faint),
]),
borderRadius:
BorderRadius.circular(widget.cell ? 5 : AppDims.rMd),
),
);
}),
),
child: Row(children: [
Expanded(
child: Text(
has ? widget.value! : '选择日期',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsBody,
color: has ? t.text : t.faint,
fontFamily: has ? AppFonts.mono : null,
fontFamilyFallback: has ? AppFonts.monoFallback : null),
),
),
Icon(LucideIcons.calendar, size: 15, color: t.faint),
]),
),
);
}),
);
}
}
+95 -2
View File
@@ -25,6 +25,7 @@ import '../../widgets/notification_bell.dart';
import '../../widgets/app_status_bar.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.dart';
import '../../widgets/ds/m_sheet.dart';
import '../../widgets/ds/m_tab_bar.dart';
import '../../widgets/theme_sheet.dart';
@@ -130,11 +131,58 @@ class AppShell extends ConsumerStatefulWidget {
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
class _AppShellState extends ConsumerState<AppShell>
with SingleTickerProviderStateMixin {
final String _loginTime = DateFormat('HH:mm:ss').format(AppStatusBar.clock());
bool _forceDialogShown = false;
bool _licenseDialogShown = false;
// ── 窄屏切页滑动过渡:方向感知的一次性滑入(tab 按左右序、进二级从右、返回从左)──
// 注意:必须在 initState 创建(惰性 late 若到 dispose 才首次求值,会在已卸载
// 的树上找 TickerMode 而崩溃——桌面路径全程不触碰该控制器)。
late final AnimationController _slideCtrl;
double _slideFrom = 1; // 1=从右滑入,-1=从左滑入
String _prevLocation = '';
int _prevBranch = 0;
@override
void initState() {
super.initState();
_slideCtrl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 240), value: 1);
_prevLocation = widget.location;
_prevBranch = widget.navigationShell.currentIndex;
}
@override
void didUpdateWidget(AppShell old) {
super.didUpdateWidget(old);
if (widget.location == _prevLocation) return;
final oldRoot = _tabRoots.contains(_prevLocation);
final newRoot = _tabRoots.contains(widget.location);
if (oldRoot && newRoot) {
// tab 间切换:按 tab 左右次序定方向
final oldPos = _tabForBranch(_prevBranch);
final newPos = _tabForBranch(widget.navigationShell.currentIndex);
_slideFrom = newPos >= oldPos ? 1 : -1;
} else if (newRoot) {
_slideFrom = -1; // 退出二级屏 → 从左滑入
} else {
_slideFrom = 1; // 进入二级屏/更深页 → 从右滑入
}
_prevLocation = widget.location;
_prevBranch = widget.navigationShell.currentIndex;
if (MediaQuery.sizeOf(context).width < 600) {
_slideCtrl.forward(from: 0);
}
}
@override
void dispose() {
_slideCtrl.dispose();
super.dispose();
}
/// 打开账号菜单(个人设置 / 退出登录)。[anchorContext] = 用户区组件的 context
/// 据其实测宽度让菜单等宽(不溢出内容区)、据其位置把菜单**完整弹到按钮正上方**
/// (留 8px 间隙,不遮挡触发器,对齐原型)。
@@ -224,6 +272,51 @@ class _AppShellState extends ConsumerState<AppShell> {
// 并在顶栏出返回箭头(_buildTopBar 内)。
final isTabRoot = _tabRoots.contains(widget.location);
// 窄屏横滑手势(2026-07-04 拍板):tab 根左右滑切换相邻 tab(不循环);
// 二级屏从左往右滑退出(iOS 返回习惯,回来源/我的),向左滑无响应。
// 内容区里的横向滚动(分段 tab / 表格横滚)在手势竞技场中优先,互不冲突。
// 切页时按方向播放一次性滑入过渡(_slideCtrldidUpdateWidget 触发)。
Widget shellContent = widget.navigationShell;
if (isMobile) {
shellContent = GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragEnd: (d) {
final v = d.primaryVelocity ?? 0;
if (v.abs() < 200) return; // 轻微拖动不触发
// 有底部 sheet(详情/筛选等「抽屉」)开着:从左往右滑关掉顶层
// sheet,任何方向都不切 tab / 不退屏。
if (hasOpenMSheet) {
if (v > 0) closeTopMSheet();
return;
}
if (isTabRoot) {
final pos = _tabForBranch(widget.navigationShell.currentIndex);
final next = v < 0 ? pos + 1 : pos - 1; // 向左滑→下一个 tab
if (next < 0 || next >= _mobileTabs.length) return;
_goBranch(_mobileTabs[next].$3);
} else if (v > 0) {
_backFromSecondary(); // 从左往右滑=退出;向左滑无响应
}
},
child: AnimatedBuilder(
animation: _slideCtrl,
builder: (ctx, child) {
final p =
Curves.easeOutCubic.transform(_slideCtrl.value); // 0→1
final w = MediaQuery.sizeOf(ctx).width;
return Opacity(
opacity: 0.4 + 0.6 * p,
child: Transform.translate(
offset: Offset(_slideFrom * 0.18 * w * (1 - p), 0),
child: child,
),
);
},
child: widget.navigationShell,
),
);
}
final Widget shell = SelectionArea(
child: Scaffold(
body: Column(
@@ -238,7 +331,7 @@ class _AppShellState extends ConsumerState<AppShell> {
children: [
..._buildBanners(context, isOnline, updateInfo,
updateNotifier, licenseInfo),
Expanded(child: widget.navigationShell),
Expanded(child: shellContent),
if (!isMobile) AppStatusBar(loginTime: _loginTime),
],
),
@@ -19,9 +19,8 @@ import '../../widgets/ds/ds_kpi.dart';
import '../../widgets/ds/ds_table.dart';
import '../../widgets/ds/m_kpi_grid.dart';
import '../../widgets/ds/m_search_row.dart';
import '../../widgets/ds/m_card.dart';
import '../../widgets/ds/m_sheet.dart';
import '../../widgets/ds/status_icon_map.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/searchable_option_field.dart';
import '../../widgets/combo_search_field.dart';
import '../../widgets/status_badge.dart';
@@ -198,8 +197,8 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
n.setDetail(const {});
}
Future<void> _pickDateRange() async {
final range = await showWheelDateRange(context, initial: _dateRange);
Future<void> _pickDateRange(BuildContext anchor) async {
final range = await showDateRangeDropdown(anchor, initial: _dateRange);
if (range != null) {
setState(() {
_dateRange = range;
@@ -210,9 +209,9 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
}
/// 入库时间预设(对齐原型:全部时间/近7天/近30天/本月/自定义)。
void _setDatePreset(String preset) {
void _setDatePreset(BuildContext anchor, String preset) {
if (preset == 'custom') {
_pickDateRange();
_pickDateRange(anchor);
return;
}
final now = DateTime.now();
@@ -245,26 +244,29 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
ref.read(stockInListProvider.notifier).setDateRange(_startDate, _endDate);
}
Widget _dateMenu(BuildContext ctx) => PopupMenuButton<String>(
onSelected: _setDatePreset,
offset: const Offset(0, 40),
color: ctx.tokens.surface,
elevation: 8,
shape: RoundedRectangleBorder(
side: BorderSide(color: ctx.tokens.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
Widget _dateMenu(BuildContext ctx) => Builder(
// anchorCtx = chip 自身位置:自定义范围下拉锚定在 chip 下方
builder: (anchorCtx) => PopupMenuButton<String>(
onSelected: (v) => _setDatePreset(anchorCtx, v),
offset: const Offset(0, 40),
color: ctx.tokens.surface,
elevation: 8,
shape: RoundedRectangleBorder(
side: BorderSide(color: ctx.tokens.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
itemBuilder: (_) => const [
PopupMenuItem(value: 'all', height: 38, child: Text('全部时间')),
PopupMenuItem(value: '7', height: 38, child: Text('近 7 天')),
PopupMenuItem(value: '30', height: 38, child: Text('近 30 天')),
PopupMenuItem(value: 'month', height: 38, child: Text('本月')),
PopupMenuItem(value: 'custom', height: 38, child: Text('自定义…')),
],
child: DsChip(
label: '入库时间',
value: _datePresetLabel.isEmpty ? null : _datePresetLabel,
onClear: () => _setDatePreset(anchorCtx, 'all')),
),
itemBuilder: (_) => const [
PopupMenuItem(value: 'all', height: 38, child: Text('全部时间')),
PopupMenuItem(value: '7', height: 38, child: Text('近 7 天')),
PopupMenuItem(value: '30', height: 38, child: Text('近 30 天')),
PopupMenuItem(value: 'month', height: 38, child: Text('本月')),
PopupMenuItem(value: 'custom', height: 38, child: Text('自定义…')),
],
child: DsChip(
label: '入库时间',
value: _datePresetLabel.isEmpty ? null : _datePresetLabel,
onClear: () => _setDatePreset('all')),
);
Future<void> _openAdvSearch() async {
@@ -574,34 +576,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
// ── 窄屏形态(对齐移动原型 m-stock-in-list.html)──────────────────
/// 状态词 → (前景, 软底):镜像原型 .badge.b-*,全走 token。
(Color, Color) _statusColors(String label) {
final t = context.tokens;
return switch (label) {
'待审核' => (t.warn, t.warnBg),
'已审核' => (t.success, t.successBg),
'已拒绝' => (t.danger, t.dangerBg),
'部分退单' => (t.warn, t.warnBg),
'已退单' => (t.danger, t.dangerBg),
'待定价' => (t.warn, t.warnBg),
_ => (t.muted, t.infoSoft), // 草稿
};
}
/// 原型 .badge.ico:纯图标小徽章(卡片右上态标 / 状态 sheet 选项行)。
Widget _icoBadge(String label) {
final (fg, bg) = _statusColors(label);
return Container(
height: 22,
width: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(AppDims.rPill),
),
child: Icon(statusIcon(label), size: 12, color: fg),
);
}
/// 原型 .m-kpi 2×2:近30天笔数/金额 + 可点筛选的待审核/草稿(再点取消)。
Widget _buildMobileKpis(StockSummary? summary, int total) {
String yuanWan(double v) => v >= 10000
@@ -717,7 +692,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
),
child: Row(children: [
if (_statusOptions[i].$2 != '全部') ...[
_icoBadge(_statusOptions[i].$2),
DsIconBadge(_statusOptions[i].$2),
const SizedBox(width: 10),
],
Text(_statusOptions[i].$2,
@@ -921,62 +896,54 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
}
}
/// 入库单:窄屏卡片(原型 .m-card:图标徽章 + › 箭头;操作统一收进详情 sheet)。
/// 入库单:窄屏卡片(原型 .m-card 双栏:单号+供应商·N 项 | 图标徽章+金额 | ›;
/// mc-foot = 日期 · 经手人 · 审核)。
Widget _orderCard(BuildContext context, StockInOrder o) {
final hasPending = o.items.any((it) => it.costPrice == 0);
return MobileListCard(
final foot = [
if (o.orderDate != null) o.orderDate!.substring(0, 10),
if (o.operatorName != null) '经手人 ${o.operatorName}',
if (o.reviewerName != null) '审核 ${o.reviewerName}',
].join(' · ');
return MCard(
onTap: () => _showDetail(context, o.id),
title: Text(o.orderNo,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: 14)),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
_icoBadge(_apiStatusToEnum(o.status).label),
if (o.returnState == 'partial') ...[
const SizedBox(width: 5),
_icoBadge('部分退单'),
] else if (o.returnState == 'full') ...[
const SizedBox(width: 5),
_icoBadge('已退单'),
],
if (hasPending) ...[
const SizedBox(width: 5),
_icoBadge('待定价'),
],
const SizedBox(width: 6),
Icon(LucideIcons.chevronRight, size: 18, color: context.tokens.faint),
]),
fields: [
MobileCardField('供应商', o.partnerName ?? '-'),
MobileCardField('仓库', o.warehouseName ?? '-'),
MobileCardField('合计金额',
o.costTotal != null ? '¥${o.costTotal!.toStringAsFixed(2)}' : '-'),
MobileCardField('入库时间', o.orderDate?.substring(0, 10) ?? '-'),
MobileCardField('入库员', o.operatorName ?? '-'),
MobileCardField('审核员', o.reviewerName ?? '-'),
nm: o.orderNo,
sub: '${o.partnerName ?? ''} · ${o.items.length}',
badges: [
DsIconBadge(_apiStatusToEnum(o.status).label),
if (o.returnState == 'partial')
const DsIconBadge('部分退单')
else if (o.returnState == 'full')
const DsIconBadge('已退单'),
if (hasPending) const DsIconBadge('待定价'),
],
amt: o.costTotal != null ? _money.format(o.costTotal) : '',
foot: foot.isEmpty ? null : foot,
);
}
// 点击行/眼睛 → 右侧详情抽屉(对齐原型 openDetail)。
// 抽屉内按钮不收抽屉(2026-07-04 拍板):操作完成后原地重拉单据刷新内容,
// 仅点遮罩/X/「修改·删除」这类离开语义才关闭。
Future<void> _showDetail(BuildContext context, int orderId) async {
await showOrderDetailDrawer(
context,
builder: (ctx) => FutureBuilder<StockInOrder>(
future: ref.read(stockInRepositoryProvider).get(orderId),
builder: (c, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError || !snap.hasData) {
return Center(
child: Text('暂无数据,网络不可用',
style: TextStyle(color: context.tokens.muted)),
);
}
return _buildDetailDrawer(snap.data!);
},
builder: (ctx) => StatefulBuilder(
builder: (sctx, setSheet) => FutureBuilder<StockInOrder>(
future: ref.read(stockInRepositoryProvider).get(orderId),
builder: (c, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError || !snap.hasData) {
return Center(
child: Text('暂无数据,网络不可用',
style: TextStyle(color: context.tokens.muted)),
);
}
return _buildDetailDrawer(snap.data!, () => setSheet(() {}));
},
),
),
);
}
@@ -988,7 +955,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
static String _fmtQty(double q) =>
q == q.roundToDouble() ? q.toStringAsFixed(0) : q.toString();
Widget _buildDetailDrawer(StockInOrder o) {
Widget _buildDetailDrawer(StockInOrder o, VoidCallback refresh) {
final t = context.tokens;
final rb = returnStateBadge(context, o.returnState);
return OrderDetailDrawer(
@@ -1028,11 +995,12 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
totalText: o.costTotal != null ? _money.format(o.costTotal) : '',
priceLabel: '进价(单瓶)',
amountLabel: '总价',
actionGroups: _detailActionGroups(o),
actionGroups: _detailActionGroups(o, refresh),
);
}
List<DrawerActionGroup> _detailActionGroups(StockInOrder o) {
List<DrawerActionGroup> _detailActionGroups(
StockInOrder o, VoidCallback refresh) {
final readonly = WriteGuard.isReadonly(ref);
final isAdmin = ref.read(isAdminProvider);
final canWithdraw = isAdmin ||
@@ -1048,6 +1016,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
if (!readonly) {
switch (o.status) {
case 'draft':
// 修改/删除是离开语义:收抽屉再走
audit.add(b('修改', () {
close();
context.go('/stock-in/edit/${o.id}');
@@ -1056,36 +1025,36 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
close();
_confirmDelete(context, o);
}, v: DsBtnVariant.danger));
audit.add(b('提交', () {
close();
_confirmSubmit(context, o);
audit.add(b('提交', () async {
await _confirmSubmit(context, o);
refresh();
}, v: DsBtnVariant.primary));
break;
case 'pending':
audit.add(b('通过', () {
close();
_confirmApprove(context, o);
audit.add(b('通过', () async {
await _confirmApprove(context, o);
refresh();
}, v: DsBtnVariant.primary));
audit.add(b('拒绝', () {
close();
_confirmReject(context, o);
audit.add(b('拒绝', () async {
await _confirmReject(context, o);
refresh();
}, v: DsBtnVariant.danger));
if (canWithdraw) {
audit.add(b('撤回', () {
close();
_confirmWithdraw(context, o);
audit.add(b('撤回', () async {
await _confirmWithdraw(context, o);
refresh();
}));
}
break;
case 'approved':
audit.add(b('结清', () {
close();
_confirmSettle(context, o.id, 'stock_in');
audit.add(b('结清', () async {
await _confirmSettle(context, o.id, 'stock_in');
refresh();
}));
if (isAdmin) {
audit.add(b('退单', () {
close();
_confirmReturn(context, o);
audit.add(b('退单', () async {
await _confirmReturn(context, o);
refresh();
}, v: DsBtnVariant.danger));
}
break;
@@ -1096,9 +1065,9 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
final pending = o.items.where((it) => it.costPrice == 0).length;
if (o.status == 'approved' && isAdmin && pending > 0) {
groups.add(DrawerActionGroup('成本 · $pending 项待定价', [
b('确认进价', () {
close();
_confirmCost(o);
b('确认进价', () async {
await _confirmCost(o);
refresh();
}, v: DsBtnVariant.primary),
]));
}
@@ -1106,12 +1075,8 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
// 窄屏详情不出打印入口(移动端无打印——用户拍板)。
if (!context.isMobile) {
groups.add(DrawerActionGroup('打印', [
b('打印标签', () {
close();
_printLabels(o);
}),
b('打印标签', () => _printLabels(o)),
b('打印单据', () async {
close();
final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (mounted) await showStockInOrderPrint(context, ref, order);
}),
@@ -1649,8 +1614,8 @@ class _AdvSearchDialogState extends ConsumerState<_AdvSearchDialog> {
Navigator.of(context).pop(_AdvResult(d, _status, _range));
}
Future<void> _pickDate() async {
final range = await showWheelDateRange(context, initial: _range);
Future<void> _pickDate(BuildContext anchor) async {
final range = await showDateRangeDropdown(anchor, initial: _range);
if (range != null) setState(() => _range = range);
}
@@ -1930,33 +1895,35 @@ class _AdvSearchDialogState extends ConsumerState<_AdvSearchDialog> {
final label = has
? '${_range!.start.toString().substring(0, 10)} ~ ${_range!.end.toString().substring(0, 10)}'
: '全部时间';
return InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(label,
style: TextStyle(
fontSize: AppDims.fsBody, color: has ? t.text : t.faint)),
),
if (has)
GestureDetector(
onTap: () => setState(() => _range = null),
child: Icon(LucideIcons.x, size: 14, color: t.muted),
)
else
Icon(LucideIcons.calendar, size: 16, color: t.faint),
]),
),
);
return Builder(
builder: (anchorCtx) => InkWell(
onTap: () => _pickDate(anchorCtx),
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(label,
style: TextStyle(
fontSize: AppDims.fsBody,
color: has ? t.text : t.faint)),
),
if (has)
GestureDetector(
onTap: () => setState(() => _range = null),
child: Icon(LucideIcons.x, size: 14, color: t.muted),
)
else
Icon(LucideIcons.calendar, size: 16, color: t.faint),
]),
),
));
}
}
@@ -2055,8 +2022,8 @@ class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> {
Navigator.of(context).pop(_AdvResult(d, _status, _range));
}
Future<void> _pickDate() async {
final range = await showWheelDateRange(context, initial: _range);
Future<void> _pickDate(BuildContext anchor) async {
final range = await showDateRangeDropdown(anchor, initial: _range);
if (range != null) setState(() => _range = range);
}
@@ -2171,37 +2138,39 @@ class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> {
final statusSelIdx = _statusCodes.indexOf(_status);
final dateHas = _range != null;
final dateField = InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(
dateHas
? '${_range!.start.toString().substring(0, 10)} ~ ${_range!.end.toString().substring(0, 10)}'
: '全部时间',
style: TextStyle(
fontSize: AppDims.fsBody, color: dateHas ? t.text : t.faint),
),
),
if (dateHas)
GestureDetector(
onTap: () => setState(() => _range = null),
child: Icon(LucideIcons.x, size: 14, color: t.muted),
)
else
Icon(LucideIcons.calendar, size: 16, color: t.faint),
]),
),
);
final dateField = Builder(
builder: (anchorCtx) => InkWell(
onTap: () => _pickDate(anchorCtx),
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(
dateHas
? '${_range!.start.toString().substring(0, 10)} ~ ${_range!.end.toString().substring(0, 10)}'
: '全部时间',
style: TextStyle(
fontSize: AppDims.fsBody,
color: dateHas ? t.text : t.faint),
),
),
if (dateHas)
GestureDetector(
onTap: () => setState(() => _range = null),
child: Icon(LucideIcons.x, size: 14, color: t.muted),
)
else
Icon(LucideIcons.calendar, size: 16, color: t.faint),
]),
),
));
return Column(
mainAxisSize: MainAxisSize.min,
@@ -33,9 +33,8 @@ import '../../widgets/ds/ds_kpi.dart';
import '../../widgets/ds/ds_table.dart';
import '../../widgets/ds/m_kpi_grid.dart';
import '../../widgets/ds/m_search_row.dart';
import '../../widgets/ds/m_card.dart';
import '../../widgets/ds/m_sheet.dart';
import '../../widgets/ds/status_icon_map.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/status_badge.dart';
import '../../widgets/searchable_option_field.dart';
import '../../widgets/combo_search_field.dart';
@@ -228,8 +227,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
n.setDetail(const {});
}
Future<void> _pickDateRange() async {
final range = await showWheelDateRange(context, initial: _dateRange);
Future<void> _pickDateRange(BuildContext anchor) async {
final range = await showDateRangeDropdown(anchor, initial: _dateRange);
if (range == null) return;
setState(() {
_dateRange = range;
@@ -240,9 +239,9 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
}
/// 出库时间预设(对齐原型:全部时间/近7天/近30天/本月/自定义)。
void _setDatePreset(String label) {
void _setDatePreset(BuildContext anchor, String label) {
if (label == '自定义…') {
_pickDateRange();
_pickDateRange(anchor);
return;
}
final now = DateTime.now();
@@ -607,34 +606,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
}
/// 状态词 → (前景, 软底):镜像原型 .badge.b-*,全走 token。
(Color, Color) _statusColors(String label) {
final t = context.tokens;
return switch (label) {
'待审核' => (t.warn, t.warnBg),
'已审核' => (t.success, t.successBg),
'已拒绝' => (t.danger, t.dangerBg),
'部分退单' => (t.warn, t.warnBg),
'已退单' => (t.danger, t.dangerBg),
'待定价' => (t.warn, t.warnBg),
_ => (t.muted, t.infoSoft), // 草稿
};
}
/// 原型 .badge.ico:纯图标小徽章(卡片右上态标 / 状态 sheet 选项行)。
Widget _icoBadge(String label) {
final (fg, bg) = _statusColors(label);
return Container(
height: 22,
width: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(AppDims.rPill),
),
child: Icon(statusIcon(label), size: 12, color: fg),
);
}
/// 原型 .m-kpi 2×2:近30天笔数/金额 + 可点筛选的待审核/草稿(再点取消)。
Widget _buildMobileKpis(int total) {
final StockSummary? summary =
@@ -761,7 +733,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
),
child: Row(children: [
if (_statusOptions[i].$2 != '全部') ...[
_icoBadge(_statusOptions[i].$2),
DsIconBadge(_statusOptions[i].$2),
const SizedBox(width: 10),
],
Text(_statusOptions[i].$2,
@@ -871,12 +843,15 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
},
);
final dateChip = _MenuChip(
label: '出库时间',
value: _datePresetLabel.isEmpty ? null : _datePresetLabel,
options: const ['全部时间', '近 7 天', '近 30 天', '本月', '自定义…'],
onSelected: _setDatePreset,
onClear: () => _setDatePreset('全部时间'),
// anchorCtx = chip 自身位置:自定义范围下拉锚定在 chip 下方
final dateChip = Builder(
builder: (anchorCtx) => _MenuChip(
label: '出库时间',
value: _datePresetLabel.isEmpty ? null : _datePresetLabel,
options: const ['全部时间', '近 7 天', '近 30 天', '本月', '自定义…'],
onSelected: (v) => _setDatePreset(anchorCtx, v),
onClear: () => _setDatePreset(anchorCtx, '全部时间'),
),
);
final advBtn = DsButton(
@@ -995,60 +970,54 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
// ── 窄屏卡片 ─────────────────────────────────────────────────
/// 出库单:窄屏卡片(原型 .m-card:图标徽章 + › 箭头;操作统一收进详情 sheet)。
/// 出库单:窄屏卡片(原型 .m-card 双栏:单号+客户·N 项 | 图标徽章+金额 | ›;
/// mc-foot = 日期 · 经手人 · 审核)。
Widget _orderCard(BuildContext context, StockOutOrder o) {
final hasPending = o.items.any((it) => it.salePrice <= 0);
return MobileListCard(
final foot = [
if (o.orderDate != null) o.orderDate!.substring(0, 10),
if (o.operatorName != null) '经手人 ${o.operatorName}',
if (o.reviewerName != null) '审核 ${o.reviewerName}',
].join(' · ');
return MCard(
onTap: () => _showDetail(context, o.id),
title: Text(o.orderNo,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: 14)),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
_icoBadge(_apiStatusToEnum(o.status).label),
if (o.returnState == 'partial') ...[
const SizedBox(width: 5),
_icoBadge('部分退单'),
] else if (o.returnState == 'full') ...[
const SizedBox(width: 5),
_icoBadge('已退单'),
],
if (hasPending) ...[
const SizedBox(width: 5),
_icoBadge('待定价'),
],
const SizedBox(width: 6),
Icon(LucideIcons.chevronRight, size: 18, color: context.tokens.faint),
]),
fields: [
MobileCardField('客户', o.partnerName ?? '-'),
MobileCardField('仓库', o.warehouseName ?? '-'),
MobileCardField('金额',
o.saleTotal != null ? '¥${o.saleTotal!.toStringAsFixed(2)}' : '-'),
MobileCardField('出库时间', o.orderDate?.substring(0, 10) ?? '-'),
nm: o.orderNo,
sub: '${o.partnerName ?? ''} · ${o.items.length}',
badges: [
DsIconBadge(_apiStatusToEnum(o.status).label),
if (o.returnState == 'partial')
const DsIconBadge('部分退单')
else if (o.returnState == 'full')
const DsIconBadge('已退单'),
if (hasPending) const DsIconBadge('待定价'),
],
amt: o.saleTotal != null ? _money.format(o.saleTotal) : '',
foot: foot.isEmpty ? null : foot,
);
}
// 点击行/眼睛 → 右侧详情抽屉(对齐原型 openDetail)。
// 抽屉内按钮不收抽屉(2026-07-04 拍板):操作完成后原地重拉单据刷新内容,
// 仅点遮罩/X/「修改·删除」这类离开语义才关闭。
Future<void> _showDetail(BuildContext context, int orderId) async {
await showOrderDetailDrawer(
context,
builder: (ctx) => FutureBuilder<StockOutOrder>(
future: ref.read(stockOutRepositoryProvider).get(orderId),
builder: (c, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError || !snap.hasData) {
return Center(
child: Text('暂无数据,网络不可用',
style: TextStyle(color: context.tokens.muted)),
);
}
return _buildDetailDrawer(snap.data!);
},
builder: (ctx) => StatefulBuilder(
builder: (sctx, setSheet) => FutureBuilder<StockOutOrder>(
future: ref.read(stockOutRepositoryProvider).get(orderId),
builder: (c, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snap.hasError || !snap.hasData) {
return Center(
child: Text('暂无数据,网络不可用',
style: TextStyle(color: context.tokens.muted)),
);
}
return _buildDetailDrawer(snap.data!, () => setSheet(() {}));
},
),
),
);
}
@@ -1060,7 +1029,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
static String _fmtQty(double q) =>
q == q.roundToDouble() ? q.toStringAsFixed(0) : q.toString();
Widget _buildDetailDrawer(StockOutOrder o) {
Widget _buildDetailDrawer(StockOutOrder o, VoidCallback refresh) {
final t = context.tokens;
final rb = returnStateBadge(context, o.returnState);
return OrderDetailDrawer(
@@ -1109,11 +1078,12 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
ref.read(isAdminProvider) ? _money.format(o.profitTotal) : null,
priceLabel: '售价',
amountLabel: '小计',
actionGroups: _detailActionGroups(o),
actionGroups: _detailActionGroups(o, refresh),
);
}
List<DrawerActionGroup> _detailActionGroups(StockOutOrder o) {
List<DrawerActionGroup> _detailActionGroups(
StockOutOrder o, VoidCallback refresh) {
final readonly = WriteGuard.isReadonly(ref);
final isAdmin = ref.read(isAdminProvider);
final isMine =
@@ -1128,6 +1098,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
if (!readonly) {
switch (o.status) {
case 'draft':
// 修改/删除是离开语义:收抽屉再走
audit.add(b('修改', () {
close();
context.go('/stock-out/edit/${o.id}');
@@ -1136,36 +1107,36 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
close();
_confirmDelete(context, o);
}, v: DsBtnVariant.danger));
audit.add(b('提交', () {
close();
_confirmSubmit(context, o);
audit.add(b('提交', () async {
await _confirmSubmit(context, o);
refresh();
}, v: DsBtnVariant.primary));
break;
case 'pending':
audit.add(b('通过', () {
close();
_confirmApprove(context, o);
audit.add(b('通过', () async {
await _confirmApprove(context, o);
refresh();
}, v: DsBtnVariant.primary));
audit.add(b('拒绝', () {
close();
_confirmReject(context, o);
audit.add(b('拒绝', () async {
await _confirmReject(context, o);
refresh();
}, v: DsBtnVariant.danger));
if (isAdmin || isMine) {
audit.add(b('撤回', () {
close();
_confirmWithdraw(context, o);
audit.add(b('撤回', () async {
await _confirmWithdraw(context, o);
refresh();
}));
}
break;
case 'approved':
audit.add(b('结清', () {
close();
_confirmSettle(context, o.id, 'stock_out');
audit.add(b('结清', () async {
await _confirmSettle(context, o.id, 'stock_out');
refresh();
}));
if (isAdmin || isMine) {
audit.add(b('退单', () {
close();
_confirmReturn(context, o);
audit.add(b('退单', () async {
await _confirmReturn(context, o);
refresh();
}, v: DsBtnVariant.danger));
}
break;
@@ -1176,9 +1147,9 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
final pending = o.items.where((it) => it.salePrice <= 0).length;
if (o.status == 'approved' && pending > 0 && !readonly) {
groups.add(DrawerActionGroup('售价 · $pending 项待定价', [
b('确认售价', () {
close();
_confirmSale(o);
b('确认售价', () async {
await _confirmSale(o);
refresh();
}, v: DsBtnVariant.primary),
]));
}
@@ -1186,12 +1157,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
// 窄屏详情不出打印入口(移动端无打印——用户拍板)。
if (!context.isMobile) {
groups.add(DrawerActionGroup('打印', [
b('打印标签', () {
close();
_printLabels(o);
}),
b('打印标签', () => _printLabels(o)),
b('打印单据', () async {
close();
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
if (mounted) await showStockOutOrderPrint(context, ref, order);
}),
@@ -1713,8 +1680,8 @@ class _AdvancedSearchDialogState extends ConsumerState<_AdvancedSearchDialog> {
));
}
Future<void> _pickDate() async {
final range = await showWheelDateRange(context, initial: _dateRange);
Future<void> _pickDate(BuildContext anchor) async {
final range = await showDateRangeDropdown(anchor, initial: _dateRange);
if (range != null) setState(() => _dateRange = range);
}
@@ -1838,38 +1805,39 @@ class _AdvancedSearchDialogState extends ConsumerState<_AdvancedSearchDialog> {
}
final dateHas = _dateRange != null;
Widget dateField() => InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
Widget dateField() => Builder(
builder: (anchorCtx) => InkWell(
onTap: () => _pickDate(anchorCtx),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(
dateHas
? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}'
: '全部时间',
style: TextStyle(
fontSize: AppDims.fsBody,
color: dateHas ? t.text : t.faint),
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(
dateHas
? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}'
: '全部时间',
style: TextStyle(
fontSize: AppDims.fsBody,
color: dateHas ? t.text : t.faint),
),
),
if (dateHas)
GestureDetector(
onTap: () => setState(() => _dateRange = null),
child: Icon(LucideIcons.x, size: 14, color: t.muted),
)
else
Icon(LucideIcons.calendar, size: 16, color: t.faint),
]),
),
if (dateHas)
GestureDetector(
onTap: () => setState(() => _dateRange = null),
child: Icon(LucideIcons.x, size: 14, color: t.muted),
)
else
Icon(LucideIcons.calendar, size: 16, color: t.faint),
]),
),
);
));
return AlertDialog(
backgroundColor: t.surface,
@@ -2135,8 +2103,8 @@ class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> {
));
}
Future<void> _pickDate() async {
final range = await showWheelDateRange(context, initial: _dateRange);
Future<void> _pickDate(BuildContext anchor) async {
final range = await showDateRangeDropdown(anchor, initial: _dateRange);
if (range != null) setState(() => _dateRange = range);
}
@@ -2225,37 +2193,39 @@ class _AdvSearchSheetState extends ConsumerState<_AdvSearchSheet> {
final statusSelIdx = _statusCodes.indexOf(_status);
final dateHas = _dateRange != null;
final dateField = InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(
dateHas
? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}'
: '全部时间',
style: TextStyle(
fontSize: AppDims.fsBody, color: dateHas ? t.text : t.faint),
),
),
if (dateHas)
GestureDetector(
onTap: () => setState(() => _dateRange = null),
child: Icon(LucideIcons.x, size: 14, color: t.muted),
)
else
Icon(LucideIcons.calendar, size: 16, color: t.faint),
]),
),
);
final dateField = Builder(
builder: (anchorCtx) => InkWell(
onTap: () => _pickDate(anchorCtx),
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 11),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(
dateHas
? '${DateFormat('yyyy-MM-dd').format(_dateRange!.start)} ~ ${DateFormat('yyyy-MM-dd').format(_dateRange!.end)}'
: '全部时间',
style: TextStyle(
fontSize: AppDims.fsBody,
color: dateHas ? t.text : t.faint),
),
),
if (dateHas)
GestureDetector(
onTap: () => setState(() => _dateRange = null),
child: Icon(LucideIcons.x, size: 14, color: t.muted),
)
else
Icon(LucideIcons.calendar, size: 16, color: t.faint),
]),
),
));
return Column(
mainAxisSize: MainAxisSize.min,
+57 -117
View File
@@ -1,14 +1,14 @@
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/material.dart';
import '../core/theme/app_dims.g.dart';
import '../core/theme/app_fonts.dart';
import '../core/theme/context_tokens.dart';
import '../core/utils/date_util.dart';
import 'wheel_date_picker.dart';
/// + //
///
/// - ****20242024-52024-5-1220240512 `yyyy-MM-dd`
/// / `01` 2024 2024-01-01
/// - **** Material `showDatePicker`input 便
/// - `yyyy-MM-dd` [value] / [onChanged] null
/// +
/// wheel_date_picker.dart / sheet
/// `yyyy-MM-dd` [value] / [onChanged]
class DatePickerField extends StatefulWidget {
final String? value; // yyyy-MM-dd
final ValueChanged<String?> onChanged;
@@ -28,91 +28,12 @@ class DatePickerField extends StatefulWidget {
}
class _DatePickerFieldState extends State<DatePickerField> {
late final TextEditingController _ctrl;
final FocusNode _focus = FocusNode();
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.value ?? '');
_focus.addListener(() {
// 2024 2024-01-01
if (!_focus.hasFocus) {
final n = _normalize(_ctrl.text);
if (n != null && n != _ctrl.text) {
_ctrl.text = n;
}
}
});
}
@override
void didUpdateWidget(DatePickerField old) {
super.didUpdateWidget(old);
//
if (!_focus.hasFocus &&
old.value != widget.value &&
(widget.value ?? '') != _ctrl.text) {
_ctrl.text = widget.value ?? '';
}
}
@override
void dispose() {
_ctrl.dispose();
_focus.dispose();
super.dispose();
}
/// `yyyy-MM-dd`/ 01 null
static String? _normalize(String raw) {
final s = raw.trim();
if (s.isEmpty) return null;
int? y, mo, d;
if (RegExp(r'^[0-9]+$').hasMatch(s)) {
// yyyymmdd / yyyymm / yyyy
if (s.length >= 8) {
y = int.tryParse(s.substring(0, 4));
mo = int.tryParse(s.substring(4, 6));
d = int.tryParse(s.substring(6, 8));
} else if (s.length == 6) {
y = int.tryParse(s.substring(0, 4));
mo = int.tryParse(s.substring(4, 6));
} else {
y = int.tryParse(s);
}
} else {
final parts =
s.split(RegExp(r'[^0-9]+')).where((e) => e.isNotEmpty).toList();
if (parts.isNotEmpty) y = int.tryParse(parts[0]);
if (parts.length >= 2) mo = int.tryParse(parts[1]);
if (parts.length >= 3) d = int.tryParse(parts[2]);
}
if (y == null || y < 1900 || y > 2200) return null;
mo ??= 1;
d ??= 1;
if (mo < 1 || mo > 12) return null;
final maxDay = DateTime(y, mo + 1, 0).day;
if (d < 1) d = 1;
if (d > maxDay) d = maxDay;
return '${y.toString().padLeft(4, '0')}-'
'${mo.toString().padLeft(2, '0')}-'
'${d.toString().padLeft(2, '0')}';
}
Future<void> _pickFromCalendar(FormFieldState<String> field) async {
final init =
parseYmd(_normalize(_ctrl.text) ?? widget.value) ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: init,
firstDate: DateTime(1900),
lastDate: DateTime(2200),
initialEntryMode: DatePickerEntryMode.input,
);
if (picked != null) {
final v = formatYmd(picked);
_ctrl.text = v;
Future<void> _pick(
BuildContext anchorCtx, FormFieldState<String> field) async {
final d =
await showDsDateDropdown(anchorCtx, initial: parseYmd(widget.value));
if (d != null) {
final v = formatYmd(d);
field.didChange(v);
widget.onChanged(v);
}
@@ -120,38 +41,57 @@ class _DatePickerFieldState extends State<DatePickerField> {
@override
Widget build(BuildContext context) {
final t = context.tokens;
return FormField<String>(
initialValue: widget.value,
validator: widget.isRequired
? (_) => _normalize(_ctrl.text) == null ? '请输入日期' : null
? (_) =>
(widget.value == null || widget.value!.isEmpty) ? '请输入日期' : null
: null,
builder: (field) {
return TextField(
controller: _ctrl,
focusNode: _focus,
keyboardType: TextInputType.datetime,
style: const TextStyle(fontSize: 13),
decoration: InputDecoration(
isDense: true,
hintText: '年-月-日',
hintStyle: const TextStyle(fontSize: 12),
contentPadding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
errorText: field.errorText,
suffixIcon: IconButton(
icon: Icon(LucideIcons.calendar,
size: 16, color: context.tokens.muted),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 34, minHeight: 34),
tooltip: '选择日期',
onPressed: () => _pickFromCalendar(field),
final has = widget.value != null && widget.value!.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Builder(
builder: (anchorCtx) => InkWell(
onTap: () => _pick(anchorCtx, field),
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: t.surface,
border:
Border.all(color: field.hasError ? t.danger : t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(
has ? widget.value! : '年-月-日',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsBody,
color: has ? t.text : t.faint,
fontFamily: has ? AppFonts.mono : null,
fontFamilyFallback:
has ? AppFonts.monoFallback : null),
),
),
Icon(LucideIcons.calendar, size: 16, color: t.muted),
]),
),
),
),
),
onChanged: (v) {
final n = _normalize(v);
field.didChange(n);
widget.onChanged(n);
},
if (field.errorText != null)
Padding(
padding: const EdgeInsets.only(top: 4, left: 2),
child: Text(field.errorText!,
style: TextStyle(fontSize: AppDims.fsXs, color: t.danger)),
),
],
);
},
);
+10 -6
View File
@@ -85,18 +85,22 @@ class _DsTableState extends State<DsTable> {
@override
Widget build(BuildContext context) {
final t = context.tokens;
// m-scroll
final mobileCardsMode = context.isMobile && widget.mobileCards != null;
return Column(
mainAxisSize: widget.shrinkWrap ? MainAxisSize.min : MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_maybeExpand(
Container(
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
clipBehavior: Clip.antiAlias,
decoration: mobileCardsMode
? null
: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
clipBehavior: mobileCardsMode ? Clip.none : Clip.antiAlias,
child: Column(
// toolbar/ toolbar
mainAxisSize:
+230
View File
@@ -0,0 +1,230 @@
// widgets/ds/m_card.dart mobile-atoms .m-card / .m-row
// .badge.ico22px /.badge.bi+
//
// mc-main(mc-nm + mc-sub mono) | mc-right( + mc-amt )
// | mc-chev mc-foot 线
// statusToneOf status_icon_map BADGE_ICON
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/app_fonts.dart';
import '../../core/theme/context_tokens.dart';
import 'status_icon_map.dart';
/// (, ) .badge.b-*
(Color fg, Color bg) statusToneOf(BuildContext context, String label) {
final t = context.tokens;
switch (label) {
case '待审核':
case '待定价':
case '部分退单':
case '预警':
case '进行中':
case '未结清':
return (t.warn, t.warnBg);
case '已审核':
case '已通过':
case '在售':
case '启用':
case '已完成':
case '已结清':
case '已兑换':
case '在线':
return (t.success, t.successBg);
case '已拒绝':
case '已驳回':
case '已退单':
case '缺货':
case '停用':
case '作废':
case '离线':
return (t.danger, t.dangerBg);
case '草稿':
return (t.muted, t.infoSoft);
default:
return (t.muted, t.borderSubtle);
}
}
/// .badge.ico22×22 pill13px
class DsIconBadge extends StatelessWidget {
final String label;
const DsIconBadge(this.label, {super.key});
@override
Widget build(BuildContext context) {
final (fg, bg) = statusToneOf(context, label);
return Tooltip(
message: label,
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: bg, borderRadius: BorderRadius.circular(AppDims.rPill)),
alignment: Alignment.center,
child:
Icon(statusIcon(label) ?? LucideIcons.circle, size: 13, color: fg),
),
);
}
}
/// + .badge.bih22 pill12px
class DsTextIconBadge extends StatelessWidget {
final String label;
const DsTextIconBadge(this.label, {super.key});
@override
Widget build(BuildContext context) {
final (fg, bg) = statusToneOf(context, label);
return Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: 9),
decoration: BoxDecoration(
color: bg, borderRadius: BorderRadius.circular(AppDims.rPill)),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(statusIcon(label) ?? LucideIcons.circle, size: 12, color: fg),
const SizedBox(width: 5),
Text(label,
style: TextStyle(
color: fg,
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600)),
]),
);
}
}
/// .m-card
class MCard extends StatelessWidget {
/// mc-nm 600 heading
final String nm;
/// mc-sub xs muted mononull
final String? sub;
/// mc-right DsIconBadge / DsBadge gap5
final List<Widget> badges;
/// mc-amt /700 heading monoamtWidget
final String? amt;
final Widget? amtWidget;
/// mc-foot + xs muted线null
final String? foot;
final VoidCallback? onTap;
const MCard({
super.key,
required this.nm,
this.sub,
this.badges = const [],
this.amt,
this.amtWidget,
this.foot,
this.onTap,
});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Material(
color: t.surface,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
side: BorderSide(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
// mc-main
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(nm,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.heading)),
if (sub != null) ...[
const SizedBox(height: 3),
Text(sub!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsXs,
color: t.muted,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
],
],
),
),
const SizedBox(width: 12),
// mc-right
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (badges.isNotEmpty)
Row(mainAxisSize: MainAxisSize.min, children: [
for (var i = 0; i < badges.length; i++) ...[
if (i > 0) const SizedBox(width: 5),
badges[i],
],
]),
if (amtWidget != null || amt != null) ...[
if (badges.isNotEmpty) const SizedBox(height: 5),
amtWidget ??
Text(amt!,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w700,
color: t.heading,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
],
],
),
const SizedBox(width: 8),
// mc-chev
Icon(LucideIcons.chevronRight, size: 18, color: t.faint),
],
),
if (foot != null)
Container(
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: t.borderSubtle)),
),
child: Row(children: [
Icon(LucideIcons.info, size: 13, color: t.muted),
const SizedBox(width: 10),
Expanded(
child: Text(foot!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsXs, color: t.muted)),
),
]),
),
],
),
),
),
);
}
}
+33
View File
@@ -10,6 +10,14 @@ import '../../core/theme/app_dims.g.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/dialog_util.dart';
/// sheet sheet
/// sheet tab / 退2026-07-04
final List<VoidCallback> _openSheetClosers = [];
bool get hasOpenMSheet => _openSheetClosers.isNotEmpty;
void closeTopMSheet() {
if (_openSheetClosers.isNotEmpty) _openSheetClosers.last();
}
/// sheet mobile-shell openSheet
/// - [title] null grip
/// - [scrollable] true SingleChildScrollViewfalse
@@ -22,6 +30,31 @@ Future<T?> showMSheet<T>(
bool scrollable = true,
double maxHeightFactor = 0.86,
List<Widget>? actions,
}) {
// sheet
final nav = Navigator.of(context);
void closer() {
if (nav.canPop()) nav.pop();
}
_openSheetClosers.add(closer);
return _showMSheetInner<T>(
context,
title: title,
builder: builder,
scrollable: scrollable,
maxHeightFactor: maxHeightFactor,
actions: actions,
).whenComplete(() => _openSheetClosers.remove(closer));
}
Future<T?> _showMSheetInner<T>(
BuildContext context, {
String? title,
required WidgetBuilder builder,
bool scrollable = true,
double maxHeightFactor = 0.86,
List<Widget>? actions,
}) {
return showModalBottomSheet<T>(
context: context,
+12 -6
View File
@@ -181,16 +181,22 @@ class _FinancePartnerDrawer extends ConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
DsButton('登记收款', small: true, onPressed: () {
Navigator.of(context).pop();
showFinanceEntryDialog(context,
// 2026-07-04
// provider
DsButton('登记收款', small: true, onPressed: () async {
await showFinanceEntryDialog(context,
type: 'receipt', partnerId: row.partnerId);
if (row.partnerId != null) {
ref.invalidate(_partnerFlowsProvider(row.partnerId!));
}
}),
const SizedBox(width: 10),
DsButton('登记付款', small: true, onPressed: () {
Navigator.of(context).pop();
showFinanceEntryDialog(context,
DsButton('登记付款', small: true, onPressed: () async {
await showFinanceEntryDialog(context,
type: 'payment', partnerId: row.partnerId);
if (row.partnerId != null) {
ref.invalidate(_partnerFlowsProvider(row.partnerId!));
}
}),
],
),
+157
View File
@@ -7,6 +7,7 @@ import '../core/responsive/responsive.dart';
import '../core/theme/context_tokens.dart';
import '../core/theme/app_dims.g.dart';
import '../core/theme/app_fonts.dart';
import 'ds/m_card.dart';
import 'ds/m_sheet.dart';
/// .drawer / .drawer-mask
@@ -126,6 +127,9 @@ class OrderDetailDrawer extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = context.tokens;
// sheet m-stock-*-list openOrderdrow +
// pe-card + drow +
if (context.isMobile) return _buildMobileSheet(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -225,6 +229,159 @@ class OrderDetailDrawer extends StatelessWidget {
],
);
}
// sheet openOrder
Widget _buildMobileSheet(BuildContext context) {
final t = context.tokens;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// + Xgrip showMSheet
Container(
padding: const EdgeInsets.fromLTRB(16, 4, 8, 8),
decoration: BoxDecoration(
border: Border(bottom: BorderSide(color: t.borderSubtle))),
child: Row(children: [
Expanded(
child: Text(title,
style: TextStyle(
fontSize: AppDims.fsTitle,
fontWeight: FontWeight.w700,
color: t.heading)),
),
IconButton(
icon: Icon(LucideIcons.x, size: 20, color: t.muted),
splashRadius: 18,
onPressed: () => Navigator.of(context).pop(),
),
]),
),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 2, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final r in infoRows)
_DrawerRow(label: r.label, value: r.value),
// pe-card + · N +
Container(
margin: const EdgeInsets.only(top: 14),
decoration: BoxDecoration(
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: const EdgeInsets.fromLTRB(14, 11, 14, 11),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: t.borderSubtle))),
child: Row(children: [
Icon(LucideIcons.tableProperties,
size: 15, color: t.primary),
const SizedBox(width: 8),
Text('$linesLabel · ${lines.length}',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.heading)),
]),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 2),
child: Column(children: [
for (var i = 0; i < lines.length; i++)
_mLineRow(t, lines[i], i == lines.length - 1),
]),
),
],
),
),
// drowlabel / mono
_mTotalRow(t, '合计金额', totalText, t.heading),
if (profitText != null)
_mTotalRow(t, '合计利润', profitText!,
profitText!.startsWith('-') ? t.danger : t.success),
//
if (actionGroups.isNotEmpty) const SizedBox(height: 16),
for (var g = 0; g < actionGroups.length; g++) ...[
if (g > 0) const SizedBox(height: 10),
Row(children: [
for (var b = 0;
b < actionGroups[g].buttons.length;
b++) ...[
if (b > 0) const SizedBox(width: 10),
Expanded(child: actionGroups[g].buttons[b]),
],
]),
],
],
),
),
),
],
);
}
/// + ·× mono /
Widget _mLineRow(dynamic t, OrderLine l, bool last) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: last
? null
: BoxDecoration(
border: Border(bottom: BorderSide(color: t.borderSubtle))),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l.name,
style: TextStyle(
fontSize: AppDims.fsBody, color: t.text, height: 1.45)),
const SizedBox(height: 2),
Text('${l.series} · ×${l.qty}',
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
],
),
),
const SizedBox(width: 10),
l.pending
? const DsTextIconBadge('待定价')
: Text(l.amount.isNotEmpty ? l.amount : l.price,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.heading,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
],
),
);
}
Widget _mTotalRow(dynamic t, String label, String value, Color color) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 11),
child: Row(children: [
Text(label, style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
const Spacer(),
Text(value,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w700,
color: color,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
]),
);
}
}
class _DrawerRow extends StatelessWidget {
@@ -194,10 +194,8 @@ class _PartnerDetailDrawer extends ConsumerWidget {
const SizedBox(width: 10),
if (onEdit != null)
WriteGuard(
child: DsButton('编辑', small: true, onPressed: () {
Navigator.of(context).pop();
onEdit!();
}),
// 2026-07-04
child: DsButton('编辑', small: true, onPressed: onEdit),
),
],
),
@@ -623,7 +623,7 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> {
if (_introDocId != null) 'description_doc_id': _introDocId,
});
if (!mounted) return;
Navigator.of(context).pop();
// 2026-07-04
showDsToast(context, '商品介绍已保存 ✓');
} catch (e) {
if (mounted) {
+521 -180
View File
@@ -1,44 +1,497 @@
// widgets/wheel_date_picker.dart 2026-07-04
//
// == sheet
// - showDsDateDropdown = + + (/)
// - showDateRangeDropdown| + (/)
// _DatePane(248)mono//
// yyyy-MM-dd /
//
// DsDateCell DatePickerField
// / datewheel.js
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../core/responsive/responsive.dart';
import '../core/theme/context_tokens.dart';
import '../core/theme/app_tokens.dart';
import '../core/theme/app_dims.g.dart';
import '../core/utils/dialog_util.dart';
import '../core/utils/date_util.dart';
import '../core/theme/app_fonts.dart';
/// // .wheel-pop / datewheel.js
/// 4 / = .wheel-center
/// primary 16px+
///
/// 248px Material +/
/// datewheel.js pop
/// [onCommit]
class WheelDatePanel extends StatefulWidget {
final DateTime initial;
final ValueChanged<DateTime> onCommit;
/// /
final String title;
const WheelDatePanel({
super.key,
required this.initial,
required this.onCommit,
this.title = '',
});
@override
State<WheelDatePanel> createState() => _WheelDatePanelState();
}
import 'ds/m_sheet.dart';
const double _kItem = 36; // .wheel-item
const double _kColsH = 180; // .wheel-cols 5
const double _kWidth = 248; // .wheel-pop
const double _kWidth = 248; //
String _pad2(int n) => n.toString().padLeft(2, '0');
int _daysIn(int y, int m) => DateTime(y, m + 1, 0).day; // m: 1-12
class _WheelDatePanelState extends State<WheelDatePanel> {
//
/// [anchorContext]
/// sheet/ null
Future<DateTime?> showDsDateDropdown(
BuildContext anchorContext, {
DateTime? initial,
String title = '选择日期',
}) {
if (anchorContext.isMobile) {
return showMSheet<DateTime>(
anchorContext,
title: title,
builder: (ctx) => Center(
child: _SingleDateBody(
initial: initial ?? DateTime.now(),
onCommit: (d) => Navigator.of(ctx).pop(d),
),
),
);
}
return Navigator.of(anchorContext).push<DateTime>(_DropdownRoute<DateTime>(
anchorRect: _anchorRect(anchorContext),
builder: (ctx) => _DropCard(
child: _SingleDateBody(
initial: initial ?? DateTime.now(),
onCommit: (d) => Navigator.of(ctx).pop(d),
),
),
));
}
/// | sheet
/// DateTimeRange/ null
Future<DateTimeRange?> showDateRangeDropdown(
BuildContext anchorContext, {
DateTimeRange? initial,
}) {
if (anchorContext.isMobile) {
return showMSheet<DateTimeRange>(
anchorContext,
title: '日期区间',
builder: (ctx) => Center(
child: _RangeBody(
initial: initial,
vertical: true,
onCommit: (r) => Navigator.of(ctx).pop(r),
),
),
);
}
return Navigator.of(anchorContext)
.push<DateTimeRange>(_DropdownRoute<DateTimeRange>(
anchorRect: _anchorRect(anchorContext),
builder: (ctx) => _DropCard(
child: _RangeBody(
initial: initial,
vertical: false,
onCommit: (r) => Navigator.of(ctx).pop(r),
),
),
));
}
Rect _anchorRect(BuildContext anchorContext) {
final overlay = Navigator.of(anchorContext)
.overlay!
.context
.findRenderObject() as RenderBox;
final box = anchorContext.findRenderObject() as RenderBox?;
return box == null
? Rect.zero
: box.localToGlobal(Offset.zero, ancestor: overlay) & box.size;
}
//
class _DropdownRoute<T> extends PopupRoute<T> {
final Rect anchorRect;
final WidgetBuilder builder;
_DropdownRoute({required this.anchorRect, required this.builder});
@override
Color? get barrierColor => Colors.transparent;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => 'date-dropdown';
@override
Duration get transitionDuration => Duration.zero;
@override
Widget buildPage(BuildContext context, Animation<double> animation,
Animation<double> secondaryAnimation) {
return CustomSingleChildLayout(
delegate: _DropdownLayout(anchorRect),
child: builder(context),
);
}
}
class _DropdownLayout extends SingleChildLayoutDelegate {
final Rect anchor;
_DropdownLayout(this.anchor);
@override
BoxConstraints getConstraintsForChild(BoxConstraints constraints) =>
BoxConstraints(
maxWidth: math.max(0, constraints.maxWidth - 16),
maxHeight: math.max(0, constraints.maxHeight - 16),
);
@override
Offset getPositionForChild(Size size, Size childSize) {
final left = anchor.left
.clamp(8.0, math.max(8.0, size.width - childSize.width - 8))
.toDouble();
final below = anchor.bottom + 6;
final above = anchor.top - childSize.height - 6;
final top = (below + childSize.height <= size.height - 8 || above < 8)
? below
: above;
return Offset(left, top.clamp(8.0, math.max(8.0, size.height - 8)));
}
@override
bool shouldRelayout(_DropdownLayout old) => old.anchor != anchor;
}
/// surface + 1px + r-lg
class _DropCard extends StatelessWidget {
final Widget child;
const _DropCard({required this.child});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Material(
color: t.surface,
shape: RoundedRectangleBorder(
side: BorderSide(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Padding(padding: const EdgeInsets.all(12), child: child),
);
}
}
//
class _SingleDateBody extends StatefulWidget {
final DateTime initial;
final ValueChanged<DateTime> onCommit;
const _SingleDateBody({required this.initial, required this.onCommit});
@override
State<_SingleDateBody> createState() => _SingleDateBodyState();
}
class _SingleDateBodyState extends State<_SingleDateBody> {
late DateTime _value = widget.initial;
int _paneEpoch = 0; //
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_DatePane(
key: ValueKey('single-$_paneEpoch'),
value: _value,
onChanged: (d) => _value = d,
),
// /
Container(
width: _kWidth,
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: t.borderSubtle)),
),
child: Row(
children: [
InkWell(
onTap: () => setState(() {
_value = DateTime.now();
_paneEpoch++;
}),
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: Text('今天',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
),
const Spacer(),
_CommitButton(onTap: () => widget.onCommit(_value)),
],
),
),
],
);
}
}
//
class _RangeBody extends StatefulWidget {
final DateTimeRange? initial;
final bool vertical; //
final ValueChanged<DateTimeRange> onCommit;
const _RangeBody(
{this.initial, required this.vertical, required this.onCommit});
@override
State<_RangeBody> createState() => _RangeBodyState();
}
class _RangeBodyState extends State<_RangeBody> {
late DateTime _start = widget.initial?.start ?? DateTime.now();
late DateTime _end = widget.initial?.end ?? DateTime.now();
void _commit() {
widget.onCommit(_end.isBefore(_start)
? DateTimeRange(start: _end, end: _start)
: DateTimeRange(start: _start, end: _end));
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final startPane = _DatePane(
title: '起始日期',
value: _start,
onChanged: (d) => setState(() => _start = d),
);
final endPane = _DatePane(
title: '结束日期',
value: _end,
onChanged: (d) => setState(() => _end = d),
);
final footerWidth = widget.vertical ? _kWidth : _kWidth * 2 + 12; //
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (widget.vertical) ...[
startPane,
const SizedBox(height: 14),
endPane,
] else
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [startPane, const SizedBox(width: 12), endPane],
),
// /
Container(
width: footerWidth,
margin: const EdgeInsets.only(top: 10),
padding: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: t.borderSubtle)),
),
child: Row(
children: [
Expanded(
child: Text(
'${formatYmd(_start)} ~ ${formatYmd(_end)}',
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.muted,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback),
),
),
_CommitButton(onTap: _commit),
],
),
),
],
);
}
}
class _CommitButton extends StatelessWidget {
final VoidCallback onTap;
const _CommitButton({required this.onTap});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 5),
decoration: BoxDecoration(
color: t.primary,
borderRadius: BorderRadius.circular(AppDims.rSm),
),
child: Text('确定',
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.onPrimary,
fontWeight: FontWeight.w600)),
),
);
}
}
// + 248
class _DatePane extends StatefulWidget {
final String? title;
final DateTime value;
final ValueChanged<DateTime> onChanged;
const _DatePane(
{super.key, this.title, required this.value, required this.onChanged});
@override
State<_DatePane> createState() => _DatePaneState();
}
class _DatePaneState extends State<_DatePane> {
late final TextEditingController _ctrl =
TextEditingController(text: formatYmd(widget.value));
final FocusNode _focus = FocusNode();
late DateTime _wheelAnchor = widget.value; //
bool _syncing = false; // onChanged
@override
void initState() {
super.initState();
_focus.addListener(() {
if (!_focus.hasFocus) {
// 2024 2024-01-01
final n = normalizeYmd(_ctrl.text);
_setText(n ?? formatYmd(_wheelAnchor));
if (n != null) _applyTyped(n);
}
if (mounted) setState(() {});
});
}
@override
void dispose() {
_ctrl.dispose();
_focus.dispose();
super.dispose();
}
void _setText(String v) {
_syncing = true;
_ctrl.text = v;
_syncing = false;
}
void _applyTyped(String ymd) {
final d = parseYmd(ymd);
if (d == null) return;
widget.onChanged(d);
if (d != _wheelAnchor) setState(() => _wheelAnchor = d);
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
return SizedBox(
width: _kWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (widget.title != null)
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 6),
child: Text(widget.title!,
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.muted)),
),
// mono
Container(
height: 34,
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: _focus.hasFocus ? t.primary : t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Center(
child: TextField(
controller: _ctrl,
focusNode: _focus,
keyboardType: TextInputType.datetime,
textAlign: TextAlign.center,
// strut ascent/descent
// height:1 =Center
strutStyle: const StrutStyle(
fontSize: AppDims.fsBody,
height: 1.0,
forceStrutHeight: true,
),
style: TextStyle(
fontSize: AppDims.fsBody,
height: 1.0,
color: t.text,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback),
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
filled: false,
hintText: '年-月-日',
hintStyle:
TextStyle(fontSize: AppDims.fsBody, color: t.faint),
),
onChanged: (v) {
if (_syncing) return;
// 8
final n = normalizeYmd(v);
if (n != null && v.trim().length >= 8) _applyTyped(n);
},
onSubmitted: (v) {
final n = normalizeYmd(v);
if (n != null) {
_setText(n);
_applyTyped(n);
}
},
),
),
),
_WheelCols(
key: ValueKey(formatYmd(_wheelAnchor)),
initial: _wheelAnchor,
onChanged: (d) {
widget.onChanged(d);
_setText(formatYmd(d));
},
),
],
),
);
}
}
// datewheel.js
class _WheelCols extends StatefulWidget {
final DateTime initial;
final ValueChanged<DateTime> onChanged;
const _WheelCols({super.key, required this.initial, required this.onChanged});
@override
State<_WheelCols> createState() => _WheelColsState();
}
class _WheelColsState extends State<_WheelCols> {
late final List<int> _years; // now-15 .. now+2 datewheel.js
late int _yIdx, _mIdx, _dIdx;
late final FixedExtentScrollController _yCtrl, _mCtrl, _dCtrl;
@@ -70,6 +523,8 @@ class _WheelDatePanelState extends State<WheelDatePanel> {
super.dispose();
}
void _emit() => widget.onChanged(DateTime(_year, _month, _dIdx + 1));
// /
void _onYearOrMonth() {
final n = _daysIn(_year, _month);
@@ -78,133 +533,54 @@ class _WheelDatePanelState extends State<WheelDatePanel> {
_dCtrl.jumpToItem(_dIdx);
}
setState(() {});
}
void _goToday() {
final now = DateTime.now();
final yi = _years.indexOf(now.year);
if (yi >= 0) _yIdx = yi;
_mIdx = now.month - 1;
_dIdx = now.day - 1;
const d = Duration(milliseconds: 250);
_yCtrl.animateToItem(_yIdx, duration: d, curve: Curves.easeOut);
_mCtrl.animateToItem(_mIdx, duration: d, curve: Curves.easeOut);
_dCtrl.animateToItem(_dIdx, duration: d, curve: Curves.easeOut);
setState(() {});
_emit();
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final days = _daysIn(_year, _month);
return Material(
color: t.surface,
elevation: 6,
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
width: _kWidth,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Column(
mainAxisSize: MainAxisSize.min,
// 1px + /
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: SizedBox(
height: _kColsH,
child: Stack(
children: [
if (widget.title.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(4, 2, 4, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(widget.title,
style: TextStyle(
fontSize: AppDims.fsTitle,
fontWeight: FontWeight.w600,
color: t.heading)),
Positioned(
left: 4,
right: 4,
top: (_kColsH - _kItem) / 2,
height: _kItem,
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
color: t.bg,
borderRadius: BorderRadius.circular(AppDims.rMd),
),
),
),
// +
SizedBox(
height: _kColsH,
child: Stack(
children: [
Positioned(
left: 4,
right: 4,
top: (_kColsH - _kItem) / 2,
height: _kItem,
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
color: t.bg,
borderRadius: BorderRadius.circular(AppDims.rMd),
border: Border(
top: BorderSide(color: t.border),
bottom: BorderSide(color: t.border),
),
),
),
),
),
Row(
children: [
_col(t, _yCtrl, _years.length,
(i) => _years[i].toString(), _yIdx, (i) {
_yIdx = i;
_onYearOrMonth();
}),
_col(t, _mCtrl, 12, (i) => _pad2(i + 1), _mIdx, (i) {
_mIdx = i;
_onYearOrMonth();
}),
_col(t, _dCtrl, days, (i) => _pad2(i + 1), _dIdx,
(i) => setState(() => _dIdx = i)),
],
),
],
),
),
// /
Container(
margin: const EdgeInsets.only(top: 6),
padding: const EdgeInsets.fromLTRB(4, 8, 4, 2),
decoration: BoxDecoration(
border: Border(top: BorderSide(color: t.borderSubtle)),
),
child: Row(
children: [
InkWell(
onTap: _goToday,
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
child: Text('今天',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted)),
),
),
const Spacer(),
InkWell(
onTap: () =>
widget.onCommit(DateTime(_year, _month, _dIdx + 1)),
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 5),
decoration: BoxDecoration(
color: t.primary,
borderRadius: BorderRadius.circular(AppDims.rSm),
),
child: Text('确定',
style: TextStyle(
fontSize: AppDims.fsSm,
color: t.onPrimary,
fontWeight: FontWeight.w600)),
),
),
],
),
Row(
children: [
_col(t, _yCtrl, _years.length, (i) => _years[i].toString(),
_yIdx, (i) {
_yIdx = i;
_onYearOrMonth();
}),
_col(t, _mCtrl, 12, (i) => _pad2(i + 1), _mIdx, (i) {
_mIdx = i;
_onYearOrMonth();
}),
_col(t, _dCtrl, days, (i) => _pad2(i + 1), _dIdx, (i) {
setState(() => _dIdx = i);
_emit();
}),
],
),
],
),
@@ -250,38 +626,3 @@ class _WheelDatePanelState extends State<WheelDatePanel> {
);
}
}
/// DsDateCell WheelDatePanel
Future<DateTime?> showWheelDatePicker(
BuildContext context, {
DateTime? initial,
String title = '',
}) {
return showAppDialog<DateTime>(
context: context,
builder: (dctx) => Align(
alignment: Alignment.center,
child: WheelDatePanel(
initial: initial ?? DateTime.now(),
title: title,
onCommit: (d) => Navigator.of(dctx).pop(d),
),
),
);
}
/// null
Future<DateTimeRange?> showWheelDateRange(
BuildContext context, {
DateTimeRange? initial,
}) async {
final start = await showWheelDatePicker(context,
initial: initial?.start, title: '起始日期');
if (start == null || !context.mounted) return null;
final end = await showWheelDatePicker(context,
initial: initial?.end ?? start, title: '结束日期');
if (end == null) return null;
return end.isBefore(start)
? DateTimeRange(start: end, end: start)
: DateTimeRange(start: start, end: end);
}
+35 -21
View File
@@ -33,19 +33,7 @@ void main() {
});
group('DatePickerField', () {
testWidgets('渲染可键入框 + 日历图标', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildTheme('a'),
home: Scaffold(
body: DatePickerField(value: '2026-06-19', onChanged: (_) {}),
),
));
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
expect(find.byIcon(LucideIcons.calendar), findsOneWidget);
});
testWidgets('初始值回显到输入框', (tester) async {
testWidgets('渲染触发字段:值回显 + 日历图标', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildTheme('a'),
home: Scaffold(
@@ -54,30 +42,56 @@ void main() {
));
await tester.pumpAndSettle();
expect(find.text('2026-06-19'), findsOneWidget);
expect(find.byIcon(LucideIcons.calendar), findsOneWidget);
//
expect(find.byType(TextField), findsNothing);
});
testWidgets('只输年份归一为 yyyy-01-01', (tester) async {
testWidgets('点选打开统一下拉:面板含输入框 + 滚轮,确定回传', (tester) async {
String? out;
await tester.pumpWidget(MaterialApp(
theme: buildTheme('a'),
home: Scaffold(
body: DatePickerField(onChanged: (v) => out = v),
body: DatePickerField(value: '2026-06-19', onChanged: (v) => out = v),
),
));
await tester.enterText(find.byType(TextField), '2024');
expect(out, '2024-01-01');
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.calendar));
await tester.pumpAndSettle();
// + + /
expect(find.byType(TextField), findsOneWidget);
expect(find.byType(ListWheelScrollView), findsNWidgets(3));
expect(find.text('今天'), findsOneWidget);
await tester.tap(find.text('确定'));
await tester.pumpAndSettle();
expect(out, '2026-06-19');
});
testWidgets('年月归一为 yyyy-MM-01', (tester) async {
testWidgets('面板内键入日期:归一并经确定回传', (tester) async {
String? out;
await tester.pumpWidget(MaterialApp(
theme: buildTheme('a'),
home: Scaffold(
body: DatePickerField(onChanged: (v) => out = v),
body: DatePickerField(value: '2026-06-19', onChanged: (v) => out = v),
),
));
await tester.enterText(find.byType(TextField), '2024-5');
expect(out, '2024-05-01');
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.calendar));
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '20240512');
await tester.pumpAndSettle();
await tester.tap(find.text('确定'));
await tester.pumpAndSettle();
expect(out, '2024-05-12');
});
testWidgets('normalizeYmd 词法:年/年月补 01', (tester) async {
expect(normalizeYmd('2024'), '2024-01-01');
expect(normalizeYmd('2024-5'), '2024-05-01');
expect(normalizeYmd('2024/05/12'), '2024-05-12');
expect(normalizeYmd('20240229'), '2024-02-29'); //
expect(normalizeYmd('20260231'), '2026-02-28'); // clamp
expect(normalizeYmd('abc'), isNull);
});
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 140 KiB

+4 -1
View File
@@ -1,5 +1,6 @@
SERVER_MODE=release
SERVER_PORT=8080
# ali 主站:nginx(443) 反代 127.0.0.1:80812026-07-02 割接后口径)
SERVER_PORT=8081
SERVER_CORS_ORIGIN=https://jiu.51yanmei.com
DATABASE_DSN=root:CHANGE_ME_DB_PASS@tcp(127.0.0.1:3306)/jiu_db?charset=utf8mb4&parseTime=True&loc=Local
JWT_SECRET=CHANGE_ME_RANDOM_32CHARS
@@ -10,3 +11,5 @@ STORAGE_BASE_URL=https://jiu.51yanmei.com/images
STORAGE_PUBLIC_URL=https://jiu.51yanmei.com
STORAGE_WEB_DIR=/opt/jiu/web
DB_PASSWORD=CHANGE_ME_DB_PASS
# pay 回调/下单 HMAC 共享密钥(与 pay 侧一致,Bitwarden 渲染)
PAY_SECRET=CHANGE_ME_PAY_SECRET
+11 -11
View File
@@ -8,7 +8,7 @@
# sh deploy/render-env.sh --check # 只校验:4 个密钥都能从金库取到、长度非空
# sh deploy/render-env.sh --print # 渲染到 stdout 预览(密钥打码,可安全粘贴)
# sh deploy/render-env.sh --out FILE # 渲染到指定文件(含明文,慎用,自负保管)
# sh deploy/render-env.sh --deploy # 渲染→scp 到 EC2→重启 jiu→健康检查→删本地临时文件
# sh deploy/render-env.sh --deploy # 渲染→scp 到目标机(默认 ali)→重启 jiu→健康检查→删本地临时文件
#
# 前提:rbw 已 unlock。
set -euo pipefail
@@ -16,11 +16,10 @@ set -euo pipefail
# ---- 可配置项 ----
RBW="${RBW_BIN:-/opt/homebrew/bin/rbw}"
BW_ITEM="${JIU_BW_ITEM:-jiu db password}" # 存放 jiu 密钥字段的 Bitwarden 条目
SECRET_KEYS="DATABASE_DSN JWT_SECRET DB_PASSWORD"
SECRET_KEYS="DATABASE_DSN JWT_SECRET DB_PASSWORD PAY_SECRET"
EC2_HOST="${EC2_HOST:-18.136.60.128}"
EC2_USER="${EC2_USER:-ec2-user}"
EC2_KEY="${EC2_KEY:-$HOME/.ssh/wangjia.pem}"
# 目标机:~/.ssh/config 别名(2026-07-02 割接后主站 = aliEC2 已退役)
TARGET="${JIU_DEPLOY_SSH:-ali}"
REMOTE_ENV="/opt/jiu/config/production.env"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
@@ -94,18 +93,19 @@ case "$MODE" in
umask 077
render > "$TMP"
echo "render-env: 已本地渲染(临时文件,退出即删)"
echo "render-env: 上传到 $EC2_USER@$EC2_HOST:$REMOTE_ENV"
scp -i "$EC2_KEY" "$TMP" "$EC2_USER@$EC2_HOST:/tmp/production.env.new"
ssh -i "$EC2_KEY" "$EC2_USER@$EC2_HOST" bash <<REMOTE
echo "render-env: 上传到 $TARGET:$REMOTE_ENV"
scp "$TMP" "$TARGET:/tmp/production.env.new"
ssh "$TARGET" bash <<REMOTE
set -e
mkdir -p /opt/jiu/config
cp "$REMOTE_ENV" "$REMOTE_ENV.bak-\$(date +%Y%m%d%H%M%S)" 2>/dev/null || true
mv /tmp/production.env.new "$REMOTE_ENV"
chmod 600 "$REMOTE_ENV"
sudo systemctl restart jiu
systemctl restart jiu
sleep 3
curl -sf http://localhost:8080/health >/dev/null && echo " 健康检查通过 ✅" || { echo " 健康检查失败 ❌"; exit 1; }
curl -sf http://localhost:8081/health >/dev/null && echo " 健康检查通过 ✅" || { echo " 健康检查失败 ❌"; exit 1; }
REMOTE
echo "render-env: 部署完成,EC2 上的 production.env 已更新并重启"
echo "render-env: 部署完成,$TARGET 上的 production.env 已更新并重启"
;;
*)
+30
View File
@@ -89,6 +89,36 @@ nginx -t && systemctl reload nginx
echo "Ali server deploy complete!"
ENDSSH
# --- 配置漂移检查(只告警不覆盖)---
# production.env 不随发版同步(密钥走 deploy/render-env.sh 手动通道);
# 这里比对模板与线上 env 的非密钥项,漂移时在 CI 日志醒目提示,防止
# 「割接手改 env 后长期无人发现」(2026-07-04 二维码 IP 事故复盘)。
TEMPLATE=/tmp/jiu-configs/deploy/production.env.template
if [ -f "$TEMPLATE" ]; then
${SSH} "${TARGET_USER}@${TARGET_HOST}" \
"grep -v '^#' /opt/jiu/config/production.env | grep -v '^$'" \
> /tmp/jiu-env-remote.txt 2>/dev/null || true
DRIFT=0
while IFS= read -r line; do
case "$line" in ''|\#*) continue;; esac
key="${line%%=*}"
case " DATABASE_DSN JWT_SECRET DB_PASSWORD PAY_SECRET " in
*" $key "*) continue;; # 密钥行不比值
esac
remote_line="$(grep "^${key}=" /tmp/jiu-env-remote.txt || true)"
if [ -n "$remote_line" ] && [ "$remote_line" != "$line" ]; then
echo "⚠️ env 漂移: 线上 [$remote_line] ≠ 模板 [$line]"
DRIFT=1
fi
done < "$TEMPLATE"
rm -f /tmp/jiu-env-remote.txt
if [ "$DRIFT" = "1" ]; then
echo "⚠️ production.env 与模板存在非密钥项漂移——确认线上口径后同步模板,或跑 deploy/render-env.sh --deploy 覆盖线上"
else
echo "==> env drift check: 非密钥项与模板一致 ✓"
fi
fi
else
# --- EC2 (live primary) ---
${SSH} "${TARGET_USER}@${TARGET_HOST}" << 'ENDSSH'
+84
View File
@@ -0,0 +1,84 @@
---
layout: base.njk
title: 正在登录
description: App 免登录跳转中。
permalink: /sso/
---
{# App→网页免登录落地页:App 先 POST /api/v1/auth/web-ticket 签一次性票据
(TTL 60s、单次可用),再打开 /sso/?t=<ticket>&redirect=<本站路径>。
本页凭票 POST /auth/web-ticket/exchange 兑换正常登录态,写入与 Web 版
App 共享的 localStorage['flutter.*'] 后跳 redirect。
安全:URL 只含票据不含令牌;redirect 仅接受本站相对路径(防 open redirect);
兑换失败一律落到 /login/ 并回传 redirect。 #}
<div class="auth-wrap">
<div class="auth" style="grid-template-columns:1fr;max-width:420px">
<div class="auth-form" style="text-align:center">
<h1 id="ssoTitle">正在登录…</h1>
<p class="alead" id="ssoMsg">正在验证来自 App 的登录票据</p>
<div class="auth-alert" id="ssoErr"></div>
</div>
</div>
</div>
<script src="/assets/auth.js"></script>
<script>
(function () {
var API = '';
function $(id) { return document.getElementById(id); }
// redirect 白名单:仅本站相对路径(以单个 / 开头),其余一律回 /profile/
function safeRedirect() {
var p = new URLSearchParams(location.search).get('redirect') || '/profile/';
if (p.charAt(0) !== '/' || p.charAt(1) === '/' || p.indexOf('\\') >= 0) {
return '/profile/';
}
return p;
}
// 与 login.njk persist 同格式(shared_preferences_web,值 JSON 编码)
function persist(data) {
var u = data.user || {};
var kv = {
access_token: data.access_token, refresh_token: data.refresh_token,
user_id: u.id || 0, username: u.username || '', real_name: u.real_name || '',
shop_no: data.shop_code || '', shop_id: String(data.shop_id || ''),
role: u.role || 'operator',
};
Object.keys(kv).forEach(function (k) {
localStorage.setItem('flutter.' + k, JSON.stringify(kv[k]));
});
}
function fail(msg) {
$('ssoTitle').textContent = '登录跳转失败';
$('ssoMsg').textContent = '';
var el = $('ssoErr');
el.textContent = msg + ',请手动登录';
el.classList.add('show');
setTimeout(function () {
location.replace('/login/?redirect=' + encodeURIComponent(safeRedirect()));
}, 1500);
}
var ticket = new URLSearchParams(location.search).get('t');
// 票据用后即从地址栏抹掉(不留浏览器历史)
try { history.replaceState(null, '', '/sso/'); } catch (e) {}
if (!ticket) { fail('缺少登录票据'); return; }
fetch(API + '/api/v1/auth/web-ticket/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticket: ticket }),
})
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (res) {
var d = res.j && res.j.data;
if (!res.ok || !d || !d.access_token) {
return fail((res.j && res.j.error) || '票据无效或已过期');
}
persist(d);
location.replace(safeRedirect());
})
.catch(function () { fail('网络异常'); });
})();
</script>