// screens/settings/users_screen.dart — 用户管理独立页(照原型 users.html 重建)。 // head + KPI 4 卡(无图标)+ toolbar(搜索 + 角色筛选)+ 表格(头像/角色徽章/ // 启停)+ 新增/编辑弹窗(角色 2×2 单选卡 + 状态开关 + 重置密码)。 // 角色口径按后端四级:superadmin/admin/operator/readonly(原型三处不一致已拉平)。 import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../core/responsive/responsive.dart'; import '../../core/theme/app_dims.g.dart'; import '../../core/theme/context_tokens.dart'; import '../../core/utils/clock.dart'; import '../../core/utils/dialog_util.dart'; import '../../models/user.dart'; import '../../providers/user_provider.dart'; import '../../widgets/ds/ds_atoms.dart'; import '../../widgets/ds/ds_kpi.dart'; import '../../widgets/ds/ds_menu.dart'; import '../../widgets/ds/ds_table.dart'; import '../../widgets/mobile_list_card.dart'; import '../../widgets/write_guard.dart'; import '../../core/theme/app_fonts.dart'; import '../../widgets/ds/ds_toast.dart'; /// 角色徽章(atoms 对色:超管=danger / 管理员=info / 操作员=ok / 只读=warn)。 DsBadge userRoleBadge(String role) { final label = switch (role) { 'superadmin' => '超级管理员', 'admin' => '管理员', 'operator' => '操作员', 'readonly' => '只读', _ => role, }; final tone = switch (role) { 'superadmin' => DsBadgeTone.danger, 'admin' => DsBadgeTone.info, 'operator' => DsBadgeTone.ok, 'readonly' => DsBadgeTone.warn, _ => DsBadgeTone.muted, }; return DsBadge(label, tone: tone); } /// 状态徽章(b-启用=success / b-停用=border-subtle/muted)。 DsBadge userStatusBadge(bool active) => active ? const DsBadge('启用', tone: DsBadgeTone.ok) : const DsBadge('停用', tone: DsBadgeTone.muted); class UsersScreen extends ConsumerStatefulWidget { const UsersScreen({super.key}); @override ConsumerState createState() => _UsersScreenState(); } class _UsersScreenState extends ConsumerState { final _searchCtrl = TextEditingController(); String _roleFilter = '全部角色'; static const _roleFilters = ['全部角色', '超级管理员', '管理员', '操作员', '只读']; @override void dispose() { _searchCtrl.dispose(); super.dispose(); } void _snack(String msg, {bool err = false}) { showDsToast(context, msg, bg: err ? context.tokens.danger : context.tokens.success); } /// 原型最近登录:今天 HH:mm / 昨天 HH:mm / MM-DD HH:mm / — String _fmtLast(String? iso) { if (iso == null || iso.isEmpty) return '—'; final d = DateTime.tryParse(iso)?.toLocal(); if (d == null) return '—'; final now = appNow(); final today = DateTime(now.year, now.month, now.day); final day = DateTime(d.year, d.month, d.day); final hm = DateFormat('HH:mm').format(d); if (day == today) return '今天 $hm'; if (day == today.subtract(const Duration(days: 1))) return '昨天 $hm'; return '${DateFormat('MM-dd').format(d)} $hm'; } @override Widget build(BuildContext context) { final t = context.tokens; final mobile = context.isMobile; final async = ref.watch(userListProvider); return async.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(LucideIcons.cloudOff, size: 40, color: t.muted), const SizedBox(height: 12), Text('暂无数据,网络不可用', style: TextStyle(color: t.muted)), const SizedBox(height: 12), DsButton('重试', variant: DsBtnVariant.primary, onPressed: () => ref.read(userListProvider.notifier).reload()), ], ), ), data: (users) { final kw = _searchCtrl.text.trim().toLowerCase(); final filtered = users.where((u) { if (_roleFilter != '全部角色' && u.roleLabel != _roleFilter) { return false; } if (kw.isEmpty) return true; return (u.realName ?? '').toLowerCase().contains(kw) || u.username.toLowerCase().contains(kw); }).toList(); return Container( color: t.bg, padding: mobile ? EdgeInsets.zero : const EdgeInsets.fromLTRB(26, 22, 26, 22), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ── 头部 ── Padding( padding: mobile ? const EdgeInsets.fromLTRB( AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2) : const EdgeInsets.only(bottom: 18), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text('用户管理', style: TextStyle( fontSize: AppDims.fsH1, fontWeight: FontWeight.w700, color: t.heading)), const SizedBox(width: AppDims.sp3), Padding( padding: const EdgeInsets.only(bottom: 2), child: Text('门店成员与权限角色', style: TextStyle( fontSize: AppDims.fsSm, color: t.muted)), ), const Spacer(), if (!mobile) WriteGuard( child: DsButton('新增用户', icon: LucideIcons.plus, variant: DsBtnVariant.primary, onPressed: () => _openUser()), ), ], ), ), // ── KPI 4 卡(原型无图标无环比,.v 26/700/mono)── Padding( padding: mobile ? const EdgeInsets.fromLTRB(AppDims.sp4, 0, AppDims.sp4, 12) : const EdgeInsets.only(bottom: 18), child: _kpis(users, mobile), ), // ── 表格(toolbar 内嵌搜索 + 角色筛选)── Expanded( child: DsTable( pagerInfoText: '共 ${filtered.length} 名成员', emptyText: '没有匹配的成员', toolbar: _toolbar(mobile), mobileCards: filtered.map(_userCard).toList(), columns: const [ DsColumn('user', '用户'), DsColumn('role', '角色'), DsColumn('status', '状态'), DsColumn('last', '最近登录'), DsColumn('actions', '操作', action: true), ], rows: filtered.map((u) { return DsRow( onTap: () => _openUser(u), cells: [ _userCell(u), userRoleBadge(u.role), userStatusBadge(u.isActive), Text(_fmtLast(u.lastLoginAt), style: TextStyle( fontSize: AppDims.fsSm, color: t.muted)), WriteGuard( child: Row( mainAxisSize: MainAxisSize.min, children: [ WriteGuard( child: _iconBtn(LucideIcons.pencil, '编辑', () => _openUser(u)), ), const SizedBox(width: 8), WriteGuard( child: _iconBtn( u.isActive ? LucideIcons.userX : LucideIcons.userCheck, u.isActive ? '停用' : '启用', () => _toggleActive(u)), ), ], ), ), ], ); }).toList(), ), ), ], ), ); }, ); } Widget _kpis(List users, bool mobile) { final total = users.length; final active = users.where((u) => u.isActive).length; final admins = users.where((u) => u.role == 'admin' || u.role == 'superadmin').length; final readonly = users.where((u) => u.role == 'readonly').length; final cards = [ DsKpi(title: '成员总数', value: '$total'), DsKpi(title: '启用中', value: '$active'), DsKpi(title: '管理员', value: '$admins'), DsKpi(title: '只读账号', value: '$readonly'), ]; if (mobile) { return SingleChildScrollView( scrollDirection: Axis.horizontal, child: IntrinsicHeight( child: Row(children: [ for (var i = 0; i < cards.length; i++) ...[ if (i > 0) const SizedBox(width: AppDims.sp3), SizedBox(width: 140, child: cards[i]), ], ]), ), ); } return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (var i = 0; i < cards.length; i++) ...[ if (i > 0) const SizedBox(width: 14), Expanded(child: cards[i]), ], ], ), ); } Widget _toolbar(bool mobile) { final search = DsSearchBox( controller: _searchCtrl, hint: '搜索姓名 / 账号', width: mobile ? null : 300, onChanged: (_) => setState(() {}), ); final roleChip = DsChip( label: '角色', value: _roleFilter == '全部角色' ? null : _roleFilter, onTap: () async { final v = await showDsMenu(context, items: [ for (final r in _roleFilters) DsMenuItem(value: r, label: r, selected: r == _roleFilter), ]); if (v != null) setState(() => _roleFilter = v); }, onClear: () => setState(() => _roleFilter = '全部角色'), ); if (mobile) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ search, const SizedBox(height: AppDims.sp2), Row(children: [ roleChip, const Spacer(), WriteGuard( child: DsButton('新增', small: true, icon: LucideIcons.plus, variant: DsBtnVariant.primary, onPressed: () => _openUser()), ), ]), ], ); } return Row(children: [search, const Spacer(), roleChip]); } /// 原型 .uname:34×34 圆头像(brand50/primary 首字)+ 姓名 + 账号 mono。 Widget _userCell(AppUser u) { final t = context.tokens; final name = u.realName?.isNotEmpty == true ? u.realName! : u.username; return Row(mainAxisSize: MainAxisSize.min, children: [ Container( width: 34, height: 34, alignment: Alignment.center, decoration: BoxDecoration(color: t.brand50, shape: BoxShape.circle), child: Text(name.characters.first, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w700, color: t.primary)), ), const SizedBox(width: 11), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: TextStyle(fontWeight: FontWeight.w600, color: t.heading)), Text(u.username, style: TextStyle( fontSize: AppDims.fsXs, color: t.muted, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback)), ], ), ]); } Widget _iconBtn(IconData icon, String tip, VoidCallback onTap) { final t = context.tokens; return Tooltip( message: tip, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(AppDims.rSm), child: Padding( padding: const EdgeInsets.all(2), child: Icon(icon, size: 16, color: t.muted), ), ), ); } Widget _userCard(AppUser u) { return MobileListCard( title: Text(u.realName?.isNotEmpty == true ? u.realName! : u.username), subtitle: Text(u.username), trailing: userRoleBadge(u.role), fields: [ MobileCardField('状态', u.isActive ? '启用' : '停用'), MobileCardField('最近登录', _fmtLast(u.lastLoginAt)), ], onTap: () => _openUser(u), ); } Future _toggleActive(AppUser u) async { try { await ref .read(userListProvider.notifier) .updateUser(u.id, {'is_active': !u.isActive}); if (mounted) _snack(u.isActive ? '已停用 · ${u.username}' : '已启用 · ${u.username}'); } catch (e) { if (mounted) _snack('操作失败:$e', err: true); } } void _openUser([AppUser? user]) { showAppDialog( context: context, builder: (ctx) => _UserFormDialog( user: user, onSaved: () => ref.read(userListProvider.notifier).reload(), ), ); } } // ── 新增/编辑用户弹窗(原型 #ov:mgrid 2 列 + 角色 2×2 rcard + 状态 switch)── 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 _nameCtrl; late final TextEditingController _userCtrl; final _passCtrl = TextEditingController(); late String _role = widget.user?.role ?? 'operator'; late bool _active = widget.user?.isActive ?? true; bool _saving = false; // 原型 .rcard 四级角色 + 描述 static const _roles = [ ('superadmin', '超级管理员', '最高权限 · 含系统参数与数据管理'), ('admin', '管理员', '全部业务权限 + 用户与授权管理'), ('operator', '操作员', '日常入库出库与查询'), ('readonly', '只读', '仅查看,禁所有写操作'), ]; @override void initState() { super.initState(); _nameCtrl = TextEditingController(text: widget.user?.realName ?? ''); _userCtrl = TextEditingController(text: widget.user?.username ?? ''); } @override void dispose() { _nameCtrl.dispose(); _userCtrl.dispose(); _passCtrl.dispose(); super.dispose(); } Future _save() async { if (!_formKey.currentState!.validate()) return; setState(() => _saving = true); final isEdit = widget.user != null; final data = { 'real_name': _nameCtrl.text.trim(), 'username': _userCtrl.text.trim(), 'role': _role, 'is_active': _active, if (!isEdit) 'password': _passCtrl.text, }; try { final notifier = ref.read(userListProvider.notifier); if (isEdit) { await notifier.updateUser(widget.user!.id, data); } else { await notifier.createUser(data); } if (mounted) { Navigator.of(context).pop(); widget.onSaved(); showDsToast(context, isEdit ? '用户已更新 ✓' : '用户已创建 ✓', bg: context.tokens.success); } } catch (e) { if (mounted) { showDsToast(context, '保存失败:$e', bg: context.tokens.danger); } } finally { if (mounted) setState(() => _saving = false); } } Future _resetPassword() async { final ctrl = TextEditingController(); final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text('重置密码 · ${widget.user!.username}'), content: SizedBox( width: ctx.dialogWidth(320), child: DsField('新密码', input: TextField( controller: ctrl, obscureText: true, decoration: const InputDecoration(hintText: '至少 8 位'), )), ), actions: [ DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)), DsButton('重置', variant: DsBtnVariant.primary, onPressed: () => Navigator.of(ctx).pop(true)), ], ), ); if (ok != true || !mounted) return; if (ctrl.text.length < 8) { showDsToast(context, '密码至少 8 位'); return; } try { await ref .read(userListProvider.notifier) .resetPassword(widget.user!.id, ctrl.text); if (mounted) { showDsToast(context, '密码已重置 ✓', bg: context.tokens.success); } } catch (e) { if (mounted) { showDsToast(context, '重置失败:$e', bg: context.tokens.danger); } } } @override Widget build(BuildContext context) { final t = context.tokens; final isEdit = widget.user != null; return Dialog( child: Container( width: context.dialogWidth(520), padding: const EdgeInsets.all(22), child: Form( key: _formKey, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(isEdit ? '编辑用户' : '新增用户', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600)), const SizedBox(height: 18), Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: DsField('姓名', required: true, input: TextFormField( controller: _nameCtrl, decoration: const InputDecoration(hintText: '例如 李采购'), validator: (v) => (v == null || v.trim().isEmpty) ? '不能为空' : null, )), ), const SizedBox(width: 14), Expanded( child: DsField('登录账号', required: true, input: TextFormField( controller: _userCtrl, decoration: const InputDecoration(hintText: '字母 / 数字'), validator: (v) => (v == null || v.trim().isEmpty) ? '不能为空' : null, )), ), ]), if (!isEdit) ...[ const SizedBox(height: 14), DsField('初始密码', required: true, input: TextFormField( controller: _passCtrl, obscureText: true, decoration: const InputDecoration(hintText: '至少 8 位,成员首次登录可改'), validator: (v) => (v == null || v.length < 8) ? '至少 8 位' : null, )), ], const SizedBox(height: 16), Text('角色', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), const SizedBox(height: 8), // 原型 .roles 2×2 单选卡 Column(children: [ for (var row = 0; row < 2; row++) Padding( padding: EdgeInsets.only(bottom: row == 0 ? 10 : 0), child: Row(children: [ for (var col = 0; col < 2; col++) ...[ if (col > 0) const SizedBox(width: 10), Expanded(child: _roleCard(_roles[row * 2 + col])), ], ]), ), ]), const SizedBox(height: 16), Row(children: [ Text('账号状态', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), const SizedBox(width: 12), Switch( value: _active, onChanged: (v) => setState(() => _active = v), ), Text(_active ? '启用' : '停用', style: TextStyle(fontSize: AppDims.fsBody, color: t.text)), ]), const SizedBox(height: 20), Row(children: [ if (isEdit) TextButton( onPressed: _resetPassword, child: const Text('重置密码'), ), const Spacer(), TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('取消'), ), const SizedBox(width: 8), ElevatedButton( onPressed: _saving ? null : _save, child: _saving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white)) // ds-ignore: primary 底白字加载圈 : const Text('保存'), ), ]), ], ), ), ), ), ); } /// 原型 .rcard:1.5px 边 r-md pad 12;选中 primary 边 + brand50 ring。 Widget _roleCard((String, String, String) role) { final t = context.tokens; final on = _role == role.$1; return InkWell( onTap: () => setState(() => _role = role.$1), borderRadius: BorderRadius.circular(AppDims.rMd), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: t.surface, border: Border.all(color: on ? t.primary : t.border, width: 1.5), borderRadius: BorderRadius.circular(AppDims.rMd), boxShadow: on ? [BoxShadow(color: t.brand50, spreadRadius: 3)] : null, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(role.$2, style: TextStyle( fontSize: AppDims.fsBody, fontWeight: FontWeight.w600, color: on ? t.primary : t.heading)), const SizedBox(height: 3), Text(role.$3, style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), ], ), ), ); } }