Files
jiu/client/lib/screens/settings/license_panel.dart
T
wangjia be9afcf68d feat(client): 移动壳基建——底部 5 tab + 我的 hub + 统一底部 sheet + 移动组件四件套
对齐移动原型体系(m-*.html)的 Stage 0 共享基建,桌面形态零改动:

- 导航:router 新增 branch 9(/me 我的 + /me/license 授权屏);窄屏 tab 根
  (库存/入库/出库/财务/我的)出底部 MTabBar,二级屏隐藏 tabbar + 顶栏返回箭头
  (PopScope 拦系统返回键防退 app);窄屏 Drawer/汉堡删除;窄屏顶栏对齐 m-top
  (标题 + 主题/通知铃/头像→我的)
- sheet:showMSheet(grip/标题/86vh/键盘避让)+ showAdaptiveSheet;六个共享
  弹层呈现函数加窄屏 sheet 分支(订单/往来/财务往来/商品编辑抽屉 + 退单/登记收支
  dialog 的 asSheet 形态)
- 徽章:DsBadge/StatusPill/StatusBadge 加图标变体(默认圆点零漂移)+
  status_icon_map 转录原型 BADGE_ICON 28 词
- 组件:MKpiGrid(2×2 可点 KPI)/ MSearchRow(搜索+状态钮+详搜钮)/
  MHubGroup(hub 入口组)/ showThemeSheet(主题外观 sheet)
- 授权:_LicensePanel/_SettingsCard 抽取为公共 LicensePanel/SettingsCard,
  窄屏授权跳转 /me/license
- golden:app_shell_mobile + me 各三主题(390×844@2x);全量 259 测试绿,
  check_ds_code / check-l1-sync 闸过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 11:56:04 +08:00

215 lines
8.0 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/license_panel.dart — 授权管理面板(自 settings_screen.dart 抽取为公共组件)。
// 四卡:授权信息 / 在线购买续费(PurchaseCard, admin only) / 兑换券 / 到期降级规则。
// 桌面:SettingsScreen 授权 tab 内嵌;移动:独立屏 LicenseScreen/me/license)复用。
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.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_fonts.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/clock.dart';
import '../../providers/license_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_toast.dart';
import '../../widgets/write_guard.dart';
import 'purchase_card.dart';
import 'settings_card.dart';
class LicensePanel extends ConsumerStatefulWidget {
const LicensePanel({super.key});
@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 role = ref.watch(authStateProvider.select((s) => s.user?.role));
final isAdmin = role == 'admin' || role == 'superadmin';
final expires = lic?.expiresAt != null
? DateFormat('yyyy-MM-dd').format(lic!.expiresAt!)
: '';
// 状态/剩余天数走可注入时钟(golden 确定化),不用 model 内的 DateTime.now()
final active = lic?.isActive == true &&
(lic?.expiresAt == null || lic!.expiresAt!.isAfter(appNow()));
final days = lic?.expiresAt != null
? lic!.expiresAt!.difference(appNow()).inDays.clamp(0, 99999)
: null;
final licCells = [
_licCell(t, '授权状态', active ? '已授权' : '未授权',
color: active ? t.success : t.warn),
_licCell(t, '到期日', expires),
_licCell(t, '剩余天数', days != null ? '$days' : ''),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// ── 卡1:授权信息(3 cell,自「关于我们」迁入)──
SettingsCard(
title: '授权信息',
desc: '当前门店的授权状态与有效期,续期时长在现有到期时间上叠加',
child: context.isMobile
? Column(children: [
for (final c in licCells)
Padding(
padding: const EdgeInsets.only(bottom: 10), child: c),
])
: Row(children: [
for (var i = 0; i < licCells.length; i++) ...[
if (i > 0) const SizedBox(width: 14),
Expanded(child: licCells[i]),
],
]),
),
const SizedBox(height: 16),
// ── 卡2:在线购买 / 续费(后端 purchase 接口 admin only
// 非管理员引导看官网价格页)──
if (isAdmin)
const SettingsCard(
title: '在线购买 / 续费',
desc: '选择套餐支付宝支付,到账后授权自动续期',
child: PurchaseCard(),
)
else
SettingsCard(
title: '在线购买 / 续费',
desc: '在线购买仅门店管理员可操作',
child: 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),
// ── 卡3:兑换券(原型 .redeeminput + 按钮,max 520)──
SettingsCard(
title: '兑换券',
desc: '输入兑换券短码续期',
child: 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: 16),
// ── 卡4:到期后的降级规则(现有能力保留)──
SettingsCard(
title: '到期后的降级规则',
desc: '授权到期后系统各功能的可用性说明',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final note in LicenseCopy.degradationNotes())
Padding(
padding: const EdgeInsets.only(top: 3),
child: Text('· $note',
style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)),
),
],
),
),
],
);
}
/// 原型 .lic-cell(与「关于我们」同款):lk 11 muted + lv 17/700/mono。
Widget _licCell(dynamic t, String label, String value, {Color? color}) =>
Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: t.bg,
border: Border.all(color: t.borderSubtle),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)),
const SizedBox(height: 6),
Text(value,
style: TextStyle(
fontSize: AppDims.fsH2,
fontWeight: FontWeight.w700,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback,
color: color ?? t.heading)),
],
),
);
}