c9217601a1
- DsTable 窄屏卡片流支持 onRefresh(RefreshIndicator,空态也可下拉)—— 等价桌面工具栏「刷新」;库存/入库/出库/往来/财务流水/用户/基础数据 全 tab 接线(基础数据下拉一并重拉五个 tab 数据源) - 库存移动端「盘点/表格视图/导出/列设置/刷新」工具行移除(2026-07-04 拍板:刷新改下拉手势、盘点入口在「我的」、其余桌面专属), 库存移动 golden 随更新 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
922 lines
32 KiB
Dart
922 lines
32 KiB
Dart
// screens/settings/users_screen.dart — 用户管理独立页(照原型 users.html 重建)。
|
||
// head + KPI 4 卡(无图标)+ toolbar(搜索 + 角色筛选)+ 表格(头像/角色徽章/
|
||
// 启停)+ 新增/编辑弹窗(角色 2×2 单选卡 + 状态开关 + 重置密码)。
|
||
// 角色口径按后端四级:superadmin/admin/operator/readonly(原型三处不一致已拉平)。
|
||
// 窄屏(原型 m-users.html):隐藏页头/KPI/toolbar,m-section + 头像卡片流 +
|
||
// FAB 新增;点卡开详情 sheet(drow + 重置密码/启停/编辑),表单走 sheet 形态。
|
||
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/ds/m_sheet.dart';
|
||
import '../../widgets/ds/status_icon_map.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<UsersScreen> createState() => _UsersScreenState();
|
||
}
|
||
|
||
class _UsersScreenState extends ConsumerState<UsersScreen> {
|
||
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) {
|
||
// ── 窄屏(原型 m-users.html):m-section + 头像卡片流 + FAB 新增 ──
|
||
if (mobile) return _mobileBody(t, 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} 名成员',
|
||
// 下拉刷新 = 桌面「刷新」
|
||
onRefresh: () => ref.read(userListProvider.notifier).reload(),
|
||
emptyText: '没有匹配的成员',
|
||
toolbar: _toolbar(mobile),
|
||
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<AppUser> 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<String>(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),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 窄屏(原型 m-users.html)────────────────────────────────
|
||
Widget _mobileBody(dynamic t, List<AppUser> users) {
|
||
return Scaffold(
|
||
backgroundColor: t.bg,
|
||
body: ListView(
|
||
padding: const EdgeInsets.all(14),
|
||
children: [
|
||
// .m-section
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(2, 2, 2, 8),
|
||
child: Text('成员 · 共 ${users.length}',
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsSm,
|
||
fontWeight: FontWeight.w700,
|
||
letterSpacing: .4,
|
||
color: t.muted)),
|
||
),
|
||
if (users.isEmpty)
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||
child: Text('暂无成员',
|
||
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||
),
|
||
for (final u in users) _mUserCard(t, u),
|
||
],
|
||
),
|
||
// 原型 .m-fab:52px primary 圆形悬浮新增(不走 M3 FAB 主题壳)
|
||
floatingActionButton: WriteGuard(
|
||
child: Material(
|
||
color: t.primary,
|
||
shape: const CircleBorder(),
|
||
child: InkWell(
|
||
onTap: () => _openUser(),
|
||
customBorder: const CircleBorder(),
|
||
child: SizedBox(
|
||
width: 52,
|
||
height: 52,
|
||
child: Icon(LucideIcons.plus, size: 24, color: t.onPrimary),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 角色徽章(图标变体,原型 badge.bi:管理员=shield / 普通=user / 只读=eye)。
|
||
DsBadge _roleBadgeIc(String role) {
|
||
// 图标词表按原型三级(icons.js BADGE_ICON 登记词),四级角色归并映射
|
||
final protoWord = switch (role) {
|
||
'superadmin' || 'admin' => '管理员',
|
||
'readonly' => '只读',
|
||
_ => '普通',
|
||
};
|
||
final tone = switch (role) {
|
||
'superadmin' => DsBadgeTone.danger,
|
||
'admin' => DsBadgeTone.info,
|
||
'operator' => DsBadgeTone.ok,
|
||
'readonly' => DsBadgeTone.warn,
|
||
_ => DsBadgeTone.muted,
|
||
};
|
||
final label = switch (role) {
|
||
'superadmin' => '超级管理员',
|
||
'admin' => '管理员',
|
||
'operator' => '操作员',
|
||
'readonly' => '只读',
|
||
_ => role,
|
||
};
|
||
return DsBadge(label, tone: tone, icon: statusIcon(protoWord));
|
||
}
|
||
|
||
/// 原型 .m-card:40 圆头像(.uav) + 姓名 + 账号(mono) + 角色徽章。
|
||
Widget _mUserCard(dynamic t, AppUser u) {
|
||
final name = u.realName?.isNotEmpty == true ? u.realName! : u.username;
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: Material(
|
||
color: t.surface,
|
||
clipBehavior: Clip.antiAlias,
|
||
shape: RoundedRectangleBorder(
|
||
side: BorderSide(color: t.border),
|
||
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||
),
|
||
child: InkWell(
|
||
onTap: () => _openUserSheet(u),
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
|
||
child: Row(children: [
|
||
Container(
|
||
width: 40,
|
||
height: 40,
|
||
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: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody,
|
||
fontWeight: FontWeight.w600,
|
||
color: t.heading)),
|
||
const SizedBox(height: 3),
|
||
Text(u.username,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsXs,
|
||
color: t.muted,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback)),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
_roleBadgeIc(u.role),
|
||
]),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 成员详情 sheet(原型 openU:drow 键值 + 重置密码/启停/编辑)。
|
||
void _openUserSheet(AppUser u) {
|
||
final name = u.realName?.isNotEmpty == true ? u.realName! : u.username;
|
||
showMSheet<void>(
|
||
context,
|
||
title: name,
|
||
builder: (ctx) {
|
||
final t = ctx.tokens;
|
||
Widget drow(String label, Widget value, {bool last = false}) =>
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||
decoration: BoxDecoration(
|
||
border: last
|
||
? null
|
||
: Border(bottom: BorderSide(color: t.borderSubtle)),
|
||
),
|
||
child: Row(children: [
|
||
Text(label,
|
||
style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)),
|
||
const Spacer(),
|
||
value,
|
||
]),
|
||
);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
drow(
|
||
'账号',
|
||
Text(u.username,
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody,
|
||
fontWeight: FontWeight.w600,
|
||
fontFamily: AppFonts.mono,
|
||
fontFamilyFallback: AppFonts.monoFallback,
|
||
color: t.text))),
|
||
drow('角色', _roleBadgeIc(u.role)),
|
||
drow(
|
||
'状态',
|
||
DsBadge(u.isActive ? '启用' : '停用',
|
||
tone: u.isActive ? DsBadgeTone.ok : DsBadgeTone.muted,
|
||
icon: statusIcon(u.isActive ? '启用' : '停用'))),
|
||
drow(
|
||
'最近登录',
|
||
Text(_fmtLast(u.lastLoginAt),
|
||
style: TextStyle(
|
||
fontSize: AppDims.fsBody,
|
||
fontWeight: FontWeight.w600,
|
||
color: t.text)),
|
||
last: true),
|
||
const SizedBox(height: 16),
|
||
WriteGuard(
|
||
child: Row(children: [
|
||
Expanded(
|
||
child: DsButton('重置密码', onPressed: () {
|
||
Navigator.of(ctx).pop();
|
||
_promptResetPassword(u);
|
||
}),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: DsButton(u.isActive ? '停用' : '启用', onPressed: () {
|
||
Navigator.of(ctx).pop();
|
||
_toggleActive(u);
|
||
}),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: DsButton('编辑', variant: DsBtnVariant.primary,
|
||
onPressed: () {
|
||
Navigator.of(ctx).pop();
|
||
_openUser(u);
|
||
}),
|
||
),
|
||
]),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 重置密码(详情 sheet 入口;提示框与表单内「重置密码」同口径)。
|
||
Future<void> _promptResetPassword(AppUser u) async {
|
||
final ctrl = TextEditingController();
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: Text('重置密码 · ${u.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(u.id, ctrl.text);
|
||
if (mounted) {
|
||
showDsToast(context, '密码已重置 ✓', bg: context.tokens.success);
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
showDsToast(context, '重置失败:$e', bg: context.tokens.danger);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _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]) {
|
||
// 窄屏:表单走底部 sheet(原型 m-users FAB → 表单弹层)
|
||
if (context.isMobile) {
|
||
showMSheet<void>(
|
||
context,
|
||
title: user == null ? '新增用户' : '编辑用户',
|
||
builder: (_) => _UserFormDialog(
|
||
user: user,
|
||
sheet: true,
|
||
onSaved: () => ref.read(userListProvider.notifier).reload(),
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
showAppDialog(
|
||
context: context,
|
||
builder: (ctx) => _UserFormDialog(
|
||
user: user,
|
||
onSaved: () => ref.read(userListProvider.notifier).reload(),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 新增/编辑用户弹窗(原型 #ov:mgrid 2 列 + 角色 2×2 rcard + 状态 switch)──
|
||
// [sheet]=true 时以 sheet 内容形态渲染(无 Dialog 壳、标题由 showMSheet 提供)。
|
||
class _UserFormDialog extends ConsumerStatefulWidget {
|
||
final AppUser? user;
|
||
final VoidCallback onSaved;
|
||
final bool sheet;
|
||
const _UserFormDialog({this.user, required this.onSaved, this.sheet = false});
|
||
|
||
@override
|
||
ConsumerState<_UserFormDialog> createState() => _UserFormDialogState();
|
||
}
|
||
|
||
class _UserFormDialogState extends ConsumerState<_UserFormDialog> {
|
||
final _formKey = GlobalKey<FormState>();
|
||
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<void> _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<void> _resetPassword() async {
|
||
final ctrl = TextEditingController();
|
||
final ok = await showDialog<bool>(
|
||
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;
|
||
final form = Form(
|
||
key: _formKey,
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// sheet 形态标题由 showMSheet 头部提供
|
||
if (!widget.sheet) ...[
|
||
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('保存'),
|
||
),
|
||
]),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
if (widget.sheet) return form;
|
||
return Dialog(
|
||
child: Container(
|
||
width: context.dialogWidth(520),
|
||
padding: const EdgeInsets.all(22),
|
||
child: form,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 原型 .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)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|