Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6d95edf12 | |||
| a773a33779 | |||
| 11413df3db | |||
| f9d5bc79c7 | |||
| 02cffcb9ba | |||
| 9efd1177b7 | |||
| 980e38e020 | |||
| 145bf06ff8 | |||
| cd477ce81a | |||
| fb236018f3 | |||
| 06dc68245a | |||
| 0425939d7e |
@@ -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
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
|
||||
@@ -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 会话不占配额,正常登录不受影响")
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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-link;TTL 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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')}';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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('在售'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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.js:pop.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),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 / 表格横滚)在手势竞技场中优先,互不冲突。
|
||||
// 切页时按方向播放一次性滑入过渡(_slideCtrl,didUpdateWidget 触发)。
|
||||
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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
/// 可键入日期框 + 日历图标(替代旧的「年/月/日」三下拉)。
|
||||
///
|
||||
/// - **直接键入**:支持「2024」「2024-5」「2024-5-12」「20240512」等,归一为 `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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// widgets/ds/m_card.dart — 移动列表卡(镜像原型 mobile-atoms .m-card / .m-row 结构)
|
||||
// 与 .badge.ico(22px 纯图标徽章)/.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.ico):22×22 圆 pill,13px 状态图标,长按出状态词。
|
||||
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.bi):h22 pill,12px 图标替圆点。
|
||||
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 mono);null 不渲染。
|
||||
final String? sub;
|
||||
|
||||
/// mc-right 上行徽章(DsIconBadge / DsBadge 等),横排 gap5。
|
||||
final List<Widget> badges;
|
||||
|
||||
/// mc-amt 金额/数量(700 heading mono);amtWidget 优先。
|
||||
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)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:内容包 SingleChildScrollView;false:内容自管布局
|
||||
@@ -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,
|
||||
|
||||
@@ -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!));
|
||||
}
|
||||
}),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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 openOrder:drow 字段 +
|
||||
// 明细 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: [
|
||||
// 头:单号 + X(grip 由 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),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 合计(原型 drow:label 左 / 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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 208 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 140 KiB |
@@ -1,5 +1,6 @@
|
||||
SERVER_MODE=release
|
||||
SERVER_PORT=8080
|
||||
# ali 主站:nginx(443) 反代 127.0.0.1:8081(2026-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
|
||||
|
||||
@@ -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 割接后主站 = ali;EC2 已退役)
|
||||
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 已更新并重启"
|
||||
;;
|
||||
|
||||
*)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||