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:
@@ -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(原型 openRulesSheet:notice + 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(原型 openWhSheet:m-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),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// 原型 .subnav:200px surface r-lg pad8;项高 40 gap10;on=brand50/primary/600。
|
||||
Widget _subnav(dynamic t) {
|
||||
return Container(
|
||||
@@ -359,6 +544,148 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 窄屏:门店信息 sheet 表单(逻辑同 _StoreInfoPanel,logo 上传窄屏省略)──
|
||||
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-field:label(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();
|
||||
|
||||
Reference in New Issue
Block a user