feat: 用户管理对接真实 API

后端: 新增 /api/v1/users CRUD + 重置密码接口
前端: user model/repository/provider + 设置页用户 tab 改为真实数据,
      支持新增、编辑、启停、重置密码

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-04-08 08:45:37 +08:00
parent f1cf8f4b95
commit a44b536c39
6 changed files with 568 additions and 105 deletions
+125
View File
@@ -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": "密码已重置"})
}
+10
View File
@@ -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")
{
+42
View File
@@ -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<String, dynamic> 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?,
);
}
+42
View File
@@ -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<UserRepository>((ref) {
return UserRepository(ref.watch(apiClientProvider));
});
final userListProvider =
AsyncNotifierProvider<UserListNotifier, List<AppUser>>(
UserListNotifier.new,
);
class UserListNotifier extends AsyncNotifier<List<AppUser>> {
@override
Future<List<AppUser>> build() {
ref.watch(authStateProvider.select((s) => s.user?.shopId));
return ref.read(userRepositoryProvider).list();
}
Future<void> reload() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(
() => ref.read(userRepositoryProvider).list());
}
Future<void> createUser(Map<String, dynamic> data) async {
await ref.read(userRepositoryProvider).create(data);
await reload();
}
Future<void> updateUser(int id, Map<String, dynamic> data) async {
await ref.read(userRepositoryProvider).update(id, data);
await reload();
}
Future<void> resetPassword(int id, String newPassword) async {
await ref.read(userRepositoryProvider).resetPassword(id, newPassword);
}
}
@@ -0,0 +1,28 @@
import '../core/api/api_client.dart';
import '../models/user.dart';
class UserRepository {
final ApiClient _client;
UserRepository(this._client);
Future<List<AppUser>> list() async {
final resp = await _client.get('/users');
final data = resp.data['data'] as List;
return data.map((e) => AppUser.fromJson(e as Map<String, dynamic>)).toList();
}
Future<AppUser> create(Map<String, dynamic> data) async {
final resp = await _client.post('/users', data: data);
return AppUser.fromJson(resp.data['data'] as Map<String, dynamic>);
}
Future<AppUser> update(int id, Map<String, dynamic> data) async {
final resp = await _client.put('/users/$id', data: data);
return AppUser.fromJson(resp.data['data'] as Map<String, dynamic>);
}
Future<void> resetPassword(int id, String newPassword) async {
await _client.put('/users/$id/reset-password',
data: {'password': newPassword});
}
}
+321 -105
View File
@@ -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<SettingsScreen> {
}
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<SettingsScreen> {
),
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<SettingsScreen> {
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<String>(
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<FormState>();
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<void> _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<String>(
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<void> _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);