diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go new file mode 100644 index 0000000..eba9eb9 --- /dev/null +++ b/backend/internal/handler/user.go @@ -0,0 +1,125 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "github.com/wangjia/jiu/backend/internal/middleware" + "github.com/wangjia/jiu/backend/internal/model" +) + +type UserHandler struct { + db *gorm.DB +} + +func NewUserHandler(db *gorm.DB) *UserHandler { + return &UserHandler{db: db} +} + +// List GET /api/v1/users +func (h *UserHandler) List(c *gin.Context) { + shopID := middleware.GetShopID(c) + var users []model.User + h.db.Where("shop_id = ? AND deleted_at IS NULL", shopID). + Order("id ASC").Find(&users) + c.JSON(http.StatusOK, gin.H{"data": users}) +} + +// Create POST /api/v1/users +func (h *UserHandler) Create(c *gin.Context) { + shopID := middleware.GetShopID(c) + var req struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + RealName string `json:"real_name"` + Phone string `json:"phone"` + Role string `json:"role"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"}) + return + } + role := req.Role + if role == "" { + role = "operator" + } + u := model.User{ + TenantBase: model.TenantBase{ShopID: shopID}, + Username: req.Username, + PasswordHash: string(hash), + RealName: req.RealName, + Phone: req.Phone, + Role: role, + IsActive: true, + } + if err := h.db.Create(&u).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "用户名已存在"}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": u}) +} + +// Update PUT /api/v1/users/:id +func (h *UserHandler) Update(c *gin.Context) { + shopID := middleware.GetShopID(c) + var u model.User + if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID). + First(&u).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + var req struct { + RealName string `json:"real_name"` + Phone string `json:"phone"` + Role string `json:"role"` + IsActive *bool `json:"is_active"` + } + c.ShouldBindJSON(&req) + if req.RealName != "" { + u.RealName = req.RealName + } + if req.Phone != "" { + u.Phone = req.Phone + } + if req.Role != "" { + u.Role = req.Role + } + if req.IsActive != nil { + u.IsActive = *req.IsActive + } + h.db.Save(&u) + c.JSON(http.StatusOK, gin.H{"data": u}) +} + +// ResetPassword PUT /api/v1/users/:id/reset-password +func (h *UserHandler) ResetPassword(c *gin.Context) { + shopID := middleware.GetShopID(c) + var u model.User + if err := h.db.Where("id = ? AND shop_id = ? AND deleted_at IS NULL", c.Param("id"), shopID). + First(&u).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"}) + return + } + var req struct { + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"}) + return + } + h.db.Model(&u).Update("password_hash", string(hash)) + c.JSON(http.StatusOK, gin.H{"message": "密码已重置"}) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index f3f8ae5..328b6df 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -25,6 +25,7 @@ func Setup(r *gin.Engine, db *gorm.DB) { stockOutH := handler.NewStockOutHandler(db, stockSvc) inventoryH := handler.NewInventoryHandler(db) importH := handler.NewImportHandler(db) + userH := handler.NewUserHandler(db) v1 := r.Group("/api/v1") @@ -105,6 +106,15 @@ func Setup(r *gin.Engine, db *gorm.DB) { inventory.GET("/checks/:id", inventoryH.GetCheck) } + // 用户管理 + users := api.Group("/users") + { + users.GET("", userH.List) + users.POST("", userH.Create) + users.PUT("/:id", userH.Update) + users.PUT("/:id/reset-password", userH.ResetPassword) + } + // 导入 imp := api.Group("/import") { diff --git a/client/lib/models/user.dart b/client/lib/models/user.dart new file mode 100644 index 0000000..b4c2515 --- /dev/null +++ b/client/lib/models/user.dart @@ -0,0 +1,42 @@ +class AppUser { + final int id; + final String username; + final String? realName; + final String? phone; + final String role; // admin | operator | readonly + final bool isActive; + final String? createdAt; + + const AppUser({ + required this.id, + required this.username, + this.realName, + this.phone, + required this.role, + required this.isActive, + this.createdAt, + }); + + String get roleLabel { + switch (role) { + case 'admin': + return '管理员'; + case 'operator': + return '操作员'; + case 'readonly': + return '只读'; + default: + return role; + } + } + + factory AppUser.fromJson(Map json) => AppUser( + id: (json['id'] as num).toInt(), + username: json['username'] as String, + realName: json['real_name'] as String?, + phone: json['phone'] as String?, + role: json['role'] as String? ?? 'operator', + isActive: json['is_active'] as bool? ?? true, + createdAt: json['created_at'] as String?, + ); +} diff --git a/client/lib/providers/user_provider.dart b/client/lib/providers/user_provider.dart new file mode 100644 index 0000000..07631d2 --- /dev/null +++ b/client/lib/providers/user_provider.dart @@ -0,0 +1,42 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/api/api_client.dart'; +import '../core/auth/auth_state.dart'; +import '../models/user.dart'; +import '../repositories/user_repository.dart'; + +final userRepositoryProvider = Provider((ref) { + return UserRepository(ref.watch(apiClientProvider)); +}); + +final userListProvider = + AsyncNotifierProvider>( + UserListNotifier.new, +); + +class UserListNotifier extends AsyncNotifier> { + @override + Future> build() { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + return ref.read(userRepositoryProvider).list(); + } + + Future reload() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard( + () => ref.read(userRepositoryProvider).list()); + } + + Future createUser(Map data) async { + await ref.read(userRepositoryProvider).create(data); + await reload(); + } + + Future updateUser(int id, Map data) async { + await ref.read(userRepositoryProvider).update(id, data); + await reload(); + } + + Future resetPassword(int id, String newPassword) async { + await ref.read(userRepositoryProvider).resetPassword(id, newPassword); + } +} diff --git a/client/lib/repositories/user_repository.dart b/client/lib/repositories/user_repository.dart new file mode 100644 index 0000000..662d0e5 --- /dev/null +++ b/client/lib/repositories/user_repository.dart @@ -0,0 +1,28 @@ +import '../core/api/api_client.dart'; +import '../models/user.dart'; + +class UserRepository { + final ApiClient _client; + UserRepository(this._client); + + Future> list() async { + final resp = await _client.get('/users'); + final data = resp.data['data'] as List; + return data.map((e) => AppUser.fromJson(e as Map)).toList(); + } + + Future create(Map data) async { + final resp = await _client.post('/users', data: data); + return AppUser.fromJson(resp.data['data'] as Map); + } + + Future update(int id, Map data) async { + final resp = await _client.put('/users/$id', data: data); + return AppUser.fromJson(resp.data['data'] as Map); + } + + Future resetPassword(int id, String newPassword) async { + await _client.put('/users/$id/reset-password', + data: {'password': newPassword}); + } +} diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index d4a31ae..17aaa4b 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/theme/app_theme.dart'; +import '../../models/user.dart'; import '../../models/warehouse.dart'; +import '../../providers/user_provider.dart'; import '../../providers/warehouse_provider.dart'; class SettingsScreen extends ConsumerStatefulWidget { @@ -53,13 +55,7 @@ class _SettingsScreenState extends ConsumerState { } Widget _buildUsersTab() { - final users = [ - {'name': '张三', 'username': 'zhangsan', 'role': '管理员', 'status': true, 'lastLogin': '2026-04-04 09:15'}, - {'name': '李四', 'username': 'lisi', 'role': '操作员', 'status': true, 'lastLogin': '2026-04-04 08:30'}, - {'name': '王五', 'username': 'wangwu', 'role': '操作员', 'status': true, 'lastLogin': '2026-04-03 17:45'}, - {'name': '赵六', 'username': 'zhaoliu', 'role': '只读', 'status': false, 'lastLogin': '2026-03-28 10:20'}, - ]; - + final asyncUsers = ref.watch(userListProvider); return Column( children: [ Container( @@ -78,66 +74,86 @@ class _SettingsScreenState extends ConsumerState { ), const Divider(height: 1), Expanded( - child: SingleChildScrollView( - child: DataTable( - headingRowColor: WidgetStateProperty.all(const Color(0xFFF0F4FF)), - columns: const [ - DataColumn(label: Text('姓名')), - DataColumn(label: Text('用户名')), - DataColumn(label: Text('角色')), - DataColumn(label: Text('状态')), - DataColumn(label: Text('最后登录')), - DataColumn(label: Text('操作')), - ], - rows: users - .map((u) => DataRow(cells: [ - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - CircleAvatar( - radius: 14, - backgroundColor: AppTheme.primary.withOpacity(0.15), - child: Text( - (u['name'] as String).substring(0, 1), - style: const TextStyle( - fontSize: 12, - color: AppTheme.primary, - fontWeight: FontWeight.w600), + child: asyncUsers.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('加载失败:$e', + style: const TextStyle(color: AppTheme.danger)), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => ref.read(userListProvider.notifier).reload(), + child: const Text('重试'), + ), + ], + ), + ), + data: (users) => SingleChildScrollView( + child: DataTable( + headingRowColor: WidgetStateProperty.all(const Color(0xFFF0F4FF)), + columns: const [ + DataColumn(label: Text('姓名')), + DataColumn(label: Text('用户名')), + DataColumn(label: Text('角色')), + DataColumn(label: Text('状态')), + DataColumn(label: Text('操作')), + ], + rows: users + .map((u) => DataRow(cells: [ + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 14, + backgroundColor: + AppTheme.primary.withOpacity(0.15), + child: Text( + (u.realName ?? u.username) + .substring(0, 1), + style: const TextStyle( + fontSize: 12, + color: AppTheme.primary, + fontWeight: FontWeight.w600), + ), ), - ), - const SizedBox(width: 8), - Text(u['name'] as String), - ], - )), - DataCell(Text(u['username'] as String, - style: const TextStyle( - fontFamily: 'monospace', fontSize: 12))), - DataCell(_RoleBadge(u['role'] as String)), - DataCell(Switch( - value: u['status'] as bool, - onChanged: (_) {}, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - )), - DataCell(Text(u['lastLogin'] as String, - style: const TextStyle( - fontSize: 12, - color: AppTheme.textSecondary))), - DataCell(Row( - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: () {}, + const SizedBox(width: 8), + Text(u.realName ?? u.username), + ], + )), + DataCell(Text(u.username, + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12))), + DataCell(_RoleBadge(u.roleLabel)), + DataCell(Switch( + value: u.isActive, + onChanged: (v) => ref + .read(userListProvider.notifier) + .updateUser(u.id, {'is_active': v}), + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + )), + DataCell(Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: () => + _showEditUserDialog(context, u), child: const Text('编辑', - style: TextStyle(fontSize: 12))), - TextButton( - onPressed: () {}, + style: TextStyle(fontSize: 12)), + ), + TextButton( + onPressed: () => + _showResetPasswordDialog(context, u), child: const Text('重置密码', - style: TextStyle(fontSize: 12))), - ], - )), - ])) - .toList(), + style: TextStyle(fontSize: 12)), + ), + ], + )), + ])) + .toList(), + ), ), ), ), @@ -417,50 +433,28 @@ class _SettingsScreenState extends ConsumerState { void _showAddUserDialog(BuildContext context) { showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text('新增用户'), - content: SizedBox( - width: 400, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const TextField( - decoration: InputDecoration(labelText: '姓名', hintText: '请输入姓名'), - ), - const SizedBox(height: 12), - const TextField( - decoration: InputDecoration(labelText: '用户名', hintText: '请输入登录用户名'), - ), - const SizedBox(height: 12), - const TextField( - obscureText: true, - decoration: InputDecoration(labelText: '初始密码', hintText: '请设置初始密码'), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - value: '操作员', - items: ['管理员', '操作员', '只读'] - .map((r) => DropdownMenuItem(value: r, child: Text(r))) - .toList(), - onChanged: (_) {}, - decoration: const InputDecoration(labelText: '角色'), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('取消'), - ), - ElevatedButton( - onPressed: () => Navigator.pop(context), - child: const Text('确定'), - ), - ], + builder: (ctx) => _UserFormDialog( + onSaved: () => ref.read(userListProvider.notifier).reload(), ), ); } + + void _showEditUserDialog(BuildContext context, AppUser user) { + showDialog( + context: context, + builder: (ctx) => _UserFormDialog( + user: user, + onSaved: () => ref.read(userListProvider.notifier).reload(), + ), + ); + } + + void _showResetPasswordDialog(BuildContext context, AppUser user) { + showDialog( + context: context, + builder: (ctx) => _ResetPasswordDialog(user: user), + ); + } } class _WarehouseFormDialog extends ConsumerStatefulWidget { @@ -590,6 +584,228 @@ class _WarehouseFormDialogState extends ConsumerState<_WarehouseFormDialog> { } } +// ── 新增/编辑用户弹窗 ───────────────────────────────────── +class _UserFormDialog extends ConsumerStatefulWidget { + final AppUser? user; + final VoidCallback onSaved; + const _UserFormDialog({this.user, required this.onSaved}); + + @override + ConsumerState<_UserFormDialog> createState() => _UserFormDialogState(); +} + +class _UserFormDialogState extends ConsumerState<_UserFormDialog> { + final _formKey = GlobalKey(); + late final TextEditingController _usernameCtrl; + late final TextEditingController _realNameCtrl; + late final TextEditingController _phoneCtrl; + late final TextEditingController _passwordCtrl; + late String _role; + bool _saving = false; + + bool get _isEdit => widget.user != null; + + @override + void initState() { + super.initState(); + _usernameCtrl = TextEditingController(text: widget.user?.username ?? ''); + _realNameCtrl = TextEditingController(text: widget.user?.realName ?? ''); + _phoneCtrl = TextEditingController(text: widget.user?.phone ?? ''); + _passwordCtrl = TextEditingController(); + _role = widget.user?.role ?? 'operator'; + } + + @override + void dispose() { + _usernameCtrl.dispose(); + _realNameCtrl.dispose(); + _phoneCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + try { + final notifier = ref.read(userListProvider.notifier); + if (_isEdit) { + await notifier.updateUser(widget.user!.id, { + 'real_name': _realNameCtrl.text.trim(), + 'phone': _phoneCtrl.text.trim(), + 'role': _role, + }); + } else { + await notifier.createUser({ + 'username': _usernameCtrl.text.trim(), + 'password': _passwordCtrl.text, + 'real_name': _realNameCtrl.text.trim(), + 'phone': _phoneCtrl.text.trim(), + 'role': _role, + }); + } + if (mounted) { + Navigator.of(context).pop(); + widget.onSaved(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(_isEdit ? '用户更新成功' : '用户创建成功'), + backgroundColor: AppTheme.success, + )); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('保存失败:$e'), + backgroundColor: AppTheme.danger, + )); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(_isEdit ? '编辑用户' : '新增用户'), + content: SizedBox( + width: 400, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!_isEdit) + TextFormField( + controller: _usernameCtrl, + decoration: const InputDecoration(labelText: '用户名'), + validator: (v) => (v == null || v.isEmpty) ? '不能为空' : null, + ), + if (!_isEdit) const SizedBox(height: 12), + TextFormField( + controller: _realNameCtrl, + decoration: const InputDecoration(labelText: '姓名'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _phoneCtrl, + decoration: const InputDecoration(labelText: '手机号'), + ), + const SizedBox(height: 12), + if (!_isEdit) ...[ + TextFormField( + controller: _passwordCtrl, + obscureText: true, + decoration: const InputDecoration(labelText: '初始密码'), + validator: (v) => (v == null || v.isEmpty) ? '不能为空' : null, + ), + const SizedBox(height: 12), + ], + DropdownButtonFormField( + value: _role, + items: const [ + DropdownMenuItem(value: 'admin', child: Text('管理员')), + DropdownMenuItem(value: 'operator', child: Text('操作员')), + DropdownMenuItem(value: 'readonly', child: Text('只读')), + ], + onChanged: (v) => setState(() => _role = v ?? 'operator'), + decoration: const InputDecoration(labelText: '角色'), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('保存'), + ), + ], + ); + } +} + +// ── 重置密码弹窗 ────────────────────────────────────────── +class _ResetPasswordDialog extends ConsumerStatefulWidget { + final AppUser user; + const _ResetPasswordDialog({required this.user}); + + @override + ConsumerState<_ResetPasswordDialog> createState() => _ResetPasswordDialogState(); +} + +class _ResetPasswordDialogState extends ConsumerState<_ResetPasswordDialog> { + final _ctrl = TextEditingController(); + bool _saving = false; + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + Future _save() async { + if (_ctrl.text.isEmpty) return; + setState(() => _saving = true); + try { + await ref.read(userListProvider.notifier).resetPassword(widget.user.id, _ctrl.text); + if (mounted) { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('密码已重置'), + backgroundColor: AppTheme.success, + )); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('重置失败:$e'), + backgroundColor: AppTheme.danger, + )); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('重置密码 — ${widget.user.username}'), + content: SizedBox( + width: 360, + child: TextField( + controller: _ctrl, + obscureText: true, + decoration: const InputDecoration(labelText: '新密码'), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + ElevatedButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, height: 16, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('确认重置'), + ), + ], + ); + } +} + class _RoleBadge extends StatelessWidget { final String role; const _RoleBadge(this.role);