diff --git a/backend/internal/handler/admin.go b/backend/internal/handler/admin.go new file mode 100644 index 0000000..1d858a1 --- /dev/null +++ b/backend/internal/handler/admin.go @@ -0,0 +1,60 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" +) + +type AdminHandler struct{ db *gorm.DB } + +func NewAdminHandler(db *gorm.DB) *AdminHandler { return &AdminHandler{db: db} } + +// ClearData 清空指定表数据(仅限当前 shop,不影响其他租户) +func (h *AdminHandler) ClearData(c *gin.Context) { + shopID := middleware.GetShopID(c) + + var body struct { + Tables []string `json:"tables"` + } + if err := c.ShouldBindJSON(&body); err != nil || len(body.Tables) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "tables is required"}) + return + } + + // 允许清空的表名白名单(key=前端传入标识,value=DB表名列表) + allowed := map[string][]string{ + "stock_in": {"stock_in_items", "stock_in_orders"}, + "stock_out": {"stock_out_items", "stock_out_orders"}, + "inventory": {"inventory_logs", "inventories"}, + "products": {"products"}, + "partners": {"partners"}, + } + + cleared := []string{} + err := h.db.Transaction(func(tx *gorm.DB) error { + for _, t := range body.Tables { + tables, ok := allowed[t] + if !ok { + continue + } + for _, tbl := range tables { + if err := tx.Exec("DELETE FROM "+tbl+" WHERE shop_id = ?", shopID).Error; err != nil { + return err + } + } + cleared = append(cleared, t) + } + return nil + }) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"cleared": cleared}) +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index c3d97f1..c8a23ef 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -84,3 +84,15 @@ func GetUserID(c *gin.Context) uint64 { id, _ := v.(uint64) return id } + +// SuperAdminOnly 仅超级管理员可访问 +func SuperAdminOnly() gin.HandlerFunc { + return func(c *gin.Context) { + role, _ := c.Get(CtxRole) + if role != "superadmin" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "superadmin only"}) + return + } + c.Next() + } +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index e7d3f74..22292ab 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -6,7 +6,7 @@ type User struct { PasswordHash string `gorm:"size:255" json:"-"` RealName string `gorm:"size:50" json:"real_name"` Phone string `gorm:"size:30" json:"phone"` - Role string `gorm:"type:enum('admin','operator','readonly');default:'operator'" json:"role"` + Role string `gorm:"type:enum('admin','operator','readonly','superadmin');default:'operator'" json:"role"` IsActive bool `gorm:"default:true" json:"is_active"` CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"` } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 3699db4..6dd9c3a 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -31,6 +31,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { financeH := handler.NewFinanceHandler(db) numberRuleH := handler.NewNumberRuleHandler(db) publicH := handler.NewPublicHandler(db) + adminH := handler.NewAdminHandler(db) // 健康检查(无需认证,用于前端连通性探测) r.GET("/health", func(c *gin.Context) { @@ -187,5 +188,12 @@ func Setup(r *gin.Engine, db *gorm.DB) { opts.POST("/specs", productOptH.CreateSpec) opts.DELETE("/specs/:id", productOptH.DeleteSpec) } + + // 超级管理员专属 + superAdmin := api.Group("/admin") + superAdmin.Use(middleware.SuperAdminOnly()) + { + superAdmin.POST("/clear-data", adminH.ClearData) + } } } diff --git a/backend/main.go b/backend/main.go index 5a3bd19..2c4f8d6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -21,6 +21,9 @@ func main() { // 初始化数据库 db := initDB() + // ALTER 已有数据库的 enum,使 superadmin 在已运行实例上也生效(忽略错误) + db.Exec("ALTER TABLE users MODIFY COLUMN role ENUM('admin','operator','readonly','superadmin') NOT NULL DEFAULT 'operator'") + // 自动迁移(GORM AutoMigrate 只增不删,生产安全) autoMigrate(db) diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql index b541ad5..e7db156 100644 --- a/backend/schema/schema.sql +++ b/backend/schema/schema.sql @@ -38,7 +38,7 @@ CREATE TABLE IF NOT EXISTS `users` ( `password_hash` VARCHAR(255) NOT NULL COMMENT 'bcrypt', `real_name` VARCHAR(50) DEFAULT NULL, `phone` VARCHAR(30) DEFAULT NULL, - `role` ENUM('admin','operator','readonly') NOT NULL DEFAULT 'operator', + `role` ENUM('admin','operator','readonly','superadmin') NOT NULL DEFAULT 'operator', `is_active` TINYINT(1) NOT NULL DEFAULT 1, `custom_fields` JSON DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/client/lib/core/auth/auth_state.dart b/client/lib/core/auth/auth_state.dart index 515ce41..bcb0b6a 100644 --- a/client/lib/core/auth/auth_state.dart +++ b/client/lib/core/auth/auth_state.dart @@ -8,6 +8,7 @@ const _kUsername = 'username'; const _kRealName = 'real_name'; const _kShopNo = 'shop_no'; const _kShopId = 'shop_id'; +const _kRole = 'role'; class AuthUser { final String accessToken; @@ -16,6 +17,7 @@ class AuthUser { final String realName; final String shopNo; final int shopId; + final String role; const AuthUser({ required this.accessToken, @@ -24,6 +26,7 @@ class AuthUser { required this.realName, required this.shopNo, required this.shopId, + this.role = 'operator', }); // Kept for ApiClient compatibility @@ -51,6 +54,7 @@ class AuthNotifier extends StateNotifier { final shopNo = prefs.getString(_kShopNo); final shopIdStr = prefs.getString(_kShopId); + final role = prefs.getString(_kRole); if (accessToken != null && refreshToken != null && username != null) { state = AuthState( initialized: true, @@ -61,6 +65,7 @@ class AuthNotifier extends StateNotifier { realName: realName ?? username, shopNo: shopNo ?? '', shopId: int.tryParse(shopIdStr ?? '') ?? 0, + role: role ?? 'operator', ), ); return; @@ -80,6 +85,7 @@ class AuthNotifier extends StateNotifier { await prefs.setString(_kRealName, user.realName); await prefs.setString(_kShopNo, user.shopNo); await prefs.setString(_kShopId, user.shopId.toString()); + await prefs.setString(_kRole, user.role); debugPrint('[Auth] login() setting state, token prefix: ${user.accessToken.substring(0, user.accessToken.length.clamp(0, 20))}...'); state = AuthState(initialized: true, user: user); debugPrint('[Auth] login() state set, isLoggedIn=${state.isLoggedIn}'); @@ -98,6 +104,7 @@ class AuthNotifier extends StateNotifier { realName: state.user!.realName, shopNo: state.user!.shopNo, shopId: state.user!.shopId, + role: state.user!.role, ), ); } @@ -111,6 +118,7 @@ class AuthNotifier extends StateNotifier { await prefs.remove(_kRealName); await prefs.remove(_kShopNo); await prefs.remove(_kShopId); + await prefs.remove(_kRole); state = const AuthState(initialized: true); } } diff --git a/client/lib/models/user.dart b/client/lib/models/user.dart index b4c2515..c58d233 100644 --- a/client/lib/models/user.dart +++ b/client/lib/models/user.dart @@ -19,6 +19,8 @@ class AppUser { String get roleLabel { switch (role) { + case 'superadmin': + return '超级管理员'; case 'admin': return '管理员'; case 'operator': diff --git a/client/lib/repositories/auth_repository.dart b/client/lib/repositories/auth_repository.dart index 6e9adbb..732b6bf 100644 --- a/client/lib/repositories/auth_repository.dart +++ b/client/lib/repositories/auth_repository.dart @@ -53,6 +53,7 @@ class AuthRepository { realName: user['real_name'] as String? ?? username, shopNo: shopCode, shopId: (data['shop_id'] as num).toInt(), + role: user['role'] as String? ?? 'operator', ); } on DioException catch (e) { final msg = e.response?.data?['error'] as String?; diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index 174914d..d8790f1 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -595,7 +595,11 @@ class _SettingsScreenState extends ConsumerState { } // ── 数据导入 Tab ────────────────────────────────────────── - Widget _buildImportTab() => const _BatchImportWidget(); + Widget _buildImportTab() { + final currentUser = ref.watch(authStateProvider).user; + final isSuperAdmin = currentUser?.role == 'superadmin'; + return _BatchImportWidget(isSuperAdmin: isSuperAdmin); + } // ── 关于 Tab ───────────────────────────────────────────── Widget _buildAboutTab() { @@ -1160,6 +1164,7 @@ class _UserFormDialogState extends ConsumerState<_UserFormDialog> { DropdownButtonFormField( value: _role, items: const [ + DropdownMenuItem(value: 'superadmin', child: Text('超级管理员')), DropdownMenuItem(value: 'admin', child: Text('管理员')), DropdownMenuItem(value: 'operator', child: Text('操作员')), DropdownMenuItem(value: 'readonly', child: Text('只读')), @@ -1271,6 +1276,10 @@ class _RoleBadge extends StatelessWidget { final Color bg; final Color fg; switch (role) { + case '超级管理员': + bg = const Color(0xFFFCE4EC); + fg = AppTheme.danger; + break; case '管理员': bg = const Color(0xFFE3F2FD); fg = AppTheme.primary; @@ -1428,7 +1437,8 @@ class _ImportSlot { } class _BatchImportWidget extends ConsumerStatefulWidget { - const _BatchImportWidget(); + final bool isSuperAdmin; + const _BatchImportWidget({required this.isSuperAdmin}); @override ConsumerState<_BatchImportWidget> createState() => _BatchImportWidgetState(); @@ -1453,6 +1463,17 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { '格式:商品编号|商品名称|系列|规格|单位|库存数量|单价|金额|生产日期|批次|分类|所在仓库|入库日期|供应商|上次盘点|备注'), ]; + final Set _selectedTables = {}; + bool _clearing = false; + + static const _clearOptions = [ + (key: 'stock_in', label: '入库单'), + (key: 'stock_out', label: '出库单'), + (key: 'inventory', label: '库存管理'), + (key: 'products', label: '商品详情'), + (key: 'partners', label: '往来单位'), + ]; + @override void initState() { super.initState(); @@ -1619,11 +1640,243 @@ class _BatchImportWidgetState extends ConsumerState<_BatchImportWidget> { ], ], ), + if (widget.isSuperAdmin) ...[ + const SizedBox(height: 32), + const Divider(), + const SizedBox(height: 16), + _buildClearDataSection(), + ], ], ), ); } + Widget _buildClearDataSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题行 + Row( + children: [ + const Icon(Icons.delete_forever, color: AppTheme.danger, size: 20), + const SizedBox(width: 8), + const Text('危险操作 — 数据清空', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppTheme.danger)), + ], + ), + const SizedBox(height: 12), + // 红色警告框 + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFFF3F3), + border: Border.all(color: AppTheme.danger.withOpacity(0.5)), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + const Icon(Icons.warning_amber_rounded, + color: AppTheme.danger, size: 18), + const SizedBox(width: 6), + const Text('警告:数据一旦清空,无法恢复!', + style: TextStyle( + color: AppTheme.danger, + fontWeight: FontWeight.w600, + fontSize: 13)), + ]), + const SizedBox(height: 6), + const Text( + '• 清空操作仅删除当前门店的数据,不影响其他门店\n' + '• 操作不可撤销,请务必提前备份数据\n' + '• 仅超级管理员可执行此操作', + style: TextStyle(fontSize: 12, color: Color(0xFFB71C1C), height: 1.6), + ), + ], + ), + ), + const SizedBox(height: 14), + // 勾选框 + Wrap( + spacing: 8, + runSpacing: 4, + children: _clearOptions.map((opt) { + final selected = _selectedTables.contains(opt.key); + return FilterChip( + label: Text(opt.label), + selected: selected, + selectedColor: AppTheme.danger.withOpacity(0.15), + checkmarkColor: AppTheme.danger, + labelStyle: TextStyle( + color: selected ? AppTheme.danger : AppTheme.textPrimary, + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + ), + side: BorderSide( + color: selected ? AppTheme.danger : Colors.grey.shade300, + ), + onSelected: _clearing + ? null + : (v) => setState(() { + if (v) { + _selectedTables.add(opt.key); + } else { + _selectedTables.remove(opt.key); + } + }), + ); + }).toList(), + ), + const SizedBox(height: 14), + // 清空按钮 + ElevatedButton.icon( + onPressed: (_clearing || _selectedTables.isEmpty) + ? null + : () => _confirmClear(), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.danger, + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.grey.shade300, + ), + icon: _clearing + ? const SizedBox( + width: 14, height: 14, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.delete_sweep_outlined, size: 18), + label: Text(_clearing ? '清空中...' : '清空选中数据'), + ), + ], + ); + } + + Future _confirmClear() async { + final tables = List.from(_selectedTables); + final labels = _clearOptions + .where((o) => tables.contains(o.key)) + .map((o) => o.label) + .toList(); + + final ctrl = TextEditingController(); + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setS) => AlertDialog( + title: Row(children: [ + const Icon(Icons.warning_amber_rounded, color: Colors.red, size: 22), + const SizedBox(width: 8), + const Text('确认清空数据', style: TextStyle(color: Colors.red)), + ]), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('即将清空以下数据表:', + style: TextStyle(fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + ...labels.map((l) => Padding( + padding: const EdgeInsets.only(left: 12, bottom: 4), + child: Row(children: [ + const Icon(Icons.remove_circle, + color: Colors.red, size: 14), + const SizedBox(width: 6), + Text(l, + style: const TextStyle( + color: Colors.red, fontWeight: FontWeight.w600)), + ]), + )), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFFF3F3), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.red.shade200), + ), + child: const Text( + '⚠️ 此操作不可撤销,数据清空后无法恢复!', + style: TextStyle( + color: Colors.red, + fontSize: 13, + fontWeight: FontWeight.w600), + ), + ), + const SizedBox(height: 16), + const Text('请输入 "确认清空" 以继续:', + style: TextStyle(fontSize: 13)), + const SizedBox(height: 8), + TextField( + controller: ctrl, + autofocus: true, + decoration: const InputDecoration( + hintText: '确认清空', + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: (_) => setS(() {}), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: ctrl.text == '确认清空' + ? () => Navigator.of(ctx).pop(true) + : null, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('确认清空'), + ), + ], + ), + ), + ); + + ctrl.dispose(); + if (confirmed != true || !mounted) return; + + setState(() => _clearing = true); + try { + final token = ref.read(authStateProvider).user?.accessToken ?? ''; + final dio = Dio(BaseOptions(baseUrl: AppConfig.apiBaseUrl)); + dio.options.headers['Authorization'] = 'Bearer $token'; + await dio.post('/admin/clear-data', data: {'tables': tables}); + if (!mounted) return; + setState(() => _selectedTables.clear()); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('已清空:${labels.join('、')}'), + backgroundColor: AppTheme.success, + ), + ); + } on DioException catch (e) { + if (!mounted) return; + final msg = (e.response?.data is Map ? e.response!.data['error'] : null) ?? + e.message ?? '未知错误'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('清空失败:$msg'), backgroundColor: AppTheme.danger), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('清空失败:$e'), backgroundColor: AppTheme.danger), + ); + } finally { + if (mounted) setState(() => _clearing = false); + } + } + Widget _buildSlotRow(int index) { final slot = _slots[index];