docs: 商业化 P0 实现计划(会员中心+广告激励,客户端)
This commit is contained in:
@@ -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<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: 提交**
|
||||
|
||||
```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<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: 提交**
|
||||
|
||||
```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<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: 删除旧页并切换入口**
|
||||
|
||||
```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<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 注册路由**
|
||||
|
||||
```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 内嵌收银台。
|
||||
Reference in New Issue
Block a user