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,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_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);
|
||||
|
||||
Reference in New Issue
Block a user