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/),
|
||||
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
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -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)
|
||||
shopH := handler.NewShopHandler(db)
|
||||
errorReportH := handler.NewErrorReportHandler(db)
|
||||
feedbackH := handler.NewFeedbackHandler(db)
|
||||
|
||||
// 健康检查(无需认证,用于前端连通性探测)
|
||||
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)
|
||||
}
|
||||
|
||||
// 意见反馈(文字 + 附图,直接提交后台)
|
||||
feedback := api.Group("/feedback")
|
||||
{
|
||||
feedback.POST("", feedbackH.Submit)
|
||||
feedback.POST("/images", feedbackH.UploadImage)
|
||||
}
|
||||
|
||||
// 编号规则
|
||||
numberRules := api.Group("/number-rules")
|
||||
{
|
||||
@@ -217,6 +225,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
{
|
||||
superAdmin.POST("/clear-data", adminH.ClearData)
|
||||
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.ProductImage{},
|
||||
&model.ErrorReport{},
|
||||
&model.Feedback{},
|
||||
)
|
||||
if err != nil {
|
||||
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`)
|
||||
) 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;
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../../screens/products/products_screen.dart';
|
||||
import '../../screens/products/product_detail_screen.dart';
|
||||
import '../../screens/public/public_product_screen.dart';
|
||||
import '../../screens/settings/settings_screen.dart';
|
||||
import '../../screens/about/about_screen.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
|
||||
Page<void> _noTransition(Widget child) =>
|
||||
@@ -131,6 +132,9 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
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);
|
||||
}
|
||||
|
||||
/// 打印任务/另存默认文件名的时间戳: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 ──────────────────────────────────────────────────────────
|
||||
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
|
||||
@@ -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) {
|
||||
reportError(e, st);
|
||||
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) {
|
||||
reportError(e, st);
|
||||
throw Exception('打印失败,请检查打印机连接和驱动是否正常。\n详情:$e');
|
||||
@@ -486,7 +495,8 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
|
||||
],
|
||||
));
|
||||
try {
|
||||
await Printing.layoutPdf(onLayout: (_) async => doc.save());
|
||||
await Printing.layoutPdf(
|
||||
name: '出库单_${_fileTs()}', onLayout: (_) async => doc.save());
|
||||
} catch (e, st) {
|
||||
reportError(e, st);
|
||||
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:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/config/app_info.dart';
|
||||
import '../../core/update/app_updater.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/number_rule.dart';
|
||||
import '../../models/user.dart';
|
||||
import '../../models/warehouse.dart';
|
||||
import '../../providers/license_provider.dart';
|
||||
import '../../providers/number_rule_provider.dart';
|
||||
import '../../providers/update_provider.dart';
|
||||
import '../../providers/user_provider.dart';
|
||||
import '../../providers/warehouse_provider.dart';
|
||||
import '../../providers/shop_provider.dart';
|
||||
@@ -45,7 +39,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 7,
|
||||
length: 6,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
@@ -65,7 +59,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
Tab(text: '编号规则'),
|
||||
Tab(text: '系统参数'),
|
||||
Tab(text: '数据导入'),
|
||||
Tab(text: '关于'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -79,7 +72,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
_buildNumberRulesTab(),
|
||||
_buildSystemParamsTab(),
|
||||
_buildImportTab(),
|
||||
_buildAboutTab(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -708,276 +700,6 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
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(
|
||||
String label, String current, ValueChanged<String> onSave) {
|
||||
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 {
|
||||
|
||||
@@ -66,6 +66,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
_NavItem(icon: Icons.people, label: '往来单位', path: '/partners'),
|
||||
_NavItem(icon: Icons.category, label: '基础数据', path: '/products'),
|
||||
_NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'),
|
||||
_NavItem(icon: Icons.info_outline, label: '关于我们', path: '/about'),
|
||||
];
|
||||
|
||||
@override
|
||||
|
||||
Reference in New Issue
Block a user