feat(client): 授权管理独立一级屏——授权/购买续费/订单管理三 tab(桌面+移动)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-11 00:24:59 +08:00
parent 9fe1e2766f
commit 22e07613a1
60 changed files with 1598 additions and 96 deletions
+13 -5
View File
@@ -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<void> _noTransition(Widget child) => NoTransitionPage<void>(child: child);
@@ -184,7 +184,8 @@ final appRouterProvider = Provider<GoRouter>((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<GoRouter>((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<GoRouter>((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())),
]),
],
),
],
@@ -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, PurchaseListResult>(
PurchaseListNotifier.new,
);
class PurchaseListNotifier extends AsyncNotifier<PurchaseListResult> {
int _page = 1;
int _pageSize = 10;
String _status = ''; // '' | pending | paid | failed
PurchaseListResult? _cache;
@override
Future<PurchaseListResult> 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<PurchaseListResult> _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<PurchaseListResult>.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();
}
}
@@ -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<String, (String, int)> _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<LicenseOrdersTab> createState() => _LicenseOrdersTabState();
}
class _LicenseOrdersTabState extends ConsumerState<LicenseOrdersTab> {
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<PurchaseRecord> _filtered(List<PurchaseRecord> 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<void>.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 = <Widget>[
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<String>(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<String>(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<void> _pickDatePreset(BuildContext anchorCtx) async {
final picked = await showDsMenu<String>(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<void> _openStatusSheet() async {
final options = <(String, String)>[
('', '全部'),
('pending', '待支付'),
('paid', '已支付'),
('failed', '已关闭'),
];
final sel = await showMSheet<String>(
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<void> _openPlanSheet() async {
final options = <(String, String)>[
('', '全部'),
for (final code in _kPlanLabels.keys) (code, _planLabel(code)),
];
final sel = await showMSheet<String>(
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<void>(
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<void> _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<void> _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<void> Function() onCancel;
final Future<void> 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 = <Widget>[];
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);
}
}
@@ -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<LicenseScreen> createState() => _LicenseScreenState();
}
class _LicenseScreenState extends ConsumerState<LicenseScreen> {
_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,
);
}
}
-23
View File
@@ -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(),
),
);
}
}
+15 -43
View File
@@ -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<LicensePanel> {
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<LicensePanel> {
? 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<LicensePanel> {
]),
),
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:兑换券(原型 .redeeminput + 按钮,max 520)──
// ── 卡2兑换券(原型 .redeeminput + 按钮,max 520)──
SettingsCard(
title: '兑换券',
desc: '输入兑换券短码续期',
@@ -208,7 +177,10 @@ class _LicensePanelState extends ConsumerState<LicensePanel> {
}
/// 原型 .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<LicensePanel> {
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)),
),
],
@@ -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<SettingsScreen> {
static const _navs = [
// 图标对齐原型 subnav spritei-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<SettingsScreen> {
0 => const _StoreInfoPanel(),
1 => _usersPanel(),
2 => _numberPanel(),
3 => const LicensePanel(),
4 => const _PrefPanel(),
3 => const _PrefPanel(),
_ => const SizedBox(),
};
+8 -5
View File
@@ -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<AppShell>
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<AppShell>
? DsBtnVariant.primary
: DsBtnVariant.danger, onPressed: () {
Navigator.pop(_);
ctx.go(ctx.isMobile ? '/me/license' : '/settings?tab=license');
ctx.go(ctx.isMobile ? '/me/license' : '/license');
}),
],
),
+4
View File
@@ -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);
@@ -46,6 +46,10 @@ const Map<String, IconData> kStatusIcons = {
'已结清': LucideIcons.check, // i-check
'收款': LucideIcons.download, // i-download
'付款': LucideIcons.upload, // i-upload
// 授权购买订单(license_purchases2026-07-10 登记)
'待支付': LucideIcons.clock, // i-ic04
'已支付': LucideIcons.check, // i-check
'已关闭': LucideIcons.x, // i-close
};
/// 查状态词对应图标;未登记返回 null(调用方回退圆点形态)。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 KiB

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 KiB

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 KiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 345 KiB

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 342 KiB

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 347 KiB

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 214 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 242 KiB

@@ -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-15appClock 冻结 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<LicenseInfo?> build() async => LicenseInfo(
id: 1,
type: 'annual',
isActive: true,
maxDevices: 3,
phase: 'normal',
expiresAt: DateTime(2027, 3, 15),
);
}
List<Override> _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),
);
}
@@ -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<LicenseInfo?> build() async => LicenseInfo(
id: 1,
type: 'annual',
isActive: true,
maxDevices: 3,
phase: 'normal',
expiresAt: DateTime(2027, 3, 15),
);
}
List<Override> _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),
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

+132
View File
@@ -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<LicenseInfo?> 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<PurchaseListResult> 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);
});
}
+5 -8
View File
@@ -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.dartiOS 态补位中性「联系客服」卡,其余平台无。
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);
});
}
+82
View File
@@ -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<LicenseInfo?> 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);
});
}
+4 -1
View File
@@ -67,7 +67,8 @@ List<Override> 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),
],
+2 -2
View File
@@ -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+退出登录确认 sheetgolden `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.24.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),录单仅桌面 | — | — |
+12
View File
@@ -90,6 +90,18 @@ export const SCREENS = {
waitFor: '.app',
threshold: 0.05, // 实测 2.7% + 2pp2026-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',