diff --git a/client/lib/core/router/app_router.dart b/client/lib/core/router/app_router.dart index e7df328..d561660 100644 --- a/client/lib/core/router/app_router.dart +++ b/client/lib/core/router/app_router.dart @@ -21,7 +21,7 @@ import '../../screens/settings/users_screen.dart'; import '../../screens/about/about_screen.dart'; import '../../screens/devices/device_management_screen.dart'; import '../../screens/me/me_screen.dart'; -import '../../screens/me/license_screen.dart'; +import '../../screens/license/license_screen.dart'; import '../auth/auth_state.dart'; Page _noTransition(Widget child) => NoTransitionPage(child: child); @@ -184,7 +184,8 @@ final appRouterProvider = Provider((ref) { pageBuilder: (_, __) => _noTransition(const DeviceManagementScreen())), ]), - // 7 系统设置(面板:门店信息/用户管理/编号规则/授权兑换券/偏好设置) + // 7 系统设置(面板:门店信息/用户管理/编号规则/偏好设置; + // 授权管理 2026-07-10 提升为独立一级屏,见 branch 10 `/license`) StatefulShellBranch(routes: [ GoRoute( path: '/settings', @@ -193,8 +194,7 @@ final appRouterProvider = Provider((ref) { 'shop': 0, 'users': 1, 'number': 2, - 'license': 3, - 'pref': 4, + 'pref': 3, }; final tab = tabIndex[state.uri.queryParameters['tab']] ?? 0; return _noTransition(SettingsScreen(initialTab: tab)); @@ -215,11 +215,19 @@ final appRouterProvider = Provider((ref) { GoRoute( path: '/me', pageBuilder: (_, __) => _noTransition(const MeScreen())), - // 授权管理独立屏(原型 m-license.html,从「我的」系统组进入) + // 授权管理独立屏(移动形态,原型 m-license.html,从「我的」系统组进入); + // 同一 LicenseScreen widget,内部按 context.isMobile 切两种布局。 GoRoute( path: '/me/license', pageBuilder: (_, __) => _noTransition(const LicenseScreen())), ]), + // 10 授权管理(桌面独立一级屏,原型 license.html,侧边栏「系统」组); + // 追加在末尾以不打乱既有分支序号(0-9 全部不变)。 + StatefulShellBranch(routes: [ + GoRoute( + path: '/license', + pageBuilder: (_, __) => _noTransition(const LicenseScreen())), + ]), ], ), ], diff --git a/client/lib/providers/license_purchase_provider.dart b/client/lib/providers/license_purchase_provider.dart new file mode 100644 index 0000000..d53fce8 --- /dev/null +++ b/client/lib/providers/license_purchase_provider.dart @@ -0,0 +1,79 @@ +// providers/license_purchase_provider.dart — 授权购买订单列表(订单管理 tab)。 +// 后端 GET /license/purchases 只支持 page/page_size/status 三个查询轴; +// 套餐/关键词/下单时间筛选在列表屏做客户端过滤(当前页),与 stock_out 列表 +// 屏「仓库」筛选同一established 口径(server 端无对应参数)。 +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/auth/auth_state.dart'; +import '../models/license.dart'; +import 'license_provider.dart' show licenseRepositoryProvider; + +final purchaseListProvider = + AsyncNotifierProvider( + PurchaseListNotifier.new, +); + +class PurchaseListNotifier extends AsyncNotifier { + int _page = 1; + int _pageSize = 10; + String _status = ''; // '' | pending | paid | failed + PurchaseListResult? _cache; + + @override + Future build() async { + ref.watch(authStateProvider.select((s) => s.user?.shopId)); + try { + final result = await _fetch(); + _cache = result; + return result; + } catch (_) { + if (_cache != null) return _cache!; + rethrow; + } + } + + Future _fetch() { + return ref.read(licenseRepositoryProvider).listPurchases( + page: _page, + pageSize: _pageSize, + status: _status.isEmpty ? null : _status, + ); + } + + int get page => _page; + int get pageSize => _pageSize; + String get currentStatus => _status; + + void reload() { + // 保留上一次数据(isReloading)→ 屏幕可继续渲染旧内容 + 叠加半透明进度,不白屏。 + state = const AsyncValue.loading() + .copyWithPrevious(state); + _fetch().then((result) { + _cache = result; + state = AsyncValue.data(result); + }, onError: (Object e, StackTrace st) { + if (_cache != null) { + state = AsyncValue.data(_cache!); + } else { + state = AsyncValue.error(e, st); + } + }); + } + + void setPage(int p) { + _page = p; + reload(); + } + + void setPageSize(int size) { + _pageSize = size; + _page = 1; + reload(); + } + + void setStatus(String status) { + _status = status; + _page = 1; + reload(); + } +} diff --git a/client/lib/screens/license/license_orders_tab.dart b/client/lib/screens/license/license_orders_tab.dart new file mode 100644 index 0000000..944b9f8 --- /dev/null +++ b/client/lib/screens/license/license_orders_tab.dart @@ -0,0 +1,926 @@ +// screens/license/license_orders_tab.dart — 授权购买订单管理 tab +// (原型 license.html #tab-orders / m-license.html #tab-orders)。 +// 数据源:providers/license_purchase_provider.dart。后端 GET /license/purchases +// 只接受 page/page_size/status 三个查询轴;套餐/关键词/下单时间筛选走客户端 +// 过滤(当前页),与 stock_out 列表「仓库」筛选同一 established 口径。 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../core/exceptions.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 '../../core/utils/money.dart'; +import '../../models/license.dart'; +import '../../providers/license_provider.dart' + show licenseProvider, licenseRepositoryProvider; +import '../../providers/license_purchase_provider.dart'; +import '../../widgets/order_detail_drawer.dart' show showOrderDetailDrawer; +import '../../widgets/ds/ds_atoms.dart'; +import '../../widgets/ds/ds_kpi.dart'; +import '../../widgets/ds/ds_menu.dart'; +import '../../widgets/ds/ds_table.dart'; +import '../../widgets/ds/ds_toast.dart'; +import '../../widgets/ds/m_card.dart'; +import '../../widgets/ds/m_kpi_grid.dart'; +import '../../widgets/ds/m_sheet.dart'; +import '../../widgets/ds/status_icon_map.dart' show statusIcon; +import '../../widgets/wheel_date_picker.dart' show showDateRangeDropdown; +import '../../widgets/write_guard.dart'; + +// bizCode → (展示名, 授权天数):与 core/config/license_plans.dart 的套餐表同源。 +const Map _kPlanLabels = { + 'promo_first_month': ('首月特惠 · 30 天', 30), + 'monthly_standard': ('标准版 · 月付', 30), + 'annual_standard': ('标准版 · 年付', 365), + 'monthly_pro': ('高级版 · 月付', 30), + 'annual_pro': ('高级版 · 年付', 365), +}; +String _planLabel(String bizCode) => _kPlanLabels[bizCode]?.$1 ?? bizCode; +int? _planDays(String bizCode) => _kPlanLabels[bizCode]?.$2; + +// 状态三态与后端对齐:pending=待支付 / paid=已支付 / failed=已关闭(取消·超时·失败归并)。 +const _kStatusLabels = {'pending': '待支付', 'paid': '已支付', 'failed': '已关闭'}; +String _statusLabel(String status) => _kStatusLabels[status] ?? status; +DsBadgeTone _statusTone(String status) => switch (status) { + 'paid' => DsBadgeTone.ok, + 'pending' => DsBadgeTone.warn, + _ => DsBadgeTone.muted, + }; + +class LicenseOrdersTab extends ConsumerStatefulWidget { + /// 详情面板「查看授权」→ 切回授权信息 tab。 + final VoidCallback onGoInfo; + + /// 详情面板「重新购买」→ 切到购买续费 tab;null 表示购买 tab 当前不可用 + /// (iOS 合规态整 tab 隐藏 / 非管理员),此时不渲染该按钮。 + final VoidCallback? onGoBuy; + + const LicenseOrdersTab({super.key, required this.onGoInfo, this.onGoBuy}); + + @override + ConsumerState createState() => _LicenseOrdersTabState(); +} + +class _LicenseOrdersTabState extends ConsumerState { + final _searchCtrl = TextEditingController(); + String _query = ''; + String _plan = ''; // '' = 全部,否则 bizCode + String _status = ''; // '' = 全部,否则 pending/paid/failed(同步服务端) + DateTimeRange? _dateRange; + String _datePresetLabel = ''; + + @override + void initState() { + super.initState(); + _status = ref.read(purchaseListProvider.notifier).currentStatus; + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + bool get _hasAnyFilter => + _query.isNotEmpty || + _plan.isNotEmpty || + _status.isNotEmpty || + _dateRange != null; + + void _resetFilters() { + setState(() { + _searchCtrl.clear(); + _query = ''; + _plan = ''; + _status = ''; + _dateRange = null; + _datePresetLabel = ''; + }); + ref.read(purchaseListProvider.notifier).setStatus(''); + } + + void _setStatus(String status) { + setState(() => _status = status); + ref.read(purchaseListProvider.notifier).setStatus(status); + } + + List _filtered(List items) { + return items.where((r) { + if (_plan.isNotEmpty && r.productBizCode != _plan) return false; + if (_dateRange != null) { + final d = r.createdAt; + if (d == null) return false; + final day = DateTime(d.year, d.month, d.day); + final start = DateTime(_dateRange!.start.year, _dateRange!.start.month, + _dateRange!.start.day); + final end = DateTime( + _dateRange!.end.year, _dateRange!.end.month, _dateRange!.end.day); + if (day.isBefore(start) || day.isAfter(end)) return false; + } + if (_query.isNotEmpty) { + final q = _query.toLowerCase(); + if (!r.outTradeNo.toLowerCase().contains(q) && + !_planLabel(r.productBizCode).toLowerCase().contains(q)) { + return false; + } + } + return true; + }).toList(); + } + + @override + Widget build(BuildContext context) { + final async = ref.watch(purchaseListProvider); + return async.when( + skipLoadingOnReload: true, + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(LucideIcons.cloudOff, size: 40, color: context.tokens.muted), + const SizedBox(height: 12), + Text('加载失败:$e', + style: TextStyle(color: context.tokens.muted), + textAlign: TextAlign.center), + const SizedBox(height: 12), + DsButton('重试', + variant: DsBtnVariant.primary, + onPressed: () => + ref.read(purchaseListProvider.notifier).reload()), + ], + ), + ), + data: (result) => Stack(children: [ + _buildLoaded(result), + if (async.isLoading) const Positioned.fill(child: DsLoadingScrim()), + ]), + ); + } + + Widget _buildLoaded(PurchaseListResult result) { + final mobile = context.isMobile; + final rows = _filtered(result.items); + final notifier = ref.read(purchaseListProvider.notifier); + if (mobile) { + return Column( + children: [ + _buildMobileKpis(result.summary), + _buildMobileSearchRow(), + _buildMobileSection(rows.length), + Expanded( + child: DsTable( + total: result.total, + page: notifier.page, + pageSize: notifier.pageSize, + onPageChanged: (p) => notifier.setPage(p), + onPageSizeChanged: (s) => notifier.setPageSize(s), + emptyText: '没有匹配的授权订单', + columns: _columns, + rows: const [], + mobileCards: rows.map(_orderCard).toList(), + onRefresh: () async { + notifier.reload(); + await Future.delayed(const Duration(milliseconds: 600)); + }, + ), + ), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildKpis(result.summary), + const SizedBox(height: 20), + Expanded( + child: DsTable( + total: result.total, + page: notifier.page, + pageSize: notifier.pageSize, + onPageChanged: (p) => notifier.setPage(p), + onPageSizeChanged: (s) => notifier.setPageSize(s), + toolbar: _buildToolbar(), + emptyText: '没有匹配的授权订单 · 试试调整筛选或搜索', + columns: _columns, + rows: rows.map(_row).toList(), + ), + ), + ], + ); + } + + // ── KPI(原型 .kpis 4 卡)──────────────────────────────────────── + Widget _buildKpis(PurchaseSummary summary) { + final lic = ref.watch(licenseProvider).valueOrNull; + final expires = lic?.expiresAt != null + ? DateFormat('yyyy-MM-dd').format(lic!.expiresAt!) + : '—'; + final days = lic?.expiresAt != null + ? lic!.expiresAt!.difference(appNow()).inDays.clamp(0, 99999) + : null; + final cards = [ + DsKpi( + title: '当前授权 · ${lic?.typeLabel ?? "—"}', + value: expires, + icon: LucideIcons.shieldCheck, + tone: DsKpiTone.ok, + delta: days != null ? '剩余 $days 天 · 续期自动叠加' : '未激活', + deltaTone: DsKpiDelta.up, + ), + DsKpi( + title: '累计购买金额', + value: '¥${yuanFromMinor(summary.paidTotalMinor)}', + icon: LucideIcons.banknote, + tone: DsKpiTone.info, + delta: '已支付 ${summary.paidCount} 笔', + ), + DsKpi( + title: '订单总数', + value: '${summary.totalCount}', + icon: LucideIcons.receiptText, + tone: DsKpiTone.info, + ), + DsKpi( + title: '待支付 · 点击筛选', + value: '${summary.pendingCount}', + icon: LucideIcons.clock, + tone: DsKpiTone.warn, + delta: summary.pendingCount > 0 ? '未支付订单 2 小时后自动关闭' : '点击筛选', + onTap: () => _setStatus(_status == 'pending' ? '' : 'pending'), + ), + ]; + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < cards.length; i++) ...[ + if (i > 0) const SizedBox(width: AppDims.sp3), + Expanded(child: cards[i]), + ], + ], + ), + ); + } + + Widget _buildMobileKpis(PurchaseSummary summary) { + final lic = ref.watch(licenseProvider).valueOrNull; + final expires = lic?.expiresAt != null + ? DateFormat('yyyy-MM-dd').format(lic!.expiresAt!) + : '—'; + final days = lic?.expiresAt != null + ? lic!.expiresAt!.difference(appNow()).inDays.clamp(0, 99999) + : null; + return Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: MKpiGrid(items: [ + MKpiItem( + label: '当前授权 · ${lic?.typeLabel ?? "—"}', + value: expires, + icon: LucideIcons.shieldCheck, + delta: days != null ? '剩余 $days 天' : '未激活', + deltaTone: MKpiDeltaTone.up, + ), + MKpiItem( + label: '累计购买金额', + value: '¥${yuanFromMinor(summary.paidTotalMinor)}', + icon: LucideIcons.banknote, + delta: '已支付 ${summary.paidCount} 笔', + ), + MKpiItem( + label: '待支付 · 点击筛选', + value: '${summary.pendingCount}', + icon: LucideIcons.clock, + delta: summary.pendingCount > 0 ? '2 小时后自动关闭' : '点击筛选', + deltaTone: summary.pendingCount > 0 + ? MKpiDeltaTone.warn + : MKpiDeltaTone.normal, + selected: _status == 'pending', + onTap: () => _setStatus(_status == 'pending' ? '' : 'pending'), + ), + MKpiItem( + label: '订单总数', + value: '${summary.totalCount}', + icon: LucideIcons.receiptText, + ), + ]), + ); + } + + // ── 桌面工具栏(原型 .toolbar)─────────────────────────────────── + Widget _buildToolbar() { + final searchField = SizedBox( + width: 260, + child: DsSearchBox( + controller: _searchCtrl, + hint: '搜索订单号 / 套餐', + onChanged: (v) => setState(() => _query = v.trim()), + ), + ); + + final planChip = Builder( + builder: (anchorCtx) => DsChip( + label: '套餐', + value: _plan.isEmpty ? null : _planLabel(_plan), + onTap: () async { + final picked = await showDsMenu(anchorCtx, items: [ + DsMenuItem(value: '', label: '全部', selected: _plan.isEmpty), + for (final code in _kPlanLabels.keys) + DsMenuItem( + value: code, + label: _planLabel(code), + selected: code == _plan), + ]); + if (picked != null) setState(() => _plan = picked); + }, + onClear: () => setState(() => _plan = ''), + ), + ); + + final statusChip = Builder( + builder: (anchorCtx) => DsChip( + label: '状态', + value: _status.isEmpty ? null : _statusLabel(_status), + onTap: () async { + final picked = await showDsMenu(anchorCtx, items: [ + DsMenuItem(value: '', label: '全部', selected: _status.isEmpty), + for (final s in _kStatusLabels.keys) + DsMenuItem( + value: s, label: _statusLabel(s), selected: s == _status), + ]); + if (picked != null) _setStatus(picked); + }, + onClear: () => _setStatus(''), + ), + ); + + final dateChip = Builder( + builder: (anchorCtx) => DsChip( + label: '下单时间', + value: _datePresetLabel.isEmpty ? null : _datePresetLabel, + onTap: () => _pickDatePreset(anchorCtx), + onClear: () => setState(() { + _dateRange = null; + _datePresetLabel = ''; + }), + ), + ); + + final resetBtn = DsButton( + '重置', + icon: LucideIcons.refreshCw, + small: true, + variant: _hasAnyFilter ? DsBtnVariant.primary : DsBtnVariant.ghost, + onPressed: _resetFilters, + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + searchField, + const SizedBox(width: AppDims.sp3), + Expanded( + child: Wrap( + spacing: AppDims.sp2, + runSpacing: AppDims.sp2, + crossAxisAlignment: WrapCrossAlignment.center, + children: [planChip, statusChip, dateChip], + ), + ), + const SizedBox(width: AppDims.sp2), + resetBtn, + ], + ); + } + + Future _pickDatePreset(BuildContext anchorCtx) async { + final picked = await showDsMenu(anchorCtx, items: [ + DsMenuItem(value: 'all', label: '全部时间', selected: _datePresetLabel.isEmpty), + const DsMenuItem(value: '7', label: '近 7 天'), + const DsMenuItem(value: '30', label: '近 30 天'), + const DsMenuItem(value: 'month', label: '本月'), + const DsMenuItem(value: 'custom', label: '自定义…'), + ]); + if (picked == null) return; + if (picked == 'custom') { + if (!anchorCtx.mounted) return; + final range = + await showDateRangeDropdown(anchorCtx, initial: _dateRange); + if (range == null) return; + setState(() { + _dateRange = range; + _datePresetLabel = '${DateFormat('yyyy-MM-dd').format(range.start)} ' + '~ ${DateFormat('yyyy-MM-dd').format(range.end)}'; + }); + return; + } + final now = appNow(); + DateTimeRange? range; + String label = ''; + switch (picked) { + case '7': + range = + DateTimeRange(start: now.subtract(const Duration(days: 6)), end: now); + label = '近 7 天'; + break; + case '30': + range = DateTimeRange( + start: now.subtract(const Duration(days: 29)), end: now); + label = '近 30 天'; + break; + case 'month': + range = DateTimeRange(start: DateTime(now.year, now.month, 1), end: now); + label = '本月'; + break; + default: + range = null; + label = ''; + } + setState(() { + _dateRange = range; + _datePresetLabel = label; + }); + } + + // ── 移动搜索行(原型:搜索框 + 状态钮 + 套餐钮)─────────────────── + Widget _buildMobileSearchRow() { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 0), + child: Row(children: [ + Expanded( + child: DsSearchBox( + controller: _searchCtrl, + hint: '搜索订单号 / 套餐', + onChanged: (v) => setState(() => _query = v.trim()), + ), + ), + const SizedBox(width: 8), + DsButton(_status.isEmpty ? '全部' : _statusLabel(_status), + small: true, + variant: + _status.isEmpty ? DsBtnVariant.ghost : DsBtnVariant.primary, + onPressed: _openStatusSheet), + const SizedBox(width: 8), + DsButton(_plan.isEmpty ? '套餐' : _planLabel(_plan), + small: true, + variant: _plan.isEmpty ? DsBtnVariant.ghost : DsBtnVariant.primary, + onPressed: _openPlanSheet), + ]), + ); + } + + Widget _buildMobileSection(int count) { + return Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 14, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text('授权订单 · 共 $count', + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w700, + letterSpacing: .4, + color: context.tokens.muted)), + ), + ); + } + + Future _openStatusSheet() async { + final options = <(String, String)>[ + ('', '全部'), + ('pending', '待支付'), + ('paid', '已支付'), + ('failed', '已关闭'), + ]; + final sel = await showMSheet( + context, + title: '状态筛选', + builder: (ctx) { + final t = ctx.tokens; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < options.length; i++) + InkWell( + onTap: () => Navigator.of(ctx).pop(options[i].$1), + child: Container( + padding: + const EdgeInsets.symmetric(vertical: 13, horizontal: 4), + decoration: BoxDecoration( + border: i < options.length - 1 + ? Border(bottom: BorderSide(color: t.borderSubtle)) + : null, + ), + child: Row(children: [ + if (options[i].$2 != '全部') ...[ + DsIconBadge(options[i].$2), + const SizedBox(width: 10), + ], + Text(options[i].$2, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: options[i].$1 == _status + ? FontWeight.w600 + : FontWeight.w400, + color: options[i].$1 == _status + ? t.primary + : t.text)), + const Spacer(), + if (options[i].$1 == _status) + Icon(LucideIcons.check, size: 18, color: t.primary), + ]), + ), + ), + ], + ); + }, + ); + if (sel == null || !mounted) return; + _setStatus(sel); + } + + Future _openPlanSheet() async { + final options = <(String, String)>[ + ('', '全部'), + for (final code in _kPlanLabels.keys) (code, _planLabel(code)), + ]; + final sel = await showMSheet( + context, + title: '套餐筛选', + builder: (ctx) { + final t = ctx.tokens; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < options.length; i++) + InkWell( + onTap: () => Navigator.of(ctx).pop(options[i].$1), + child: Container( + padding: + const EdgeInsets.symmetric(vertical: 13, horizontal: 4), + decoration: BoxDecoration( + border: i < options.length - 1 + ? Border(bottom: BorderSide(color: t.borderSubtle)) + : null, + ), + child: Row(children: [ + Expanded( + child: Text(options[i].$2, + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: options[i].$1 == _plan + ? FontWeight.w600 + : FontWeight.w400, + color: options[i].$1 == _plan + ? t.primary + : t.text)), + ), + if (options[i].$1 == _plan) + Icon(LucideIcons.check, size: 18, color: t.primary), + ]), + ), + ), + ], + ); + }, + ); + if (sel == null || !mounted) return; + setState(() => _plan = sel); + } + + // ── 表格列 / 行(桌面)─────────────────────────────────────────── + static const _columns = [ + DsColumn('no', '订单号'), + DsColumn('plan', '套餐'), + DsColumn('days', '时长', numeric: true), + DsColumn('amount', '金额', numeric: true), + DsColumn('status', '状态'), + DsColumn('by', '下单人'), + DsColumn('created', '下单时间'), + DsColumn('paid', '支付时间'), + DsColumn('actions', '操作', action: true), + ]; + + DsRow _row(PurchaseRecord r) { + final t = context.tokens; + final days = _planDays(r.productBizCode); + return DsRow( + onTap: () => _showDetail(r), + cells: [ + Text(r.outTradeNo, + style: TextStyle( + color: t.faint, + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontSize: AppDims.fsSm)), + Text(_planLabel(r.productBizCode)), + Text(days != null ? '$days 天' : '—'), + Text('¥${r.displayAmount}', + style: const TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback)), + _statusBadge(r.status), + Text(r.userName.isEmpty ? '—' : r.userName), + Text( + r.createdAt != null + ? DateFormat('yyyy-MM-dd HH:mm').format(r.createdAt!) + : '—', + style: TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontSize: AppDims.fsSm)), + Text( + r.paidAt != null + ? DateFormat('yyyy-MM-dd HH:mm').format(r.paidAt!) + : '—', + style: TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontSize: AppDims.fsSm, + color: r.paidAt == null ? t.faint : null)), + IconButton( + icon: Icon(LucideIcons.eye, size: 16, color: t.muted), + tooltip: '详情', + splashRadius: 18, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + padding: EdgeInsets.zero, + onPressed: () => _showDetail(r), + ), + ], + ); + } + + Widget _statusBadge(String status) => DsBadge(_statusLabel(status), + tone: _statusTone(status), icon: statusIcon(_statusLabel(status))); + + MCard _orderCard(PurchaseRecord r) { + final createdText = r.createdAt != null + ? DateFormat('yyyy-MM-dd HH:mm').format(r.createdAt!) + : '—'; + return MCard( + onTap: () => _showDetail(r), + nm: _planLabel(r.productBizCode), + sub: r.outTradeNo, + badges: [DsIconBadge(_statusLabel(r.status))], + amt: '¥${r.displayAmount}', + foot: r.userName.isEmpty ? createdText : '$createdText · ${r.userName}', + ); + } + + // ── 详情抽屉/sheet ──────────────────────────────────────────── + void _showDetail(PurchaseRecord r) { + showOrderDetailDrawer( + context, + builder: (ctx) => _PurchaseDetailPanel( + record: r, + onGoInfo: () { + Navigator.of(ctx).pop(); + widget.onGoInfo(); + }, + onGoBuy: widget.onGoBuy == null + ? null + : () { + Navigator.of(ctx).pop(); + widget.onGoBuy!(); + }, + onCancel: () => _cancelOrder(ctx, r), + onContinuePay: () => _continuePay(r), + ), + ); + } + + Future _cancelOrder(BuildContext sheetCtx, PurchaseRecord r) async { + try { + final ok = + await ref.read(licenseRepositoryProvider).cancelPurchase(r.outTradeNo); + if (!mounted) return; + if (ok) { + if (sheetCtx.mounted) Navigator.of(sheetCtx).pop(); + ref.read(purchaseListProvider.notifier).reload(); + showDsToast(context, '订单已取消', bg: context.tokens.success); + } else { + showDsToast(context, '取消失败,可能已支付,请刷新', bg: context.tokens.danger); + } + } on AppException catch (_) { + if (!mounted) return; + showDsToast(context, '取消失败,可能已支付,请刷新', bg: context.tokens.danger); + } + } + + Future _continuePay(PurchaseRecord r) async { + if (r.payUrl.isEmpty) { + if (mounted) showDsToast(context, '支付链接不可用,请刷新后重试'); + return; + } + await launchUrl(Uri.parse(r.payUrl), mode: LaunchMode.externalApplication); + } +} + +/// 订单详情面板内容(桌面右滑抽屉 / 移动底部 sheet 共用同一 body, +/// 由 showOrderDetailDrawer 决定外层壳;本面板自带标题行 + 关闭按钮)。 +class _PurchaseDetailPanel extends StatelessWidget { + final PurchaseRecord record; + final VoidCallback onGoInfo; + final VoidCallback? onGoBuy; + final Future Function() onCancel; + final Future Function() onContinuePay; + + const _PurchaseDetailPanel({ + required this.record, + required this.onGoInfo, + required this.onGoBuy, + required this.onCancel, + required this.onContinuePay, + }); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final r = record; + final rows = <(String, Widget)>[ + ('订单号', + Text(r.outTradeNo, + style: const TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback))), + ('套餐', Text(_planLabel(r.productBizCode))), + ('授权时长', Text('${_planDays(r.productBizCode) ?? '—'} 天')), + ('金额', + Text('¥${r.displayAmount}', + style: const TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback))), + ( + '状态', + DsBadge(_statusLabel(r.status), + tone: _statusTone(r.status), icon: statusIcon(_statusLabel(r.status))) + ), + ( + '支付渠道', + Text(r.status == 'paid' ? '支付宝' : '—', + style: r.status == 'paid' ? null : TextStyle(color: t.faint)) + ), + ('下单人', Text(r.userName.isEmpty ? '—' : r.userName)), + ( + '下单时间', + Text( + r.createdAt != null + ? DateFormat('yyyy-MM-dd HH:mm').format(r.createdAt!) + : '—', + style: const TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontSize: AppDims.fsSm)) + ), + ( + '支付时间', + r.paidAt != null + ? Text(DateFormat('yyyy-MM-dd HH:mm').format(r.paidAt!), + style: const TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontSize: AppDims.fsSm)) + : Text('—', style: TextStyle(color: t.faint)) + ), + if (r.status == 'paid') + ( + '授权续期至', + Text( + r.renewedTo != null + ? DateFormat('yyyy-MM-dd').format(r.renewedTo!) + : '—', + style: TextStyle( + fontFamily: AppFonts.mono, + fontFamilyFallback: AppFonts.monoFallback, + fontWeight: FontWeight.w600, + color: t.success)) + ), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(20, 18, 16, 14), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: t.border))), + child: Row(children: [ + Expanded( + child: Text('订单详情', + style: TextStyle( + fontSize: AppDims.fsH2, + fontWeight: FontWeight.w700, + color: t.heading)), + ), + IconButton( + icon: Icon(LucideIcons.x, size: 20, color: t.muted), + splashRadius: 18, + onPressed: () => Navigator.of(context).pop(), + ), + ]), + ), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final row in rows) _drow(t, row.$1, row.$2), + if (r.status == 'pending') ...[ + const SizedBox(height: 14), + _notice(t, '订单未支付将在下单 2 小时后自动关闭;' + '支付成功后授权时长自动叠加到当前到期日,不浪费剩余天数。'), + ], + if (r.status == 'failed') ...[ + const SizedBox(height: 14), + _notice(t, '订单已关闭。如需购买请重新下单,价格以下单时套餐表为准。'), + ], + const SizedBox(height: 18), + _actions(context, t), + ], + ), + ), + ), + ], + ); + } + + Widget _drow(dynamic t, String label, Widget value) => Container( + padding: const EdgeInsets.symmetric(vertical: 11), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: t.borderSubtle))), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle(fontSize: AppDims.fsBody, color: t.muted)), + const Spacer(), + DefaultTextStyle( + style: TextStyle( + fontSize: AppDims.fsBody, + fontWeight: FontWeight.w600, + color: t.text), + child: value, + ), + ], + ), + ); + + Widget _notice(dynamic t, String text) => Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: t.infoSoft, + borderRadius: BorderRadius.circular(AppDims.rMd), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(LucideIcons.info, size: 16, color: t.primary), + const SizedBox(width: 8), + Expanded( + child: Text(text, + style: TextStyle(fontSize: AppDims.fsSm, color: t.text)), + ), + ], + ), + ); + + Widget _actions(BuildContext context, dynamic t) { + final buttons = []; + switch (record.status) { + case 'pending': + buttons.add(WriteGuard( + child: DsButton('取消订单', + small: true, variant: DsBtnVariant.danger, onPressed: onCancel), + )); + buttons.add(DsButton('继续支付', + small: true, + icon: LucideIcons.creditCard, + variant: DsBtnVariant.primary, + onPressed: onContinuePay)); + break; + case 'failed': + if (onGoBuy != null) { + buttons.add(DsButton('重新购买', + small: true, + icon: LucideIcons.plus, + variant: DsBtnVariant.primary, + onPressed: onGoBuy)); + } + break; + case 'paid': + buttons.add(DsButton('查看授权', + small: true, + icon: LucideIcons.shieldCheck, + onPressed: onGoInfo)); + break; + } + if (buttons.isEmpty) return const SizedBox.shrink(); + return Wrap(spacing: 10, runSpacing: 10, children: buttons); + } +} diff --git a/client/lib/screens/license/license_screen.dart b/client/lib/screens/license/license_screen.dart new file mode 100644 index 0000000..b9aff7c --- /dev/null +++ b/client/lib/screens/license/license_screen.dart @@ -0,0 +1,193 @@ +// screens/license/license_screen.dart — 授权管理独立一级屏(原型 license.html / +// m-license.html),三 tab:授权信息 / 在线购买续费 / 订单管理。 +// 桌面 `/license`、移动 `/me/license` 复用同一 Widget,内部按 context.isMobile +// 切换两种形态(桌面:头部+DsSeg+卡片区;移动:通栏 DsSeg+滚动内容)。 +// +// tab 可见性: +// - 「在线购买续费」iOS App Store 合规态(hideExternalPurchaseUi)整 tab 隐藏; +// - 「订单管理」GET /license/purchases 仅管理员可用(后端判权),非管理员隐藏该 tab。 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../core/auth/auth_state.dart'; +import '../../core/config/store_compliance.dart'; +import '../../core/responsive/responsive.dart'; +import '../../core/theme/app_dims.g.dart'; +import '../../core/theme/context_tokens.dart'; +import '../../core/utils/money.dart'; +import '../../core/utils/web_launch.dart'; +import '../../providers/license_purchase_provider.dart'; +import '../../widgets/ds/ds_atoms.dart'; +import 'license_orders_tab.dart'; +import '../settings/license_panel.dart'; +import '../settings/purchase_card.dart'; +import '../settings/settings_card.dart'; + +enum _LicSection { info, buy, orders } + +class LicenseScreen extends ConsumerStatefulWidget { + const LicenseScreen({super.key}); + + @override + ConsumerState createState() => _LicenseScreenState(); +} + +class _LicenseScreenState extends ConsumerState { + _LicSection _section = _LicSection.info; + + void _goInfo() => setState(() => _section = _LicSection.info); + void _goBuy() => setState(() => _section = _LicSection.buy); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final role = ref.watch(authStateProvider.select((s) => s.user?.role)); + final isAdmin = role == 'admin' || role == 'superadmin'; + final showBuy = !hideExternalPurchaseUi; + final showOrders = isAdmin; + + // 当前 tab 若因权限/合规态变得不可见,回退到「授权信息」。 + if ((_section == _LicSection.buy && !showBuy) || + (_section == _LicSection.orders && !showOrders)) { + _section = _LicSection.info; + } + + final sections = [ + _LicSection.info, + if (showBuy) _LicSection.buy, + if (showOrders) _LicSection.orders, + ]; + + final mobile = context.isMobile; + final segLabels = { + _LicSection.info: '授权信息', + _LicSection.buy: mobile ? '购买 / 续费' : '在线购买 / 续费', + _LicSection.orders: '订单管理', + }; + + Widget seg = DsSeg( + items: [for (final s in sections) segLabels[s]!], + index: sections.indexOf(_section).clamp(0, sections.length - 1), + onChanged: (i) => setState(() => _section = sections[i]), + ); + + Widget body = switch (_section) { + _LicSection.info => _infoBody(mobile), + _LicSection.buy => _buyBody(mobile, isAdmin), + _LicSection.orders => LicenseOrdersTab( + onGoInfo: _goInfo, + onGoBuy: showBuy ? _goBuy : null, + ), + }; + + if (mobile) { + return Container( + color: t.bg, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 0), + child: Align(alignment: Alignment.centerLeft, child: seg), + ), + const SizedBox(height: 12), + Expanded(child: body), + ], + ), + ); + } + + return Container( + color: t.bg, + padding: const EdgeInsets.fromLTRB(26, 22, 26, 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(context, ref, t), + const SizedBox(height: 18), + Align(alignment: Alignment.centerLeft, child: seg), + const SizedBox(height: 18), + Expanded(child: body), + ], + ), + ); + } + + Widget _buildHeader(BuildContext context, WidgetRef ref, dynamic t) { + final sub = switch (_section) { + _LicSection.info => '授权状态 · 兑换券 · 降级规则', + _LicSection.buy => '选择套餐支付宝支付 · 到账自动续期', + _LicSection.orders => () { + final result = ref.watch(purchaseListProvider).valueOrNull; + if (result == null) return '订单流水'; + return '共 ${result.total} 笔 · ' + '累计 ¥${yuanFromMinor(result.summary.paidTotalMinor)}'; + }(), + }; + return 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(sub, style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + ), + const Spacer(), + if (_section == _LicSection.orders) + DsButton('刷新', + icon: LucideIcons.refreshCw, + onPressed: () => + ref.read(purchaseListProvider.notifier).reload()), + ], + ); + } + + Widget _infoBody(bool mobile) { + final child = mobile + ? const LicensePanel() + : ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: const LicensePanel(), + ); + return SingleChildScrollView( + padding: mobile ? const EdgeInsets.fromLTRB(14, 0, 14, 14) : EdgeInsets.zero, + child: child, + ); + } + + Widget _buyBody(bool mobile, bool isAdmin) { + final card = SettingsCard( + title: '在线购买 / 续费', + desc: isAdmin ? '选择套餐支付宝支付,到账后授权自动续期' : '在线购买仅门店管理员可操作', + child: isAdmin + ? const PurchaseCard() + : Builder(builder: (ctx) { + final t = ctx.tokens; + return Row(children: [ + Text('在线购买请联系门店管理员 · ', + style: TextStyle(fontSize: AppDims.fsSm, color: t.muted)), + InkWell( + onTap: () => launchAuthedWebPath(ref, '/#pricing'), + child: Text('查看套餐价格 →', + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w600, + color: t.primary)), + ), + ]); + }), + ); + final child = mobile + ? card + : ConstrainedBox(constraints: const BoxConstraints(maxWidth: 560), child: card); + return SingleChildScrollView( + padding: mobile ? const EdgeInsets.fromLTRB(14, 0, 14, 14) : EdgeInsets.zero, + child: child, + ); + } +} diff --git a/client/lib/screens/me/license_screen.dart b/client/lib/screens/me/license_screen.dart deleted file mode 100644 index 31a2eb3..0000000 --- a/client/lib/screens/me/license_screen.dart +++ /dev/null @@ -1,23 +0,0 @@ -// screens/me/license_screen.dart — 授权管理独立屏(镜像原型 m-license.html)。 -// 移动端从「我的 → 系统 → 授权管理」进入(/me/license);内容 = 公共 LicensePanel -// 四卡(授权信息 / 在线购买续费 / 兑换券 / 降级规则),与桌面设置授权 tab 同源。 -import 'package:flutter/material.dart'; - -import '../../core/theme/context_tokens.dart'; -import '../settings/license_panel.dart'; - -class LicenseScreen extends StatelessWidget { - const LicenseScreen({super.key}); - - @override - Widget build(BuildContext context) { - final t = context.tokens; - return Container( - color: t.bg, - child: const SingleChildScrollView( - padding: EdgeInsets.all(14), - child: LicensePanel(), - ), - ); - } -} diff --git a/client/lib/screens/settings/license_panel.dart b/client/lib/screens/settings/license_panel.dart index 7b25661..c541be7 100644 --- a/client/lib/screens/settings/license_panel.dart +++ b/client/lib/screens/settings/license_panel.dart @@ -1,16 +1,15 @@ -// screens/settings/license_panel.dart — 授权管理面板(自 settings_screen.dart 抽取为公共组件)。 -// 四卡:授权信息 / 在线购买续费(PurchaseCard, admin only) / 兑换券 / 到期降级规则。 -// 桌面:SettingsScreen 授权 tab 内嵌;移动:独立屏 LicenseScreen(/me/license)复用。 +// screens/settings/license_panel.dart — 授权管理「授权信息」tab 内容 +// (2026-07-10 独立一级屏三 tab 化:本组件收窄为原四卡去掉「在线购买续费」, +// 该卡已提升为 LicenseScreen 的独立 tab2,见 screens/license/license_screen.dart)。 +// 三卡:授权信息三格 / 兑换券 / 到期降级规则 + iOS 合规态「联系客服」卡。 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'; @@ -20,7 +19,6 @@ 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 { @@ -67,8 +65,6 @@ class _LicensePanelState extends ConsumerState { 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!) : '—'; @@ -79,10 +75,12 @@ class _LicensePanelState extends ConsumerState { ? lic!.expiresAt!.difference(appNow()).inDays.clamp(0, 99999) : null; final licCells = [ + // 值为中文词(已授权/未授权):不走等宽字体(JetBrains Mono 无 CJK 字形, + // 回退链缺 NotoSansSC 会在 golden 环境 tofu),其余两格是纯数字/日期保留 mono。 _licCell(t, '授权状态', active ? '已授权' : '未授权', - color: active ? t.success : t.warn), + color: active ? t.success : t.warn, mono: false), _licCell(t, '到期日', expires), - _licCell(t, '剩余天数', days != null ? '$days 天' : '—'), + _licCell(t, '剩余天数', days != null ? '$days 天' : '—', mono: false), ]; return Column( @@ -100,36 +98,7 @@ class _LicensePanelState extends ConsumerState { ]), ), 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)── + // ── 卡2:兑换券(原型 .redeem:input + 按钮,max 520)── SettingsCard( title: '兑换券', desc: '输入兑换券短码续期', @@ -208,7 +177,10 @@ class _LicensePanelState extends ConsumerState { } /// 原型 .lic-cell(与「关于我们」同款):lk 11 muted + lv 17/700/mono。 - Widget _licCell(dynamic t, String label, String value, {Color? color}) => + /// [mono] 为 false 时值不走等宽字体(CJK 词如「已授权」,mono 回退链无 + /// CJK 字形会 tofu)。 + Widget _licCell(dynamic t, String label, String value, + {Color? color, bool mono = true}) => Container( // 窄屏三格横排时收窄内距,值文本按格宽缩放防溢出(如 2026-06-25) padding: context.isMobile @@ -234,8 +206,8 @@ class _LicensePanelState extends ConsumerState { style: TextStyle( fontSize: AppDims.fsH2, fontWeight: FontWeight.w700, - fontFamily: AppFonts.mono, - fontFamilyFallback: AppFonts.monoFallback, + fontFamily: mono ? AppFonts.mono : null, + fontFamilyFallback: mono ? AppFonts.monoFallback : null, color: color ?? t.heading)), ), ], diff --git a/client/lib/screens/settings/settings_screen.dart b/client/lib/screens/settings/settings_screen.dart index 6526844..4e620fe 100644 --- a/client/lib/screens/settings/settings_screen.dart +++ b/client/lib/screens/settings/settings_screen.dart @@ -1,11 +1,12 @@ // screens/settings/settings_screen.dart — 系统设置(照原型 settings.html 重建)。 -// 布局:.head + .settings grid(左 .subnav 200px + 右面板卡)。子导航 5 项 = -// 原型 4(门店信息/用户管理/授权管理/偏好设置)+ 保留真实功能「编号规则」。 +// 布局:.head + .settings grid(左 .subnav 200px + 右面板卡)。子导航 4 项 = +// 原型 3(门店信息/用户管理/偏好设置)+ 保留真实功能「编号规则」。 // 用户管理面板 = 预览表 + 「管理全部用户」→ /settings/users 独立页(原型 users.html)。 // 原「系统参数」假设置(本地 state 不落后端)已按用户口径删除。 // 窄屏(原型 m-settings.html):hub 形态三组(门店/偏好/数据),标题在壳顶栏; // 门店信息/编号规则/默认仓库走底部 sheet;?tab= 深链窄屏忽略(无面板可落)。 -// 授权入口不在此(拍板:授权在「我的」系统组 /me/license)。 +// 授权管理 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'; @@ -32,14 +33,13 @@ 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'; 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} + /// 初始面板:{shop:0, users:1, number:2, pref:3} final int initialTab; const SettingsScreen({super.key, this.initialTab = 0}); @@ -50,11 +50,10 @@ class SettingsScreen extends ConsumerStatefulWidget { class _SettingsScreenState extends ConsumerState { static const _navs = [ // 图标对齐原型 subnav sprite:i-ic44=house / i-ic45=user-plus / - // i-ic07=trending-up / i-ic46=contrast(编号规则为 app 专属项,用 hash) + // 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); @@ -355,8 +354,7 @@ class _SettingsScreenState extends ConsumerState { 0 => const _StoreInfoPanel(), 1 => _usersPanel(), 2 => _numberPanel(), - 3 => const LicensePanel(), - 4 => const _PrefPanel(), + 3 => const _PrefPanel(), _ => const SizedBox(), }; diff --git a/client/lib/screens/shell/app_shell.dart b/client/lib/screens/shell/app_shell.dart index 4dcbecd..670a4de 100644 --- a/client/lib/screens/shell/app_shell.dart +++ b/client/lib/screens/shell/app_shell.dart @@ -58,6 +58,9 @@ const _navGroups = <_NavGroup>[ ]), _NavGroup('系统', [ _NavItem(LucideIcons.monitorSmartphone, '设备管理', 6), + // branch 10(追加在末尾,不打乱既有分支序号),显示位次按原型 shell.js + // NAVS 排在「设备管理」与「系统设置」之间。 + _NavItem(LucideIcons.shield, '授权管理', 10), _NavItem(LucideIcons.settings, '系统设置', 7), _NavItem(LucideIcons.info, '关于我们', 8), ]), @@ -99,7 +102,7 @@ String _mobileTitle(String loc) { if (loc == '/settings/users') return '用户管理'; if (loc.startsWith('/settings')) return '系统设置'; if (loc.startsWith('/about')) return '关于我们'; - if (loc == '/me/license') return '授权管理'; + if (loc == '/me/license' || loc.startsWith('/license')) return '授权管理'; if (loc.startsWith('/me')) return '我的'; return ''; } @@ -729,9 +732,9 @@ class _AppShellState extends ConsumerState overflow: TextOverflow.ellipsis), ), TextButton( - // 窄屏授权入口是独立屏(我的→授权管理),宽屏保持设置授权 tab - onPressed: () => context.go( - context.isMobile ? '/me/license' : '/settings?tab=license'), + // 授权管理已提升为独立一级屏:窄屏 我的→授权管理,宽屏侧边栏→授权管理 + onPressed: () => + context.go(context.isMobile ? '/me/license' : '/license'), style: TextButton.styleFrom(foregroundColor: AppChrome.onAccentFaint), child: const Text('去激活'), @@ -762,7 +765,7 @@ class _AppShellState extends ConsumerState ? DsBtnVariant.primary : DsBtnVariant.danger, onPressed: () { Navigator.pop(_); - ctx.go(ctx.isMobile ? '/me/license' : '/settings?tab=license'); + ctx.go(ctx.isMobile ? '/me/license' : '/license'); }), ], ), diff --git a/client/lib/widgets/ds/m_card.dart b/client/lib/widgets/ds/m_card.dart index 0724b8b..28c0bed 100644 --- a/client/lib/widgets/ds/m_card.dart +++ b/client/lib/widgets/ds/m_card.dart @@ -31,6 +31,7 @@ import 'status_icon_map.dart'; case '已结清': case '已兑换': case '在线': + case '已支付': return (t.success, t.successBg); case '已拒绝': case '已驳回': @@ -40,7 +41,10 @@ import 'status_icon_map.dart'; case '作废': case '离线': return (t.danger, t.dangerBg); + case '待支付': + return (t.warn, t.warnBg); case '草稿': + case '已关闭': return (t.muted, t.infoSoft); default: return (t.muted, t.borderSubtle); diff --git a/client/lib/widgets/ds/status_icon_map.dart b/client/lib/widgets/ds/status_icon_map.dart index 7c316ac..3c71b27 100644 --- a/client/lib/widgets/ds/status_icon_map.dart +++ b/client/lib/widgets/ds/status_icon_map.dart @@ -46,6 +46,10 @@ const Map kStatusIcons = { '已结清': LucideIcons.check, // i-check '收款': LucideIcons.download, // i-download '付款': LucideIcons.upload, // i-upload + // 授权购买订单(license_purchases,2026-07-10 登记) + '待支付': LucideIcons.clock, // i-ic04 + '已支付': LucideIcons.check, // i-check + '已关闭': LucideIcons.x, // i-close }; /// 查状态词对应图标;未登记返回 null(调用方回退圆点形态)。 diff --git a/client/test/golden/goldens/about_a.png b/client/test/golden/goldens/about_a.png index 424e887..4b669a8 100644 Binary files a/client/test/golden/goldens/about_a.png and b/client/test/golden/goldens/about_a.png differ diff --git a/client/test/golden/goldens/about_b.png b/client/test/golden/goldens/about_b.png index cb82bb0..d25d31c 100644 Binary files a/client/test/golden/goldens/about_b.png and b/client/test/golden/goldens/about_b.png differ diff --git a/client/test/golden/goldens/about_c.png b/client/test/golden/goldens/about_c.png index 8cd018f..c06cdc7 100644 Binary files a/client/test/golden/goldens/about_c.png and b/client/test/golden/goldens/about_c.png differ diff --git a/client/test/golden/goldens/app_shell_a.png b/client/test/golden/goldens/app_shell_a.png index d9e85a0..7e4eee1 100644 Binary files a/client/test/golden/goldens/app_shell_a.png and b/client/test/golden/goldens/app_shell_a.png differ diff --git a/client/test/golden/goldens/app_shell_b.png b/client/test/golden/goldens/app_shell_b.png index 757b7fd..d2cccb5 100644 Binary files a/client/test/golden/goldens/app_shell_b.png and b/client/test/golden/goldens/app_shell_b.png differ diff --git a/client/test/golden/goldens/app_shell_c.png b/client/test/golden/goldens/app_shell_c.png index dfa1489..7e171bf 100644 Binary files a/client/test/golden/goldens/app_shell_c.png and b/client/test/golden/goldens/app_shell_c.png differ diff --git a/client/test/golden/goldens/device_management_a.png b/client/test/golden/goldens/device_management_a.png index b48920d..c011936 100644 Binary files a/client/test/golden/goldens/device_management_a.png and b/client/test/golden/goldens/device_management_a.png differ diff --git a/client/test/golden/goldens/device_management_b.png b/client/test/golden/goldens/device_management_b.png index 31bd82d..9a392dd 100644 Binary files a/client/test/golden/goldens/device_management_b.png and b/client/test/golden/goldens/device_management_b.png differ diff --git a/client/test/golden/goldens/device_management_c.png b/client/test/golden/goldens/device_management_c.png index 79b1c2d..18eb716 100644 Binary files a/client/test/golden/goldens/device_management_c.png and b/client/test/golden/goldens/device_management_c.png differ diff --git a/client/test/golden/goldens/finance_a.png b/client/test/golden/goldens/finance_a.png index ddf73d7..d398fe7 100644 Binary files a/client/test/golden/goldens/finance_a.png and b/client/test/golden/goldens/finance_a.png differ diff --git a/client/test/golden/goldens/finance_b.png b/client/test/golden/goldens/finance_b.png index cd628f7..a64fe4d 100644 Binary files a/client/test/golden/goldens/finance_b.png and b/client/test/golden/goldens/finance_b.png differ diff --git a/client/test/golden/goldens/finance_c.png b/client/test/golden/goldens/finance_c.png index 75c01f8..12f8c0c 100644 Binary files a/client/test/golden/goldens/finance_c.png and b/client/test/golden/goldens/finance_c.png differ diff --git a/client/test/golden/goldens/inventory_list_a.png b/client/test/golden/goldens/inventory_list_a.png index 1c57b1c..ad413c5 100644 Binary files a/client/test/golden/goldens/inventory_list_a.png and b/client/test/golden/goldens/inventory_list_a.png differ diff --git a/client/test/golden/goldens/inventory_list_b.png b/client/test/golden/goldens/inventory_list_b.png index 5ce830e..c6fbb63 100644 Binary files a/client/test/golden/goldens/inventory_list_b.png and b/client/test/golden/goldens/inventory_list_b.png differ diff --git a/client/test/golden/goldens/inventory_list_c.png b/client/test/golden/goldens/inventory_list_c.png index f621bd9..e4d342f 100644 Binary files a/client/test/golden/goldens/inventory_list_c.png and b/client/test/golden/goldens/inventory_list_c.png differ diff --git a/client/test/golden/goldens/license_a.png b/client/test/golden/goldens/license_a.png new file mode 100644 index 0000000..687107a Binary files /dev/null and b/client/test/golden/goldens/license_a.png differ diff --git a/client/test/golden/goldens/license_b.png b/client/test/golden/goldens/license_b.png new file mode 100644 index 0000000..657c64c Binary files /dev/null and b/client/test/golden/goldens/license_b.png differ diff --git a/client/test/golden/goldens/license_c.png b/client/test/golden/goldens/license_c.png new file mode 100644 index 0000000..3796ece Binary files /dev/null and b/client/test/golden/goldens/license_c.png differ diff --git a/client/test/golden/goldens/license_mobile_a.png b/client/test/golden/goldens/license_mobile_a.png new file mode 100644 index 0000000..9e5bdc0 Binary files /dev/null and b/client/test/golden/goldens/license_mobile_a.png differ diff --git a/client/test/golden/goldens/license_mobile_b.png b/client/test/golden/goldens/license_mobile_b.png new file mode 100644 index 0000000..78d484c Binary files /dev/null and b/client/test/golden/goldens/license_mobile_b.png differ diff --git a/client/test/golden/goldens/license_mobile_c.png b/client/test/golden/goldens/license_mobile_c.png new file mode 100644 index 0000000..92b286e Binary files /dev/null and b/client/test/golden/goldens/license_mobile_c.png differ diff --git a/client/test/golden/goldens/partners_a.png b/client/test/golden/goldens/partners_a.png index 4e8125d..cd9e24b 100644 Binary files a/client/test/golden/goldens/partners_a.png and b/client/test/golden/goldens/partners_a.png differ diff --git a/client/test/golden/goldens/partners_b.png b/client/test/golden/goldens/partners_b.png index 8cc93e4..fd1f667 100644 Binary files a/client/test/golden/goldens/partners_b.png and b/client/test/golden/goldens/partners_b.png differ diff --git a/client/test/golden/goldens/partners_c.png b/client/test/golden/goldens/partners_c.png index 5e80f0a..90e41a6 100644 Binary files a/client/test/golden/goldens/partners_c.png and b/client/test/golden/goldens/partners_c.png differ diff --git a/client/test/golden/goldens/products_archive_a.png b/client/test/golden/goldens/products_archive_a.png index c882d57..e564916 100644 Binary files a/client/test/golden/goldens/products_archive_a.png and b/client/test/golden/goldens/products_archive_a.png differ diff --git a/client/test/golden/goldens/products_archive_b.png b/client/test/golden/goldens/products_archive_b.png index f91c08e..f6eb32f 100644 Binary files a/client/test/golden/goldens/products_archive_b.png and b/client/test/golden/goldens/products_archive_b.png differ diff --git a/client/test/golden/goldens/products_archive_c.png b/client/test/golden/goldens/products_archive_c.png index 22c4418..74e6d24 100644 Binary files a/client/test/golden/goldens/products_archive_c.png and b/client/test/golden/goldens/products_archive_c.png differ diff --git a/client/test/golden/goldens/settings_a.png b/client/test/golden/goldens/settings_a.png index 553a4c6..24c8b75 100644 Binary files a/client/test/golden/goldens/settings_a.png and b/client/test/golden/goldens/settings_a.png differ diff --git a/client/test/golden/goldens/settings_b.png b/client/test/golden/goldens/settings_b.png index 6a0a51f..e15a122 100644 Binary files a/client/test/golden/goldens/settings_b.png and b/client/test/golden/goldens/settings_b.png differ diff --git a/client/test/golden/goldens/settings_c.png b/client/test/golden/goldens/settings_c.png index 35c216d..7ef48a7 100644 Binary files a/client/test/golden/goldens/settings_c.png and b/client/test/golden/goldens/settings_c.png differ diff --git a/client/test/golden/goldens/stock_in_list_a.png b/client/test/golden/goldens/stock_in_list_a.png index 4d62fcc..c8f6494 100644 Binary files a/client/test/golden/goldens/stock_in_list_a.png and b/client/test/golden/goldens/stock_in_list_a.png differ diff --git a/client/test/golden/goldens/stock_in_list_b.png b/client/test/golden/goldens/stock_in_list_b.png index 1491bc7..e1fe1db 100644 Binary files a/client/test/golden/goldens/stock_in_list_b.png and b/client/test/golden/goldens/stock_in_list_b.png differ diff --git a/client/test/golden/goldens/stock_in_list_c.png b/client/test/golden/goldens/stock_in_list_c.png index 7b5c1f5..f2a5b1f 100644 Binary files a/client/test/golden/goldens/stock_in_list_c.png and b/client/test/golden/goldens/stock_in_list_c.png differ diff --git a/client/test/golden/goldens/stock_out_list_a.png b/client/test/golden/goldens/stock_out_list_a.png index deb14e1..305fd11 100644 Binary files a/client/test/golden/goldens/stock_out_list_a.png and b/client/test/golden/goldens/stock_out_list_a.png differ diff --git a/client/test/golden/goldens/stock_out_list_b.png b/client/test/golden/goldens/stock_out_list_b.png index 15ede41..ebe0f10 100644 Binary files a/client/test/golden/goldens/stock_out_list_b.png and b/client/test/golden/goldens/stock_out_list_b.png differ diff --git a/client/test/golden/goldens/stock_out_list_c.png b/client/test/golden/goldens/stock_out_list_c.png index af37308..69f8dba 100644 Binary files a/client/test/golden/goldens/stock_out_list_c.png and b/client/test/golden/goldens/stock_out_list_c.png differ diff --git a/client/test/golden/goldens/users_a.png b/client/test/golden/goldens/users_a.png index e74caaa..d96c903 100644 Binary files a/client/test/golden/goldens/users_a.png and b/client/test/golden/goldens/users_a.png differ diff --git a/client/test/golden/goldens/users_b.png b/client/test/golden/goldens/users_b.png index 67543b4..e5d94c8 100644 Binary files a/client/test/golden/goldens/users_b.png and b/client/test/golden/goldens/users_b.png differ diff --git a/client/test/golden/goldens/users_c.png b/client/test/golden/goldens/users_c.png index 07e117e..e1c9610 100644 Binary files a/client/test/golden/goldens/users_c.png and b/client/test/golden/goldens/users_c.png differ diff --git a/client/test/golden/license_golden_test.dart b/client/test/golden/license_golden_test.dart new file mode 100644 index 0000000..bb0bea1 --- /dev/null +++ b/client/test/golden/license_golden_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:jiu_client/core/auth/auth_state.dart'; +import 'package:jiu_client/core/utils/clock.dart'; +import 'package:jiu_client/providers/license_provider.dart'; +import 'package:jiu_client/screens/license/license_screen.dart'; + +import '../support/shell_harness.dart'; + +/// 授权管理独立一级屏 golden × 三主题(回归闸 + 保真基准),照原型 +/// design/prototype/screens/license.html:默认落「授权信息」tab(与原型截图 +/// 默认态一致),三 tab 均可见(admin + 非 iOS)。到期日照抄 about/settings +/// 现有 golden 基准 2027-03-15,appClock 冻结 2026-07-13 09:30:15。 +/// node tools/fidelity.mjs license +/// 窄屏另见 license_mobile_golden_test.dart。 +/// 更新基准:flutter test --update-goldens test/golden/license_golden_test.dart + +class _FakeLicenseActive extends LicenseNotifier { + @override + Future build() async => LicenseInfo( + id: 1, + type: 'annual', + isActive: true, + maxDevices: 3, + phase: 'normal', + expiresAt: DateTime(2027, 3, 15), + ); +} + +List _overrides() => [ + licenseProvider.overrideWith(() => _FakeLicenseActive()), + isReadonlyProvider.overrideWithValue(false), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + appClock = () => DateTime(2026, 7, 13, 9, 30, 15); + + shellGoldenAcrossThemes( + 'license 授权信息 tab 整框(桌面)', + path: '/license', + screen: (_) => const LicenseScreen(), + goldenPrefix: 'license', + overrides: _overrides, + logical: const Size(1280, 900), + ); +} diff --git a/client/test/golden/license_mobile_golden_test.dart b/client/test/golden/license_mobile_golden_test.dart new file mode 100644 index 0000000..d40a243 --- /dev/null +++ b/client/test/golden/license_mobile_golden_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:jiu_client/core/auth/auth_state.dart'; +import 'package:jiu_client/core/utils/clock.dart'; +import 'package:jiu_client/providers/license_provider.dart'; +import 'package:jiu_client/screens/license/license_screen.dart'; + +import '../support/golden_harness.dart'; + +/// 授权管理独立一级屏窄屏 golden × 三主题(390×844 @2x),对齐原型 +/// m-license.html:通栏 DsSeg 三 tab + 默认「授权信息」tab 内容。 +/// 更新基准:flutter test --update-goldens test/golden/license_mobile_golden_test.dart + +class _FakeAuth extends AuthNotifier { + _FakeAuth() { + state = const AuthState( + user: AuthUser( + accessToken: 't', + refreshToken: 'r', + id: 1, + username: 'wang', + realName: '王经理', + shopNo: 'DSJH-001', + shopId: 1, + role: 'admin'), + ); + } +} + +class _FakeLicenseActive extends LicenseNotifier { + @override + Future build() async => LicenseInfo( + id: 1, + type: 'annual', + isActive: true, + maxDevices: 3, + phase: 'normal', + expiresAt: DateTime(2027, 3, 15), + ); +} + +List _overrides() => [ + authStateProvider.overrideWith((ref) => _FakeAuth()), + licenseProvider.overrideWith(() => _FakeLicenseActive()), + isReadonlyProvider.overrideWithValue(false), + ]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + appClock = () => DateTime(2026, 7, 13, 9, 30, 15); + + goldenAcrossThemes( + 'license 授权信息 tab 整屏(移动)', + goldenPrefix: 'license_mobile', + child: () => const Scaffold(body: LicenseScreen()), + overrides: _overrides, + logical: const Size(390, 844), + ); +} diff --git a/client/test/golden/proto/license_a.png b/client/test/golden/proto/license_a.png new file mode 100644 index 0000000..483e89c Binary files /dev/null and b/client/test/golden/proto/license_a.png differ diff --git a/client/test/golden/proto/license_b.png b/client/test/golden/proto/license_b.png new file mode 100644 index 0000000..bddac5d Binary files /dev/null and b/client/test/golden/proto/license_b.png differ diff --git a/client/test/golden/proto/license_c.png b/client/test/golden/proto/license_c.png new file mode 100644 index 0000000..c6b7351 Binary files /dev/null and b/client/test/golden/proto/license_c.png differ diff --git a/client/test/license_orders_tab_test.dart b/client/test/license_orders_tab_test.dart new file mode 100644 index 0000000..ecaf506 --- /dev/null +++ b/client/test/license_orders_tab_test.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/core/auth/auth_state.dart'; +import 'package:jiu_client/core/theme/themes.dart'; +import 'package:jiu_client/models/license.dart'; +import 'package:jiu_client/providers/license_provider.dart'; +import 'package:jiu_client/providers/license_purchase_provider.dart'; +import 'package:jiu_client/screens/license/license_screen.dart'; + +/// 订单管理 tab 冒烟测试:切到该 tab 能正常渲染 KPI/表格,点击行能打开详情 +/// 抽屉且不抛异常(此 tab 无 golden 覆盖,靠本测试兜底基础可用性)。 + +class _FakeAuth extends AuthNotifier { + _FakeAuth() { + state = const AuthState( + user: AuthUser( + accessToken: 't', + refreshToken: 'r', + id: 1, + username: 'wang', + realName: '王经理', + shopNo: 'S001', + shopId: 1, + role: 'admin'), + ); + } +} + +class _FakeLicense extends LicenseNotifier { + @override + Future build() async => LicenseInfo( + id: 1, + type: 'annual', + isActive: true, + maxDevices: 2, + phase: 'normal', + expiresAt: DateTime.now().add(const Duration(days: 100)), + ); +} + +final _records = [ + PurchaseRecord( + outTradeNo: 'pay-20260710101433-9c41f7ae', + productBizCode: 'annual_pro', + amountMinor: 599900, + currency: 'CNY', + amount: '', + status: 'pending', + payUrl: 'https://pay.example.com/checkout/1', + userName: '王老板', + createdAt: DateTime(2026, 7, 10, 10, 14), + ), + PurchaseRecord( + outTradeNo: 'pay-20260610093015-4d7a20cf', + productBizCode: 'monthly_pro', + amountMinor: 59900, + currency: 'CNY', + amount: '', + status: 'paid', + payUrl: '', + userName: '王老板', + createdAt: DateTime(2026, 6, 10, 9, 30), + paidAt: DateTime(2026, 6, 10, 9, 31), + renewedTo: DateTime(2027, 6, 4), + ), + PurchaseRecord( + outTradeNo: 'pay-20260702174902-b2e85c10', + productBizCode: 'annual_pro', + amountMinor: 599900, + currency: 'CNY', + amount: '', + status: 'failed', + payUrl: '', + userName: '王老板', + createdAt: DateTime(2026, 7, 2, 17, 49), + ), +]; + +class _FakePurchaseList extends PurchaseListNotifier { + @override + Future build() async => PurchaseListResult( + items: _records, + total: _records.length, + summary: const PurchaseSummary( + paidTotalMinor: 419700, + paidCount: 5, + pendingCount: 1, + totalCount: 7, + ), + ); +} + +Widget _screen() => ProviderScope( + overrides: [ + authStateProvider.overrideWith((ref) => _FakeAuth()), + licenseProvider.overrideWith(() => _FakeLicense()), + purchaseListProvider.overrideWith(() => _FakePurchaseList()), + ], + child: MaterialApp( + theme: buildTheme('a'), + home: const Scaffold(body: LicenseScreen()), + ), + ); + +void main() { + testWidgets('订单管理 tab:KPI + 表格 + 详情抽屉均正常渲染', (tester) async { + await tester.pumpWidget(_screen()); + await tester.pumpAndSettle(); + + await tester.tap(find.text('订单管理')); + await tester.pumpAndSettle(); + + // KPI + expect(find.text('订单总数'), findsOneWidget); + expect(find.text('待支付 · 点击筛选'), findsOneWidget); + + // 表格行(套餐 + 状态徽章) + expect(find.text('高级版 · 年付'), findsWidgets); + expect(find.text('高级版 · 月付'), findsOneWidget); + expect(find.text('待支付'), findsWidgets); + expect(find.text('已支付'), findsWidgets); + expect(find.text('已关闭'), findsWidgets); + + // 点开第一行详情(待支付单):抽屉出现「继续支付」「取消订单」 + await tester.tap(find.text('pay-20260710101433-9c41f7ae')); + await tester.pumpAndSettle(); + expect(find.text('订单详情'), findsOneWidget); + expect(find.text('继续支付'), findsOneWidget); + expect(find.text('取消订单'), findsOneWidget); + }); +} diff --git a/client/test/license_panel_ios_test.dart b/client/test/license_panel_ios_test.dart index fbc10f2..6388f8f 100644 --- a/client/test/license_panel_ios_test.dart +++ b/client/test/license_panel_ios_test.dart @@ -7,8 +7,9 @@ import 'package:jiu_client/core/theme/themes.dart'; import 'package:jiu_client/providers/license_provider.dart'; import 'package:jiu_client/screens/settings/license_panel.dart'; -/// iOS App Store 合规形态(苹果 3.1.1):授权管理页隐藏一切外部购买 UI, -/// 补位中性「联系客服」卡;其余平台购买卡照旧、无客服卡。 +/// iOS App Store 合规形态(苹果 3.1.1):LicensePanel(授权信息 tab 内容, +/// 2026-07-10 起「在线购买续费」已独立为 LicenseScreen 的 tab2,见 +/// license_screen_tabs_test.dart)iOS 态补位中性「联系客服」卡,其余平台无。 class _FakeAuth extends AuthNotifier { _FakeAuth(String role) { @@ -54,24 +55,20 @@ Widget _panel({required String role}) => ProviderScope( void main() { tearDown(() => debugForceHideExternalPurchaseUi = null); - testWidgets('iOS 形态:购买卡与价格链接消失,出现联系客服卡', (tester) async { + testWidgets('iOS 形态:出现联系客服卡', (tester) async { debugForceHideExternalPurchaseUi = true; await tester.pumpWidget(_panel(role: 'operator')); await tester.pumpAndSettle(); - expect(find.text('在线购买 / 续费'), findsNothing); - expect(find.textContaining('查看套餐价格'), findsNothing); expect(find.text('联系客服'), findsOneWidget); expect(find.text(supportEmail), findsOneWidget); }); - testWidgets('非 iOS:购买卡照旧,无联系客服卡', (tester) async { + testWidgets('非 iOS:无联系客服卡', (tester) async { debugForceHideExternalPurchaseUi = false; await tester.pumpWidget(_panel(role: 'operator')); await tester.pumpAndSettle(); - expect(find.text('在线购买 / 续费'), findsOneWidget); - expect(find.textContaining('查看套餐价格'), findsOneWidget); expect(find.text('联系客服'), findsNothing); }); } diff --git a/client/test/license_screen_tabs_test.dart b/client/test/license_screen_tabs_test.dart new file mode 100644 index 0000000..c1eeeef --- /dev/null +++ b/client/test/license_screen_tabs_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiu_client/core/auth/auth_state.dart'; +import 'package:jiu_client/core/config/store_compliance.dart'; +import 'package:jiu_client/core/theme/themes.dart'; +import 'package:jiu_client/providers/license_provider.dart'; +import 'package:jiu_client/screens/license/license_screen.dart'; + +/// 授权管理独立一级屏(2026-07-10 三 tab 化)tab 可见性回归: +/// - 「在线购买续费」iOS App Store 合规态(3.1.1)整 tab 隐藏; +/// - 「订单管理」GET /license/purchases 仅管理员可用,非管理员隐藏该 tab。 + +class _FakeAuth extends AuthNotifier { + _FakeAuth(String role) { + state = AuthState( + user: AuthUser( + accessToken: 't', + refreshToken: 'r', + id: 1, + username: 'wang', + realName: '王经理', + shopNo: 'S001', + shopId: 1, + role: role), + ); + } +} + +class _FakeLicense extends LicenseNotifier { + @override + Future build() async => LicenseInfo( + id: 1, + type: 'annual', + isActive: true, + maxDevices: 2, + phase: 'normal', + expiresAt: DateTime.now().add(const Duration(days: 100)), + ); +} + +Widget _screen({required String role}) => ProviderScope( + overrides: [ + authStateProvider.overrideWith((ref) => _FakeAuth(role)), + licenseProvider.overrideWith(() => _FakeLicense()), + ], + child: MaterialApp( + theme: buildTheme('a'), + home: const Scaffold(body: LicenseScreen()), + ), + ); + +void main() { + tearDown(() => debugForceHideExternalPurchaseUi = null); + + testWidgets('管理员 + 非 iOS:三 tab 全部可见', (tester) async { + debugForceHideExternalPurchaseUi = false; + await tester.pumpWidget(_screen(role: 'admin')); + await tester.pumpAndSettle(); + + expect(find.text('在线购买 / 续费'), findsOneWidget); + expect(find.text('订单管理'), findsOneWidget); + }); + + testWidgets('iOS 合规态:购买 tab 隐藏,订单 tab 仍在', (tester) async { + debugForceHideExternalPurchaseUi = true; + await tester.pumpWidget(_screen(role: 'admin')); + await tester.pumpAndSettle(); + + expect(find.text('在线购买 / 续费'), findsNothing); + expect(find.text('订单管理'), findsOneWidget); + }); + + testWidgets('非管理员:订单管理 tab 隐藏,购买 tab 仍可见', (tester) async { + debugForceHideExternalPurchaseUi = false; + await tester.pumpWidget(_screen(role: 'operator')); + await tester.pumpAndSettle(); + + expect(find.text('在线购买 / 续费'), findsOneWidget); + expect(find.text('订单管理'), findsNothing); + }); +} diff --git a/client/test/support/shell_harness.dart b/client/test/support/shell_harness.dart index d6d9ed0..7cd3391 100644 --- a/client/test/support/shell_harness.dart +++ b/client/test/support/shell_harness.dart @@ -67,7 +67,8 @@ List shellOverrides() => [ isReadonlyProvider.overrideWithValue(false), ]; -// 外壳 nav 引用 9 个分支序号(入/出库·库存·财务·往来·基础数据·设备·设置·关于)。 +// 外壳 nav 引用的分支序号需与 app_router.dart 一一对齐(0-8 侧栏 + 9 我的 +// 占位 + 10 授权管理),否则侧栏高亮(_navGroups item.index)会错位。 StatefulShellBranch _branch(String path, [WidgetBuilder? builder]) => StatefulShellBranch(routes: [ GoRoute( @@ -92,6 +93,8 @@ GoRouter _router(String initialPath, WidgetBuilder screen) => GoRouter( '/devices', '/settings', '/about', + '/me', // 9:桌面侧栏不含此项,占位保持分支序号对齐 + '/license', // 10:授权管理独立一级屏 ]) _branch(p, p == initialPath ? screen : null), ], diff --git a/design/CONTRACT.md b/design/CONTRACT.md index 71f3e1e..f208d96 100644 --- a/design/CONTRACT.md +++ b/design/CONTRACT.md @@ -114,8 +114,8 @@ AppTokens 目前**仅颜色**。原型还驱动: | 注册新门店 | `register.html` | `register_screen.dart` | 快照 | ✅ **Phase2 重建(2026-07-03)**:品牌面板三步时间线+grid2 表单+协议勾选;**不入 fidelity 闸**(少 门店编号/兑换券 两字段致结构错位,用户拍板先不加,见下) | ✅ golden ×3 | ✅ golden ×3 | | 移动壳 | `mobile-shell.js`(m-top/m-tabbar) | `app_shell.dart` 窄屏 + `m_tab_bar.dart` | 同步 | ✅ **2026-07-04 移动壳落地**:底部 5 tab(库存/入库/出库/财务/我的)+顶栏(标题/主题/铃/头像)+二级屏返回箭头;golden `app_shell_mobile` ×3 | ✅ golden 自比 | ✅ golden 自比 | | 我的(移动) | `m-me.html` | `me_screen.dart`(`/me`) | 同步 | ✅ 用户头卡+经营管理/系统 hub+退出登录确认 sheet;golden `me` ×3 | ✅ golden 自比 | ✅ golden 自比 | -| 授权管理(桌面) | `license.html` | 未实现(原 settings 授权 tab 提升独立屏,侧边栏「系统」组) | 同步(design-first) | 🟡 2026-07-10 原型建成评审中:**授权信息/在线购买续费/订单管理三 tab**(tab1=授权三格+兑换券+降级规则+iOS 客服卡;tab2=购买卡三阶段,iOS 合规态整 tab 隐藏;tab3=license_purchases 订单流水 KPI/筛选/表格/抽屉);实现后补 fidelity | — | — | -| 授权管理(移动) | `m-license.html` | `license_screen.dart`(`/me/license`) | 同步 | ✅ 原型已回填(三格横排/购买卡/iOS 双态);**2026-07-10 升级三 tab**(授权/购买续费/订单管理,订单 tab=KPI/筛选 sheet/订单卡流/详情 sheet),真实端 tab 化待实现 | ✅ golden(licPanel 随 settings) | ✅ 同左 | +| 授权管理(桌面) | `license.html` | `screens/license/license_screen.dart`(`/license`,侧边栏「系统」组) | 同步 | ✅ 2026-07-10 独立一级屏三 tab 落地:tab1=授权三格+兑换券+降级规则+iOS 客服卡(`LicensePanel`);tab2=`PurchaseCard`(iOS 合规态/非管理员分别隐藏 tab、降级为联系管理员提示);tab3=`LicenseOrdersTab`(KPI 4 卡+套餐/状态/时间筛选+DsTable+详情抽屉,仅管理员可见);fidelity 2.2–4.0%≤8% | ✅ fidelity | — | +| 授权管理(移动) | `m-license.html` | 同上 Widget(`/me/license`,「我的」系统组) | 同步 | ✅ 同桌面三 tab 化(通栏 DsSeg + MCard 订单流 + 详情 sheet),与桌面共用 `LicenseScreen`;golden `license_mobile` ×3 | ✅ golden 自比 | ✅ 同左 | | 库存盘点(移动) | `m-inventory-check.html` | `inventory_check_screen.dart` 窄屏 | 同步 | ✅ 卡片流+详情 sheet+底部操作条(原型为桩,按桩粒度);golden `m_inventory_check` ×3 | ✅ golden 自比 | ✅ golden 自比 | | 通知/通知详情(移动) | `m-notifications.html` `m-notification.html` | —(二期未落地) | 二期 | 后端无通知模块;铃铛保持「暂无通知」 | — | — | | 建单表单(移动) | `m-stock-in.html` `m-stock-out.html` | —(拍板不落地) | 不落地 | 2026-07-04 拍板:移动端无建单入口(列表无 +/FAB),录单仅桌面 | — | — | diff --git a/tools/screens.mjs b/tools/screens.mjs index ddd30c5..2722015 100644 --- a/tools/screens.mjs +++ b/tools/screens.mjs @@ -90,6 +90,18 @@ export const SCREENS = { waitFor: '.app', threshold: 0.05, // 实测 2.7% + 2pp(2026-07-07 收紧,原 0.08) }, + license: { + // 2026-07-10 授权管理独立一级屏(原 settings 授权 tab 提升);golden 默认 + // 落「授权信息」tab(与原型截图默认态一致,仅原型 .ds-states 演示开关 + // 隐藏不入镜)。已知差异:KPI「当前授权 · X」用 lic.typeLabel(月度/年度/ + // 永久/试用),无法映射原型的套餐档位名(标准版/高级版,后端未存该轴)。 + // 校准:实测 a 3.7 / b 2.2 / c 4.0% + html: 'design/prototype/screens/license.html', + prefix: 'license', + width: 1280, height: 900, dpr: 2, + waitFor: '.app', + threshold: 0.06, // 实测 4.0% + 2pp + }, 'stock-in': { html: 'design/prototype/screens/stock-in-list.html', prefix: 'stock_in_list',