feat(client): 设置/用户/设备/关于窄屏对齐移动原型(hub 形态/卡片流/sheet/无打印)

- 系统设置窄屏改 hub 三组(门店/偏好/数据):门店信息/编号规则/默认仓库走
  底部 sheet,主题外观接 showThemeSheet,?tab= 深链窄屏忽略;桌面 subnav 不动
- 用户管理窄屏:m-section + 头像卡片流 + 角色图标徽章 + FAB 新增;
  点卡开详情 sheet(drow + 重置密码/启停/编辑),表单复用为 sheet 形态
- 设备管理窄屏:会话卡流(本机标注/在线图标徽章)+ 外设卡流(mc-foot 脚注),
  「+ 添加设备」在区块标题行右侧(m-pill 选择 sheet),打印模板窄屏不渲染
- 关于我们窄屏:品牌 hero + hub 链接组 + 产品信息 drow 卡 + 时间线 pill 标签
  + 意见反馈 sheet(类型 pill/描述/联系方式);授权信息不显示(已迁 /me/license)
- golden:四屏窄屏基准独立成 *_mobile_golden_test.dart(390×844 @2x 三主题),
  桌面 golden 零漂移

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-04 12:19:15 +08:00
parent dde87a32b6
commit 9445042cb4
24 changed files with 1975 additions and 311 deletions
+363 -36
View File
@@ -3,6 +3,9 @@
// 原型 4(门店信息/用户管理/授权管理/偏好设置)+ 保留真实功能「编号规则」。
// 用户管理面板 = 预览表 + 「管理全部用户」→ /settings/users 独立页(原型 users.html)。
// 原「系统参数」假设置(本地 state 不落后端)已按用户口径删除。
// 窄屏(原型 m-settings.html):hub 形态三组(门店/偏好/数据),标题在壳顶栏;
// 门店信息/编号规则/默认仓库走底部 sheet;?tab= 深链窄屏忽略(无面板可落)。
// 授权入口不在此(拍板:授权在「我的」系统组 /me/license)。
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -25,6 +28,9 @@ import '../../providers/warehouse_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_menu.dart';
import '../../widgets/ds/ds_table.dart';
import '../../widgets/ds/m_hub.dart';
import '../../widgets/ds/m_sheet.dart';
import '../../widgets/theme_sheet.dart';
import '../../widgets/write_guard.dart';
import 'license_panel.dart';
import 'settings_card.dart';
@@ -56,20 +62,17 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
@override
Widget build(BuildContext context) {
final t = context.tokens;
final mobile = context.isMobile;
// 窄屏:hub 形态(原型 m-settings.html);?tab= 深链在窄屏无面板可落,忽略
if (context.isMobile) return _mobileHub(t);
return Container(
color: t.bg,
padding:
mobile ? EdgeInsets.zero : const EdgeInsets.fromLTRB(26, 22, 26, 22),
padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── 头部(原型 .head)──
Padding(
padding: mobile
? const EdgeInsets.fromLTRB(
AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2)
: const EdgeInsets.only(bottom: 18),
padding: const EdgeInsets.only(bottom: 18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
@@ -87,42 +90,224 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
],
),
),
if (mobile)
Padding(
padding:
const EdgeInsets.fromLTRB(AppDims.sp4, 0, AppDims.sp4, 12),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DsSeg(
items: [for (final n in _navs) n.$2],
index: _panel,
onChanged: (i) => setState(() => _panel = i),
),
),
),
Expanded(
child: mobile
? SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
AppDims.sp4, 0, AppDims.sp4, AppDims.sp4),
child: _panelBody(),
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_subnav(t),
const SizedBox(width: 22),
Expanded(
child: SingleChildScrollView(child: _panelBody()),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_subnav(t),
const SizedBox(width: 22),
Expanded(
child: SingleChildScrollView(child: _panelBody()),
),
],
),
),
],
),
);
}
// ── 窄屏 hub(原型 m-settings.html:门店/偏好/数据 三组)─────────
Widget _mobileHub(dynamic t) {
return Container(
color: t.bg,
child: ListView(
padding: const EdgeInsets.all(14),
children: [
MHubGroup(label: '门店', items: [
// 原型 i-ic57(门店信息)→ house,与桌面 subnav 同源
MHubItem(
icon: LucideIcons.house, label: '门店信息', onTap: _openShopSheet),
MHubItem(
icon: LucideIcons.userPlus,
label: '用户与角色',
onTap: () => context.push('/settings/users')),
MHubItem(
icon: LucideIcons.hash, label: '编号规则', onTap: _openRulesSheet),
]),
MHubGroup(label: '偏好', items: [
MHubItem(
icon: LucideIcons.shirt,
label: '主题外观',
onTap: () => showThemeSheet(context)),
MHubItem(
icon: LucideIcons.warehouse,
label: '默认仓库',
onTap: _openWarehouseSheet),
MHubItem(
icon: LucideIcons.monitorSmartphone,
label: '设备管理',
onTap: () => context.go('/devices')),
]),
MHubGroup(label: '数据', items: [
MHubItem(
icon: LucideIcons.cloud,
label: '数据备份',
onTap: () => showDsToast(context, '云端每日自动备份,无需手动操作')),
MHubItem(
icon: LucideIcons.info,
label: '关于与版本',
onTap: () => context.go('/about')),
]),
],
),
);
}
/// 门店信息 sheet(原型 openShopSheet:名称/编号只读/电话/微信/地址 + 保存)。
void _openShopSheet() {
showMSheet<void>(
context,
title: '门店信息',
builder: (_) => const _ShopInfoSheetBody(),
);
}
/// 编号规则 sheet(原型 openRulesSheetnotice + drow 行;编辑走既有 dialog)。
void _openRulesSheet() {
showMSheet<void>(
context,
title: '编号规则',
builder: (_) => Consumer(builder: (ctx, ref, _) {
final t = ctx.tokens;
final async = ref.watch(numberRuleListProvider);
return async.when(
loading: () => const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator())),
error: (e, _) => Text('加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
data: (rules) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 原型 .notice.info
Container(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: t.infoSoft,
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Icon(LucideIcons.info, size: 16, color: t.primary),
const SizedBox(width: 8),
Expanded(
child: Text('单据编号自动生成,规则全店统一;序号按规则递增。',
style:
TextStyle(fontSize: AppDims.fsSm, color: t.text)),
),
]),
),
if (rules.isEmpty)
Text('暂无编号规则',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
for (var i = 0; i < rules.length; i++)
Container(
padding: const EdgeInsets.symmetric(vertical: 11),
decoration: BoxDecoration(
border: i == rules.length - 1
? null
: Border(bottom: BorderSide(color: t.borderSubtle)),
),
child: Row(children: [
Text(rules[i].typeLabel,
style: TextStyle(
fontSize: AppDims.fsBody, color: t.muted)),
const Spacer(),
Text(rules[i].exampleNo,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: t.text)),
WriteGuard(
child: Padding(
padding: const EdgeInsets.only(left: 10),
child: InkWell(
onTap: () => _editRule(rules[i]),
child: Icon(LucideIcons.pencil,
size: 15, color: t.muted),
),
),
),
]),
),
],
),
);
}),
);
}
/// 默认仓库 sheet(原型 openWhSheetm-opt 行选择,点选即保存)。
void _openWarehouseSheet() {
showMSheet<void>(
context,
title: '默认仓库',
builder: (_) => Consumer(builder: (ctx, ref, _) {
final t = ctx.tokens;
final readonly = WriteGuard.isReadonly(ref);
final warehouses =
ref.watch(warehouseListProvider).valueOrNull ?? const [];
if (warehouses.isEmpty) {
return Text('暂无仓库',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted));
}
return Column(children: [
for (var i = 0; i < warehouses.length; i++)
Builder(builder: (_) {
final w = warehouses[i];
return InkWell(
// 只读角色不可改默认仓库(与桌面 WriteGuard 同口径)
onTap: readonly
? null
: () async {
Navigator.of(ctx).pop();
if (w.isDefault) return;
try {
await ref
.read(warehouseListProvider.notifier)
.updateWarehouse(w.id, {'is_default': true});
if (mounted) {
showDsToast(context, '默认仓库:${w.name}',
bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '设置失败:$e',
bg: context.tokens.danger);
}
}
},
child: Container(
padding: const EdgeInsets.fromLTRB(4, 13, 4, 13),
decoration: BoxDecoration(
border: i == warehouses.length - 1
? null
: Border(bottom: BorderSide(color: t.borderSubtle)),
),
child: Row(children: [
Expanded(
child: Text(w.name,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: w.isDefault
? FontWeight.w600
: FontWeight.w400,
color: w.isDefault ? t.primary : t.text)),
),
if (w.isDefault)
Icon(LucideIcons.check, size: 18, color: t.primary),
]),
),
);
}),
]);
}),
);
}
/// 原型 .subnav200px surface r-lg pad8;项高 40 gap10on=brand50/primary/600。
Widget _subnav(dynamic t) {
return Container(
@@ -359,6 +544,148 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
}
}
// ── 窄屏:门店信息 sheet 表单(逻辑同 _StoreInfoPanellogo 上传窄屏省略)──
class _ShopInfoSheetBody extends ConsumerStatefulWidget {
const _ShopInfoSheetBody();
@override
ConsumerState<_ShopInfoSheetBody> createState() => _ShopInfoSheetBodyState();
}
class _ShopInfoSheetBodyState extends ConsumerState<_ShopInfoSheetBody> {
final _nameCtrl = TextEditingController();
final _phoneCtrl = TextEditingController();
final _wechatCtrl = TextEditingController();
final _addressCtrl = TextEditingController();
ShopInfo? _loaded;
bool _saving = false;
@override
void dispose() {
_nameCtrl.dispose();
_phoneCtrl.dispose();
_wechatCtrl.dispose();
_addressCtrl.dispose();
super.dispose();
}
void _fill(ShopInfo shop) {
_loaded = shop;
_nameCtrl.text = shop.name;
_phoneCtrl.text = shop.phone;
_wechatCtrl.text = shop.wechatId;
_addressCtrl.text = shop.address;
}
Future<void> _save() async {
final shop = _loaded;
if (shop == null) return;
setState(() => _saving = true);
try {
await ref.read(shopRepositoryProvider).updateInfo({
'name': _nameCtrl.text.trim(),
'address': _addressCtrl.text.trim(),
'phone': _phoneCtrl.text.trim(),
'manager_name': shop.managerName,
'wechat_id': _wechatCtrl.text.trim(),
if (shop.logoUrl.isNotEmpty) 'logo_url': shop.logoUrl,
'custom_fields': shop.customFields,
});
ref.invalidate(shopInfoProvider);
if (mounted) {
Navigator.of(context).pop();
showDsToast(context, '门店信息已保存 ✓', bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '保存失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final async = ref.watch(shopInfoProvider);
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
final canEdit = (role == 'admin' || role == 'superadmin') &&
!WriteGuard.isReadonly(ref) &&
!WriteGuard.licenseBlocked(ref);
return async.when(
loading: () => const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator())),
error: (e, _) => Text('加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
data: (shop) {
if (_loaded?.code != shop.code || _loaded?.name != shop.name) {
_fill(shop);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_field('门店名称', DsInput(controller: _nameCtrl, enabled: canEdit)),
_field(
'门店编号(不可改)',
DsInput(
controller: TextEditingController(text: shop.code),
enabled: false)),
_field('联系电话', DsInput(controller: _phoneCtrl, enabled: canEdit)),
_field(
'微信',
DsInput(
controller: _wechatCtrl,
enabled: canEdit,
hintText: '微信号 / 客服微信')),
_field(
'门店地址',
DsInput(
controller: _addressCtrl,
enabled: canEdit,
hintText: '省 / 市 / 详细地址')),
const SizedBox(height: 4),
// 原型底排:取消 ghost(1) + 保存 primary(2)
Row(children: [
Expanded(
child: DsButton(canEdit ? '取消' : '关闭',
onPressed: () => Navigator.of(context).pop()),
),
if (canEdit) ...[
const SizedBox(width: 10),
Expanded(
flex: 2,
child: DsButton(_saving ? '保存中…' : '保存',
variant: DsBtnVariant.primary,
onPressed: _saving ? null : _save),
),
],
]),
],
);
},
);
}
/// 原型 .m-fieldlabel(fs-sm muted) 上、间距 6、下距 14。
Widget _field(String label, Widget input) {
final t = context.tokens;
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 6),
input,
],
),
);
}
}
// ── 面板 1:门店信息(内联表单)──────────────────────────────
class _StoreInfoPanel extends ConsumerStatefulWidget {
const _StoreInfoPanel();
+383 -121
View File
@@ -2,6 +2,8 @@
// 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';
@@ -18,7 +20,8 @@ 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/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';
@@ -108,6 +111,8 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
),
),
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) {
@@ -171,7 +176,6 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
pagerInfoText: '${filtered.length} 名成员',
emptyText: '没有匹配的成员',
toolbar: _toolbar(mobile),
mobileCards: filtered.map(_userCard).toList(),
columns: const [
DsColumn('user', '用户'),
DsColumn('role', '角色'),
@@ -350,19 +354,260 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
);
}
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),
// ── 窄屏(原型 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
@@ -376,6 +621,19 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
}
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(
@@ -387,10 +645,12 @@ class _UsersScreenState extends ConsumerState<UsersScreen> {
}
// ── 新增/编辑用户弹窗(原型 #ov:mgrid 2 列 + 角色 2×2 rcard + 状态 switch)──
// [sheet]=true 时以 sheet 内容形态渲染(无 Dialog 壳、标题由 showMSheet 提供)。
class _UserFormDialog extends ConsumerStatefulWidget {
final AppUser? user;
final VoidCallback onSaved;
const _UserFormDialog({this.user, required this.onSaved});
final bool sheet;
const _UserFormDialog({this.user, required this.onSaved, this.sheet = false});
@override
ConsumerState<_UserFormDialog> createState() => _UserFormDialogState();
@@ -507,118 +767,120 @@ class _UserFormDialogState extends ConsumerState<_UserFormDialog> {
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(
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('保存'),
),
]),
],
),
),
),
child: form,
),
);
}