Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94f0fb2095 | |||
| 36e5677b7c | |||
| 7a1d8465e5 | |||
| dd09f1a526 | |||
| 53a52cc530 |
@@ -5,6 +5,15 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.0.17] - 2026-06-05
|
||||||
|
|
||||||
|
### 新功能
|
||||||
|
- 意见反馈(Bug 反馈 / 功能建议)改为应用内直接提交,支持文字 + 图片(最多 9 张),不再跳转邮件
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
- 「关于」更名为「关于我们」并移至左侧菜单,成为独立页面
|
||||||
|
- 打印时默认文件名带类型与时间戳(如 标签_20260605_103000、入库单_…、出库单_…),方便「打印到 PDF」保存归档
|
||||||
|
|
||||||
## [1.0.16] - 2026-06-05
|
## [1.0.16] - 2026-06-05
|
||||||
|
|
||||||
### 新功能
|
### 新功能
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/wangjia/jiu/backend/config"
|
||||||
|
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||||
|
"github.com/wangjia/jiu/backend/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FeedbackHandler struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
func NewFeedbackHandler(db *gorm.DB) *FeedbackHandler {
|
||||||
|
return &FeedbackHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
type submitFeedbackRequest struct {
|
||||||
|
Type string `json:"type" binding:"required,oneof=bug suggestion"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Images []string `json:"images"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit POST /api/v1/feedback (需登录)
|
||||||
|
func (h *FeedbackHandler) Submit(c *gin.Context) {
|
||||||
|
shopID := middleware.GetShopID(c)
|
||||||
|
userID := middleware.GetUserID(c)
|
||||||
|
role := c.GetString(middleware.CtxRole)
|
||||||
|
|
||||||
|
var req submitFeedbackRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Content) == "" && len(req.Images) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写反馈内容或添加图片"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Images) > 9 {
|
||||||
|
req.Images = req.Images[:9]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去规范化:从 JWT 身份补全 username / shop_code,便于后台查看
|
||||||
|
var username string
|
||||||
|
var u model.User
|
||||||
|
if err := h.db.Select("username").First(&u, userID).Error; err == nil {
|
||||||
|
username = u.Username
|
||||||
|
}
|
||||||
|
var shopCode string
|
||||||
|
var shop model.Shop
|
||||||
|
if err := h.db.Select("code").First(&shop, shopID).Error; err == nil {
|
||||||
|
shopCode = shop.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
fb := model.Feedback{
|
||||||
|
ShopID: shopID,
|
||||||
|
ShopCode: shopCode,
|
||||||
|
UserID: userID,
|
||||||
|
Username: username,
|
||||||
|
Role: role,
|
||||||
|
Type: req.Type,
|
||||||
|
Content: strings.TrimSpace(req.Content),
|
||||||
|
Images: req.Images,
|
||||||
|
AppVersion: req.AppVersion,
|
||||||
|
Platform: req.Platform,
|
||||||
|
ClientIP: c.ClientIP(),
|
||||||
|
Status: "new",
|
||||||
|
}
|
||||||
|
if err := h.db.Create(&fb).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "submit failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"id": fb.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadImage POST /api/v1/feedback/images (需登录)
|
||||||
|
func (h *FeedbackHandler) UploadImage(c *gin.Context) {
|
||||||
|
shopID := middleware.GetShopID(c)
|
||||||
|
|
||||||
|
// 限制 5MB
|
||||||
|
if err := c.Request.ParseMultipartForm(5 << 20); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "文件超过 5MB 限制"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, _, err := c.Request.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请上传文件(field: file)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
img, _, err := image.Decode(file)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 JPEG/PNG 图片"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resized := imaging.Fit(img, 1600, 1600, imaging.Lanczos)
|
||||||
|
|
||||||
|
filename := uuid.New().String() + ".jpg"
|
||||||
|
subdir := fmt.Sprintf("%s/feedback/%d", config.C.Storage.UploadDir, shopID)
|
||||||
|
if err := os.MkdirAll(subdir, 0755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "存储目录创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(subdir, filename)
|
||||||
|
if err := imaging.Save(resized, fullPath, imaging.JPEGQuality(85)); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "图片保存失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
relURL := fmt.Sprintf("/images/feedback/%d/%s", shopID, filename)
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"url": relURL})
|
||||||
|
}
|
||||||
|
|
||||||
|
// List GET /api/v1/admin/feedback (仅超级管理员)
|
||||||
|
func (h *FeedbackHandler) List(c *gin.Context) {
|
||||||
|
var q struct {
|
||||||
|
Type string `form:"type"`
|
||||||
|
Status string `form:"status"`
|
||||||
|
ShopID uint64 `form:"shop_id"`
|
||||||
|
Page int `form:"page,default=1"`
|
||||||
|
PageSize int `form:"page_size,default=50"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindQuery(&q); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if q.PageSize <= 0 || q.PageSize > 200 {
|
||||||
|
q.PageSize = 50
|
||||||
|
}
|
||||||
|
if q.Page <= 0 {
|
||||||
|
q.Page = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
db := h.db.Model(&model.Feedback{})
|
||||||
|
if q.Type != "" {
|
||||||
|
db = db.Where("type = ?", q.Type)
|
||||||
|
}
|
||||||
|
if q.Status != "" {
|
||||||
|
db = db.Where("status = ?", q.Status)
|
||||||
|
}
|
||||||
|
if q.ShopID != 0 {
|
||||||
|
db = db.Where("shop_id = ?", q.ShopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
db.Count(&total)
|
||||||
|
|
||||||
|
var list []model.Feedback
|
||||||
|
db.Order("id DESC").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&list)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": list,
|
||||||
|
"total": total,
|
||||||
|
"page": q.Page,
|
||||||
|
"page_size": q.PageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus PATCH /api/v1/admin/feedback/:id (仅超级管理员)
|
||||||
|
func (h *FeedbackHandler) UpdateStatus(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status" binding:"required,oneof=new handled"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res := h.db.Model(&model.Feedback{}).Where("id = ?", id).Update("status", req.Status)
|
||||||
|
if res.Error != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": res.Error.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "updated"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||||
|
"github.com/wangjia/jiu/backend/internal/model"
|
||||||
|
"github.com/wangjia/jiu/backend/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFeedbackHandler_SubmitAndList(t *testing.T) {
|
||||||
|
db := testutil.SetupTestDB()
|
||||||
|
require.NoError(t, db.AutoMigrate(&model.Feedback{}))
|
||||||
|
shop := testutil.CreateTestShop(db, "FB001")
|
||||||
|
user := testutil.CreateTestUser(db, shop.ID, "fbuser", "pass", "admin")
|
||||||
|
token := getAuthToken(user.ID, shop.ID, "admin")
|
||||||
|
|
||||||
|
fh := NewFeedbackHandler(db)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
api := r.Group("/api/v1")
|
||||||
|
api.Use(middleware.JWT())
|
||||||
|
api.POST("/feedback", fh.Submit)
|
||||||
|
adminG := api.Group("/admin")
|
||||||
|
adminG.Use(middleware.SuperAdminOnly())
|
||||||
|
adminG.GET("/feedback", fh.List)
|
||||||
|
|
||||||
|
// 1. 正常提交(文字 + 图片)
|
||||||
|
w := makeRequest(r, "POST", "/api/v1/feedback", token, map[string]interface{}{
|
||||||
|
"type": "bug",
|
||||||
|
"content": "打印时卡住了",
|
||||||
|
"images": []string{"/images/feedback/1/a.jpg"},
|
||||||
|
})
|
||||||
|
require.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
assert.NotZero(t, parseResponse(w)["id"])
|
||||||
|
|
||||||
|
// 去规范化字段已写入
|
||||||
|
var fb model.Feedback
|
||||||
|
require.NoError(t, db.Order("id DESC").First(&fb).Error)
|
||||||
|
assert.Equal(t, "fbuser", fb.Username)
|
||||||
|
assert.Equal(t, "FB001", fb.ShopCode)
|
||||||
|
assert.Equal(t, "new", fb.Status)
|
||||||
|
assert.Equal(t, 1, len(fb.Images))
|
||||||
|
|
||||||
|
// 2. 内容与图片都为空 → 400
|
||||||
|
w = makeRequest(r, "POST", "/api/v1/feedback", token, map[string]interface{}{
|
||||||
|
"type": "suggestion",
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
|
||||||
|
// 3. 非法 type → 400
|
||||||
|
w = makeRequest(r, "POST", "/api/v1/feedback", token, map[string]interface{}{
|
||||||
|
"type": "other",
|
||||||
|
"content": "x",
|
||||||
|
})
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
|
||||||
|
// 4. 普通管理员访问 admin 列表 → 403
|
||||||
|
w = makeRequest(r, "GET", "/api/v1/admin/feedback", token, nil)
|
||||||
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
|
||||||
|
// 5. 超级管理员可列出
|
||||||
|
superToken := getAuthToken(user.ID, shop.ID, "superadmin")
|
||||||
|
w = makeRequest(r, "GET", "/api/v1/admin/feedback", superToken, nil)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.GreaterOrEqual(t, parseResponse(w)["total"].(float64), float64(1))
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StringSlice 以 JSON 数组形式存储的字符串切片(用于图片 URL 列表)
|
||||||
|
type StringSlice []string
|
||||||
|
|
||||||
|
func (s StringSlice) Value() (driver.Value, error) {
|
||||||
|
if s == nil {
|
||||||
|
return "[]", nil
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(s)
|
||||||
|
return string(b), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StringSlice) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*s = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
b = []byte(v)
|
||||||
|
case []byte:
|
||||||
|
b = v
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("cannot scan %T into StringSlice", value)
|
||||||
|
}
|
||||||
|
if len(b) == 0 {
|
||||||
|
*s = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(b, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feedback 用户意见反馈(bug / 功能建议):文字 + 附图。多租户按 ShopID 隔离。
|
||||||
|
type Feedback struct {
|
||||||
|
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
ShopID uint64 `gorm:"index;not null" json:"shop_id"`
|
||||||
|
ShopCode string `gorm:"size:50" json:"shop_code"`
|
||||||
|
UserID uint64 `gorm:"index" json:"user_id"`
|
||||||
|
Username string `gorm:"size:50" json:"username"`
|
||||||
|
Role string `gorm:"size:20" json:"role"`
|
||||||
|
Type string `gorm:"size:20;not null;index" json:"type"` // bug / suggestion
|
||||||
|
Content string `gorm:"type:text" json:"content"`
|
||||||
|
Images StringSlice `gorm:"type:json" json:"images"`
|
||||||
|
AppVersion string `gorm:"size:30" json:"app_version"`
|
||||||
|
Platform string `gorm:"size:20" json:"platform"`
|
||||||
|
ClientIP string `gorm:"size:60" json:"client_ip"`
|
||||||
|
Status string `gorm:"size:20;not null;default:new;index" json:"status"` // new / handled
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
adminH := handler.NewAdminHandler(db)
|
adminH := handler.NewAdminHandler(db)
|
||||||
shopH := handler.NewShopHandler(db)
|
shopH := handler.NewShopHandler(db)
|
||||||
errorReportH := handler.NewErrorReportHandler(db)
|
errorReportH := handler.NewErrorReportHandler(db)
|
||||||
|
feedbackH := handler.NewFeedbackHandler(db)
|
||||||
|
|
||||||
// 健康检查(无需认证,用于前端连通性探测)
|
// 健康检查(无需认证,用于前端连通性探测)
|
||||||
r.GET("/health", func(c *gin.Context) {
|
r.GET("/health", func(c *gin.Context) {
|
||||||
@@ -171,6 +172,13 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
shop.POST("/logo", middleware.AdminOnly(), shopH.UploadLogo)
|
shop.POST("/logo", middleware.AdminOnly(), shopH.UploadLogo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 意见反馈(文字 + 附图,直接提交后台)
|
||||||
|
feedback := api.Group("/feedback")
|
||||||
|
{
|
||||||
|
feedback.POST("", feedbackH.Submit)
|
||||||
|
feedback.POST("/images", feedbackH.UploadImage)
|
||||||
|
}
|
||||||
|
|
||||||
// 编号规则
|
// 编号规则
|
||||||
numberRules := api.Group("/number-rules")
|
numberRules := api.Group("/number-rules")
|
||||||
{
|
{
|
||||||
@@ -217,6 +225,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
{
|
{
|
||||||
superAdmin.POST("/clear-data", adminH.ClearData)
|
superAdmin.POST("/clear-data", adminH.ClearData)
|
||||||
superAdmin.GET("/errors", errorReportH.List)
|
superAdmin.GET("/errors", errorReportH.List)
|
||||||
|
superAdmin.GET("/feedback", feedbackH.List)
|
||||||
|
superAdmin.PATCH("/feedback/:id", feedbackH.UpdateStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ func autoMigrate(db *gorm.DB) {
|
|||||||
&model.ProductSpecOption{},
|
&model.ProductSpecOption{},
|
||||||
&model.ProductImage{},
|
&model.ProductImage{},
|
||||||
&model.ErrorReport{},
|
&model.ErrorReport{},
|
||||||
|
&model.Feedback{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("auto migrate failed: %v", err)
|
log.Fatalf("auto migrate failed: %v", err)
|
||||||
|
|||||||
@@ -446,4 +446,26 @@ CREATE TABLE IF NOT EXISTS `product_spec_options` (
|
|||||||
KEY `idx_deleted_at` (`deleted_at`)
|
KEY `idx_deleted_at` (`deleted_at`)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品规格选项';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品规格选项';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `feedbacks` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`shop_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`shop_code` VARCHAR(50) DEFAULT NULL,
|
||||||
|
`user_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||||
|
`username` VARCHAR(50) DEFAULT NULL,
|
||||||
|
`role` VARCHAR(20) DEFAULT NULL,
|
||||||
|
`type` VARCHAR(20) NOT NULL COMMENT 'bug / suggestion',
|
||||||
|
`content` TEXT,
|
||||||
|
`images` JSON,
|
||||||
|
`app_version` VARCHAR(30) DEFAULT NULL,
|
||||||
|
`platform` VARCHAR(20) DEFAULT NULL,
|
||||||
|
`client_ip` VARCHAR(60) DEFAULT NULL,
|
||||||
|
`status` VARCHAR(20) NOT NULL DEFAULT 'new',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_shop_id` (`shop_id`),
|
||||||
|
KEY `idx_user_id` (`user_id`),
|
||||||
|
KEY `idx_type` (`type`),
|
||||||
|
KEY `idx_status` (`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户意见反馈';
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../../screens/products/products_screen.dart';
|
|||||||
import '../../screens/products/product_detail_screen.dart';
|
import '../../screens/products/product_detail_screen.dart';
|
||||||
import '../../screens/public/public_product_screen.dart';
|
import '../../screens/public/public_product_screen.dart';
|
||||||
import '../../screens/settings/settings_screen.dart';
|
import '../../screens/settings/settings_screen.dart';
|
||||||
|
import '../../screens/about/about_screen.dart';
|
||||||
import '../auth/auth_state.dart';
|
import '../auth/auth_state.dart';
|
||||||
|
|
||||||
Page<void> _noTransition(Widget child) =>
|
Page<void> _noTransition(Widget child) =>
|
||||||
@@ -131,6 +132,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
pageBuilder: (_, __) => _noTransition(const SettingsScreen())),
|
pageBuilder: (_, __) => _noTransition(const SettingsScreen())),
|
||||||
|
GoRoute(
|
||||||
|
path: '/about',
|
||||||
|
pageBuilder: (_, __) => _noTransition(const AboutScreen())),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ Future<pw.Font> _loadBoldFont() async {
|
|||||||
return pw.Font.ttf(data);
|
return pw.Font.ttf(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 打印任务/另存默认文件名的时间戳:yyyyMMdd_HHmmss
|
||||||
|
String _fileTs() {
|
||||||
|
final n = DateTime.now();
|
||||||
|
String p(int v) => v.toString().padLeft(2, '0');
|
||||||
|
return '${n.year}${p(n.month)}${p(n.day)}_${p(n.hour)}${p(n.minute)}${p(n.second)}';
|
||||||
|
}
|
||||||
|
|
||||||
// ── 设计色彩 token ──────────────────────────────────────────────────────────
|
// ── 设计色彩 token ──────────────────────────────────────────────────────────
|
||||||
const _navy = PdfColor(0.122, 0.165, 0.227); // #1F2A3A header/chip
|
const _navy = PdfColor(0.122, 0.165, 0.227); // #1F2A3A header/chip
|
||||||
const _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 footer/reversed text
|
const _cream = PdfColor(0.957, 0.925, 0.847); // #F4ECD8 footer/reversed text
|
||||||
@@ -191,7 +198,8 @@ Future<void> printProductLabelImpl({
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
await Printing.layoutPdf(
|
||||||
|
name: '标签_${_fileTs()}', onLayout: (_) async => doc.save());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
reportError(e, st);
|
reportError(e, st);
|
||||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||||
@@ -431,7 +439,8 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
));
|
));
|
||||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
await Printing.layoutPdf(
|
||||||
|
name: '入库单_${_fileTs()}', onLayout: (_) async => doc.save());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
reportError(e, st);
|
reportError(e, st);
|
||||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||||
@@ -486,7 +495,8 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
|||||||
],
|
],
|
||||||
));
|
));
|
||||||
try {
|
try {
|
||||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
await Printing.layoutPdf(
|
||||||
|
name: '出库单_${_fileTs()}', onLayout: (_) async => doc.save());
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
reportError(e, st);
|
reportError(e, st);
|
||||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../core/api/api_client.dart';
|
||||||
|
import '../repositories/feedback_repository.dart';
|
||||||
|
|
||||||
|
final feedbackRepositoryProvider = Provider<FeedbackRepository>((ref) {
|
||||||
|
return FeedbackRepository(ref.watch(apiClientProvider));
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import '../core/api/api_client.dart';
|
||||||
|
import '../core/exceptions.dart';
|
||||||
|
|
||||||
|
class FeedbackRepository {
|
||||||
|
final ApiClient _client;
|
||||||
|
const FeedbackRepository(this._client);
|
||||||
|
|
||||||
|
/// 上传单张图片,返回服务器相对 URL(/images/feedback/...)。
|
||||||
|
Future<String> uploadImage({
|
||||||
|
Uint8List? bytes,
|
||||||
|
String? filePath,
|
||||||
|
String filename = 'image.jpg',
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final MultipartFile file;
|
||||||
|
if (bytes != null) {
|
||||||
|
file = MultipartFile.fromBytes(bytes, filename: filename);
|
||||||
|
} else {
|
||||||
|
file = await MultipartFile.fromFile(filePath!);
|
||||||
|
}
|
||||||
|
final formData = FormData.fromMap({'file': file});
|
||||||
|
final resp = await _client.post('/feedback/images', data: formData);
|
||||||
|
return (resp.data as Map<String, dynamic>)['url'] as String;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw AppException(
|
||||||
|
e.response?.data?['error'] as String? ?? '图片上传失败',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提交反馈(bug / suggestion),文字 + 图片 URL 列表。
|
||||||
|
Future<void> submit({
|
||||||
|
required String type,
|
||||||
|
required String content,
|
||||||
|
required List<String> images,
|
||||||
|
}) async {
|
||||||
|
String appVersion = '';
|
||||||
|
try {
|
||||||
|
appVersion = (await PackageInfo.fromPlatform()).version;
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await _client.post('/feedback', data: {
|
||||||
|
'type': type,
|
||||||
|
'content': content,
|
||||||
|
'images': images,
|
||||||
|
'app_version': appVersion,
|
||||||
|
'platform': _platform(),
|
||||||
|
});
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw AppException(
|
||||||
|
e.response?.data?['error'] as String? ?? '提交失败',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _platform() {
|
||||||
|
if (kIsWeb) return 'web';
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
return 'windows';
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
return 'macos';
|
||||||
|
case TargetPlatform.android:
|
||||||
|
return 'android';
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
return 'ios';
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
return 'linux';
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import '../../core/config/app_config.dart';
|
||||||
|
import '../../core/config/app_info.dart';
|
||||||
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../core/update/app_updater.dart';
|
||||||
|
import '../../core/utils/dialog_util.dart';
|
||||||
|
import '../../providers/feedback_provider.dart';
|
||||||
|
import '../../providers/license_provider.dart';
|
||||||
|
import '../../providers/update_provider.dart';
|
||||||
|
|
||||||
|
/// 「关于我们」独立页面(左侧菜单项)。原为系统设置里的「关于」Tab。
|
||||||
|
class AboutScreen extends ConsumerStatefulWidget {
|
||||||
|
const AboutScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<AboutScreen> createState() => _AboutScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
|
||||||
|
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
||||||
|
final licenseAsync = ref.watch(licenseProvider);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ── 版本信息 ──
|
||||||
|
_AboutSection(
|
||||||
|
title: '版本信息',
|
||||||
|
children: [
|
||||||
|
_AboutRow(label: '当前版本', value: appVersion),
|
||||||
|
if (updateInfo != null && updateInfo.hasUpdate)
|
||||||
|
_AboutRow(
|
||||||
|
label: '最新版本',
|
||||||
|
value: 'v${updateInfo.latestVersion}',
|
||||||
|
valueColor: AppTheme.success,
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () => startInAppUpdate(context, updateInfo),
|
||||||
|
child: const Text('立即更新'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_AboutRow(
|
||||||
|
label: '最新版本',
|
||||||
|
value: updateInfo != null ? '已是最新' : '检查中…',
|
||||||
|
valueColor: AppTheme.textSecondary,
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('正在检查更新…')));
|
||||||
|
await ref.read(updateProvider.notifier).forceCheck();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final r = ref.read(updateProvider).valueOrNull;
|
||||||
|
messenger.hideCurrentSnackBar();
|
||||||
|
final String msg;
|
||||||
|
if (r == null) {
|
||||||
|
msg = '检查更新失败,请检查网络';
|
||||||
|
} else if (r.hasUpdate) {
|
||||||
|
msg = '发现新版本 v${r.latestVersion}';
|
||||||
|
} else {
|
||||||
|
msg = '已是最新版本(v${r.latestVersion})';
|
||||||
|
}
|
||||||
|
messenger.showSnackBar(SnackBar(content: Text(msg)));
|
||||||
|
},
|
||||||
|
child: const Text('检查更新'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ── 授权信息 ──
|
||||||
|
_AboutSection(
|
||||||
|
title: '授权信息',
|
||||||
|
children: [
|
||||||
|
licenseAsync.when(
|
||||||
|
loading: () => const _AboutRow(label: '授权状态', value: '加载中…'),
|
||||||
|
error: (_, __) =>
|
||||||
|
const _AboutRow(label: '授权状态', value: '暂无授权信息'),
|
||||||
|
data: (lic) {
|
||||||
|
if (lic == null) {
|
||||||
|
return const _AboutRow(label: '授权状态', value: '未激活');
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_AboutRow(label: '授权类型', value: lic.typeLabel),
|
||||||
|
_AboutRow(
|
||||||
|
label: '授权状态',
|
||||||
|
value: lic.isExpired
|
||||||
|
? '已过期'
|
||||||
|
: lic.isActive
|
||||||
|
? '正常'
|
||||||
|
: '已停用',
|
||||||
|
valueColor: lic.isExpired
|
||||||
|
? AppTheme.danger
|
||||||
|
: lic.isActive
|
||||||
|
? AppTheme.success
|
||||||
|
: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
if (lic.expiresAt != null)
|
||||||
|
_AboutRow(
|
||||||
|
label: '到期时间',
|
||||||
|
value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!),
|
||||||
|
trailing: lic.daysRemaining != null &&
|
||||||
|
lic.daysRemaining! <= 30
|
||||||
|
? Chip(
|
||||||
|
label: Text(
|
||||||
|
lic.isExpired
|
||||||
|
? '已过期'
|
||||||
|
: '剩余 ${lic.daysRemaining} 天',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11, color: Colors.white),
|
||||||
|
),
|
||||||
|
backgroundColor: lic.isExpired
|
||||||
|
? AppTheme.danger
|
||||||
|
: Colors.orange,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
materialTapTargetSize:
|
||||||
|
MaterialTapTargetSize.shrinkWrap,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const _AboutRow(label: '到期时间', value: '永久有效'),
|
||||||
|
if (lic.activatedAt != null)
|
||||||
|
_AboutRow(
|
||||||
|
label: '激活时间',
|
||||||
|
value: DateFormat('yyyy-MM-dd')
|
||||||
|
.format(lic.activatedAt!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _showRenewDialog(),
|
||||||
|
icon: const Icon(Icons.card_membership, size: 16),
|
||||||
|
label: const Text('续费 / 升级授权'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ── 关于我们 ──
|
||||||
|
_AboutSection(
|
||||||
|
title: '关于我们',
|
||||||
|
children: [
|
||||||
|
_AboutRow(label: '服务商', value: AppInfo.provider),
|
||||||
|
_AboutRow(label: '官方网站', value: AppInfo.website),
|
||||||
|
_AboutRow(label: '联系邮箱', value: AppInfo.email),
|
||||||
|
const _AboutRow(label: '技术支持', value: '周一至周五 9:00 - 18:00'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'mailto:${AppInfo.email}?subject=酒库管理系统咨询');
|
||||||
|
if (await canLaunchUrl(uri)) launchUrl(uri);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.email_outlined, size: 16),
|
||||||
|
label: const Text('发送邮件'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// ── 意见反馈 ──
|
||||||
|
_AboutSection(
|
||||||
|
title: '意见反馈',
|
||||||
|
children: [
|
||||||
|
const _AboutRow(
|
||||||
|
label: '问题反馈',
|
||||||
|
value: '遇到 Bug 或有功能建议,欢迎告知我们',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: () => _showFeedbackDialog(isBug: true),
|
||||||
|
icon: const Icon(Icons.bug_report_outlined, size: 16),
|
||||||
|
label: const Text('反馈 Bug'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () => _showFeedbackDialog(isBug: false),
|
||||||
|
icon: const Icon(Icons.lightbulb_outline, size: 16),
|
||||||
|
label: const Text('功能建议'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRenewDialog() {
|
||||||
|
showAppDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('续费 / 升级授权'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('请联系我们获取续费报价:'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
SelectableText('📧 ${AppInfo.email}',
|
||||||
|
style: const TextStyle(fontSize: 13)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('关闭'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: AppInfo.email));
|
||||||
|
if (ctx.mounted) {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('邮箱已复制到剪贴板')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('复制邮箱'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFeedbackDialog({required bool isBug}) {
|
||||||
|
showAppDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _FeedbackDialog(type: isBug ? 'bug' : 'suggestion'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 反馈表单弹窗:文字 + 附图,直接提交后台。
|
||||||
|
class _FeedbackDialog extends ConsumerStatefulWidget {
|
||||||
|
final String type; // bug / suggestion
|
||||||
|
const _FeedbackDialog({required this.type});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<_FeedbackDialog> createState() => _FeedbackDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FeedbackDialogState extends ConsumerState<_FeedbackDialog> {
|
||||||
|
final _ctrl = TextEditingController();
|
||||||
|
final List<String> _images = []; // 已上传的图片 URL
|
||||||
|
bool _uploading = false;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
static const _maxImages = 9;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addImages() async {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.image,
|
||||||
|
allowMultiple: true,
|
||||||
|
withData: true,
|
||||||
|
);
|
||||||
|
if (result == null) return;
|
||||||
|
setState(() => _uploading = true);
|
||||||
|
try {
|
||||||
|
final repo = ref.read(feedbackRepositoryProvider);
|
||||||
|
for (final f in result.files) {
|
||||||
|
if (_images.length >= _maxImages) break;
|
||||||
|
if (f.bytes == null) continue;
|
||||||
|
final url = await repo.uploadImage(bytes: f.bytes!, filename: f.name);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _images.add(url));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('图片上传失败:$e')));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _uploading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (_ctrl.text.trim().isEmpty && _images.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(const SnackBar(content: Text('请填写反馈内容或添加图片')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
await ref.read(feedbackRepositoryProvider).submit(
|
||||||
|
type: widget.type,
|
||||||
|
content: _ctrl.text.trim(),
|
||||||
|
images: _images,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
Navigator.pop(context);
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(const SnackBar(content: Text('反馈已提交,感谢您的反馈!')));
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _submitting = false);
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('提交失败:$e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isBug = widget.type == 'bug';
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(isBug ? '反馈 Bug' : '功能建议'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 440,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _ctrl,
|
||||||
|
maxLines: 6,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: isBug ? '请描述问题的复现步骤和预期行为…' : '请描述您希望增加的功能…',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < _images.length; i++) _thumb(i),
|
||||||
|
if (_images.length < _maxImages) _addButton(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_uploading)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8),
|
||||||
|
child: Row(children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('图片上传中…', style: TextStyle(fontSize: 12)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _submitting ? null : () => Navigator.pop(context),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: (_submitting || _uploading) ? null : _submit,
|
||||||
|
child: Text(_submitting ? '提交中…' : '提交'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _thumb(int i) {
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
child: Image.network(
|
||||||
|
'${AppConfig.baseUrl}${_images[i]}',
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) => Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
color: Colors.grey.shade200,
|
||||||
|
child: const Icon(Icons.broken_image, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: -8,
|
||||||
|
top: -8,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setState(() => _images.removeAt(i)),
|
||||||
|
child: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.black54, shape: BoxShape.circle),
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: const Icon(Icons.close, size: 14, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _addButton() {
|
||||||
|
return InkWell(
|
||||||
|
onTap: _uploading ? null : _addImages,
|
||||||
|
child: Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade400),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.add_a_photo_outlined, color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 关于页辅助 widgets ──────────────────────────────────────
|
||||||
|
|
||||||
|
class _AboutSection extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<Widget> children;
|
||||||
|
const _AboutSection({required this.title, required this.children});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.primaryDark)),
|
||||||
|
const Divider(height: 24),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AboutRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color? valueColor;
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
|
const _AboutRow({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.valueColor,
|
||||||
|
this.trailing,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 88,
|
||||||
|
child: Text(label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13, color: AppTheme.textSecondary)),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: valueColor ?? AppTheme.textPrimary,
|
||||||
|
fontWeight: FontWeight.w500)),
|
||||||
|
),
|
||||||
|
if (trailing != null) trailing!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,19 +7,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
|
||||||
import '../../core/auth/auth_state.dart';
|
import '../../core/auth/auth_state.dart';
|
||||||
import '../../core/config/app_config.dart';
|
import '../../core/config/app_config.dart';
|
||||||
import '../../core/config/app_info.dart';
|
|
||||||
import '../../core/update/app_updater.dart';
|
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
import '../../models/number_rule.dart';
|
import '../../models/number_rule.dart';
|
||||||
import '../../models/user.dart';
|
import '../../models/user.dart';
|
||||||
import '../../models/warehouse.dart';
|
import '../../models/warehouse.dart';
|
||||||
import '../../providers/license_provider.dart';
|
|
||||||
import '../../providers/number_rule_provider.dart';
|
import '../../providers/number_rule_provider.dart';
|
||||||
import '../../providers/update_provider.dart';
|
|
||||||
import '../../providers/user_provider.dart';
|
import '../../providers/user_provider.dart';
|
||||||
import '../../providers/warehouse_provider.dart';
|
import '../../providers/warehouse_provider.dart';
|
||||||
import '../../providers/shop_provider.dart';
|
import '../../providers/shop_provider.dart';
|
||||||
@@ -45,7 +39,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return DefaultTabController(
|
return DefaultTabController(
|
||||||
length: 7,
|
length: 6,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -65,7 +59,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
Tab(text: '编号规则'),
|
Tab(text: '编号规则'),
|
||||||
Tab(text: '系统参数'),
|
Tab(text: '系统参数'),
|
||||||
Tab(text: '数据导入'),
|
Tab(text: '数据导入'),
|
||||||
Tab(text: '关于'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -79,7 +72,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
_buildNumberRulesTab(),
|
_buildNumberRulesTab(),
|
||||||
_buildSystemParamsTab(),
|
_buildSystemParamsTab(),
|
||||||
_buildImportTab(),
|
_buildImportTab(),
|
||||||
_buildAboutTab(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -708,276 +700,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
|
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 关于 Tab ─────────────────────────────────────────────
|
|
||||||
Widget _buildAboutTab() {
|
|
||||||
final appVersion = ref.watch(appVersionProvider).valueOrNull ?? 'v1.0.0';
|
|
||||||
final updateInfo = ref.watch(updateProvider).valueOrNull;
|
|
||||||
final licenseAsync = ref.watch(licenseProvider);
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// ── 版本信息 ──
|
|
||||||
_AboutSection(
|
|
||||||
title: '版本信息',
|
|
||||||
children: [
|
|
||||||
_AboutRow(label: '当前版本', value: appVersion),
|
|
||||||
if (updateInfo != null && updateInfo.hasUpdate)
|
|
||||||
_AboutRow(
|
|
||||||
label: '最新版本',
|
|
||||||
value: 'v${updateInfo.latestVersion}',
|
|
||||||
valueColor: AppTheme.success,
|
|
||||||
trailing: TextButton(
|
|
||||||
onPressed: () => startInAppUpdate(context, updateInfo),
|
|
||||||
child: const Text('立即更新'),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
_AboutRow(
|
|
||||||
label: '最新版本',
|
|
||||||
value: updateInfo != null ? '已是最新' : '检查中…',
|
|
||||||
valueColor: AppTheme.textSecondary,
|
|
||||||
trailing: TextButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
messenger.showSnackBar(
|
|
||||||
const SnackBar(content: Text('正在检查更新…')));
|
|
||||||
await ref.read(updateProvider.notifier).forceCheck();
|
|
||||||
if (!context.mounted) return;
|
|
||||||
final r = ref.read(updateProvider).valueOrNull;
|
|
||||||
messenger.hideCurrentSnackBar();
|
|
||||||
final String msg;
|
|
||||||
if (r == null) {
|
|
||||||
msg = '检查更新失败,请检查网络';
|
|
||||||
} else if (r.hasUpdate) {
|
|
||||||
msg = '发现新版本 v${r.latestVersion}';
|
|
||||||
} else {
|
|
||||||
msg = '已是最新版本(v${r.latestVersion})';
|
|
||||||
}
|
|
||||||
messenger.showSnackBar(SnackBar(content: Text(msg)));
|
|
||||||
},
|
|
||||||
child: const Text('检查更新'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// ── 授权信息 ──
|
|
||||||
_AboutSection(
|
|
||||||
title: '授权信息',
|
|
||||||
children: [
|
|
||||||
licenseAsync.when(
|
|
||||||
loading: () => const _AboutRow(label: '授权状态', value: '加载中…'),
|
|
||||||
error: (_, __) =>
|
|
||||||
const _AboutRow(label: '授权状态', value: '暂无授权信息'),
|
|
||||||
data: (lic) {
|
|
||||||
if (lic == null) {
|
|
||||||
return const _AboutRow(label: '授权状态', value: '未激活');
|
|
||||||
}
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
_AboutRow(label: '授权类型', value: lic.typeLabel),
|
|
||||||
_AboutRow(
|
|
||||||
label: '授权状态',
|
|
||||||
value: lic.isExpired
|
|
||||||
? '已过期'
|
|
||||||
: lic.isActive
|
|
||||||
? '正常'
|
|
||||||
: '已停用',
|
|
||||||
valueColor: lic.isExpired
|
|
||||||
? AppTheme.danger
|
|
||||||
: lic.isActive
|
|
||||||
? AppTheme.success
|
|
||||||
: AppTheme.textSecondary,
|
|
||||||
),
|
|
||||||
if (lic.expiresAt != null)
|
|
||||||
_AboutRow(
|
|
||||||
label: '到期时间',
|
|
||||||
value: DateFormat('yyyy-MM-dd').format(lic.expiresAt!),
|
|
||||||
trailing: lic.daysRemaining != null &&
|
|
||||||
lic.daysRemaining! <= 30
|
|
||||||
? Chip(
|
|
||||||
label: Text(
|
|
||||||
lic.isExpired
|
|
||||||
? '已过期'
|
|
||||||
: '剩余 ${lic.daysRemaining} 天',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11, color: Colors.white),
|
|
||||||
),
|
|
||||||
backgroundColor: lic.isExpired
|
|
||||||
? AppTheme.danger
|
|
||||||
: Colors.orange,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
materialTapTargetSize:
|
|
||||||
MaterialTapTargetSize.shrinkWrap,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const _AboutRow(label: '到期时间', value: '永久有效'),
|
|
||||||
if (lic.activatedAt != null)
|
|
||||||
_AboutRow(
|
|
||||||
label: '激活时间',
|
|
||||||
value: DateFormat('yyyy-MM-dd')
|
|
||||||
.format(lic.activatedAt!),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => _showRenewDialog(),
|
|
||||||
icon: const Icon(Icons.card_membership, size: 16),
|
|
||||||
label: const Text('续费 / 升级授权'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// ── 关于我们 ──
|
|
||||||
_AboutSection(
|
|
||||||
title: '关于我们',
|
|
||||||
children: [
|
|
||||||
_AboutRow(label: '服务商', value: AppInfo.provider),
|
|
||||||
_AboutRow(label: '官方网站', value: AppInfo.website),
|
|
||||||
_AboutRow(label: '联系邮箱', value: AppInfo.email),
|
|
||||||
const _AboutRow(label: '技术支持', value: '周一至周五 9:00 - 18:00'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () async {
|
|
||||||
final uri = Uri.parse(
|
|
||||||
'mailto:${AppInfo.email}?subject=酒库管理系统咨询');
|
|
||||||
if (await canLaunchUrl(uri)) launchUrl(uri);
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.email_outlined, size: 16),
|
|
||||||
label: const Text('发送邮件'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
|
|
||||||
// ── 意见反馈 ──
|
|
||||||
_AboutSection(
|
|
||||||
title: '意见反馈',
|
|
||||||
children: [
|
|
||||||
const _AboutRow(
|
|
||||||
label: '问题反馈',
|
|
||||||
value: '遇到 Bug 或有功能建议,欢迎告知我们',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: () => _showFeedbackDialog(isBug: true),
|
|
||||||
icon: const Icon(Icons.bug_report_outlined, size: 16),
|
|
||||||
label: const Text('反馈 Bug'),
|
|
||||||
),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => _showFeedbackDialog(isBug: false),
|
|
||||||
icon: const Icon(Icons.lightbulb_outline, size: 16),
|
|
||||||
label: const Text('功能建议'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showRenewDialog() {
|
|
||||||
showAppDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: const Text('续费 / 升级授权'),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text('请联系我们获取续费报价:'),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SelectableText('📧 ${AppInfo.email}',
|
|
||||||
style: const TextStyle(fontSize: 13)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('关闭'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
await Clipboard.setData(
|
|
||||||
ClipboardData(text: AppInfo.email));
|
|
||||||
if (ctx.mounted) {
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('邮箱已复制到剪贴板')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('复制邮箱'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showFeedbackDialog({required bool isBug}) {
|
|
||||||
final ctrl = TextEditingController();
|
|
||||||
showAppDialog(
|
|
||||||
context: context,
|
|
||||||
builder: (ctx) => AlertDialog(
|
|
||||||
title: Text(isBug ? '反馈 Bug' : '功能建议'),
|
|
||||||
content: SizedBox(
|
|
||||||
width: 400,
|
|
||||||
child: TextField(
|
|
||||||
controller: ctrl,
|
|
||||||
maxLines: 6,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: isBug
|
|
||||||
? '请描述问题的复现步骤和预期行为…'
|
|
||||||
: '请描述您希望增加的功能…',
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(ctx),
|
|
||||||
child: const Text('取消'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final subject = Uri.encodeComponent(isBug ? 'Bug反馈' : '功能建议');
|
|
||||||
final body = Uri.encodeComponent(ctrl.text);
|
|
||||||
final uri = Uri.parse(
|
|
||||||
'mailto:${AppInfo.email}?subject=$subject&body=$body');
|
|
||||||
if (await canLaunchUrl(uri)) launchUrl(uri);
|
|
||||||
if (ctx.mounted) Navigator.pop(ctx);
|
|
||||||
},
|
|
||||||
child: const Text('通过邮件发送'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _showEditParamDialog(
|
void _showEditParamDialog(
|
||||||
String label, String current, ValueChanged<String> onSave) {
|
String label, String current, ValueChanged<String> onSave) {
|
||||||
final ctrl = TextEditingController(text: current);
|
final ctrl = TextEditingController(text: current);
|
||||||
@@ -1469,74 +1191,6 @@ class _ParamRow extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 关于页辅助 widgets ──────────────────────────────────────
|
|
||||||
|
|
||||||
class _AboutSection extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final List<Widget> children;
|
|
||||||
const _AboutSection({required this.title, required this.children});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppTheme.primaryDark)),
|
|
||||||
const Divider(height: 24),
|
|
||||||
...children,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AboutRow extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
final Color? valueColor;
|
|
||||||
final Widget? trailing;
|
|
||||||
|
|
||||||
const _AboutRow({
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
this.valueColor,
|
|
||||||
this.trailing,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 88,
|
|
||||||
child: Text(label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13, color: AppTheme.textSecondary)),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Text(value,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: valueColor ?? AppTheme.textPrimary,
|
|
||||||
fontWeight: FontWeight.w500)),
|
|
||||||
),
|
|
||||||
if (trailing != null) trailing!,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 批量数据导入 ───────────────────────────────────────────
|
// ── 批量数据导入 ───────────────────────────────────────────
|
||||||
|
|
||||||
class _ImportSlot {
|
class _ImportSlot {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
|||||||
_NavItem(icon: Icons.people, label: '往来单位', path: '/partners'),
|
_NavItem(icon: Icons.people, label: '往来单位', path: '/partners'),
|
||||||
_NavItem(icon: Icons.category, label: '基础数据', path: '/products'),
|
_NavItem(icon: Icons.category, label: '基础数据', path: '/products'),
|
||||||
_NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'),
|
_NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'),
|
||||||
|
_NavItem(icon: Icons.info_outline, label: '关于我们', path: '/about'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
Reference in New Issue
Block a user