Files
jiu/client/lib/screens/settings/users_screen.dart
T
wangjia c9217601a1 feat(client): 全列表下拉刷新 + 库存移动工具行移除
- DsTable 窄屏卡片流支持 onRefresh(RefreshIndicator,空态也可下拉)——
  等价桌面工具栏「刷新」;库存/入库/出库/往来/财务流水/用户/基础数据
  全 tab 接线(基础数据下拉一并重拉五个 tab 数据源)
- 库存移动端「盘点/表格视图/导出/列设置/刷新」工具行移除(2026-07-04
  拍板:刷新改下拉手势、盘点入口在「我的」、其余桌面专属),
  库存移动 golden 随更新

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 23:52:06 +08:00

922 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// screens/settings/users_screen.dart — 用户管理独立页(照原型 users.html 重建)。
// head + KPI 4 卡(无图标)+ toolbar(搜索 + 角色筛选)+ 表格(头像/角色徽章/
// 启停)+ 新增/编辑弹窗(角色 2×2 单选卡 + 状态开关 + 重置密码)。
// 角色口径按后端四级:superadmin/admin/operator/readonly(原型三处不一致已拉平)。
// 窄屏(原型 m-users.html):隐藏页头/KPI/toolbarm-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]);
}
/// 原型 .uname34×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-fab52px 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-card40 圆头像(.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,
),
);
}
/// 原型 .rcard1.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)),
],
),
),
);
}
}