Files
slogan/docs/superpowers/plans/2026-07-31-commerce-p0-app.md
T

33 KiB
Raw Blame History

商业化 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 testflutter build web --release 全量编译、mock 后端冒烟。


任务总览与文件映射

任务 文件
T1 pubspec.yamllib/core/ads/ads_service.darttest/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.dartlib/main.dart
T4 lib/features/member/pay_page.dartlib/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:

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
  url_launcher: ^6.3.0
  • Step 4: 广告抽象 lib/core/ads/ads_service.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

/// 广告服务抽象:P0 用 Mock(保证业务链路可开发可测),P1 换穿山甲 SDK
abstract class AdsService {
  bool get enabled;
  Future<bool> showRewarded();
}

/// 本地模拟激励视频(约 1 秒"播放"后返回完整观看)
class MockAdsService implements AdsService {
  @override
  bool get enabled => true;

  @override
  Future<bool> showRewarded() async {
    await Future.delayed(const Duration(milliseconds: 900));
    return true;
  }
}

final adsServiceProvider = Provider<AdsService>((ref) {
  // P1AppConfig.pangleAppId 非空时替换为 PangleAdsService(穿山甲 SDK 实现)
  return MockAdsService();
});
  • Step 5: 提交
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 测试依赖的 MemberInfobenefitTexts

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<String> benefits;

  const MemberInfo({
    required this.isVip,
    required this.expireAt,
    required this.planName,
    required this.benefits,
  });

  factory MemberInfo.fromJson(Map<String, dynamic> 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<dynamic>? ?? []).cast<String>(),
      );
}

/// 权益 key → 文案
const benefitLabels = {
  'effect_unlimited': '无限效果图',
  'ai_priority': '优先 AI 方案',
  'cps_commission_x15': '返现加成 1.5x',
  'store_discount': '门店折扣',
};

List<String> benefitTexts(MemberInfo m) =>
    m.benefits.map((b) => benefitLabels[b] ?? b).toList();

class MemberNotifier extends AsyncNotifier<MemberInfo> {
  @override
  Future<MemberInfo> build() async {
    final api = ref.read(apiClientProvider);
    final data = await api.get<Map<String, dynamic>>('/member/status');
    return MemberInfo.fromJson(data ?? {});
  }

  Future<void> refresh() async {
    state = await AsyncValue.guard(build);
  }

  /// 领取广告激励(服务端限频);adType: effect_extra | vip_trial
  /// 返回当日剩余次数;超出限频抛 ApiException
  Future<int> claimReward(String adType) async {
    final api = ref.read(apiClientProvider);
    final data =
        await api.post<Map<String, dynamic>>('/ad/reward/claim', {'ad_type': adType});
    await refresh(); // vip_trial 可能开通体验会员
    return (data?['reward']?['remaining_today'] as num?)?.toInt() ?? 0;
  }
}

final memberProvider =
    AsyncNotifierProvider<MemberNotifier, MemberInfo>(MemberNotifier.new);

class MemberPlan {
  final int id;
  final String name;
  final int priceFen;
  final int durationDays;
  final List<String> features;

  const MemberPlan({
    required this.id,
    required this.name,
    required this.priceFen,
    required this.durationDays,
    required this.features,
  });

  factory MemberPlan.fromJson(Map<String, dynamic> 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<String> _parseFeatures(String s) {
    try {
      return (jsonDecode(s) as List<dynamic>).cast<String>();
    } catch (_) {
      return const [];
    }
  }

  String get priceText =>
      ${(priceFen / 100).toStringAsFixed(priceFen % 100 == 0 ? 0 : 1)}';
}

final memberPlanProvider = FutureProvider<List<MemberPlan>>((ref) async {
  final api = ref.read(apiClientProvider);
  final list = await api.get<List<dynamic>>('/member/plan/list');
  return (list ?? [])
      .map((e) => MemberPlan.fromJson(e as Map<String, dynamic>))
      .toList();
});

class OrderResult {
  final String orderNo;
  final String payUrl;

  const OrderResult({required this.orderNo, required this.payUrl});
}

/// 创建支付订单(后端调虎皮棋下单,返回收银台 URL)
Future<OrderResult> createMemberOrder(WidgetRef ref, int planId) async {
  final api = ref.read(apiClientProvider);
  final data =
      await api.post<Map<String, dynamic>>('/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<String> fetchOrderStatus(WidgetRef ref, String orderNo) async {
  final api = ref.read(apiClientProvider);
  final data = await api.get<Map<String, dynamic>>('/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: 提交
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(完整代码,含会员卡/套餐弹层/广告激励卡/合作门店)

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<List<PartnerStoreInfo>> {
  @override
  Future<List<PartnerStoreInfo>> build() async {
    final api = ref.read(apiClientProvider);
    final list = await api.get<List<dynamic>>('/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<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(build);
  }
}

final storeProvider =
    AsyncNotifierProvider<StoreNotifier, List<PartnerStoreInfo>>(StoreNotifier.new);

const _storeTypeLabels = {1: '造型', 2: '服装'};

/// 会员中心:会员状态/套餐充值/广告激励 + 合作门店(P0;最近优惠 P1)
class MemberCenterPage extends ConsumerStatefulWidget {
  const MemberCenterPage({super.key});

  @override
  ConsumerState<MemberCenterPage> createState() => _MemberCenterPageState();
}

class _MemberCenterPageState extends ConsumerState<MemberCenterPage> {
  int? _typeFilter;
  bool _rewarding = false;

  bool get _isIOS => Platform.isIOS;

  Future<void> _openPlans() async {
    final plans = await showModalBottomSheet<List<MemberPlan>>(
      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<void> _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<MemberInfo> 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),
                          ),
                      ],
                    ),
            ),
          ],
        ),
      ),
    );
  }
}

注意:_openPlansplans 变量名与 memberPlanProvider 无关;showModalBottomSheet 返回选中的套餐。依赖 AppConfig 在 import 中(本页未用到可删,apiClientProvider 来自 api_client.dartStoreNotifier 里用到——需 import ../../core/network/api_client.dart)。

  • Step 2: 删除旧页并切换入口
rm lib/features/commercial/commercial_page.dart

home_page.dart 修改:import 换 ../member/member_center_page.dart,第 4 Tab 用 MemberCenterPage(),标题与 label 改为「会员中心」:

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.dartimport '../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: 提交
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

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<PayPage> createState() => _PayPageState();
}

class _PayPageState extends ConsumerState<PayPage> {
  PayPhase _phase = PayPhase.launching;
  Timer? _timer;
  int _elapsed = 0;

  @override
  void initState() {
    super.initState();
    _start();
  }

  @override
  void dispose() {
    _timer?.cancel();
    super.dispose();
  }

  Future<void> _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<void> _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 注册路由
      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: 提交
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 时):

# 会员状态:未登录返回 401;已登录非会员返回 is_vip=false
# 套餐列表:返回 2 个套餐
# 下单:报"支付未开通" → App 开通按钮 toast 提示,不崩溃
# 广告:claim 前 2 次成功(remaining 1/0),第 3 次报"今日次数已用完" → toast 展示
  • Step 4: 提交
git add -A
git status   # 确认无残留
git log --oneline -5

自检清单

  • /commercialhome 第 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 内嵌收银台。