Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2f7ab77fa | |||
| cf468f51cf | |||
| 90f318e246 | |||
| 53fa259284 | |||
| 7c82553594 | |||
| dfa1920dda | |||
| 48e282dd97 |
@@ -46,6 +46,12 @@ jobs:
|
||||
run: sh scripts/ci/provision-mac.sh
|
||||
|
||||
- name: Compile (Flutter macOS)
|
||||
env:
|
||||
MACOS_DEVELOPER_ID_CERT_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_CERT_P12_BASE64 }}
|
||||
MACOS_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_CERT_PASSWORD }}
|
||||
APPSTORE_API_KEY_ID: ${{ secrets.APPSTORE_API_KEY_ID }}
|
||||
APPSTORE_API_ISSUER_ID: ${{ secrets.APPSTORE_API_ISSUER_ID }}
|
||||
APPSTORE_API_KEY_P8_BASE64: ${{ secrets.APPSTORE_API_KEY_P8_BASE64 }}
|
||||
run: sh scripts/ci/compile-macos.sh "${{ gitea.ref_name }}"
|
||||
|
||||
- name: Upload macos artifacts
|
||||
|
||||
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.57] - 2026-06-17
|
||||
|
||||
### 新功能
|
||||
- 新增「设备 / 状态管理」页面:可查看本店当前在线的设备与登录会话(用户、平台、登录时间、最近活跃、在线状态);管理员/超级管理员可一键将指定设备强制下线
|
||||
|
||||
### 改进
|
||||
- 顶栏精简,不再显示门店号;用户名移到左侧导航栏底部,点击可查看门店 / 账号 / 版本信息
|
||||
- 账号在其他设备登录或会话失效时,自动退出并给出明确提示
|
||||
|
||||
## [1.0.56] - 2026-06-17
|
||||
|
||||
### 新功能
|
||||
- 库存查询在手机上支持「表格 / 列表」两种视图,可一键切换
|
||||
- 入库单、出库单、库存、财务的「显示字段」选择会被记住,下次进入自动保持
|
||||
|
||||
### 改进
|
||||
- 库存查询搜索框移到工具栏最前,查找更顺手
|
||||
- 手机端列表页工具栏重新排布,按钮不再拥挤
|
||||
- 「退出登录」统一收到左侧导航栏,界面更清爽
|
||||
- macOS 安装包已签名并通过 Apple 公证,安装与自动更新不再弹安全警告
|
||||
|
||||
### 修复
|
||||
- 修复入库单 / 出库单「显示字段」勾选后部分列仍不显示的问题
|
||||
|
||||
## [1.0.55] - 2026-06-17
|
||||
|
||||
### 改进
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.0.56] - 2026-06-17
|
||||
|
||||
### 新功能
|
||||
- 在线设备/会话管理:服务端记录每次登录会话,支持查看本店在线设备、按平台类(桌面端 / 移动端 / 网页端)分别限制并发登录数,管理员可强制将指定设备下线
|
||||
- 登录失败锁定:同一账号连续多次密码错误后自动锁定一段时间,防止暴力破解
|
||||
- 记录账号最近登录时间
|
||||
|
||||
### 修复
|
||||
- 修复被禁用账号仍能凭刷新令牌继续续期登录的安全漏洞:续期时校验账号状态与会话是否有效
|
||||
|
||||
## [1.0.55] - 2026-06-17
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -13,6 +13,7 @@ type Config struct {
|
||||
JWT JWTConfig
|
||||
License LicenseConfig
|
||||
Storage StorageConfig
|
||||
Session SessionConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -39,6 +40,15 @@ type LicenseConfig struct {
|
||||
Ed25519PrivateKey string `mapstructure:"ed25519_private_key"` // base64 Ed25519 private key for token signing (keep in Bitwarden)
|
||||
}
|
||||
|
||||
// SessionConfig 登录会话与并发限制(全局默认,可被每店 session_policy 覆盖)。
|
||||
type SessionConfig struct {
|
||||
LimitDesktop int `mapstructure:"limit_desktop"` // 桌面端(win/mac/linux)最大并发会话,0=禁止
|
||||
LimitMobile int `mapstructure:"limit_mobile"` // 移动端(android/ios)最大并发会话,0=禁止
|
||||
LimitWeb int `mapstructure:"limit_web"` // web 端最大并发会话,0=禁止
|
||||
MaxFailures int `mapstructure:"max_failures"` // 连续登录失败几次后锁定
|
||||
LockMinutes int `mapstructure:"lock_minutes"` // 锁定时长(分钟)
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
UploadDir string `mapstructure:"upload_dir"`
|
||||
BaseURL string `mapstructure:"base_url"`
|
||||
@@ -75,6 +85,11 @@ func Load() {
|
||||
viper.SetDefault("server.cors_origin", "*")
|
||||
viper.SetDefault("jwt.access_expire_min", 60)
|
||||
viper.SetDefault("jwt.refresh_expire_h", 168) // 7天
|
||||
viper.SetDefault("session.limit_desktop", 2)
|
||||
viper.SetDefault("session.limit_mobile", 2)
|
||||
viper.SetDefault("session.limit_web", 2) // 默认不禁 web(官网挂着 Web 版 app);设 0 可禁
|
||||
viper.SetDefault("session.max_failures", 5)
|
||||
viper.SetDefault("session.lock_minutes", 15)
|
||||
viper.SetDefault("database.max_idle_conns", 10)
|
||||
viper.SetDefault("database.max_open_conns", 100)
|
||||
viper.SetDefault("storage.upload_dir", "./uploads/images")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
@@ -19,18 +21,34 @@ func NewAuthHandler(svc *service.AuthService) *AuthHandler {
|
||||
// Login POST /api/v1/auth/login
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req struct {
|
||||
ShopCode string `json:"shop_code" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
ShopCode string `json:"shop_code" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
Platform string `json:"platform"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pair, user, err := h.svc.Login(req.ShopCode, req.Username, req.Password)
|
||||
dev := service.DeviceInfo{
|
||||
DeviceID: req.DeviceID,
|
||||
DeviceName: req.DeviceName,
|
||||
Platform: req.Platform,
|
||||
IP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
}
|
||||
pair, user, err := h.svc.Login(req.ShopCode, req.Username, req.Password, dev)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
status := http.StatusUnauthorized
|
||||
if errors.Is(err, service.ErrTooManyAttempts) {
|
||||
status = http.StatusTooManyRequests
|
||||
} else if errors.Is(err, service.ErrPlatformNotAllowed) {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -79,9 +97,28 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||
|
||||
pair, err := h.svc.RefreshTokens(req.RefreshToken)
|
||||
if err != nil {
|
||||
// 被踢/登出/禁用 → 给前端明确 code,便于提示"账号已在其他设备登录/已失效"
|
||||
if errors.Is(err, service.ErrSessionRevoked) || errors.Is(err, service.ErrUserInactive) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error(), "code": "SESSION_REVOKED"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
util.RespondSuccess(c, pair)
|
||||
}
|
||||
|
||||
// Logout POST /api/v1/auth/logout —— 撤销当前会话
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
if err := h.svc.Logout(middleware.GetSID(c)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Ping POST /api/v1/auth/ping —— 心跳,仅触发中间件刷新 last_seen;会话已撤销则中间件直接 401
|
||||
func (h *AuthHandler) Ping(c *gin.Context) {
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestFeedbackHandler_SubmitAndList(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
api := r.Group("/api/v1")
|
||||
api.Use(middleware.JWT())
|
||||
api.Use(middleware.JWT(db))
|
||||
api.POST("/feedback", fh.Submit)
|
||||
adminG := api.Group("/admin")
|
||||
adminG.Use(middleware.SuperAdminOnly())
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
type SessionHandler struct {
|
||||
svc *service.AuthService
|
||||
}
|
||||
|
||||
func NewSessionHandler(svc *service.AuthService) *SessionHandler {
|
||||
return &SessionHandler{svc: svc}
|
||||
}
|
||||
|
||||
// List GET /api/v1/sessions —— 列出本店在线会话(所有登录用户只读)
|
||||
func (h *SessionHandler) List(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
views, err := h.svc.ListSessions(shopID, middleware.GetSID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, views)
|
||||
}
|
||||
|
||||
// ForceLogout DELETE /api/v1/sessions/:id —— 强制下线(仅 admin/superadmin,挂 AdminOnly)
|
||||
func (h *SessionHandler) ForceLogout(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session id"})
|
||||
return
|
||||
}
|
||||
shopID := middleware.GetShopID(c)
|
||||
if err := h.svc.ForceLogout(shopID, id); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
api.Use(middleware.JWT())
|
||||
api.Use(middleware.JWT(db))
|
||||
|
||||
// 商品路由
|
||||
products := api.Group("/products")
|
||||
|
||||
@@ -3,17 +3,22 @@ package middleware
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID uint64 `json:"user_id"`
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
Role string `json:"role"`
|
||||
LicenseExpiresAt *int64 `json:"lic_exp,omitempty"` // unix seconds; nil = perpetual
|
||||
UserID uint64 `json:"user_id"`
|
||||
ShopID uint64 `json:"shop_id"`
|
||||
Role string `json:"role"`
|
||||
SID string `json:"sid,omitempty"` // 服务端会话标识(user_sessions.sid)
|
||||
LicenseExpiresAt *int64 `json:"lic_exp,omitempty"` // unix seconds; nil = perpetual
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -21,10 +26,14 @@ const (
|
||||
CtxUserID = "user_id"
|
||||
CtxShopID = "shop_id"
|
||||
CtxRole = "role"
|
||||
CtxSID = "sid"
|
||||
CtxLicenseExpiresAt = "lic_exp"
|
||||
)
|
||||
|
||||
func JWT() gin.HandlerFunc {
|
||||
// lastSeenThrottle 控制 last_seen_at 写频率,避免每请求一写。
|
||||
const lastSeenThrottle = 30 * time.Second
|
||||
|
||||
func JWT(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
@@ -42,14 +51,44 @@ func JWT() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 会话校验:带 sid 的 token 必须对应一条未撤销会话(支持踢人/登出/禁用即时失效)。
|
||||
// 存量无 sid 的 token 过渡放行(其 access ≤60min 过期后会换到带 sid 的会话)。
|
||||
if claims.SID != "" {
|
||||
var sess model.UserSession
|
||||
if err := db.Where("sid = ?", claims.SID).First(&sess).Error; err != nil || sess.RevokedAt != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session revoked", "code": "SESSION_REVOKED"})
|
||||
return
|
||||
}
|
||||
// 节流刷新 last_seen,用于在线状态判定
|
||||
if time.Since(sess.LastSeenAt) > lastSeenThrottle {
|
||||
db.Model(&model.UserSession{}).Where("id = ?", sess.ID).
|
||||
Update("last_seen_at", time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
c.Set(CtxShopID, claims.ShopID)
|
||||
c.Set(CtxRole, claims.Role)
|
||||
c.Set(CtxSID, claims.SID)
|
||||
c.Set(CtxLicenseExpiresAt, claims.LicenseExpiresAt)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetSID 从 context 中获取当前会话 sid(可能为空:存量无 sid token)。
|
||||
func GetSID(c *gin.Context) string {
|
||||
v, _ := c.Get(CtxSID)
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// GetRole 从 context 中获取当前用户角色。
|
||||
func GetRole(c *gin.Context) string {
|
||||
v, _ := c.Get(CtxRole)
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// AdminOnly 仅管理员可访问
|
||||
func AdminOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
Base
|
||||
ShopID uint64 `gorm:"not null;index;uniqueIndex:uk_shop_username" json:"shop_id"`
|
||||
Username string `gorm:"size:50;uniqueIndex:uk_shop_username" json:"username"`
|
||||
PasswordHash string `gorm:"size:255" json:"-"`
|
||||
RealName string `gorm:"size:50" json:"real_name"`
|
||||
Phone string `gorm:"size:30" json:"phone"`
|
||||
Role string `gorm:"type:enum('admin','operator','readonly','superadmin');default:'operator'" json:"role"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
ShopID uint64 `gorm:"not null;index;uniqueIndex:uk_shop_username" json:"shop_id"`
|
||||
Username string `gorm:"size:50;uniqueIndex:uk_shop_username" json:"username"`
|
||||
PasswordHash string `gorm:"size:255" json:"-"`
|
||||
RealName string `gorm:"size:50" json:"real_name"`
|
||||
Phone string `gorm:"size:30" json:"phone"`
|
||||
Role string `gorm:"type:enum('admin','operator','readonly','superadmin');default:'operator'" json:"role"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UserSession 服务端登录会话。JWT 的 sid claim 指向此表一行,
|
||||
// 用于支持登出/踢人/在线状态监控(JWT 本身无状态,无法撤销)。
|
||||
type UserSession struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ShopID uint64 `gorm:"not null;index:idx_session_shop_user" json:"shop_id"`
|
||||
UserID uint64 `gorm:"not null;index:idx_session_shop_user" json:"user_id"`
|
||||
SID string `gorm:"column:sid;size:64;not null;uniqueIndex" json:"sid"` // 嵌入 JWT
|
||||
DeviceID string `gorm:"size:255" json:"device_id"`
|
||||
DeviceName string `gorm:"size:255" json:"device_name"`
|
||||
Platform string `gorm:"size:50" json:"platform"` // windows|macos|linux|android|ios|web
|
||||
PlatformClass string `gorm:"size:20;index" json:"platform_class"` // desktop|mobile|web
|
||||
IP string `gorm:"size:64" json:"ip"`
|
||||
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
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
|
||||
RefreshExpAt time.Time `json:"refresh_exp_at"`
|
||||
}
|
||||
|
||||
func (UserSession) TableName() string { return "user_sessions" }
|
||||
|
||||
// PlatformClass 把具体平台归类,用于按类限并发。
|
||||
func PlatformClass(platform string) string {
|
||||
switch platform {
|
||||
case "android", "ios":
|
||||
return "mobile"
|
||||
case "web":
|
||||
return "web"
|
||||
case "windows", "macos", "linux":
|
||||
return "desktop"
|
||||
default:
|
||||
return "desktop" // 未知平台按桌面端处理
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
|
||||
// 处理器
|
||||
authH := handler.NewAuthHandler(authSvc)
|
||||
sessionH := handler.NewSessionHandler(authSvc)
|
||||
licenseH := handler.NewLicenseHandler(licenseSvc)
|
||||
productH := handler.NewProductHandler(db)
|
||||
warehouseH := handler.NewWarehouseHandler(db)
|
||||
@@ -69,7 +70,17 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
|
||||
// 需要 JWT 的基础路由组
|
||||
api := v1.Group("")
|
||||
api.Use(middleware.JWT())
|
||||
api.Use(middleware.JWT(db))
|
||||
|
||||
// 会话/在线状态(豁免 LicenseGuard:登出/心跳/在线列表在锁定期也要可用)
|
||||
{
|
||||
api.POST("/auth/logout", authH.Logout)
|
||||
api.POST("/auth/ping", authH.Ping)
|
||||
// 在线会话列表:所有登录用户只读
|
||||
api.GET("/sessions", sessionH.List)
|
||||
// 强制下线:仅 admin/superadmin
|
||||
api.DELETE("/sessions/:id", middleware.AdminOnly(), sessionH.ForceLogout)
|
||||
}
|
||||
|
||||
// 许可证路由:豁免 LicenseGuard(锁定时仍需查看状态和激活)
|
||||
license := api.Group("/license")
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -19,8 +20,65 @@ var (
|
||||
ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
ErrUserInactive = errors.New("user is disabled")
|
||||
ErrLicenseLocked = errors.New("license locked, please renew or contact support")
|
||||
ErrPlatformNotAllowed = errors.New("该平台不允许登录")
|
||||
ErrTooManyAttempts = errors.New("登录失败次数过多,账号已临时锁定,请稍后再试")
|
||||
ErrSessionRevoked = errors.New("session revoked")
|
||||
)
|
||||
|
||||
// DeviceInfo 登录请求携带的设备信息,用于会话记录与按平台限并发。
|
||||
type DeviceInfo struct {
|
||||
DeviceID string
|
||||
DeviceName string
|
||||
Platform string
|
||||
IP string
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// loginLimiter 内存登录失败限流器(单实例,重启即清零)。
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*limiterEntry
|
||||
}
|
||||
|
||||
type limiterEntry struct {
|
||||
failures int
|
||||
lockedTill time.Time
|
||||
}
|
||||
|
||||
var loginLim = &loginLimiter{entries: map[string]*limiterEntry{}}
|
||||
|
||||
// locked 返回该 key 是否处于锁定中。
|
||||
func (l *loginLimiter) locked(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
e := l.entries[key]
|
||||
return e != nil && time.Now().Before(e.lockedTill)
|
||||
}
|
||||
|
||||
// recordFailure 记一次失败,达到阈值则锁定。
|
||||
func (l *loginLimiter) recordFailure(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
e := l.entries[key]
|
||||
if e == nil {
|
||||
e = &limiterEntry{}
|
||||
l.entries[key] = e
|
||||
}
|
||||
e.failures++
|
||||
max := config.C.Session.MaxFailures
|
||||
if max > 0 && e.failures >= max {
|
||||
e.lockedTill = time.Now().Add(time.Duration(config.C.Session.LockMinutes) * time.Minute)
|
||||
e.failures = 0
|
||||
}
|
||||
}
|
||||
|
||||
// reset 登录成功后清除失败计数。
|
||||
func (l *loginLimiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.entries, key)
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
@@ -37,15 +95,22 @@ type TokenPair struct {
|
||||
}
|
||||
|
||||
// Login 账号密码登录
|
||||
func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *model.User, error) {
|
||||
func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) (*TokenPair, *model.User, error) {
|
||||
limiterKey := shopCode + "|" + username
|
||||
if loginLim.locked(limiterKey) {
|
||||
return nil, nil, ErrTooManyAttempts
|
||||
}
|
||||
|
||||
var shop model.Shop
|
||||
if err := s.db.Where("code = ?", shopCode).First(&shop).Error; err != nil {
|
||||
loginLim.recordFailure(limiterKey)
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := s.db.Where("shop_id = ? AND username = ? AND deleted_at IS NULL", shop.ID, username).
|
||||
First(&user).Error; err != nil {
|
||||
loginLim.recordFailure(limiterKey)
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
@@ -54,6 +119,7 @@ func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *m
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
loginLim.recordFailure(limiterKey)
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
@@ -61,13 +127,176 @@ func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *m
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pair, err := s.issueTokens(user.ID, shop.ID, user.Role)
|
||||
// 按平台类限并发:取有效配额,0=禁止该平台,超额则踢最旧会话腾位。
|
||||
pclass := model.PlatformClass(dev.Platform)
|
||||
quota := s.effectiveQuota(&shop, pclass)
|
||||
if quota <= 0 {
|
||||
return nil, nil, ErrPlatformNotAllowed
|
||||
}
|
||||
|
||||
sid := uuid.New().String()
|
||||
now := time.Now()
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 统计该 user 在该 class 的活跃会话;超额踢最旧
|
||||
var active []model.UserSession
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("shop_id = ? AND user_id = ? AND platform_class = ? AND revoked_at IS NULL",
|
||||
shop.ID, user.ID, pclass).
|
||||
Order("last_seen_at ASC").Find(&active).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 需要腾出 (active+1) - quota 个位置
|
||||
for i := 0; i <= len(active)-quota; i++ {
|
||||
if err := tx.Model(&model.UserSession{}).Where("id = ?", active[i].ID).
|
||||
Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "kicked"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
sess := model.UserSession{
|
||||
ShopID: shop.ID,
|
||||
UserID: user.ID,
|
||||
SID: sid,
|
||||
DeviceID: dev.DeviceID,
|
||||
DeviceName: dev.DeviceName,
|
||||
Platform: dev.Platform,
|
||||
PlatformClass: pclass,
|
||||
IP: dev.IP,
|
||||
UserAgent: dev.UserAgent,
|
||||
LastSeenAt: now,
|
||||
RefreshExpAt: now.Add(time.Duration(config.C.JWT.RefreshExpireH) * time.Hour),
|
||||
}
|
||||
if err := tx.Create(&sess).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.User{}).Where("id = ?", user.ID).Update("last_login_at", now).Error
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
loginLim.reset(limiterKey)
|
||||
user.LastLoginAt = &now
|
||||
|
||||
pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pair, &user, nil
|
||||
}
|
||||
|
||||
// effectiveQuota 返回某店某平台类的有效并发配额:优先每店 session_policy 覆盖,否则全局默认。
|
||||
func (s *AuthService) effectiveQuota(shop *model.Shop, pclass string) int {
|
||||
def := map[string]int{
|
||||
"desktop": config.C.Session.LimitDesktop,
|
||||
"mobile": config.C.Session.LimitMobile,
|
||||
"web": config.C.Session.LimitWeb,
|
||||
}[pclass]
|
||||
|
||||
if shop.CustomFields == nil {
|
||||
return def
|
||||
}
|
||||
raw, ok := shop.CustomFields["session_policy"]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
policy, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
v, ok := policy[pclass]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
default:
|
||||
return def
|
||||
}
|
||||
}
|
||||
|
||||
// Logout 撤销指定会话(用户主动登出)。sid 为空(存量 token)时为 no-op。
|
||||
func (s *AuthService) Logout(sid string) error {
|
||||
if sid == "" {
|
||||
return nil
|
||||
}
|
||||
return s.db.Model(&model.UserSession{}).
|
||||
Where("sid = ? AND revoked_at IS NULL", sid).
|
||||
Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "logout"}).Error
|
||||
}
|
||||
|
||||
// SessionView 在线会话视图(含用户名,供管理端列表展示)。
|
||||
type SessionView struct {
|
||||
ID uint64 `json:"id"`
|
||||
UserID uint64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
RealName string `json:"real_name"`
|
||||
Platform string `json:"platform"`
|
||||
PlatformClass string `json:"platform_class"`
|
||||
DeviceName string `json:"device_name"`
|
||||
IP string `json:"ip"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
Online bool `json:"online"`
|
||||
IsCurrent bool `json:"is_current"`
|
||||
}
|
||||
|
||||
// OnlineThreshold last_seen 在此时间内视为在线。
|
||||
const OnlineThreshold = 90 * time.Second
|
||||
|
||||
// ListSessions 列出本店活跃(未撤销)会话,按最近活跃排序。currentSID 标记当前请求自身会话。
|
||||
func (s *AuthService) ListSessions(shopID uint64, currentSID string) ([]SessionView, error) {
|
||||
var rows []struct {
|
||||
model.UserSession
|
||||
Username string
|
||||
RealName string
|
||||
}
|
||||
err := s.db.Table("user_sessions AS s").
|
||||
Select("s.*, u.username AS username, u.real_name AS real_name").
|
||||
Joins("LEFT JOIN users u ON u.id = s.user_id").
|
||||
Where("s.shop_id = ? AND s.revoked_at IS NULL", shopID).
|
||||
Order("s.last_seen_at DESC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
views := make([]SessionView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
views = append(views, SessionView{
|
||||
ID: r.ID,
|
||||
UserID: r.UserID,
|
||||
Username: r.Username,
|
||||
RealName: r.RealName,
|
||||
Platform: r.Platform,
|
||||
PlatformClass: r.PlatformClass,
|
||||
DeviceName: r.DeviceName,
|
||||
IP: r.IP,
|
||||
CreatedAt: r.CreatedAt,
|
||||
LastSeenAt: r.LastSeenAt,
|
||||
Online: now.Sub(r.LastSeenAt) <= OnlineThreshold,
|
||||
IsCurrent: currentSID != "" && r.SID == currentSID,
|
||||
})
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// ForceLogout 管理员强制下线本店某会话(按 id + shop_id 隔离)。
|
||||
func (s *AuthService) ForceLogout(shopID, sessionID uint64) error {
|
||||
res := s.db.Model(&model.UserSession{}).
|
||||
Where("id = ? AND shop_id = ? AND revoked_at IS NULL", sessionID, shopID).
|
||||
Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "admin"})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RegisterInput 注册新门店所需参数
|
||||
type RegisterInput struct {
|
||||
ShopName string `json:"shop_name" binding:"required"`
|
||||
@@ -146,7 +375,8 @@ func HashPassword(plain string) (string, error) {
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
// RefreshTokens 用 Refresh Token 换新 Token Pair
|
||||
// RefreshTokens 用 Refresh Token 换新 Token Pair。
|
||||
// 修复历史漏洞:续期时重新查库校验用户/会话状态,被禁用或被踢的用户无法再续命。
|
||||
func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
|
||||
claims := &middleware.Claims{}
|
||||
token, err := jwt.ParseWithClaims(refreshToken, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
@@ -155,7 +385,35 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
|
||||
if err != nil || !token.Valid {
|
||||
return nil, errors.New("invalid refresh token")
|
||||
}
|
||||
return s.issueTokens(claims.UserID, claims.ShopID, claims.Role)
|
||||
|
||||
// 带 sid 的 token 必须对应未撤销会话(被踢/登出后无法续期)。
|
||||
if claims.SID != "" {
|
||||
var sess model.UserSession
|
||||
if err := s.db.Where("sid = ?", claims.SID).First(&sess).Error; err != nil || sess.RevokedAt != nil {
|
||||
return nil, ErrSessionRevoked
|
||||
}
|
||||
}
|
||||
|
||||
// 重新加载用户:不存在或已禁用 → 拒绝续期(修复漏洞)。
|
||||
var user model.User
|
||||
if err := s.db.Where("id = ? AND deleted_at IS NULL", claims.UserID).First(&user).Error; err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if !user.IsActive {
|
||||
return nil, ErrUserInactive
|
||||
}
|
||||
|
||||
if err := s.checkLicenseNotLocked(user.ShopID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 刷新会话存活时间(同 sid 续期)。
|
||||
if claims.SID != "" {
|
||||
s.db.Model(&model.UserSession{}).Where("sid = ?", claims.SID).
|
||||
Update("last_seen_at", time.Now())
|
||||
}
|
||||
|
||||
return s.issueTokens(user.ID, user.ShopID, user.Role, claims.SID)
|
||||
}
|
||||
|
||||
func (s *AuthService) checkLicenseNotLocked(shopID uint64) error {
|
||||
@@ -170,7 +428,7 @@ func (s *AuthService) checkLicenseNotLocked(shopID uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPair, error) {
|
||||
func (s *AuthService) issueTokens(userID, shopID uint64, role, sid string) (*TokenPair, error) {
|
||||
cfg := config.C.JWT
|
||||
now := time.Now()
|
||||
|
||||
@@ -188,6 +446,7 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPai
|
||||
UserID: userID,
|
||||
ShopID: shopID,
|
||||
Role: role,
|
||||
SID: sid,
|
||||
LicenseExpiresAt: licExpAt,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(accessExp),
|
||||
@@ -204,6 +463,7 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPai
|
||||
UserID: userID,
|
||||
ShopID: shopID,
|
||||
Role: role,
|
||||
SID: sid,
|
||||
LicenseExpiresAt: licExpAt,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(refreshExp),
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestAuthService_Login_Success(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := NewAuthService(db)
|
||||
pair, user, err := svc.Login("HOTEL001", "admin", "password123")
|
||||
pair, user, err := svc.Login("HOTEL001", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pair)
|
||||
@@ -31,7 +31,7 @@ func TestAuthService_Login_WrongPassword(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := NewAuthService(db)
|
||||
pair, user, err := svc.Login("HOTEL002", "admin", "wrongpassword")
|
||||
pair, user, err := svc.Login("HOTEL002", "admin", "wrongpassword", DeviceInfo{Platform: "windows"})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrInvalidCredentials, err)
|
||||
@@ -44,7 +44,7 @@ func TestAuthService_Login_WrongHotel(t *testing.T) {
|
||||
testutil.CreateTestShop(db, "HOTEL003")
|
||||
|
||||
svc := NewAuthService(db)
|
||||
pair, user, err := svc.Login("NONEXISTENT", "admin", "password123")
|
||||
pair, user, err := svc.Login("NONEXISTENT", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrInvalidCredentials, err)
|
||||
@@ -60,7 +60,7 @@ func TestAuthService_Login_DisabledUser(t *testing.T) {
|
||||
db.Model(user).Update("is_active", false)
|
||||
|
||||
svc := NewAuthService(db)
|
||||
pair, u, err := svc.Login("HOTEL004", "disabled", "password123")
|
||||
pair, u, err := svc.Login("HOTEL004", "disabled", "password123", DeviceInfo{Platform: "windows"})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrUserInactive, err)
|
||||
@@ -74,7 +74,7 @@ func TestAuthService_Login_WrongUsername(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := NewAuthService(db)
|
||||
pair, user, err := svc.Login("HOTEL005", "nonexistent", "password123")
|
||||
pair, user, err := svc.Login("HOTEL005", "nonexistent", "password123", DeviceInfo{Platform: "windows"})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrInvalidCredentials, err)
|
||||
@@ -88,7 +88,7 @@ func TestAuthService_RefreshTokens(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := NewAuthService(db)
|
||||
pair, _, err := svc.Login("HOTEL006", "admin", "password123")
|
||||
pair, _, err := svc.Login("HOTEL006", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, pair)
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"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 TestLogin_PerClassQuota_KicksOldest(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SESS01")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
dev := DeviceInfo{Platform: "windows"}
|
||||
p1, _, err := svc.Login("SESS01", "admin", "password123", dev)
|
||||
require.NoError(t, err)
|
||||
p2, _, err := svc.Login("SESS01", "admin", "password123", dev)
|
||||
require.NoError(t, err)
|
||||
// 配额 2,第 3 次桌面端登录应踢掉最旧(p1)
|
||||
_, _, err = svc.Login("SESS01", "admin", "password123", dev)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 共 2 个未撤销会话
|
||||
var active int64
|
||||
db.Model(&model.UserSession{}).Where("shop_id = ? AND revoked_at IS NULL", shop.ID).Count(&active)
|
||||
assert.Equal(t, int64(2), active)
|
||||
|
||||
// p1 的 refresh 应失败(会话被踢)
|
||||
_, err = svc.RefreshTokens(p1.RefreshToken)
|
||||
assert.ErrorIs(t, err, ErrSessionRevoked)
|
||||
// p2 仍可续期
|
||||
_, err = svc.RefreshTokens(p2.RefreshToken)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// 移动端与桌面端配额互不影响。
|
||||
func TestLogin_QuotaIsolatedByClass(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SESS02")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
_, _, err := svc.Login("SESS02", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
_, _, err = svc.Login("SESS02", "admin", "password123", DeviceInfo{Platform: "android"})
|
||||
require.NoError(t, err)
|
||||
|
||||
var active int64
|
||||
db.Model(&model.UserSession{}).Where("shop_id = ? AND revoked_at IS NULL", shop.ID).Count(&active)
|
||||
assert.Equal(t, int64(2), active) // 桌面 1 + 移动 1,都没被踢
|
||||
}
|
||||
|
||||
// 配额为 0 的平台拒绝登录(如禁 web)。
|
||||
func TestLogin_PlatformDisabled(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SESS03")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
old := config.C.Session.LimitWeb
|
||||
config.C.Session.LimitWeb = 0
|
||||
defer func() { config.C.Session.LimitWeb = old }()
|
||||
|
||||
_, _, err := svc.Login("SESS03", "admin", "password123", DeviceInfo{Platform: "web"})
|
||||
assert.ErrorIs(t, err, ErrPlatformNotAllowed)
|
||||
}
|
||||
|
||||
// 每店 session_policy 覆盖全局默认。
|
||||
func TestLogin_PerShopPolicyOverride(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SESS04")
|
||||
// 该店限桌面 1
|
||||
db.Model(&model.Shop{}).Where("id = ?", shop.ID).
|
||||
Update("custom_fields", model.JSON{"session_policy": map[string]interface{}{"desktop": float64(1)}})
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
p1, _, err := svc.Login("SESS04", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
_, _, err = svc.Login("SESS04", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 第 2 次登录应把 p1 踢掉(配额 1)
|
||||
_, err = svc.RefreshTokens(p1.RefreshToken)
|
||||
assert.ErrorIs(t, err, ErrSessionRevoked)
|
||||
}
|
||||
|
||||
// 被禁用用户无法用 refresh token 续命(修复历史漏洞)。
|
||||
func TestRefresh_DisabledUserRejected(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SESS05")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
pair, _, err := svc.Login("SESS05", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
|
||||
db.Model(user).Update("is_active", false)
|
||||
|
||||
_, err = svc.RefreshTokens(pair.RefreshToken)
|
||||
assert.ErrorIs(t, err, ErrUserInactive)
|
||||
}
|
||||
|
||||
// 管理员强制下线后,该会话 token 无法续期。
|
||||
func TestForceLogout_RevokesSession(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SESS06")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
pair, _, err := svc.Login("SESS06", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
|
||||
views, err := svc.ListSessions(shop.ID, "")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, views, 1)
|
||||
assert.True(t, views[0].Online)
|
||||
|
||||
require.NoError(t, svc.ForceLogout(shop.ID, views[0].ID))
|
||||
|
||||
_, err = svc.RefreshTokens(pair.RefreshToken)
|
||||
assert.ErrorIs(t, err, ErrSessionRevoked)
|
||||
|
||||
// 列表中不再出现
|
||||
views, err = svc.ListSessions(shop.ID, "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, views, 0)
|
||||
}
|
||||
|
||||
// 强制下线跨店隔离:不能下线别店会话。
|
||||
func TestForceLogout_TenantIsolation(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shopA := testutil.CreateTestShop(db, "SESS07A")
|
||||
shopB := testutil.CreateTestShop(db, "SESS07B")
|
||||
testutil.CreateTestUser(db, shopA.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
_, _, err := svc.Login("SESS07A", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
views, _ := svc.ListSessions(shopA.ID, "")
|
||||
require.Len(t, views, 1)
|
||||
|
||||
// 用 shopB 的 shopID 尝试下线 shopA 的会话 → 找不到
|
||||
err = svc.ForceLogout(shopB.ID, views[0].ID)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// 连续登录失败达到阈值后锁定。
|
||||
func TestLogin_LockoutAfterFailures(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SESS08")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
for i := 0; i < config.C.Session.MaxFailures; i++ {
|
||||
_, _, err := svc.Login("SESS08", "admin", "wrong", DeviceInfo{Platform: "windows"})
|
||||
assert.ErrorIs(t, err, ErrInvalidCredentials)
|
||||
}
|
||||
// 锁定后即便密码正确也被拒
|
||||
_, _, err := svc.Login("SESS08", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
assert.ErrorIs(t, err, ErrTooManyAttempts)
|
||||
}
|
||||
@@ -97,6 +97,7 @@ func autoMigrate(db *gorm.DB) {
|
||||
&model.User{},
|
||||
&model.License{},
|
||||
&model.LicenseDevice{},
|
||||
&model.UserSession{},
|
||||
&model.ProductCategory{},
|
||||
&model.Product{},
|
||||
&model.Warehouse{},
|
||||
|
||||
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||
`phone` VARCHAR(30) DEFAULT NULL,
|
||||
`role` ENUM('admin','operator','readonly','superadmin') NOT NULL DEFAULT 'operator',
|
||||
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`last_login_at` DATETIME DEFAULT NULL COMMENT '最近登录时间',
|
||||
`custom_fields` JSON DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -52,6 +53,32 @@ CREATE TABLE IF NOT EXISTS `users` (
|
||||
KEY `idx_deleted_at` (`deleted_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 登录会话(支持登出/踢人/在线状态监控;JWT 的 sid claim 指向此表)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `user_sessions` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||
`user_id` BIGINT UNSIGNED NOT NULL,
|
||||
`sid` VARCHAR(64) NOT NULL COMMENT '嵌入 JWT 的会话标识',
|
||||
`device_id` VARCHAR(255) DEFAULT NULL,
|
||||
`device_name` VARCHAR(255) DEFAULT NULL,
|
||||
`platform` VARCHAR(50) DEFAULT NULL COMMENT 'windows|macos|linux|android|ios|web',
|
||||
`platform_class` VARCHAR(20) DEFAULT NULL COMMENT 'desktop|mobile|web',
|
||||
`ip` VARCHAR(64) DEFAULT NULL,
|
||||
`user_agent` VARCHAR(512) DEFAULT NULL,
|
||||
`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',
|
||||
`refresh_exp_at` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_session_sid` (`sid`),
|
||||
KEY `idx_session_shop_user` (`shop_id`, `user_id`),
|
||||
KEY `idx_session_class` (`platform_class`),
|
||||
KEY `idx_session_revoked` (`revoked_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录会话';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 许可证
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
@@ -30,6 +30,13 @@ func InitConfig() {
|
||||
License: config.LicenseConfig{
|
||||
HMACSecret: "test-license-hmac-secret",
|
||||
},
|
||||
Session: config.SessionConfig{
|
||||
LimitDesktop: 2,
|
||||
LimitMobile: 2,
|
||||
LimitWeb: 2,
|
||||
MaxFailures: 5,
|
||||
LockMinutes: 15,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +83,27 @@ func SetupTestDB() *gorm.DB {
|
||||
phone TEXT,
|
||||
role TEXT DEFAULT 'operator',
|
||||
is_active INTEGER DEFAULT 1,
|
||||
last_login_at DATETIME,
|
||||
custom_fields TEXT
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS uk_shop_username ON users(shop_id, username)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
shop_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
sid TEXT NOT NULL UNIQUE,
|
||||
device_id TEXT,
|
||||
device_name TEXT,
|
||||
platform TEXT,
|
||||
platform_class TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
created_at DATETIME,
|
||||
last_seen_at DATETIME,
|
||||
revoked_at DATETIME,
|
||||
revoked_reason TEXT,
|
||||
refresh_exp_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS licenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME,
|
||||
|
||||
@@ -33,8 +33,11 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
onTokenRefreshed: (newToken) {
|
||||
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
|
||||
},
|
||||
onAuthFailed: () {
|
||||
onAuthFailed: (reason) {
|
||||
if (ref.read(authStateProvider).isLoggedIn) {
|
||||
if (reason != null) {
|
||||
ref.read(sessionEndedMessageProvider.notifier).state = reason;
|
||||
}
|
||||
ref.read(authStateProvider.notifier).logout();
|
||||
}
|
||||
},
|
||||
@@ -54,7 +57,7 @@ class ApiClient {
|
||||
String? token,
|
||||
String? refreshToken,
|
||||
void Function(String newToken)? onTokenRefreshed,
|
||||
void Function()? onAuthFailed,
|
||||
void Function(String? reason)? onAuthFailed,
|
||||
void Function()? onConnectionError,
|
||||
}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
@@ -110,7 +113,14 @@ class ApiClient {
|
||||
return handler.resolve(retryResp);
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
if (!_disposed) onAuthFailed?.call();
|
||||
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
|
||||
String? reason;
|
||||
if (refreshErr is DioException &&
|
||||
refreshErr.response?.data is Map &&
|
||||
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
|
||||
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
|
||||
}
|
||||
if (!_disposed) onAuthFailed?.call(reason);
|
||||
}
|
||||
} else if (e.response?.statusCode == 401) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../config/app_config.dart';
|
||||
|
||||
const _kAccessToken = 'access_token';
|
||||
const _kRefreshToken = 'refresh_token';
|
||||
@@ -111,6 +113,20 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
|
||||
Future<void> logout() async {
|
||||
debugPrint('[Auth] logout() called! stack: ${StackTrace.current}');
|
||||
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
|
||||
final token = state.user?.accessToken;
|
||||
if (token != null && token.isNotEmpty) {
|
||||
try {
|
||||
await Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 4),
|
||||
receiveTimeout: const Duration(seconds: 4),
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
)).post('/auth/logout');
|
||||
} catch (_) {
|
||||
// ignore: 后端不可达或会话已失效都无所谓
|
||||
}
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kAccessToken);
|
||||
await prefs.remove(_kRefreshToken);
|
||||
@@ -127,6 +143,9 @@ final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
|
||||
(ref) => AuthNotifier(),
|
||||
);
|
||||
|
||||
/// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。
|
||||
final sessionEndedMessageProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
/// 当前登录用户是否为只读角色(role == 'readonly')。
|
||||
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
|
||||
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
|
||||
|
||||
@@ -17,6 +17,7 @@ import '../../screens/public/public_product_screen.dart';
|
||||
import '../../screens/public/public_shop_products_screen.dart';
|
||||
import '../../screens/settings/settings_screen.dart';
|
||||
import '../../screens/about/about_screen.dart';
|
||||
import '../../screens/devices/device_management_screen.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
|
||||
Page<void> _noTransition(Widget child) =>
|
||||
@@ -142,6 +143,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
pageBuilder: (_, __) => _noTransition(const SettingsScreen())),
|
||||
GoRoute(
|
||||
path: '/devices',
|
||||
pageBuilder: (_, __) =>
|
||||
_noTransition(const DeviceManagementScreen())),
|
||||
GoRoute(
|
||||
path: '/about',
|
||||
pageBuilder: (_, __) => _noTransition(const AboutScreen())),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 列表屏「显示字段」(列显隐)的本地持久化。
|
||||
///
|
||||
/// 每个屏用各自的 [id](如 `inventory_list` / `stock_in_list`)独立存储。
|
||||
/// 存的是「隐藏列的 key 集合」。[load] 返回 null 表示该屏从未保存过,
|
||||
/// 调用方据此回退到按 minWidth 计算的首次默认隐藏集。
|
||||
class ColumnPrefs {
|
||||
static String _key(String id) => 'hidden_cols:$id';
|
||||
|
||||
static Future<Set<String>?> load(String id) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getStringList(_key(id))?.toSet();
|
||||
}
|
||||
|
||||
static Future<void> save(String id, Set<String> hidden) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(_key(id), hidden.toList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/// 在线会话/设备(对应后端 service.SessionView)。
|
||||
class DeviceSession {
|
||||
final int id;
|
||||
final int userId;
|
||||
final String username;
|
||||
final String realName;
|
||||
final String platform;
|
||||
final String platformClass;
|
||||
final String deviceName;
|
||||
final String ip;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? lastSeenAt;
|
||||
final bool online;
|
||||
final bool isCurrent;
|
||||
|
||||
const DeviceSession({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.username,
|
||||
required this.realName,
|
||||
required this.platform,
|
||||
required this.platformClass,
|
||||
required this.deviceName,
|
||||
required this.ip,
|
||||
required this.createdAt,
|
||||
required this.lastSeenAt,
|
||||
required this.online,
|
||||
required this.isCurrent,
|
||||
});
|
||||
|
||||
factory DeviceSession.fromJson(Map<String, dynamic> json) {
|
||||
DateTime? parse(String? s) =>
|
||||
(s == null || s.isEmpty) ? null : DateTime.tryParse(s)?.toLocal();
|
||||
return DeviceSession(
|
||||
id: (json['id'] as num).toInt(),
|
||||
userId: (json['user_id'] as num?)?.toInt() ?? 0,
|
||||
username: json['username'] as String? ?? '',
|
||||
realName: json['real_name'] as String? ?? '',
|
||||
platform: json['platform'] as String? ?? '',
|
||||
platformClass: json['platform_class'] as String? ?? '',
|
||||
deviceName: json['device_name'] as String? ?? '',
|
||||
ip: json['ip'] as String? ?? '',
|
||||
createdAt: parse(json['created_at'] as String?),
|
||||
lastSeenAt: parse(json['last_seen_at'] as String?),
|
||||
online: json['online'] as bool? ?? false,
|
||||
isCurrent: json['is_current'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
/// 平台中文显示名。
|
||||
String get platformLabel {
|
||||
switch (platform) {
|
||||
case 'windows':
|
||||
return 'Windows';
|
||||
case 'macos':
|
||||
return 'macOS';
|
||||
case 'linux':
|
||||
return 'Linux';
|
||||
case 'android':
|
||||
return 'Android';
|
||||
case 'ios':
|
||||
return 'iOS';
|
||||
case 'web':
|
||||
return '网页版';
|
||||
default:
|
||||
return platform.isEmpty ? '未知' : platform;
|
||||
}
|
||||
}
|
||||
|
||||
/// 平台类中文显示名。
|
||||
String get platformClassLabel {
|
||||
switch (platformClass) {
|
||||
case 'desktop':
|
||||
return '桌面端';
|
||||
case 'mobile':
|
||||
return '移动端';
|
||||
case 'web':
|
||||
return '网页端';
|
||||
default:
|
||||
return platformClass;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
|
||||
/// 登录态心跳:已登录时每 30s 打一次 POST /auth/ping。
|
||||
/// 若会话已被撤销(被踢/管理员强制下线),后端返回 401,
|
||||
/// ApiClient 的拦截器会触发 refresh→失败→onAuthFailed→logout,
|
||||
/// 因此空闲用户也能在 ~30s 内感知到被下线。
|
||||
final sessionHeartbeatProvider = Provider<SessionHeartbeat>((ref) {
|
||||
final hb = SessionHeartbeat(ref);
|
||||
ref.onDispose(hb.dispose);
|
||||
hb.start();
|
||||
return hb;
|
||||
});
|
||||
|
||||
class SessionHeartbeat {
|
||||
final Ref _ref;
|
||||
Timer? _timer;
|
||||
|
||||
SessionHeartbeat(this._ref);
|
||||
|
||||
void start() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 30), (_) => _ping());
|
||||
}
|
||||
|
||||
Future<void> _ping() async {
|
||||
if (!_ref.read(authStateProvider).isLoggedIn) return;
|
||||
try {
|
||||
await _ref.read(apiClientProvider).post('/auth/ping');
|
||||
} catch (e) {
|
||||
// 401 已由 ApiClient 拦截器处理(触发登出);其余错误忽略
|
||||
debugPrint('[Heartbeat] ping failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() => _timer?.cancel();
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../models/session.dart';
|
||||
import '../repositories/session_repository.dart';
|
||||
|
||||
final sessionRepositoryProvider = Provider<SessionRepository>((ref) {
|
||||
return SessionRepository(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final sessionListProvider =
|
||||
AsyncNotifierProvider<SessionListNotifier, List<DeviceSession>>(
|
||||
SessionListNotifier.new,
|
||||
);
|
||||
|
||||
class SessionListNotifier extends AsyncNotifier<List<DeviceSession>> {
|
||||
List<DeviceSession> _cache = [];
|
||||
|
||||
@override
|
||||
Future<List<DeviceSession>> build() async {
|
||||
ref.watch(authStateProvider.select((s) => s.user?.shopId));
|
||||
try {
|
||||
final result = await ref.read(sessionRepositoryProvider).list();
|
||||
_cache = result;
|
||||
return result;
|
||||
} catch (_) {
|
||||
if (_cache.isNotEmpty) return _cache;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reload() async {
|
||||
state = const AsyncValue.loading();
|
||||
try {
|
||||
final result = await ref.read(sessionRepositoryProvider).list();
|
||||
_cache = result;
|
||||
state = AsyncValue.data(result);
|
||||
} catch (e, st) {
|
||||
if (_cache.isNotEmpty) {
|
||||
state = AsyncValue.data(_cache);
|
||||
} else {
|
||||
state = AsyncValue.error(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> forceLogout(int id) async {
|
||||
await ref.read(sessionRepositoryProvider).forceLogout(id);
|
||||
await reload();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../core/device/device_id.dart';
|
||||
|
||||
typedef AuthLoginFn = Future<AuthUser> Function({
|
||||
required String shopCode,
|
||||
@@ -37,10 +38,15 @@ class AuthRepository {
|
||||
);
|
||||
}
|
||||
try {
|
||||
final deviceId = await DeviceId.get();
|
||||
final platform = DeviceId.platformName;
|
||||
final resp = await PublicApiClient.post('/auth/login', data: {
|
||||
'shop_code': shopCode,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'device_id': deviceId,
|
||||
'device_name': platform,
|
||||
'platform': platform,
|
||||
});
|
||||
|
||||
final data = resp.data['data'] as Map<String, dynamic>;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import '../core/api/api_client.dart';
|
||||
import '../models/session.dart';
|
||||
|
||||
class SessionRepository {
|
||||
final ApiClient _client;
|
||||
SessionRepository(this._client);
|
||||
|
||||
/// GET /api/v1/sessions —— 本店在线会话(所有登录用户只读)
|
||||
Future<List<DeviceSession>> list() async {
|
||||
final resp = await _client.get('/sessions');
|
||||
final data = (resp.data['data'] as List?) ?? [];
|
||||
return data
|
||||
.map((e) => DeviceSession.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/sessions/:id —— 强制下线(仅 admin/superadmin)
|
||||
Future<void> forceLogout(int id) async {
|
||||
await _client.delete('/sessions/$id');
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 被踢下线 / 会话失效:登出后跳回登录页,弹一次提示并清空
|
||||
ref.listen<String?>(sessionEndedMessageProvider, (prev, next) {
|
||||
if (next != null && next.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(next), backgroundColor: AppTheme.danger));
|
||||
ref.read(sessionEndedMessageProvider.notifier).state = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.primaryDark,
|
||||
body: Stack(
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/session.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/mobile_list_card.dart';
|
||||
|
||||
/// 设备 / 状态管理:本店在线设备/会话列表。
|
||||
/// 所有登录用户只读;管理员/超级管理员可「强制下线」其他登录者。
|
||||
class DeviceManagementScreen extends ConsumerWidget {
|
||||
const DeviceManagementScreen({super.key});
|
||||
|
||||
static final _fmt = DateFormat('MM-dd HH:mm');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncSessions = ref.watch(sessionListProvider);
|
||||
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
|
||||
final canKick = role == 'admin' || role == 'superadmin';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('设备 / 状态管理',
|
||||
style:
|
||||
TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
canKick ? '管理员可强制下线其他登录设备' : '仅查看,无操作权限',
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: AppTheme.textSecondary),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: '刷新',
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: () =>
|
||||
ref.read(sessionListProvider.notifier).reload(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: asyncSessions.when(
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline,
|
||||
size: 40, color: AppTheme.textSecondary),
|
||||
const SizedBox(height: 12),
|
||||
Text('加载失败:$e',
|
||||
style:
|
||||
const TextStyle(color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: () =>
|
||||
ref.read(sessionListProvider.notifier).reload(),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (sessions) =>
|
||||
_buildTable(context, ref, sessions, canKick),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTable(BuildContext context, WidgetRef ref,
|
||||
List<DeviceSession> sessions, bool canKick) {
|
||||
Widget onlineBadge(bool online) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.circle,
|
||||
size: 9,
|
||||
color: online ? AppTheme.success : AppTheme.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
Text(online ? '在线' : '离线',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color:
|
||||
online ? AppTheme.success : AppTheme.textSecondary)),
|
||||
],
|
||||
);
|
||||
|
||||
String fmt(DateTime? t) => t == null ? '-' : _fmt.format(t);
|
||||
|
||||
Widget? kickButton(DeviceSession s, {bool dense = false}) {
|
||||
if (!canKick) return null;
|
||||
if (s.isCurrent) {
|
||||
return const Text('当前设备',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary));
|
||||
}
|
||||
return TextButton(
|
||||
key: Key('btn_kick_${s.id}'),
|
||||
onPressed: () => _confirmKick(context, ref, s),
|
||||
child: Text('强制下线',
|
||||
style: TextStyle(
|
||||
fontSize: dense ? 13 : 12, color: AppTheme.danger)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget sessionCard(DeviceSession s) {
|
||||
final action = kickButton(s, dense: true);
|
||||
return MobileListCard(
|
||||
title: Text(s.username.isEmpty ? '用户#${s.userId}' : s.username),
|
||||
subtitle: Text('${s.platformLabel} · ${s.platformClassLabel}'),
|
||||
trailing: onlineBadge(s.online),
|
||||
fields: [
|
||||
if (s.deviceName.isNotEmpty) MobileCardField('设备', s.deviceName),
|
||||
if (s.ip.isNotEmpty) MobileCardField('IP', s.ip),
|
||||
MobileCardField('登录时间', fmt(s.createdAt)),
|
||||
MobileCardField('最近活跃', fmt(s.lastSeenAt)),
|
||||
if (s.isCurrent) const MobileCardField('备注', '当前设备'),
|
||||
],
|
||||
actions: action == null ? null : [action],
|
||||
);
|
||||
}
|
||||
|
||||
return DataTableCard(
|
||||
mobileCards: sessions.map(sessionCard).toList(),
|
||||
columns: const [
|
||||
DataColumn(label: Text('用户')),
|
||||
DataColumn(label: Text('平台')),
|
||||
DataColumn(label: Text('设备')),
|
||||
DataColumn(label: Text('IP')),
|
||||
DataColumn(label: Text('登录时间')),
|
||||
DataColumn(label: Text('最近活跃')),
|
||||
DataColumn(label: Text('状态')),
|
||||
DataColumn(label: Text('操作')),
|
||||
],
|
||||
rows: sessions.isEmpty
|
||||
? [
|
||||
const DataRow(cells: [
|
||||
DataCell(Text('暂无在线设备',
|
||||
style: TextStyle(color: AppTheme.textSecondary))),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
DataCell(SizedBox()),
|
||||
])
|
||||
]
|
||||
: sessions.map((s) {
|
||||
return DataRow(cells: [
|
||||
DataCell(Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(s.username.isEmpty ? '用户#${s.userId}' : s.username,
|
||||
style:
|
||||
const TextStyle(fontWeight: FontWeight.w500)),
|
||||
if (s.isCurrent) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primary.withAlpha(26),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text('本机',
|
||||
style: TextStyle(
|
||||
fontSize: 11, color: AppTheme.primary)),
|
||||
),
|
||||
],
|
||||
],
|
||||
)),
|
||||
DataCell(Text('${s.platformLabel} · ${s.platformClassLabel}')),
|
||||
DataCell(Text(s.deviceName.isEmpty ? '-' : s.deviceName)),
|
||||
DataCell(Text(s.ip.isEmpty ? '-' : s.ip,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary))),
|
||||
DataCell(Text(fmt(s.createdAt))),
|
||||
DataCell(Text(fmt(s.lastSeenAt))),
|
||||
DataCell(onlineBadge(s.online)),
|
||||
DataCell(kickButton(s) ?? const SizedBox()),
|
||||
]);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmKick(
|
||||
BuildContext context, WidgetRef ref, DeviceSession s) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('强制下线'),
|
||||
content: Text(
|
||||
'确认将「${s.username.isEmpty ? '用户#${s.userId}' : s.username}」的'
|
||||
'${s.platformLabel}设备下线?该设备约 30 秒内退出登录。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.danger,
|
||||
foregroundColor: Colors.white),
|
||||
child: const Text('强制下线'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
try {
|
||||
await ref.read(sessionListProvider.notifier).forceLogout(s.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('已下线'), backgroundColor: AppTheme.success));
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('操作失败:$e'), backgroundColor: AppTheme.danger));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../../providers/finance_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/mobile_list_card.dart';
|
||||
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
||||
import '../../core/storage/column_prefs.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../core/utils/export_util.dart';
|
||||
@@ -53,7 +54,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
|
||||
Set<String> _filterType = {};
|
||||
Set<String> _filterPartner = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
|
||||
|
||||
static const _screenId = 'finance';
|
||||
|
||||
static const _colDefs = [
|
||||
ColDef('date', '日期', required: true),
|
||||
@@ -73,6 +76,9 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
final now = DateTime.now();
|
||||
_month = '${now.year}-${now.month.toString().padLeft(2, '0')}';
|
||||
_fetch();
|
||||
ColumnPrefs.load(_screenId).then((saved) {
|
||||
if (saved != null && mounted) setState(() => _hiddenCols = saved);
|
||||
});
|
||||
}
|
||||
|
||||
void _fetch() {
|
||||
@@ -230,11 +236,15 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
..sort();
|
||||
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final visibleCols = _colDefs
|
||||
.where((c) =>
|
||||
!_hiddenCols.contains(c.key) &&
|
||||
(c.minWidth == null || screenWidth >= c.minWidth!))
|
||||
.toList();
|
||||
// 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。
|
||||
final hidden = _hiddenCols ??
|
||||
_colDefs
|
||||
.where((c) => c.minWidth != null && screenWidth < c.minWidth!)
|
||||
.map((c) => c.key)
|
||||
.toSet();
|
||||
// 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。
|
||||
final visibleCols =
|
||||
_colDefs.where((c) => !hidden.contains(c.key)).toList();
|
||||
|
||||
final columns = visibleCols.map((c) {
|
||||
final label = switch (c.key) {
|
||||
@@ -455,8 +465,11 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
const SizedBox(width: 8),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: _hiddenCols,
|
||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
||||
hidden: hidden,
|
||||
onChanged: (v) {
|
||||
setState(() => _hiddenCols = v);
|
||||
ColumnPrefs.save(_screenId, v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../../providers/inventory_provider.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/mobile_list_card.dart';
|
||||
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
||||
import '../../core/storage/column_prefs.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../core/utils/export_util.dart';
|
||||
import '../../core/utils/print_util.dart';
|
||||
@@ -37,7 +38,10 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
Set<String> _filterWarehouse = {};
|
||||
Set<String> _filterSpec = {};
|
||||
Set<String> _filterSeries = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
Set<String>? _hiddenCols; // null = 尚未载入本地存档
|
||||
bool _mobileTableView = false; // 移动端:false=卡片列表,true=表格(横向滚动)
|
||||
|
||||
static const _screenId = 'inventory_list';
|
||||
|
||||
static const _colDefs = [
|
||||
ColDef('code', '商品编码', required: true),
|
||||
@@ -57,6 +61,14 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
];
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ColumnPrefs.load(_screenId).then((saved) {
|
||||
if (saved != null && mounted) setState(() => _hiddenCols = saved);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
@@ -499,8 +511,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
? items
|
||||
: items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList();
|
||||
|
||||
final hidden = _hiddenCols ?? <String>{};
|
||||
final visibleCols =
|
||||
_colDefs.where((c) => !_hiddenCols.contains(c.key)).toList();
|
||||
_colDefs.where((c) => !hidden.contains(c.key)).toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
@@ -553,18 +566,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
ref.read(inventoryListProvider.notifier).setPage(p),
|
||||
onPageSizeChanged: (s) =>
|
||||
ref.read(inventoryListProvider.notifier).setPageSize(s),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
if (!WriteGuard.isReadonly(ref)) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('发起盘点'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => exportExcel(
|
||||
toolbar: Builder(builder: (ctx) {
|
||||
final isMobile = ctx.isMobile;
|
||||
void doExport() => exportExcel(
|
||||
filename: '库存查询',
|
||||
headers: ['商品编码', '商品名称', '规格', '系列', '批次号', '仓库', '库存量', '单价', '生产日期', '入库时间', '供应商', '安全库存', '状态'],
|
||||
rows: filteredItems.map((item) {
|
||||
@@ -589,38 +593,112 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
status,
|
||||
];
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
final searchField = TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: InputDecoration(
|
||||
hintText: '名称/编码/拼音,回车搜索',
|
||||
prefixIcon: const Icon(Icons.search, size: 16),
|
||||
hintStyle: const TextStyle(fontSize: 12),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search, size: 16),
|
||||
tooltip: '搜索',
|
||||
onPressed: _triggerSearch,
|
||||
),
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text('导出'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: _hiddenCols,
|
||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
||||
),
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: InputDecoration(
|
||||
hintText: '名称/编码/拼音,回车搜索',
|
||||
prefixIcon: const Icon(Icons.search, size: 16),
|
||||
hintStyle: const TextStyle(fontSize: 12),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.search, size: 16),
|
||||
tooltip: '搜索',
|
||||
onPressed: _triggerSearch,
|
||||
),
|
||||
onSubmitted: (_) => _triggerSearch(),
|
||||
);
|
||||
|
||||
final canCheck = !WriteGuard.isReadonly(ref);
|
||||
|
||||
if (isMobile) {
|
||||
// 移动端:搜索独占一行 + 紧凑图标操作行(含 表格/列表 切换)
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
searchField,
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
if (canCheck)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('盘点'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: _mobileTableView ? '卡片视图' : '表格视图',
|
||||
icon: Icon(
|
||||
_mobileTableView
|
||||
? Icons.view_agenda_outlined
|
||||
: Icons.table_rows_outlined,
|
||||
size: 20),
|
||||
onPressed: () => setState(
|
||||
() => _mobileTableView = !_mobileTableView),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '导出',
|
||||
icon: const Icon(Icons.download, size: 20),
|
||||
onPressed: doExport,
|
||||
),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: hidden,
|
||||
compact: true,
|
||||
onChanged: (v) {
|
||||
setState(() => _hiddenCols = v);
|
||||
ColumnPrefs.save(_screenId, v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
onSubmitted: (_) => _triggerSearch(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 桌面:搜索置于最前,按钮成组靠左
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(width: 220, child: searchField),
|
||||
const SizedBox(width: 12),
|
||||
if (canCheck) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('发起盘点'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: doExport,
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text('导出'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
mobileCards:
|
||||
filteredItems.map(_inventoryCard).toList(),
|
||||
const SizedBox(width: 8),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: hidden,
|
||||
onChanged: (v) {
|
||||
setState(() => _hiddenCols = v);
|
||||
ColumnPrefs.save(_screenId, v);
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
],
|
||||
);
|
||||
}),
|
||||
mobileCards: _mobileTableView
|
||||
? null
|
||||
: filteredItems.map(_inventoryCard).toList(),
|
||||
columns: visibleCols.map((c) {
|
||||
final label = switch (c.key) {
|
||||
'spec' => FilterableColumnHeader(
|
||||
|
||||
@@ -79,28 +79,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// 退出登录(常驻底部;登出后路由 redirect 自动跳 /login)
|
||||
const Divider(height: 1),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(authStateProvider.notifier).logout(),
|
||||
icon: const Icon(Icons.logout, size: 18),
|
||||
label: const Text('退出登录'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.danger,
|
||||
side: const BorderSide(color: AppTheme.danger),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../core/config/app_config.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../providers/session_heartbeat.dart';
|
||||
import '../../providers/shop_provider.dart';
|
||||
import '../../providers/update_provider.dart';
|
||||
import '../../core/update/app_updater.dart';
|
||||
@@ -72,6 +73,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
path: '/finance'),
|
||||
_NavItem(icon: Icons.people, label: '往来单位', path: '/partners'),
|
||||
_NavItem(icon: Icons.category, label: '基础数据', path: '/products'),
|
||||
_NavItem(icon: Icons.devices, label: '设备管理', path: '/devices'),
|
||||
_NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'),
|
||||
_NavItem(icon: Icons.info_outline, label: '关于我们', path: '/about'),
|
||||
];
|
||||
@@ -166,6 +168,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(authStateProvider).user;
|
||||
// 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线
|
||||
ref.watch(sessionHeartbeatProvider);
|
||||
final isOnline = ref.watch(connectivityProvider);
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final isMobile = context.isMobile;
|
||||
@@ -237,35 +241,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
],
|
||||
const Spacer(),
|
||||
if (user != null) ...[
|
||||
// 窄屏隐藏门店号/用户名文字块,仅保留用户菜单,避免顶栏拥挤
|
||||
if (!isMobile) ...[
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () =>
|
||||
_showShopPanel(context, user, version: appVersion),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.business,
|
||||
color: Colors.white70, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(user.shopNo,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
const Icon(Icons.person_outline,
|
||||
color: Colors.white70, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Text(user.username,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 13)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
// 门店号已移除;用户名移到左侧栏底部。顶栏仅保留「个人设置」下拉。
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.keyboard_arrow_down,
|
||||
color: Colors.white70),
|
||||
@@ -289,16 +265,6 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
label: '个人设置',
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(height: 1),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'logout',
|
||||
padding: EdgeInsets.zero,
|
||||
child: _HoverMenuItem(
|
||||
icon: Icons.logout_outlined,
|
||||
label: '退出登录',
|
||||
danger: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -334,6 +300,95 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
// 当前登录账号(顶栏移下来的用户名):点击弹门店/账号/版本面板
|
||||
if (user != null) ...[
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => _showShopPanel(context, user,
|
||||
version: appVersion),
|
||||
hoverColor: Colors.white.withAlpha(13),
|
||||
splashColor: Colors.white.withAlpha(26),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _sidebarExpanded ? 16 : 3),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: _sidebarExpanded
|
||||
? MainAxisAlignment.start
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.person_outline,
|
||||
color: Colors.white60,
|
||||
size: 20),
|
||||
if (_sidebarExpanded) ...[
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
user.username,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14),
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// 退出登录(侧栏底部常驻;统一为左侧导航唯一退出入口)
|
||||
const Divider(height: 1, color: Colors.white24),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
ref
|
||||
.read(authStateProvider.notifier)
|
||||
.logout();
|
||||
context.go('/login');
|
||||
},
|
||||
hoverColor: Colors.white.withAlpha(13),
|
||||
splashColor: Colors.white.withAlpha(26),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: _sidebarExpanded ? 16 : 3),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: _sidebarExpanded
|
||||
? MainAxisAlignment.start
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.logout,
|
||||
color: Colors.white60, size: 20),
|
||||
if (_sidebarExpanded) ...[
|
||||
const SizedBox(width: 12),
|
||||
const Text('退出登录',
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -930,12 +985,10 @@ class _InfoRow extends StatelessWidget {
|
||||
class _HoverMenuItem extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool danger;
|
||||
|
||||
const _HoverMenuItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.danger = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -947,12 +1000,8 @@ class _HoverMenuItemState extends State<_HoverMenuItem> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = widget.danger
|
||||
? const Color(0xFFE53935)
|
||||
: const Color(0xFF333333);
|
||||
final hoverBg = widget.danger
|
||||
? const Color(0xFFFFF0F0)
|
||||
: const Color(0xFFF0F4FF);
|
||||
const color = Color(0xFF333333);
|
||||
const hoverBg = Color(0xFFF0F4FF);
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../repositories/stock_in_repository.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/mobile_list_card.dart';
|
||||
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
||||
import '../../core/storage/column_prefs.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/status_badge.dart';
|
||||
import '../../core/utils/export_util.dart';
|
||||
@@ -36,7 +37,9 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
DateTimeRange? _dateRange;
|
||||
Set<String> _filterWarehouse = {};
|
||||
Set<String> _filterSupplier = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
|
||||
|
||||
static const _screenId = 'stock_in_list';
|
||||
|
||||
static const _colDefs = [
|
||||
ColDef('order_no', '入库单号', required: true),
|
||||
@@ -51,6 +54,14 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
ColDef('actions', '操作', required: true),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ColumnPrefs.load(_screenId).then((saved) {
|
||||
if (saved != null && mounted) setState(() => _hiddenCols = saved);
|
||||
});
|
||||
}
|
||||
|
||||
String? get _startDate => _dateRange != null
|
||||
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
|
||||
: null;
|
||||
@@ -177,11 +188,15 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
required List<String> supplierOptions,
|
||||
}) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final visibleCols = _colDefs
|
||||
.where((c) =>
|
||||
!_hiddenCols.contains(c.key) &&
|
||||
(c.minWidth == null || screenWidth >= c.minWidth!))
|
||||
.toList();
|
||||
// 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。
|
||||
final hidden = _hiddenCols ??
|
||||
_colDefs
|
||||
.where((c) => c.minWidth != null && screenWidth < c.minWidth!)
|
||||
.map((c) => c.key)
|
||||
.toSet();
|
||||
// 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。
|
||||
final visibleCols =
|
||||
_colDefs.where((c) => !hidden.contains(c.key)).toList();
|
||||
|
||||
final columns = visibleCols.map((c) {
|
||||
final label = switch (c.key) {
|
||||
@@ -279,64 +294,123 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
ref.read(stockInListProvider.notifier).setPage(p),
|
||||
onPageSizeChanged: (s) =>
|
||||
ref.read(stockInListProvider.notifier).setPageSize(s),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
if (showNewButton && !WriteGuard.isReadonly(ref))
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-in/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建入库审核单'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStatusFilter) ...[
|
||||
_StatusFilterDropdown(
|
||||
value: _statusFilter,
|
||||
onChanged: (v) {
|
||||
setState(() => _statusFilter = v ?? '');
|
||||
ref.read(stockInListProvider.notifier).setStatus(v ?? '');
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickDateRange,
|
||||
icon: const Icon(Icons.date_range, size: 16),
|
||||
label: Text(
|
||||
_dateRange == null ? '选择日期' : '$_startDate ~ $_endDate',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
if (_dateRange != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear, size: 16),
|
||||
onPressed: () {
|
||||
setState(() => _dateRange = null);
|
||||
ref.read(stockInListProvider.notifier).setDateRange(null, null);
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => exportExcel(
|
||||
toolbar: Builder(builder: (ctx) {
|
||||
final isMobile = ctx.isMobile;
|
||||
void doExport() => exportExcel(
|
||||
filename: '入库单列表',
|
||||
headers: ['单号', '仓库', '供应商', '状态', '日期', '总金额'],
|
||||
rows: orders.map((o) => [
|
||||
o.orderNo, o.warehouseName ?? '', o.partnerName ?? '',
|
||||
o.status, o.orderDate, o.totalAmount,
|
||||
]).toList(),
|
||||
);
|
||||
|
||||
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
|
||||
? ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-in/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isMobile ? '新建' : '新建入库审核单'),
|
||||
style: isMobile
|
||||
? ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
: null;
|
||||
|
||||
final statusFilter = showStatusFilter
|
||||
? _StatusFilterDropdown(
|
||||
value: _statusFilter,
|
||||
onChanged: (v) {
|
||||
setState(() => _statusFilter = v ?? '');
|
||||
ref.read(stockInListProvider.notifier).setStatus(v ?? '');
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
final dateBtn = OutlinedButton.icon(
|
||||
onPressed: _pickDateRange,
|
||||
icon: const Icon(Icons.date_range, size: 16),
|
||||
label: Text(
|
||||
_dateRange == null ? '选择日期' : '$_startDate ~ $_endDate',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
);
|
||||
|
||||
final clearDate = _dateRange != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 16),
|
||||
onPressed: () {
|
||||
setState(() => _dateRange = null);
|
||||
ref
|
||||
.read(stockInListProvider.notifier)
|
||||
.setDateRange(null, null);
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
if (isMobile) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (newBtn != null) newBtn,
|
||||
if (statusFilter != null) statusFilter,
|
||||
dateBtn,
|
||||
if (clearDate != null) clearDate,
|
||||
IconButton(
|
||||
tooltip: '导出',
|
||||
icon: const Icon(Icons.download, size: 20),
|
||||
onPressed: doExport,
|
||||
),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: hidden,
|
||||
compact: true,
|
||||
onChanged: (v) {
|
||||
setState(() => _hiddenCols = v);
|
||||
ColumnPrefs.save(_screenId, v);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (newBtn != null) newBtn,
|
||||
const Spacer(),
|
||||
if (statusFilter != null) ...[
|
||||
statusFilter,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
dateBtn,
|
||||
if (clearDate != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
clearDate,
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: doExport,
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text('导出'),
|
||||
),
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text('导出'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: _hiddenCols,
|
||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: hidden,
|
||||
onChanged: (v) {
|
||||
setState(() => _hiddenCols = v);
|
||||
ColumnPrefs.save(_screenId, v);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
mobileCards: orders.map((o) => _orderCard(context, o)).toList(),
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../repositories/stock_out_repository.dart';
|
||||
import '../../widgets/data_table_card.dart';
|
||||
import '../../widgets/mobile_list_card.dart';
|
||||
import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButton, FilterableColumnHeader;
|
||||
import '../../core/storage/column_prefs.dart';
|
||||
import '../../widgets/page_scaffold.dart';
|
||||
import '../../widgets/status_badge.dart';
|
||||
import '../../core/utils/export_util.dart';
|
||||
@@ -35,7 +36,17 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
DateTimeRange? _dateRange;
|
||||
Set<String> _filterWarehouse = {};
|
||||
Set<String> _filterCustomer = {};
|
||||
Set<String> _hiddenCols = {};
|
||||
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
|
||||
|
||||
static const _screenId = 'stock_out_list';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
ColumnPrefs.load(_screenId).then((saved) {
|
||||
if (saved != null && mounted) setState(() => _hiddenCols = saved);
|
||||
});
|
||||
}
|
||||
|
||||
static const _colDefs = [
|
||||
ColDef('order_no', '出库单号', required: true),
|
||||
@@ -177,11 +188,15 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
required List<String> customerOptions,
|
||||
}) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final visibleCols = _colDefs
|
||||
.where((c) =>
|
||||
!_hiddenCols.contains(c.key) &&
|
||||
(c.minWidth == null || screenWidth >= c.minWidth!))
|
||||
.toList();
|
||||
// 隐藏列:本地存档优先;无存档时按 minWidth 计算首次默认隐藏集。
|
||||
final hidden = _hiddenCols ??
|
||||
_colDefs
|
||||
.where((c) => c.minWidth != null && screenWidth < c.minWidth!)
|
||||
.map((c) => c.key)
|
||||
.toSet();
|
||||
// 列可见性只看用户选择(minWidth 仅作首次默认,不再运行时强制隐藏)。
|
||||
final visibleCols =
|
||||
_colDefs.where((c) => !hidden.contains(c.key)).toList();
|
||||
|
||||
final columns = visibleCols.map((c) {
|
||||
final label = switch (c.key) {
|
||||
@@ -285,64 +300,123 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
ref.read(stockOutListProvider.notifier).setPage(p),
|
||||
onPageSizeChanged: (s) =>
|
||||
ref.read(stockOutListProvider.notifier).setPageSize(s),
|
||||
toolbar: Row(
|
||||
children: [
|
||||
if (showNewButton && !WriteGuard.isReadonly(ref))
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-out/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建出库审核单'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (showStatusFilter) ...[
|
||||
_StatusFilterDropdown(
|
||||
value: _statusFilter,
|
||||
onChanged: (v) {
|
||||
setState(() => _statusFilter = v ?? '');
|
||||
ref.read(stockOutListProvider.notifier).setStatus(v ?? '');
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
OutlinedButton.icon(
|
||||
onPressed: _pickDateRange,
|
||||
icon: const Icon(Icons.date_range, size: 16),
|
||||
label: Text(
|
||||
_dateRange == null ? '选择日期' : '$_startDate ~ $_endDate',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
if (_dateRange != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear, size: 16),
|
||||
onPressed: () {
|
||||
setState(() => _dateRange = null);
|
||||
ref.read(stockOutListProvider.notifier).setDateRange(null, null);
|
||||
},
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => exportExcel(
|
||||
toolbar: Builder(builder: (ctx) {
|
||||
final isMobile = ctx.isMobile;
|
||||
void doExport() => exportExcel(
|
||||
filename: '出库单列表',
|
||||
headers: ['单号', '仓库', '客户', '状态', '日期', '总金额'],
|
||||
rows: orders.map((o) => [
|
||||
o.orderNo, o.warehouseName ?? '', o.partnerName ?? '',
|
||||
o.status, o.orderDate, o.totalAmount,
|
||||
]).toList(),
|
||||
);
|
||||
|
||||
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
|
||||
? ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-out/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isMobile ? '新建' : '新建出库审核单'),
|
||||
style: isMobile
|
||||
? ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
: null;
|
||||
|
||||
final statusFilter = showStatusFilter
|
||||
? _StatusFilterDropdown(
|
||||
value: _statusFilter,
|
||||
onChanged: (v) {
|
||||
setState(() => _statusFilter = v ?? '');
|
||||
ref.read(stockOutListProvider.notifier).setStatus(v ?? '');
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
final dateBtn = OutlinedButton.icon(
|
||||
onPressed: _pickDateRange,
|
||||
icon: const Icon(Icons.date_range, size: 16),
|
||||
label: Text(
|
||||
_dateRange == null ? '选择日期' : '$_startDate ~ $_endDate',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
);
|
||||
|
||||
final clearDate = _dateRange != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 16),
|
||||
onPressed: () {
|
||||
setState(() => _dateRange = null);
|
||||
ref
|
||||
.read(stockOutListProvider.notifier)
|
||||
.setDateRange(null, null);
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
if (isMobile) {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (newBtn != null) newBtn,
|
||||
if (statusFilter != null) statusFilter,
|
||||
dateBtn,
|
||||
if (clearDate != null) clearDate,
|
||||
IconButton(
|
||||
tooltip: '导出',
|
||||
icon: const Icon(Icons.download, size: 20),
|
||||
onPressed: doExport,
|
||||
),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: hidden,
|
||||
compact: true,
|
||||
onChanged: (v) {
|
||||
setState(() => _hiddenCols = v);
|
||||
ColumnPrefs.save(_screenId, v);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (newBtn != null) newBtn,
|
||||
const Spacer(),
|
||||
if (statusFilter != null) ...[
|
||||
statusFilter,
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
dateBtn,
|
||||
if (clearDate != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
clearDate,
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: doExport,
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text('导出'),
|
||||
),
|
||||
icon: const Icon(Icons.download, size: 16),
|
||||
label: const Text('导出'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: _hiddenCols,
|
||||
onChanged: (v) => setState(() => _hiddenCols = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ColumnToggleButton(
|
||||
columns: _colDefs,
|
||||
hidden: hidden,
|
||||
onChanged: (v) {
|
||||
setState(() => _hiddenCols = v);
|
||||
ColumnPrefs.save(_screenId, v);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
mobileCards: orders.map((o) => _orderCard(context, o)).toList(),
|
||||
|
||||
@@ -304,15 +304,26 @@ class ColumnToggleButton extends StatelessWidget {
|
||||
final Set<String> hidden;
|
||||
final ValueChanged<Set<String>> onChanged;
|
||||
|
||||
/// 紧凑模式:只显示图标(移动端用,省空间)。
|
||||
final bool compact;
|
||||
|
||||
const ColumnToggleButton({
|
||||
super.key,
|
||||
required this.columns,
|
||||
required this.hidden,
|
||||
required this.onChanged,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (compact) {
|
||||
return IconButton(
|
||||
tooltip: '显示字段',
|
||||
icon: const Icon(Icons.view_column_outlined, size: 20),
|
||||
onPressed: () => _show(context),
|
||||
);
|
||||
}
|
||||
return OutlinedButton(
|
||||
onPressed: () => _show(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# macOS 分发(Developer ID + 公证)— 一次性配置
|
||||
|
||||
macOS 客户端通过**官网下载 zip + 应用内自动更新**分发(非 Mac App Store)。要让用户双击/自更新重启时不被 Gatekeeper 拦截(「已损坏」「无法验证开发者」),CI 构建出的 `.app` 必须 **用 Developer ID Application 证书签名 + 提交 Apple 公证(Notarization)+ staple 票据**。
|
||||
|
||||
所有凭证通过 Forgejo Secrets 注入,**不入库**。macOS **强制签名+公证**:未配置证书 secret 时 `build-macos` job 会**直接报错、流水线红**,绝不产出未签名包(避免把会被 Gatekeeper 拦截的包发出去)。
|
||||
|
||||
> 与 iOS 的关系:公证复用 iOS 那套 **App Store Connect API key**,无需新建。只需**额外**做一张 macOS 专用的 **Developer ID Application** 证书(iOS 的 Apple Distribution 证书在这里不可用)。
|
||||
|
||||
## 前置
|
||||
|
||||
- Apple Developer Program 账号(与 iOS 同一个,Team ID `BYL4KQHMTN`)。
|
||||
- 已按 `docs/ios-signing.md` 配好 `APPSTORE_API_KEY_ID` / `APPSTORE_API_ISSUER_ID` / `APPSTORE_API_KEY_P8_BASE64`(公证直接复用,本文不再重复)。
|
||||
|
||||
## 1. 创建 Developer ID Application 证书
|
||||
|
||||
**为什么不是 Apple Distribution**:Apple Distribution 证书只用于 App Store / TestFlight;官网自分发必须用 **Developer ID Application**,否则公证会失败。
|
||||
|
||||
两种方式任选:
|
||||
|
||||
- **Xcode(推荐,最省事)**:Xcode → Settings → Accounts → 选中团队 → Manage Certificates → 左下 `+` → **Developer ID Application**。生成后证书带私钥落在「钥匙串访问」的「登录」里。
|
||||
- **开发者后台**:Certificates, Identifiers & Profiles → Certificates → `+` → **Developer ID Application** → 用钥匙串「证书助理」生成的 CSR 上传 → 下载 `.cer` 双击导入钥匙串。
|
||||
|
||||
## 2. 导出为 .p12
|
||||
|
||||
「钥匙串访问」→「我的证书」里找到 **Developer ID Application: …(BYL4KQHMTN)**,**连同私钥**一起右键导出为 `.p12`,设一个导出密码:
|
||||
|
||||
```bash
|
||||
base64 -i devid.p12 | pbcopy # 复制 base64 到剪贴板
|
||||
```
|
||||
|
||||
## 3. 在 Forgejo 仓库配置 Secrets
|
||||
|
||||
仓库 → Settings → Actions → Secrets,**新增 2 项**:
|
||||
|
||||
| Secret 名 | 值 |
|
||||
|-----------|-----|
|
||||
| `MACOS_DEVELOPER_ID_CERT_P12_BASE64` | 第 2 步 `.p12` 的 base64 |
|
||||
| `MACOS_DEVELOPER_ID_CERT_PASSWORD` | `.p12` 导出密码 |
|
||||
|
||||
公证用的 `APPSTORE_API_KEY_ID` / `APPSTORE_API_ISSUER_ID` / `APPSTORE_API_KEY_P8_BASE64` 已在 iOS 配置时存在,**复用即可**。
|
||||
|
||||
## 4. 验证
|
||||
|
||||
打 `client-v*` tag 触发流水线,确认 `build-macos` job:
|
||||
- 日志出现 `signing + notarizing`(非 `SKIP`);
|
||||
- 出现 `signed + notarized + stapled`;
|
||||
- 在一台**没装过本 app 的干净 Mac** 上双击官网下载的 `jiu-macos-x64.zip` 解出的 `.app`,**不弹** Gatekeeper 警告直接打开;
|
||||
- 断网再双击仍能打开(票据已 staple 进 bundle)。
|
||||
|
||||
本地手动验证已签名包:
|
||||
|
||||
```bash
|
||||
codesign --verify --deep --strict --verbose=2 Jiu.app # 应无报错
|
||||
xcrun stapler validate Jiu.app # The validate action worked!
|
||||
spctl -a -vvv -t install Jiu.app # source=Notarized Developer ID, accepted
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
- **公证失败**:`xcrun notarytool log <submission-id> --key … --key-id … --issuer …` 看详细原因。最常见三类:
|
||||
1. 没用 `--options runtime`(hardened runtime 未启用);
|
||||
2. 没带 `--timestamp`(安全时间戳缺失);
|
||||
3. 用了 Apple Distribution 而非 **Developer ID Application** 证书。
|
||||
- **`security import` 报私钥缺失**:导出 `.p12` 时没勾选私钥,重新从「我的证书」节点(不是单独的证书条目)导出。
|
||||
- **`spctl` 显示 rejected**:通常是 staple 没成功或公证未通过——先确认 notarytool 返回 `Accepted`。
|
||||
|
||||
## 工作原理
|
||||
|
||||
- `scripts/ci/compile-macos.sh`:开头先校验证书/API key secret,缺失立即 `exit 1`(fail-fast,不浪费一次 build);`flutter build macos` 后建临时 keychain 导入 Developer ID 证书 → inside-out `codesign`(hardened runtime + timestamp + Release.entitlements)→ `notarytool submit --wait`(复用 App Store Connect API key)→ `stapler staple` → 重新 `ditto` 打 `dist/jiu-macos-x64.zip`。**强制签名+公证,不产出未签名包。**
|
||||
- `.gitea/workflows/deploy-client.yml` 的 `build-macos` job:通过 `env` 注入上述 secrets。
|
||||
- 自更新链路:`client/lib/core/update/app_updater_io.dart` 下载该 zip → `ditto` 解压 → 替换 `.app` → `open` 重启;因票据已 staple,重启的新 app 离线也通过 Gatekeeper。
|
||||
@@ -10,6 +10,20 @@ VER="${TAG#client-v}"
|
||||
|
||||
echo "==> compile-macos: version=${VER}"
|
||||
|
||||
# macOS 通过官网下载 + 应用内自更新分发,未签名/未公证的包会被 Gatekeeper 拦截,
|
||||
# 因此【强制】签名+公证:缺任一关键 secret 立即报错、流水线红,绝不产出未签名包。
|
||||
# 在跑昂贵的 flutter build 之前先做这一检查,快速失败。详见 docs/macos-signing.md。
|
||||
#
|
||||
# 需要的 CI secrets:
|
||||
# MACOS_DEVELOPER_ID_CERT_P12_BASE64 Developer ID Application 证书 (.p12) 的 base64
|
||||
# MACOS_DEVELOPER_ID_CERT_PASSWORD .p12 导出密码
|
||||
# APPSTORE_API_KEY_ID / APPSTORE_API_ISSUER_ID / APPSTORE_API_KEY_P8_BASE64 复用 iOS 的 App Store Connect API key(公证用)
|
||||
if [ -z "${MACOS_DEVELOPER_ID_CERT_P12_BASE64:-}" ] || [ -z "${APPSTORE_API_KEY_P8_BASE64:-}" ]; then
|
||||
echo "ERROR: macOS 签名/公证所需 secret 未配置,拒绝产出未签名包。" >&2
|
||||
echo " 需要 MACOS_DEVELOPER_ID_CERT_P12_BASE64 + APPSTORE_API_KEY_P8_BASE64,详见 docs/macos-signing.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sync Flutter pubspec version (BSD sed on macOS)
|
||||
sed -i '' "s/^version:.*/version: ${VER}+1/" client/pubspec.yaml
|
||||
|
||||
@@ -21,11 +35,70 @@ flutter build macos --release \
|
||||
"--dart-define=APP_VERSION=v${VER}"
|
||||
cd ..
|
||||
|
||||
# Package .app bundle into zip
|
||||
APP_SRC="client/build/macos/Build/Products/Release/jiu_client.app"
|
||||
|
||||
# --- 代码签名 + 公证(Developer ID + Notarization)---
|
||||
# secret 已在脚本开头校验过(缺失会 fail-fast),此处必有证书与 API key。
|
||||
echo "==> compile-macos: signing + notarizing"
|
||||
WORK="$(mktemp -d)"
|
||||
KEYCHAIN="$WORK/jiu-mac.keychain-db"
|
||||
cleanup_mac() {
|
||||
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup_mac EXIT
|
||||
|
||||
# 1. 临时 keychain 导入 Developer ID Application 证书
|
||||
KEYCHAIN_PWD="ci-temp-$$"
|
||||
security create-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
|
||||
echo "${MACOS_DEVELOPER_ID_CERT_P12_BASE64}" | base64 --decode > "$WORK/devid.p12"
|
||||
security import "$WORK/devid.p12" -k "$KEYCHAIN" \
|
||||
-P "${MACOS_DEVELOPER_ID_CERT_PASSWORD:-}" -T /usr/bin/codesign -T /usr/bin/security
|
||||
# 允许 codesign 非交互访问私钥
|
||||
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null
|
||||
# 把临时 keychain 加入搜索列表(保留默认 login keychain)
|
||||
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
|
||||
|
||||
# 解出签名身份 "Developer ID Application: NAME (TEAMID)"
|
||||
IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" \
|
||||
| grep 'Developer ID Application' | head -1 | sed -E 's/.*"(.*)"/\1/')"
|
||||
if [ -z "$IDENTITY" ]; then
|
||||
echo "ERROR: Developer ID Application identity not found in keychain" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "==> compile-macos: signing identity '${IDENTITY}'"
|
||||
|
||||
# 2. inside-out 签名:先签嵌套 framework/dylib,再签 app(均启用 hardened runtime + 安全时间戳)
|
||||
if [ -d "${APP_SRC}/Contents/Frameworks" ]; then
|
||||
find "${APP_SRC}/Contents/Frameworks" \( -name '*.framework' -o -name '*.dylib' \) -print0 \
|
||||
| xargs -0 -I{} codesign --force --options runtime --timestamp --sign "$IDENTITY" {}
|
||||
fi
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements client/macos/Runner/Release.entitlements \
|
||||
--sign "$IDENTITY" "${APP_SRC}"
|
||||
codesign --verify --deep --strict --verbose=2 "${APP_SRC}"
|
||||
|
||||
# 3. 提交公证(复用 App Store Connect API key),阻塞等待结果
|
||||
ditto -c -k --keepParent "${APP_SRC}" "$WORK/notarize.zip"
|
||||
echo "${APPSTORE_API_KEY_P8_BASE64}" | base64 --decode > "$WORK/AuthKey.p8"
|
||||
xcrun notarytool submit "$WORK/notarize.zip" \
|
||||
--key "$WORK/AuthKey.p8" \
|
||||
--key-id "${APPSTORE_API_KEY_ID}" \
|
||||
--issuer "${APPSTORE_API_ISSUER_ID}" \
|
||||
--wait
|
||||
|
||||
# 4. staple 公证票据进 .app(离线也能通过 Gatekeeper),并核验
|
||||
xcrun stapler staple "${APP_SRC}"
|
||||
xcrun stapler validate "${APP_SRC}"
|
||||
spctl -a -vvv -t install "${APP_SRC}" || true # 期望 source=Notarized Developer ID
|
||||
echo "==> compile-macos: signed + notarized + stapled"
|
||||
|
||||
# Package .app bundle into zip(此时 APP_SRC 已签名+stapled)
|
||||
# ditto preserves symlinks, Unix permissions, and extended attributes (code-signing).
|
||||
# Python's zipfile follows symlinks and drops permissions — do NOT use it for .app bundles.
|
||||
mkdir -p dist
|
||||
APP_SRC="client/build/macos/Build/Products/Release/jiu_client.app"
|
||||
ditto -c -k --keepParent "${APP_SRC}" dist/jiu-macos-x64.zip
|
||||
echo "Packaged dist/jiu-macos-x64.zip ($(du -sh dist/jiu-macos-x64.zip | cut -f1))"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user