git-subtree-dir: app git-subtree-mainline:6ebd902c6bgit-subtree-split:a11a668869
33 KiB
商业化 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:
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) {
// P1:AppConfig.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 测试依赖的
MemberInfo与benefitTexts)
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),
),
],
),
),
],
),
),
);
}
}
注意:
_openPlans里plans变量名与memberPlanProvider无关;showModalBottomSheet返回选中的套餐。依赖AppConfig在 import 中(本页未用到可删,apiClientProvider来自api_client.dart,StoreNotifier 里用到——需 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.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: 提交
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
自检清单
- /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 内嵌收银台。