# 商业化 P0 实现计划(客户端)· slogan-app > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 会员中心(套餐展示 → 下单 → 系统浏览器支付 → 轮询确认)+ 广告激励入口(Mock 激励视频 → 领取加次/体验会员),后端未配置时接口报错自动隐藏充值入口。 **Architecture:** /commercial 页重构为会员中心(home 第 4 Tab 与路由都指向它)。新增 `lib/core/ads/`(AdsService 抽象 + Mock 实现,P1 换穿山甲)、`lib/features/member/`(provider + 会员中心页 + 支付页)。支付用 `url_launcher` 打开系统浏览器,`/pay` 页 2s 轮询订单状态(60s 上限),成功后刷新会员状态。iOS 端隐藏充值入口(App Store 政策),保留广告激励。 **Tech Stack:** Flutter 3.44 / Riverpod 3 / go_router 17 / dio 5 / url_launcher ^6.3.0 **关联 spec:** `docs/superpowers/specs/2026-07-31-commerce-monetization-design.md` 支柱 A/B 客户端部分。 **验证方式(沿用 MVP 惯例):** `dart analyze`(中文路径下 flutter analyze 崩溃)、`flutter test`、`flutter build web --release` 全量编译、mock 后端冒烟。 --- ## 任务总览与文件映射 | 任务 | 文件 | |---|---| | T1 | `pubspec.yaml`、`lib/core/ads/ads_service.dart`、`test/features/member/benefits_test.dart` | | T2 | `lib/features/member/member_provider.dart` | | T3 | `lib/features/member/member_center_page.dart`(新建)、`lib/features/commercial/commercial_page.dart`(删除)、`lib/features/home/home_page.dart`、`lib/main.dart` | | T4 | `lib/features/member/pay_page.dart`、`lib/main.dart` 路由 | | T5 | analyze + test + build + 冒烟 | --- ### Task 1: url_launcher 依赖 + 广告抽象 + 权益文案纯函数(TDD) **Files:** - Modify: `pubspec.yaml` - Create: `lib/core/ads/ads_service.dart` - Test: `test/features/member/benefits_test.dart` - [ ] **Step 1: 写失败测试**(权益 key → 中文文案;benefitTexts 尚未存在) `test/features/member/benefits_test.dart`: ```dart import 'package:flutter_test/flutter_test.dart'; import 'package:slogan_app/features/member/member_provider.dart'; void main() { test('benefitTexts 映射已知权益文案,未知 key 原样保留', () { final info = MemberInfo( isVip: true, expireAt: '2026-08-30 12:00:00', planName: '月卡', benefits: const ['effect_unlimited', 'unknown_key'], ); expect(benefitTexts(info), ['无限效果图', 'unknown_key']); }); test('benefitTexts 空权益返回空列表', () { final info = MemberInfo( isVip: false, expireAt: '', planName: '', benefits: const [], ); expect(benefitTexts(info), isEmpty); }); } ``` - [ ] **Step 2: 运行确认失败** Run: `dart analyze lib test 2>&1 | head -5` Expected: 报 `member_provider.dart` 不存在 / import 失败 - [ ] **Step 3: pubspec 加 url_launcher** ```yaml url_launcher: ^6.3.0 ``` - [ ] **Step 4: 广告抽象 `lib/core/ads/ads_service.dart`** ```dart import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 广告服务抽象:P0 用 Mock(保证业务链路可开发可测),P1 换穿山甲 SDK abstract class AdsService { bool get enabled; Future showRewarded(); } /// 本地模拟激励视频(约 1 秒"播放"后返回完整观看) class MockAdsService implements AdsService { @override bool get enabled => true; @override Future showRewarded() async { await Future.delayed(const Duration(milliseconds: 900)); return true; } } final adsServiceProvider = Provider((ref) { // P1:AppConfig.pangleAppId 非空时替换为 PangleAdsService(穿山甲 SDK 实现) return MockAdsService(); }); ``` - [ ] **Step 5: 提交** ```bash git add pubspec.yaml lib/core/ads test/features/member git commit -m "feat: 广告服务抽象(Mock 激励视频)+ url_launcher 依赖" ``` --- ### Task 2: member provider(会员状态/套餐/下单/轮询/领奖) **Files:** - Create: `lib/features/member/member_provider.dart` - [ ] **Step 1: 实现 member_provider.dart**(含 Task 1 测试依赖的 `MemberInfo` 与 `benefitTexts`) ```dart import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/network/api_client.dart'; class MemberInfo { final bool isVip; final String expireAt; final String planName; final List benefits; const MemberInfo({ required this.isVip, required this.expireAt, required this.planName, required this.benefits, }); factory MemberInfo.fromJson(Map e) => MemberInfo( isVip: e['is_vip'] as bool? ?? false, expireAt: e['expire_at'] as String? ?? '', planName: e['plan_name'] as String? ?? '', benefits: (e['benefits'] as List? ?? []).cast(), ); } /// 权益 key → 文案 const benefitLabels = { 'effect_unlimited': '无限效果图', 'ai_priority': '优先 AI 方案', 'cps_commission_x15': '返现加成 1.5x', 'store_discount': '门店折扣', }; List benefitTexts(MemberInfo m) => m.benefits.map((b) => benefitLabels[b] ?? b).toList(); class MemberNotifier extends AsyncNotifier { @override Future build() async { final api = ref.read(apiClientProvider); final data = await api.get>('/member/status'); return MemberInfo.fromJson(data ?? {}); } Future refresh() async { state = await AsyncValue.guard(build); } /// 领取广告激励(服务端限频);adType: effect_extra | vip_trial /// 返回当日剩余次数;超出限频抛 ApiException Future claimReward(String adType) async { final api = ref.read(apiClientProvider); final data = await api.post>('/ad/reward/claim', {'ad_type': adType}); await refresh(); // vip_trial 可能开通体验会员 return (data?['reward']?['remaining_today'] as num?)?.toInt() ?? 0; } } final memberProvider = AsyncNotifierProvider(MemberNotifier.new); class MemberPlan { final int id; final String name; final int priceFen; final int durationDays; final List features; const MemberPlan({ required this.id, required this.name, required this.priceFen, required this.durationDays, required this.features, }); factory MemberPlan.fromJson(Map e) => MemberPlan( id: (e['id'] as num).toInt(), name: e['name'] as String? ?? '', priceFen: (e['price_fen'] as num?)?.toInt() ?? 0, durationDays: (e['duration_days'] as num?)?.toInt() ?? 30, features: _parseFeatures(e['features'] as String? ?? ''), ); static List _parseFeatures(String s) { try { return (jsonDecode(s) as List).cast(); } catch (_) { return const []; } } String get priceText => '¥${(priceFen / 100).toStringAsFixed(priceFen % 100 == 0 ? 0 : 1)}'; } final memberPlanProvider = FutureProvider>((ref) async { final api = ref.read(apiClientProvider); final list = await api.get>('/member/plan/list'); return (list ?? []) .map((e) => MemberPlan.fromJson(e as Map)) .toList(); }); class OrderResult { final String orderNo; final String payUrl; const OrderResult({required this.orderNo, required this.payUrl}); } /// 创建支付订单(后端调虎皮棋下单,返回收银台 URL) Future createMemberOrder(WidgetRef ref, int planId) async { final api = ref.read(apiClientProvider); final data = await api.post>('/member/order/create', {'plan_id': planId}); return OrderResult( orderNo: data?['order_no'] as String? ?? '', payUrl: data?['pay_url'] as String? ?? '', ); } /// 订单状态(支付页 2s 轮询):pending | paid | closed Future fetchOrderStatus(WidgetRef ref, String orderNo) async { final api = ref.read(apiClientProvider); final data = await api.get>('/member/order/status', query: {'order_no': orderNo}); return data?['status'] as String? ?? ''; } ``` - [ ] **Step 2: 运行测试 + 静态检查** Run: `dart analyze lib/features/member lib/core/ads 2>&1 | tail -3` Expected: 无 issue Run: `flutter test test/features/member/benefits_test.dart` Expected: 2 个用例 PASS - [ ] **Step 3: 提交** ```bash git add lib/features/member git commit -m "feat: 会员 provider(状态/套餐/下单/轮询/领奖)" ``` --- ### Task 3: 会员中心页(commercial 重构)+ 入口切换 **Files:** - Create: `lib/features/member/member_center_page.dart` - Delete: `lib/features/commercial/commercial_page.dart` - Modify: `lib/features/home/home_page.dart` - Modify: `lib/main.dart` - [ ] **Step 1: 创建 member_center_page.dart**(完整代码,含会员卡/套餐弹层/广告激励卡/合作门店) ```dart import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; import '../../core/ads/ads_service.dart'; import '../../core/config/app_config.dart'; import '../../shared/widgets/error_view.dart'; import '../../shared/widgets/loading_view.dart'; import 'member_provider.dart'; import 'pay_page.dart'; class PartnerStoreInfo { final int id; final String name; final int type; // 1 造型/发型店,2 服装店 final String address; final String commissionPolicy; const PartnerStoreInfo({ required this.id, required this.name, required this.type, required this.address, required this.commissionPolicy, }); } class StoreNotifier extends AsyncNotifier> { @override Future> build() async { final api = ref.read(apiClientProvider); final list = await api.get>('/partner-store/list'); return list .map((e) => PartnerStoreInfo( id: (e['id'] as num).toInt(), name: e['name'] as String? ?? '', type: (e['type'] as num?)?.toInt() ?? 1, address: e['address'] as String? ?? '', commissionPolicy: e['commission_policy'] as String? ?? '', )) .toList(); } Future refresh() async { state = const AsyncLoading(); state = await AsyncValue.guard(build); } } final storeProvider = AsyncNotifierProvider>(StoreNotifier.new); const _storeTypeLabels = {1: '造型', 2: '服装'}; /// 会员中心:会员状态/套餐充值/广告激励 + 合作门店(P0;最近优惠 P1) class MemberCenterPage extends ConsumerStatefulWidget { const MemberCenterPage({super.key}); @override ConsumerState createState() => _MemberCenterPageState(); } class _MemberCenterPageState extends ConsumerState { int? _typeFilter; bool _rewarding = false; bool get _isIOS => Platform.isIOS; Future _openPlans() async { final plans = await showModalBottomSheet>( context: context, builder: (ctx) => const _PlanSheet(), ); if (plans == null || !mounted) return; try { final result = await createMemberOrder(ref, plans.id); if (!mounted || result.payUrl.isEmpty) return; await context.push('/pay', extra: PayArgs(orderNo: result.orderNo, payUrl: result.payUrl)); ref.read(memberProvider.notifier).refresh(); } catch (e) { if (!mounted) return; Fluttertoast.showToast( msg: e.toString().replaceFirst('Exception: ', '')); } } Future _claimReward(String adType, String successMsg) async { if (_rewarding) return; final ads = ref.read(adsServiceProvider); if (!ads.enabled) { Fluttertoast.showToast(msg: '广告功能暂未开通'); return; } setState(() => _rewarding = true); try { final watched = await ads.showRewarded(); if (!watched) { Fluttertoast.showToast(msg: '未完整观看,无法领取'); return; } final remaining = await ref.read(memberProvider.notifier).claimReward(adType); if (!mounted) return; Fluttertoast.showToast(msg: '$successMsg(今日剩余 $remaining 次)'); } catch (e) { if (!mounted) return; Fluttertoast.showToast( msg: e.toString().replaceFirst('Exception: ', '')); } finally { if (mounted) setState(() => _rewarding = false); } } @override Widget build(BuildContext context) { final member = ref.watch(memberProvider); final stores = ref.watch(storeProvider); final scheme = Theme.of(context).colorScheme; return ListView( padding: const EdgeInsets.all(16), children: [ _MemberCard( member: member, isIOS: _isIOS, onOpenPlans: _openPlans, ), if (member.valueOrNull?.isVip == false) ...[ const SizedBox(height: 12), _AdsRewardCard( rewarding: _rewarding, onClaim: (adType, msg) => _claimReward(adType, msg), ), ], const SizedBox(height: 16), Row( children: [ Text('合作门店', style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), const Spacer(), ChoiceChip( label: const Text('全部'), selected: _typeFilter == null, onSelected: (_) => setState(() => _typeFilter = null), ), const SizedBox(width: 8), for (final entry in _storeTypeLabels.entries) ...[ ChoiceChip( label: Text(entry.value), selected: _typeFilter == entry.key, onSelected: (_) => setState(() => _typeFilter = entry.key), ), const SizedBox(width: 8), ], ], ), const SizedBox(height: 8), stores.when( loading: () => const Padding( padding: EdgeInsets.only(top: 32), child: LoadingView(text: '加载门店...')), error: (e, _) => Padding( padding: const EdgeInsets.only(top: 32), child: ErrorView( message: e.toString().replaceFirst('Exception: ', ''), onRetry: () => ref.read(storeProvider.notifier).refresh(), ), ), data: (list) { final shown = _typeFilter == null ? list : list.where((s) => s.type == _typeFilter).toList(); if (shown.isEmpty) { return const Padding( padding: EdgeInsets.only(top: 32), child: Center( child: Text('附近暂无可合作门店', style: TextStyle(color: Colors.grey))), ); } return Column( children: [ for (final s in shown) ...[ Card( child: ListTile( leading: CircleAvatar( backgroundColor: scheme.primaryContainer, child: Icon( s.type == 1 ? Icons.content_cut : Icons.checkroom), ), title: Text(s.name), subtitle: Text('${s.address}\n${s.commissionPolicy}'), isThreeLine: true, trailing: const Icon(Icons.chevron_right, color: Colors.grey), ), ), const SizedBox(height: 8), ], ], ); }, ), ], ); } } class _MemberCard extends ConsumerWidget { final AsyncValue member; final bool isIOS; final VoidCallback onOpenPlans; const _MemberCard( {required this.member, required this.isIOS, required this.onOpenPlans}); @override Widget build(BuildContext context, WidgetRef ref) { final scheme = Theme.of(context).colorScheme; return Card( color: scheme.primaryContainer, child: Padding( padding: const EdgeInsets.all(16), child: member.when( loading: () => const Text('加载会员状态...', style: TextStyle(fontSize: 13)), error: (e, _) => Text('会员状态加载失败:$e', style: const TextStyle(fontSize: 12)), data: (m) { if (m.isVip) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(Icons.workspace_premium, size: 30, color: scheme.primary), const SizedBox(width: 10), const Text('形象会员', style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold)), const Spacer(), Chip( label: Text(m.planName), labelStyle: TextStyle(color: scheme.primary, fontSize: 12), visualDensity: VisualDensity.compact, ), ]), const SizedBox(height: 6), Text('有效期至 ${m.expireAt}', style: TextStyle(fontSize: 12, color: scheme.primary)), if (benefitTexts(m).isNotEmpty) ...[ const SizedBox(height: 8), Wrap( spacing: 6, runSpacing: 6, children: [ for (final b in benefitTexts(m)) Chip( label: Text(b), labelStyle: const TextStyle(fontSize: 11), visualDensity: VisualDensity.compact, ), ], ), ], ], ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Row(children: [ Icon(Icons.workspace_premium, size: 30), SizedBox(width: 10), Text('形象会员', style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold)), ]), const SizedBox(height: 6), const Text('会员专享:无限次效果图生成 · 优先 AI 方案 · 门店专属折扣', style: TextStyle(fontSize: 12)), const SizedBox(height: 10), if (isIOS) const Text('iOS 端暂不支持充值(App Store 政策),可观看广告获得体验会员', style: TextStyle(fontSize: 11, color: Colors.grey)) else FilledButton.icon( onPressed: onOpenPlans, icon: const Icon(Icons.payment, size: 18), label: const Text('开通会员'), ), ], ); }, ), ), ); } } class _AdsRewardCard extends ConsumerWidget { final bool rewarding; final void Function(String adType, String msg) onClaim; const _AdsRewardCard({required this.rewarding, required this.onClaim}); @override Widget build(BuildContext context, WidgetRef ref) { return Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('免费获取权益', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), const SizedBox(height: 8), ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: const Icon(Icons.ondemand_video, color: Colors.deepPurple), title: const Text('看视频 · 效果图 +1'), subtitle: const Text('每日最多 2 次,次日重置'), trailing: OutlinedButton( onPressed: rewarding ? null : () => onClaim('effect_extra', '已获得 1 次效果图'), child: const Text('看视频'), ), ), ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: const Icon(Icons.ondemand_video, color: Colors.teal), title: const Text('看视频 · 体验会员 1 天'), subtitle: const Text('每日最多 1 次,含无限效果图'), trailing: OutlinedButton( onPressed: rewarding ? null : () => onClaim('vip_trial', '已获得 1 天体验会员'), child: const Text('看视频'), ), ), ], ), ), ); } } class _PlanSheet extends ConsumerWidget { const _PlanSheet(); @override Widget build(BuildContext context, WidgetRef ref) { final plans = ref.watch(memberPlanProvider); return SafeArea( child: Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text('选择会员套餐', textAlign: TextAlign.center, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 12), plans.when( loading: () => const Padding( padding: EdgeInsets.all(24), child: LoadingView()), error: (e, _) => Text('套餐加载失败:$e', textAlign: TextAlign.center, style: const TextStyle(color: Colors.grey)), data: (list) => list.isEmpty ? const Padding( padding: EdgeInsets.all(24), child: Text('暂未开放套餐', textAlign: TextAlign.center), ) : Column( mainAxisSize: MainAxisSize.min, children: [ for (final p in list) ListTile( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), tileColor: Theme.of(context) .colorScheme .primaryContainer .withValues(alpha: 0.5), title: Text(p.name, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600)), subtitle: Text( '${p.durationDays} 天 · ${p.features.map((f) => benefitLabels[f] ?? f).join(' · ')}', style: const TextStyle(fontSize: 12), maxLines: 2, overflow: TextOverflow.ellipsis, ), trailing: Text(p.priceText, style: TextStyle( color: Theme.of(context).colorScheme.primary, fontSize: 16, fontWeight: FontWeight.bold)), onTap: () => Navigator.pop(context, p), ), ], ), ), ], ), ), ); } } ``` > 注意:`_openPlans` 里 `plans` 变量名与 `memberPlanProvider` 无关;`showModalBottomSheet` 返回选中的套餐。依赖 `AppConfig` 在 import 中(本页未用到可删,`apiClientProvider` 来自 `api_client.dart`,StoreNotifier 里用到——需 import `../../core/network/api_client.dart`)。 - [ ] **Step 2: 删除旧页并切换入口** ```bash rm lib/features/commercial/commercial_page.dart ``` `home_page.dart` 修改:import 换 `../member/member_center_page.dart`,第 4 Tab 用 `MemberCenterPage()`,标题与 label 改为「会员中心」: ```dart import '../member/member_center_page.dart'; // ... static const _titles = ['我的形象', '我的衣橱', '穿搭方案', '会员中心']; // ... MemberCenterPage(), // ... NavigationDestination( icon: Icon(Icons.store_outlined), selectedIcon: Icon(Icons.store), label: '会员'), ``` - [ ] **Step 3: main.dart /commercial 路由指向新页** `lib/main.dart`:`import '../features/member/member_center_page.dart';`,第 78-82 行 `/commercial` 的 builder 改为 `MemberCenterPage()`。 - [ ] **Step 4: 静态检查** Run: `dart analyze lib 2>&1 | tail -5` Expected: 无 error(可能提示 unused import `app_config.dart`,删掉即可) - [ ] **Step 5: 提交** ```bash git add lib/features/member/member_center_page.dart lib/features/home/home_page.dart lib/main.dart git add -u lib/features/commercial git commit -m "feat: 会员中心页(会员卡/套餐弹层/广告激励/合作门店)" ``` --- ### Task 4: 支付页(轮询确认结果) **Files:** - Create: `lib/features/member/pay_page.dart` - Modify: `lib/main.dart` - [ ] **Step 1: 创建 pay_page.dart** ```dart import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; import 'member_provider.dart'; class PayArgs { final String orderNo; final String payUrl; const PayArgs({required this.orderNo, required this.payUrl}); } enum PayPhase { launching, paying, paid, timeout, failed } /// 支付页:打开系统浏览器收银台,2s 轮询订单状态(上限 60s) class PayPage extends ConsumerStatefulWidget { final PayArgs args; const PayPage({super.key, required this.args}); @override ConsumerState createState() => _PayPageState(); } class _PayPageState extends ConsumerState { PayPhase _phase = PayPhase.launching; Timer? _timer; int _elapsed = 0; @override void initState() { super.initState(); _start(); } @override void dispose() { _timer?.cancel(); super.dispose(); } Future _start() async { try { final ok = await launchUrl(Uri.parse(widget.args.payUrl), mode: LaunchMode.externalApplication); if (!ok) { setState(() => _phase = PayPhase.failed); return; } setState(() => _phase = PayPhase.paying); } catch (e) { setState(() => _phase = PayPhase.failed); return; } _timer = Timer.periodic(const Duration(seconds: 2), (_) => _check()); } Future _check() async { _elapsed += 2; try { final status = await fetchOrderStatus(ref, widget.args.orderNo); if (status == 'paid') { _timer?.cancel(); if (!mounted) return; setState(() => _phase = PayPhase.paid); Fluttertoast.showToast(msg: '会员开通成功'); return; } if (_elapsed >= 60) { _timer?.cancel(); if (!mounted) return; setState(() => _phase = PayPhase.timeout); } } catch (_) { // 轮询失败不中断,下次再试 } } @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar(title: const Text('会员支付')), body: Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ switch (_phase) { PayPhase.launching || PayPhase.paying => Column(children: [ const CircularProgressIndicator(), const SizedBox(height: 16), const Text('请在浏览器中完成支付,正在确认结果…'), const SizedBox(height: 8), Text('订单号 ${widget.args.orderNo}', style: const TextStyle(color: Colors.grey, fontSize: 12)), const SizedBox(height: 16), OutlinedButton( onPressed: () => _check(), child: const Text('我已完成支付'), ), ]), PayPhase.paid => Column(children: [ Icon(Icons.check_circle, size: 64, color: scheme.primary), const SizedBox(height: 12), const Text('支付成功,会员已开通!', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 16), FilledButton( onPressed: () => context.pop(), child: const Text('返回会员中心'), ), ]), PayPhase.timeout => Column(children: [ Icon(Icons.hourglass_empty, size: 64, color: Colors.orange), const SizedBox(height: 12), const Text('支付结果确认中'), const SizedBox(height: 8), const Text('可稍后到会员中心查看开通状态,以支付结果为准', textAlign: TextAlign.center, style: TextStyle(color: Colors.grey, fontSize: 12)), const SizedBox(height: 16), OutlinedButton( onPressed: () => context.pop(), child: const Text('返回'), ), ]), PayPhase.failed => Column(children: [ Icon(Icons.error_outline, size: 64, color: scheme.error), const SizedBox(height: 12), const Text('无法打开支付页面'), const SizedBox(height: 16), OutlinedButton( onPressed: () => context.pop(), child: const Text('返回'), ), ]), }, ], ), ), ), ); } } ``` - [ ] **Step 2: main.dart 注册路由** ```dart GoRoute( path: '/pay', builder: (context, state) => PayPage(args: state.extra! as PayArgs), ), ``` (import `../features/member/pay_page.dart`) - [ ] **Step 3: 静态检查** Run: `dart analyze lib 2>&1 | tail -5` Expected: 无 error - [ ] **Step 4: 提交** ```bash git add lib/features/member/pay_page.dart lib/main.dart git commit -m "feat: 支付页(系统浏览器收银台 + 2s 轮询确认)" ``` --- ### Task 5: 全量验证与冒烟 - [ ] **Step 1: 单元测试** Run: `flutter test` Expected: 全部 PASS(含新增 benefits 2 个用例) - [ ] **Step 2: 全量编译(web 兜底)** Run: `cd /Users/zhangbin/Desktop/d盘/work/slogan/slogan-app && flutter build web --release` Expected: 构建成功(若提示 stale cache,先 `flutter clean && flutter pub get`) - [ ] **Step 3: 冒烟(起后端,mock 支付/广告降级路径)** 后端按后端计划 T9 起服务(不配 mock 时): ```bash # 会员状态:未登录返回 401;已登录非会员返回 is_vip=false # 套餐列表:返回 2 个套餐 # 下单:报"支付未开通" → App 开通按钮 toast 提示,不崩溃 # 广告:claim 前 2 次成功(remaining 1/0),第 3 次报"今日次数已用完" → toast 展示 ``` - [ ] **Step 4: 提交** ```bash git add -A git status # 确认无残留 git log --oneline -5 ``` --- ## 自检清单 - [ ] /commercial(home 第 4 Tab 与路由)指向会员中心,旧 commercial_page 已删除 - [ ] iOS 隐藏充值入口(Platform.isIOS),广告激励保留 - [ ] 支付页轮询 2s/60s 上限,paid/timeout/failed 三态完整;返回后刷新会员状态 - [ ] 广告入口走 adsServiceProvider 抽象,Mock 可用,穿山甲 P1 替换点已注明 - [ ] 所有后端接口错误 toast 展示 message,不崩溃;未开通时入口不渲染/隐藏 - [ ] `dart analyze` 无 error、`flutter test` 全过、web 编译成功 ## 后续计划(P1,不在本计划内) 方案页三处 CPS 入口(做同款发型/买同款/到店试穿)、/cps-product-list 商品列表、衣橱「找升级款」、最近优惠、穿山甲 SDK 替换 Mock、webview 内嵌收银台。