Files
jiu/client/lib/screens/settings/settings_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

979 lines
36 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 + 右面板卡)。子导航 5 项 =
// 原型 4(门店信息/用户管理/授权兑换券/偏好设置)+ 保留真实功能「编号规则」。
// 用户管理面板 = 预览表 + 「管理全部用户」→ /settings/users 独立页(原型 users.html)。
// 原「系统参数」假设置(本地 state 不落后端)已按用户口径删除。
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:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/auth/auth_state.dart';
import '../../core/config/app_info.dart';
import '../../core/config/license_copy.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/clock.dart';
import '../../core/utils/dialog_util.dart';
import '../../models/number_rule.dart';
import '../../models/shop.dart';
import '../../providers/license_provider.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/write_guard.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, license:3, pref:4}
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-ic07=trending-up / i-ic46=contrast(编号规则为 app 专属项,用 hash)
(LucideIcons.house, '门店信息'),
(LucideIcons.userPlus, '用户管理'),
(LucideIcons.hash, '编号规则'),
(LucideIcons.trendingUp, '授权兑换券'),
(LucideIcons.contrast, '偏好设置'),
];
late int _panel = widget.initialTab.clamp(0, _navs.length - 1);
@override
Widget build(BuildContext context) {
final t = context.tokens;
final mobile = context.isMobile;
return Container(
color: t.bg,
padding:
mobile ? EdgeInsets.zero : 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),
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)),
),
],
),
),
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()),
),
],
),
),
],
),
);
}
/// 原型 .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 _LicensePanel(),
4 => 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);
}
}
}),
],
),
);
}
}
/// 原型右侧 .cardsurface / 1px border / r-lg / padding 22 24 / mb18
/// h2 17 heading + desc 12 muted(可带右上角按钮)。
class _SettingsCard extends StatelessWidget {
final String title;
final String desc;
final Widget child;
final Widget? trailing;
const _SettingsCard(
{required this.title,
required this.desc,
required this.child,
this.trailing});
@override
Widget build(BuildContext context) {
final t = context.tokens;
return Container(
margin: const EdgeInsets.only(bottom: 18),
padding: const EdgeInsets.fromLTRB(24, 22, 24, 22),
decoration: BoxDecoration(
color: t.surface,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rLg),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
color: t.heading)),
const SizedBox(height: 3),
Text(desc,
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
],
),
),
if (trailing != null) trailing!,
]),
const SizedBox(height: 16),
child,
],
),
);
}
}
// ── 面板 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)),
]),
),
],
);
}
}
// ── 面板 4:授权兑换券 ───────────────────────────────────────
class _LicensePanel extends ConsumerStatefulWidget {
const _LicensePanel();
@override
ConsumerState<_LicensePanel> createState() => _LicensePanelState();
}
class _LicensePanelState extends ConsumerState<_LicensePanel> {
final _voucherCtrl = TextEditingController();
bool _redeeming = false;
@override
void dispose() {
_voucherCtrl.dispose();
super.dispose();
}
Future<void> _redeem() async {
final code = _voucherCtrl.text.trim();
if (code.isEmpty) {
showDsToast(context, '请输入兑换券短码');
return;
}
setState(() => _redeeming = true);
try {
await ref.read(licenseRepositoryProvider).activate(code);
ref.invalidate(licenseProvider);
_voucherCtrl.clear();
if (mounted) {
showDsToast(context, '兑换成功,已续期 ✓', bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '兑换失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _redeeming = false);
}
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final lic = ref.watch(licenseProvider).valueOrNull;
final expires = lic?.expiresAt != null
? DateFormat('yyyy-MM-dd').format(lic!.expiresAt!)
: '';
// 剩余天数走可注入时钟(golden 确定化),不用 model 内的 DateTime.now()
final days = lic?.expiresAt != null
? lic!.expiresAt!.difference(appNow()).inDays.clamp(0, 99999)
: null;
return _SettingsCard(
title: '授权兑换券',
desc: '本系统以「时长兑换券」授权,输入兑换券短码即可续期,无需订阅',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 原型 .lic 统计条:bg 底 / border / r-md / pad 18 20
Container(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(children: [
_stat(t, '授权到期日', expires),
const SizedBox(width: 24),
_stat(t, '剩余天数', days != null ? '剩余 $days' : '', warn: true),
const Spacer(),
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: t.okSoft,
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child:
Icon(LucideIcons.shieldCheck, size: 22, color: t.success),
),
]),
),
const SizedBox(height: 16),
// 兑换(原型 .redeeminput + 按钮,max 520
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('兑换券短码',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 6),
DsInput(
controller: _voucherCtrl,
hintText: '输入兑换券短码',
),
],
),
),
const SizedBox(width: 10),
WriteGuard(
child: DsButton(_redeeming ? '兑换中…' : '兑换续期',
variant: DsBtnVariant.primary,
onPressed: _redeeming ? null : _redeem),
),
],
),
),
const SizedBox(height: 14),
Row(children: [
Text('没有兑换券?',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
InkWell(
onTap: () => launchUrl(Uri.parse('${AppInfo.website}/#pricing')),
child: Text('购买授权时长 →',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.primary)),
),
]),
const SizedBox(height: 16),
// 过期降级说明(现有能力保留)
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.borderSubtle),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('到期后的降级规则',
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: t.heading)),
const SizedBox(height: 6),
for (final note in LicenseCopy.degradationNotes())
Padding(
padding: const EdgeInsets.only(top: 3),
child: Text('· $note',
style:
TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
],
),
),
],
),
);
}
Widget _stat(dynamic t, String label, String value, {bool warn = false}) =>
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
const SizedBox(height: 4),
Text(value,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: warn ? t.warn : t.heading)),
],
);
}
// ── 面板 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)),
],
),
),
);
}
}