feat(client): 授权管理独立一级屏——授权/购买续费/订单管理三 tab(桌面+移动)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:兑换券(原型 .redeem:input + 按钮,max 520)──
|
||||
// ── 卡2:兑换券(原型 .redeem:input + 按钮,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 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<SettingsScreen> {
|
||||
0 => const _StoreInfoPanel(),
|
||||
1 => _usersPanel(),
|
||||
2 => _numberPanel(),
|
||||
3 => const LicensePanel(),
|
||||
4 => const _PrefPanel(),
|
||||
3 => const _PrefPanel(),
|
||||
_ => const SizedBox(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
}),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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_purchases,2026-07-10 登记)
|
||||
'待支付': LucideIcons.clock, // i-ic04
|
||||
'已支付': LucideIcons.check, // i-check
|
||||
'已关闭': LucideIcons.x, // i-close
|
||||
};
|
||||
|
||||
/// 查状态词对应图标;未登记返回 null(调用方回退圆点形态)。
|
||||
|
||||
|
Before Width: | Height: | Size: 477 KiB After Width: | Height: | Size: 482 KiB |
|
Before Width: | Height: | Size: 480 KiB After Width: | Height: | Size: 483 KiB |
|
Before Width: | Height: | Size: 526 KiB After Width: | Height: | Size: 529 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 115 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 405 KiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 417 KiB After Width: | Height: | Size: 422 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 272 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 426 KiB |
|
Before Width: | Height: | Size: 414 KiB After Width: | Height: | Size: 419 KiB |
|
Before Width: | Height: | Size: 422 KiB After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 345 KiB After Width: | Height: | Size: 350 KiB |
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 347 KiB After Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 214 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 296 KiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 305 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 241 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 237 KiB |
|
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-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<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),
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 276 KiB |
|
After Width: | Height: | Size: 264 KiB |
|
After Width: | Height: | Size: 276 KiB |
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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),录单仅桌面 | — | — |
|
||||
|
||||
@@ -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',
|
||||
|
||||