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
This commit is contained in:
wangjia
2026-07-03 09:58:14 +08:00
parent ca7595b113
commit 6238b86dcb
286 changed files with 16831 additions and 11136 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,657 @@
// 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)),
],
),
),
);
}
}