Files
jiu/client/lib/screens/settings/users_screen.dart
T
wangjia 6238b86dcb feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 09:58:14 +08:00

658 lines
23 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(原型三处不一致已拉平)。
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<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) {
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<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),
),
),
);
}
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<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]) {
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<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;
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('保存'),
),
]),
],
),
),
),
),
);
}
/// 原型 .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)),
],
),
),
);
}
}