// 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/license_copy.dart'; import '../../core/config/store_compliance.dart'; import '../../core/utils/web_launch.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 createState() => _LicensePanelState(); } class _LicensePanelState extends ConsumerState { final _voucherCtrl = TextEditingController(); bool _redeeming = false; @override void dispose() { _voucherCtrl.dispose(); super.dispose(); } Future _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: Row(children: [ for (var i = 0; i < licCells.length; i++) ...[ if (i > 0) SizedBox(width: context.isMobile ? 8 : 14), Expanded(child: licCells[i]), ], ]), ), const SizedBox(height: 16), // ── 卡2:在线购买 / 续费(后端 purchase 接口 admin only, // 非管理员引导看官网价格页)。iOS App Store 包整卡不渲染 ── if (!hideExternalPurchaseUi) ...[ 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( // 免登跳官网(一次性换票 SSO):浏览器落地即已登录,可直接购买 onTap: () => launchAuthedWebPath(ref, '/#pricing'), child: Text('查看套餐价格 →', style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w600, color: t.primary)), ), ]), ), const SizedBox(height: 16), ], // ── 卡3:兑换券(原型 .redeem:input + 按钮,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)), ), ], ), ), // ── 卡5:联系客服(仅 iOS 形态,补位购买卡;措辞保持中性, // 不得出现购买/续费引导字样——苹果 3.1.1 红线)── if (hideExternalPurchaseUi) ...[ const SizedBox(height: 16), SettingsCard( title: '联系客服', desc: '授权与使用问题可通过邮件联系我们', child: Row(children: [ Text('客服邮箱 · ', style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), InkWell( onTap: () => launchUrl(Uri(scheme: 'mailto', path: supportEmail)), child: Text(supportEmail, style: TextStyle( fontSize: AppDims.fsSm, fontWeight: FontWeight.w600, fontFamily: AppFonts.mono, color: t.primary)), ), ]), ), ], ], ); } /// 原型 .lic-cell(与「关于我们」同款):lk 11 muted + lv 17/700/mono。 Widget _licCell(dynamic t, String label, String value, {Color? color}) => Container( // 窄屏三格横排时收窄内距,值文本按格宽缩放防溢出(如 2026-06-25) padding: context.isMobile ? const EdgeInsets.fromLTRB(10, 12, 10, 12) : 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, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: AppDims.fsXs, color: t.muted)), const SizedBox(height: 6), FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text(value, style: TextStyle( fontSize: AppDims.fsH2, fontWeight: FontWeight.w700, fontFamily: AppFonts.mono, fontFamilyFallback: AppFonts.monoFallback, color: color ?? t.heading)), ), ], ), ); }