diff --git a/docs/superpowers/plans/2026-07-31-commerce-p0-app.md b/docs/superpowers/plans/2026-07-31-commerce-p0-app.md new file mode 100644 index 0000000..7cdbdc6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-commerce-p0-app.md @@ -0,0 +1,969 @@ +# 商业化 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 内嵌收银台。