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:
@@ -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": "密码已重置"})
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
stockOutH := handler.NewStockOutHandler(db, stockSvc)
|
stockOutH := handler.NewStockOutHandler(db, stockSvc)
|
||||||
inventoryH := handler.NewInventoryHandler(db)
|
inventoryH := handler.NewInventoryHandler(db)
|
||||||
importH := handler.NewImportHandler(db)
|
importH := handler.NewImportHandler(db)
|
||||||
|
userH := handler.NewUserHandler(db)
|
||||||
|
|
||||||
v1 := r.Group("/api/v1")
|
v1 := r.Group("/api/v1")
|
||||||
|
|
||||||
@@ -105,6 +106,15 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
|||||||
inventory.GET("/checks/:id", inventoryH.GetCheck)
|
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")
|
imp := api.Group("/import")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/theme/app_theme.dart';
|
import '../../core/theme/app_theme.dart';
|
||||||
|
import '../../models/user.dart';
|
||||||
import '../../models/warehouse.dart';
|
import '../../models/warehouse.dart';
|
||||||
|
import '../../providers/user_provider.dart';
|
||||||
import '../../providers/warehouse_provider.dart';
|
import '../../providers/warehouse_provider.dart';
|
||||||
|
|
||||||
class SettingsScreen extends ConsumerStatefulWidget {
|
class SettingsScreen extends ConsumerStatefulWidget {
|
||||||
@@ -53,13 +55,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildUsersTab() {
|
Widget _buildUsersTab() {
|
||||||
final users = [
|
final asyncUsers = ref.watch(userListProvider);
|
||||||
{'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'},
|
|
||||||
];
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -78,66 +74,86 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: asyncUsers.when(
|
||||||
child: DataTable(
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
headingRowColor: WidgetStateProperty.all(const Color(0xFFF0F4FF)),
|
error: (e, _) => Center(
|
||||||
columns: const [
|
child: Column(
|
||||||
DataColumn(label: Text('姓名')),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
DataColumn(label: Text('用户名')),
|
children: [
|
||||||
DataColumn(label: Text('角色')),
|
Text('加载失败:$e',
|
||||||
DataColumn(label: Text('状态')),
|
style: const TextStyle(color: AppTheme.danger)),
|
||||||
DataColumn(label: Text('最后登录')),
|
const SizedBox(height: 12),
|
||||||
DataColumn(label: Text('操作')),
|
ElevatedButton(
|
||||||
],
|
onPressed: () => ref.read(userListProvider.notifier).reload(),
|
||||||
rows: users
|
child: const Text('重试'),
|
||||||
.map((u) => DataRow(cells: [
|
),
|
||||||
DataCell(Row(
|
],
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
),
|
||||||
CircleAvatar(
|
data: (users) => SingleChildScrollView(
|
||||||
radius: 14,
|
child: DataTable(
|
||||||
backgroundColor: AppTheme.primary.withOpacity(0.15),
|
headingRowColor: WidgetStateProperty.all(const Color(0xFFF0F4FF)),
|
||||||
child: Text(
|
columns: const [
|
||||||
(u['name'] as String).substring(0, 1),
|
DataColumn(label: Text('姓名')),
|
||||||
style: const TextStyle(
|
DataColumn(label: Text('用户名')),
|
||||||
fontSize: 12,
|
DataColumn(label: Text('角色')),
|
||||||
color: AppTheme.primary,
|
DataColumn(label: Text('状态')),
|
||||||
fontWeight: FontWeight.w600),
|
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),
|
||||||
const SizedBox(width: 8),
|
Text(u.realName ?? u.username),
|
||||||
Text(u['name'] as String),
|
],
|
||||||
],
|
)),
|
||||||
)),
|
DataCell(Text(u.username,
|
||||||
DataCell(Text(u['username'] as String,
|
style: const TextStyle(
|
||||||
style: const TextStyle(
|
fontFamily: 'monospace', fontSize: 12))),
|
||||||
fontFamily: 'monospace', fontSize: 12))),
|
DataCell(_RoleBadge(u.roleLabel)),
|
||||||
DataCell(_RoleBadge(u['role'] as String)),
|
DataCell(Switch(
|
||||||
DataCell(Switch(
|
value: u.isActive,
|
||||||
value: u['status'] as bool,
|
onChanged: (v) => ref
|
||||||
onChanged: (_) {},
|
.read(userListProvider.notifier)
|
||||||
materialTapTargetSize:
|
.updateUser(u.id, {'is_active': v}),
|
||||||
MaterialTapTargetSize.shrinkWrap,
|
materialTapTargetSize:
|
||||||
)),
|
MaterialTapTargetSize.shrinkWrap,
|
||||||
DataCell(Text(u['lastLogin'] as String,
|
)),
|
||||||
style: const TextStyle(
|
DataCell(Row(
|
||||||
fontSize: 12,
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: AppTheme.textSecondary))),
|
children: [
|
||||||
DataCell(Row(
|
TextButton(
|
||||||
mainAxisSize: MainAxisSize.min,
|
onPressed: () =>
|
||||||
children: [
|
_showEditUserDialog(context, u),
|
||||||
TextButton(
|
|
||||||
onPressed: () {},
|
|
||||||
child: const Text('编辑',
|
child: const Text('编辑',
|
||||||
style: TextStyle(fontSize: 12))),
|
style: TextStyle(fontSize: 12)),
|
||||||
TextButton(
|
),
|
||||||
onPressed: () {},
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
_showResetPasswordDialog(context, u),
|
||||||
child: const Text('重置密码',
|
child: const Text('重置密码',
|
||||||
style: TextStyle(fontSize: 12))),
|
style: TextStyle(fontSize: 12)),
|
||||||
],
|
),
|
||||||
)),
|
],
|
||||||
]))
|
)),
|
||||||
.toList(),
|
]))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -417,50 +433,28 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
|||||||
void _showAddUserDialog(BuildContext context) {
|
void _showAddUserDialog(BuildContext context) {
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (ctx) => _UserFormDialog(
|
||||||
title: const Text('新增用户'),
|
onSaved: () => ref.read(userListProvider.notifier).reload(),
|
||||||
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('确定'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
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 {
|
class _RoleBadge extends StatelessWidget {
|
||||||
final String role;
|
final String role;
|
||||||
const _RoleBadge(this.role);
|
const _RoleBadge(this.role);
|
||||||
|
|||||||
Reference in New Issue
Block a user