Files
jiu/client/lib/screens/settings/settings_screen.dart
T

1069 lines
40 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/settings_screen.dart — 系统设置(照原型 settings.html 重建)。
// 布局:.head + .settings grid(左 .subnav 200px + 右面板卡)。子导航 4 项 =
// 原型 3(门店信息/用户管理/偏好设置)+ 保留真实功能「编号规则」。
// 用户管理面板 = 预览表 + 「管理全部用户」→ /settings/users 独立页(原型 users.html)。
// 原「系统参数」假设置(本地 state 不落后端)已按用户口径删除。
// 窄屏(原型 m-settings.html):hub 形态三组(门店/偏好/数据),标题在壳顶栏;
// 门店信息/编号规则/默认仓库走底部 sheet;?tab= 深链窄屏忽略(无面板可落)。
// 授权管理 2026-07-10 提升为独立一级屏(桌面侧边栏「系统」组 /license,
// 移动「我的」系统组 /me/license),已从本屏子导航摘除。
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/auth/auth_state.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/app_tokens.g.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/theme/theme_controller.dart';
import '../../core/utils/dialog_util.dart';
import '../../models/number_rule.dart';
import '../../models/shop.dart';
import '../../providers/number_rule_provider.dart';
import '../../providers/shop_provider.dart';
import '../../providers/user_provider.dart';
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 'settings_card.dart';
import 'users_screen.dart' show userRoleBadge, userStatusBadge;
import '../../core/theme/app_fonts.dart';
import '../../widgets/ds/ds_toast.dart';
class SettingsScreen extends ConsumerStatefulWidget {
/// 初始面板:{shop:0, users:1, number:2, pref:3}
final int initialTab;
const SettingsScreen({super.key, this.initialTab = 0});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
static const _navs = [
// 图标对齐原型 subnav spritei-ic44=house / i-ic45=user-plus /
// i-ic46=contrast(编号规则为 app 专属项,用 hash)
(LucideIcons.house, '门店信息'),
(LucideIcons.userPlus, '用户管理'),
(LucideIcons.hash, '编号规则'),
(LucideIcons.contrast, '偏好设置'),
];
late int _panel = widget.initialTab.clamp(0, _navs.length - 1);
@override
Widget build(BuildContext context) {
final t = context.tokens;
// 窄屏:hub 形态(原型 m-settings.html);?tab= 深链在窄屏无面板可落,忽略
if (context.isMobile) return _mobileHub(t);
return Container(
color: t.bg,
padding: const EdgeInsets.fromLTRB(26, 22, 26, 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── 头部(原型 .head)──
Padding(
padding: 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)),
),
],
),
),
Expanded(
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(
width: 200,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < _navs.length; i++)
InkWell(
onTap: () => setState(() => _panel = i),
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: i == _panel ? t.brand50 : null,
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Icon(_navs[i].$1,
size: 16, color: i == _panel ? t.primary : t.muted),
const SizedBox(width: 10),
Text(_navs[i].$2,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight:
i == _panel ? FontWeight.w600 : FontWeight.w400,
color: i == _panel ? t.primary : t.text)),
]),
),
),
],
),
);
}
Widget _panelBody() => switch (_panel) {
0 => const _StoreInfoPanel(),
1 => _usersPanel(),
2 => _numberPanel(),
3 => const _PrefPanel(),
_ => const SizedBox(),
};
// ── 面板 2:用户管理(预览表 + 独立页入口)──────────────────
Widget _usersPanel() {
final t = context.tokens;
final async = ref.watch(userListProvider);
return SettingsCard(
title: '用户管理',
desc: '管理门店成员与权限角色',
trailing: DsButton('管理全部用户',
small: true,
icon: LucideIcons.plus,
variant: DsBtnVariant.primary,
onPressed: () => context.push('/settings/users')),
child: 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: (users) {
final preview = users.take(4).toList();
return DsTable(
shrinkWrap: true,
emptyText: '暂无成员',
columns: const [
DsColumn('user', '用户'),
DsColumn('role', '角色'),
DsColumn('status', '状态'),
DsColumn('actions', '操作', action: true),
],
rows: preview.map((u) {
return DsRow(cells: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
u.realName?.isNotEmpty == true
? u.realName!
: u.username,
style: TextStyle(
fontWeight: FontWeight.w600, color: t.heading)),
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(u.username,
style: TextStyle(
fontSize: AppDims.fsXs,
color: t.faint,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
),
],
),
userRoleBadge(u.role),
userStatusBadge(u.isActive),
InkWell(
onTap: () => context.push('/settings/users'),
child: Icon(LucideIcons.pencil, size: 16, color: t.muted),
),
]);
}).toList(),
);
},
),
);
}
// ── 面板 3:编号规则(保留真实功能,DsTable 版式)────────────
Widget _numberPanel() {
final t = context.tokens;
final async = ref.watch(numberRuleListProvider);
return SettingsCard(
title: '编号规则',
desc: '单据编号的前缀 / 日期格式 / 序号',
child: async.when(
loading: () => const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator())),
error: (e, _) => Row(children: [
Text('加载失败',
style: TextStyle(color: t.muted, fontSize: AppDims.fsSm)),
const SizedBox(width: 10),
DsButton('重试',
small: true,
onPressed: () =>
ref.read(numberRuleListProvider.notifier).reload()),
]),
data: (rules) => DsTable(
shrinkWrap: true,
emptyText: '暂无编号规则',
columns: const [
DsColumn('type', '单据类型'),
DsColumn('prefix', '前缀'),
DsColumn('date', '日期格式'),
DsColumn('example', '下一编号示例'),
DsColumn('no', '当前序号', numeric: true),
DsColumn('actions', '操作', action: true),
],
rows: rules.map((r) {
return DsRow(cells: [
Text(r.typeLabel,
style:
TextStyle(fontWeight: FontWeight.w600, color: t.heading)),
Text(r.prefix,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
Text(r.dateFormat,
style: TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
fontSize: AppDims.fsSm,
color: t.muted)),
Text(r.exampleNo,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
Text('${r.currentNo}',
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
WriteGuard(
child: InkWell(
onTap: () => _editRule(r),
child: Icon(LucideIcons.pencil, size: 16, color: t.muted),
),
),
]);
}).toList(),
),
),
);
}
void _editRule(NumberRule r) {
final prefixCtrl = TextEditingController(text: r.prefix);
final noCtrl = TextEditingController(text: '${r.currentNo}');
showAppDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text('编辑规则 · ${r.typeLabel}'),
content: SizedBox(
width: ctx.dialogWidth(360),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('前缀',
style: TextStyle(
fontSize: AppDims.fsSm, color: ctx.tokens.muted)),
const SizedBox(height: 6),
DsInput(controller: prefixCtrl),
const SizedBox(height: 14),
Text('当前序号',
style: TextStyle(
fontSize: AppDims.fsSm, color: ctx.tokens.muted)),
const SizedBox(height: 6),
DsInput(controller: noCtrl, keyboardType: TextInputType.number),
],
),
),
actions: [
DsButton('取消', onPressed: () => Navigator.of(ctx).pop()),
DsButton('保存', variant: DsBtnVariant.primary, onPressed: () async {
Navigator.of(ctx).pop();
try {
await ref.read(numberRuleListProvider.notifier).updateRule(r.id, {
'prefix': prefixCtrl.text.trim(),
'current_no': int.tryParse(noCtrl.text) ?? r.currentNo,
});
if (mounted) {
showDsToast(context, '规则已更新 ✓', bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '保存失败:$e', bg: context.tokens.danger);
}
}
}),
],
),
);
}
}
// ── 窄屏:门店信息 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();
@override
ConsumerState<_StoreInfoPanel> createState() => _StoreInfoPanelState();
}
class _StoreInfoPanelState extends ConsumerState<_StoreInfoPanel> {
final _nameCtrl = TextEditingController();
final _phoneCtrl = TextEditingController();
final _wechatCtrl = TextEditingController();
final _addressCtrl = TextEditingController();
ShopInfo? _loaded;
bool _saving = false;
bool _uploadingLogo = 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) {
showDsToast(context, '门店信息已保存 ✓', bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '保存失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _uploadLogo() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
withData: true,
);
final file = result?.files.firstOrNull;
if (file == null || file.bytes == null || !mounted) return;
setState(() => _uploadingLogo = true);
try {
await ref.read(shopRepositoryProvider).uploadLogo(file.bytes!, file.name);
ref.invalidate(shopInfoProvider);
if (mounted) {
showDsToast(context, 'Logo 已更新 ✓', bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '上传失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _uploadingLogo = 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) &&
// 授权过期 >7 天同样禁编辑(后端 LicenseGuard 会 403,这里不误导)
!WriteGuard.licenseBlocked(ref);
return SettingsCard(
title: '门店信息',
desc: '用于单据抬头、对账与多端展示',
child: 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 ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 原型 .form-grid 2 列 gap16
_formRow([
_field(
'门店名称', DsInput(controller: _nameCtrl, enabled: canEdit)),
_field(
'门店编号',
DsInput(
controller: TextEditingController(text: shop.code),
enabled: false),
hint: '系统分配,不可修改',
),
]),
_formRow([
_field('联系电话',
DsInput(controller: _phoneCtrl, enabled: canEdit)),
_field(
'微信',
DsInput(
controller: _wechatCtrl,
enabled: canEdit,
hintText: '微信号 / 客服微信')),
]),
_field(
'门店地址',
DsInput(
controller: _addressCtrl,
enabled: canEdit,
hintText: '省 / 市 / 详细地址')),
const SizedBox(height: 14),
// Logo(现有能力保留)
Row(children: [
Container(
width: 46,
height: 46,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: shop.logoUrl.isNotEmpty
? Image.network(shop.logoUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(LucideIcons.image, color: t.faint))
: Icon(LucideIcons.image, size: 18, color: t.faint),
),
const SizedBox(width: 12),
if (canEdit)
DsButton(_uploadingLogo ? '上传中…' : '更换 Logo',
small: true,
icon: LucideIcons.upload,
onPressed: _uploadingLogo ? null : _uploadLogo),
]),
const SizedBox(height: 18),
if (canEdit)
Row(children: [
DsButton(_saving ? '保存中…' : '保存',
variant: DsBtnVariant.primary,
onPressed: _saving ? null : _save),
const SizedBox(width: 10),
DsButton('撤销', onPressed: () {
_fill(shop);
setState(() {});
showDsToast(context, '已撤销修改');
}),
]),
],
),
);
},
),
);
}
Widget _formRow(List<Widget> fields) => Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < fields.length; i++) ...[
if (i > 0) const SizedBox(width: 16),
Expanded(child: fields[i]),
],
],
),
);
Widget _field(String label, Widget input, {String? hint}) {
final t = context.tokens;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 6),
input,
if (hint != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(children: [
// 原型 i-ic03 = lock(系统分配,不可修改)
Icon(LucideIcons.lock, size: 12, color: t.faint),
const SizedBox(width: 4),
Text(hint,
style: TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
]),
),
],
);
}
}
// ── 面板 5:偏好设置(主题三卡 + 默认仓库)───────────────────
class _PrefPanel extends ConsumerWidget {
const _PrefPanel();
// (key, 名称, 说明)——原型 tcard 文案
static const _themes = [
('a', 'A 经典蓝', '浅色 · 默认'),
('b', 'B 琥珀', '深色'),
('c', 'C 酒窖', '暖浅色'),
];
@override
Widget build(BuildContext context, WidgetRef ref) {
final t = context.tokens;
final current = ref.watch(themeControllerProvider);
final warehouses = ref.watch(warehouseListProvider).valueOrNull ?? const [];
final defaultWh = warehouses.where((w) => w.isDefault).firstOrNull;
return SettingsCard(
title: '偏好设置',
desc: '主题与默认值,仅影响当前账号在本端的体验',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('主题选择',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 620),
child: context.isMobile
? Column(children: [
for (final th in _themes)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _tcard(context, ref, th, current == th.$1),
),
])
: Row(children: [
for (var i = 0; i < _themes.length; i++) ...[
if (i > 0) const SizedBox(width: 14),
Expanded(
child: _tcard(context, ref, _themes[i],
current == _themes[i].$1)),
],
]),
),
const SizedBox(height: 18),
Text('默认仓库',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Builder(
builder: (bctx) => WriteGuard(
child: InkWell(
onTap: () async {
final v = await showDsMenu<int>(bctx, items: [
for (final w in warehouses)
DsMenuItem(
value: w.id, label: w.name, selected: w.isDefault),
]);
if (v == null) return;
try {
await ref
.read(warehouseListProvider.notifier)
.updateWarehouse(v, {'is_default': true});
if (bctx.mounted) {
showDsToast(bctx, '默认仓库已更新 ✓');
}
} catch (e) {
if (bctx.mounted) {
showDsToast(bctx, '设置失败:$e');
}
}
},
borderRadius: BorderRadius.circular(AppDims.rMd),
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
Expanded(
child: Text(defaultWh?.name ?? '选择默认仓库…',
style: TextStyle(
fontSize: AppDims.fsBody,
color:
defaultWh != null ? t.text : t.faint))),
Icon(LucideIcons.chevronDown, size: 14, color: t.faint),
]),
),
),
),
),
),
],
),
);
}
/// 原型 .tcard1.5px 边 r-lg pad14;选中 primary 边 + brand50 ring
/// swatch 高 54 四色等分(primary/primary-dark/bg/accent+ 名称/说明 + 勾。
Widget _tcard(BuildContext context, WidgetRef ref,
(String, String, String) theme, bool on) {
final t = context.tokens;
final preview = appTokensOf(theme.$1);
return InkWell(
onTap: () {
ref.read(themeControllerProvider.notifier).set(theme.$1);
showDsToast(context, '已切换为 ${theme.$2} 主题');
},
borderRadius: BorderRadius.circular(AppDims.rLg),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: on ? t.primary : t.border, width: 1.5),
borderRadius: BorderRadius.circular(AppDims.rLg),
boxShadow: on ? [BoxShadow(color: t.brand50, spreadRadius: 3)] : null,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// .swatch 高54 四色等分
ClipRRect(
borderRadius: BorderRadius.circular(AppDims.rSm),
child: SizedBox(
height: 54,
child: Row(children: [
Expanded(child: Container(color: preview.primary)),
Expanded(child: Container(color: preview.primaryDark)),
Expanded(child: Container(color: preview.page)),
Expanded(child: Container(color: preview.accent)),
]),
),
),
const SizedBox(height: 8),
Row(children: [
Expanded(
child: Text(theme.$2,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.heading)),
),
if (on) Icon(LucideIcons.check, size: 14, color: t.primary),
]),
Text(theme.$3,
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
],
),
),
);
}
}