Add 'app/' from commit 'a11a66886941bba128d89c1124115e1b1c87a128'
git-subtree-dir: app git-subtree-mainline:6ebd902c6bgit-subtree-split:a11a668869
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 内嵌收银台。
|
||||
@@ -0,0 +1,372 @@
|
||||
# slogan-app MVP 实现计划
|
||||
|
||||
> **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:** 实现 slogan-app Flutter MVP:登录 → 4 Tab 框架 → 拍照上传 → 衣橱管理 → 化身查看 → 穿搭生成(日期/地点 → 轮询 → 方案流 3D+2D 切换查看)。
|
||||
|
||||
**Architecture:** Flutter 单仓,Riverpod 状态管理,Dio 网络层(JWT 拦截器 + 统一响应),AvatarViewer 渲染抽象层(three_dart v1 实现,flutter_scene 后续替换),核心交互为方案 PageView 横滑 + 3D 化身拖拽旋转。
|
||||
|
||||
**Tech Stack:** Flutter (stable) / Riverpod / Dio / go_router / three_dart + three_dart_jsm / cached_network_image / camera + image_picker / shared_preferences
|
||||
|
||||
**后端对接:** slogan-agent(Go)API,见 slogan-agent 仓库 `docs/superpowers/specs/2026-07-31-slogan-agent-design.md` 第 11 节路由表。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 项目骨架
|
||||
|
||||
**Files:**
|
||||
- Run: `flutter create` 初始化(org 自定,项目名 slogan_app)
|
||||
- Modify: `pubspec.yaml`(依赖)
|
||||
- Create: `lib/main.dart`(入口 + 主题 + go_router 路由表)
|
||||
- Create: `lib/core/config/app_config.dart`
|
||||
|
||||
- [ ] **Step 1: 初始化**
|
||||
|
||||
```bash
|
||||
cd slogan-app
|
||||
flutter create --org com.slogan --project-name slogan_app .
|
||||
```
|
||||
|
||||
- [ ] **Step 2: pubspec.yaml 依赖**
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
flutter_riverpod: ^2.6.0
|
||||
dio: ^5.7.0
|
||||
go_router: ^14.0.0
|
||||
cached_network_image: ^3.4.0
|
||||
image_picker: ^1.1.0
|
||||
camera: ^0.11.0
|
||||
shared_preferences: ^2.3.0
|
||||
three_dart: ^0.2.0
|
||||
three_dart_jsm: ^0.2.0
|
||||
fluttertoast: ^8.2.0
|
||||
```
|
||||
|
||||
(版本以 pub.dev 最新稳定为准,`flutter pub add` 逐个添加)
|
||||
|
||||
- [ ] **Step 3: main.dart**:MaterialApp + Theme(Material 3,seed 主色)+ go_router 路由(login / home(4 tab) / photo-guide / plan-flow / plan-detail);启动时读取 token → 无 token 重定向登录页
|
||||
|
||||
- [ ] **Step 4: 验证** `flutter analyze` 无错误 + `flutter test` 默认通过
|
||||
|
||||
- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: flutter skeleton with router and theme"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 网络层(ApiClient + JWT 拦截器)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/core/network/api_client.dart`
|
||||
- Create: `lib/core/network/api_exception.dart`
|
||||
- Create: `lib/core/storage/token_storage.dart`
|
||||
- Test: `test/core/network/api_client_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(mock Dio adapter:401 触发登出回调;code!=0 抛 ApiException 带 message;成功解析 data)
|
||||
|
||||
- [ ] **Step 2: 确认失败** `flutter test`
|
||||
|
||||
- [ ] **Step 3: 实现 api_client.dart**
|
||||
|
||||
```dart
|
||||
class ApiClient {
|
||||
ApiClient({Dio? dio, required TokenStorage tokenStorage})
|
||||
: _tokenStorage = tokenStorage {
|
||||
_dio = dio ?? Dio(BaseOptions(
|
||||
baseUrl: AppConfig.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
));
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
final token = _tokenStorage.token;
|
||||
if (token != null) options.headers['Authorization'] = 'Bearer $token';
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (e, handler) {
|
||||
if (e.response?.statusCode == 401) onUnauthorized?.call();
|
||||
handler.next(e);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<T> post<T>(String path, Map<String, dynamic> body,
|
||||
{T Function(dynamic data)? parse}) async {
|
||||
final res = await _dio.post(path, data: body);
|
||||
return _unwrap<T>(res, parse);
|
||||
}
|
||||
Future<T> get<T>(String path, {Map<String, dynamic>? query, T Function(dynamic data)? parse}) async { ... }
|
||||
Future<T> upload<T>(String path, Map<String, dynamic> fields, String fileField, String filePath, {String Function(dynamic)? parse}) async { ... }
|
||||
|
||||
T _unwrap<T>(Response res, ...) {
|
||||
final code = res.data['code'] as int;
|
||||
final message = res.data['message'] as String? ?? '';
|
||||
if (code != 0) throw ApiException(code, message);
|
||||
return parse?.call(res.data['data']) ?? res.data['data'] as T;
|
||||
}
|
||||
|
||||
VoidCallback? onUnauthorized;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: TokenStorage**(shared_preferences 封装:token 读写 + 清空)
|
||||
|
||||
- [ ] **Step 5: 测试通过** + `flutter analyze` + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 认证(登录页 + 状态)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/core/auth/auth_provider.dart`
|
||||
- Create: `lib/features/auth/login_page.dart`
|
||||
- Test: `test/core/auth/auth_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(ProviderContainer:登录成功 → token 持久化 + 状态 authenticated;失败 → 状态 error 携带 message)
|
||||
|
||||
- [ ] **Step 2: 实现 auth_provider.dart**(AsyncNotifier:login(account, password) → ApiClient.post('/user/login') → 存 token)
|
||||
|
||||
- [ ] **Step 3: login_page.dart**:账号/密码输入 + 登录按钮 + 加载态 + 错误提示(fluttertoast);登录成功 go_router push 替换到 home
|
||||
|
||||
- [ ] **Step 4: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 4 Tab 主框架
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/home/home_page.dart`(BottomNavigationBar + IndexedStack 4 Tab)
|
||||
- Create: `lib/features/profile/profile_page.dart`(占位)
|
||||
- Create: `lib/features/wardrobe/wardrobe_page.dart`(占位)
|
||||
- Create: `lib/features/outfit/outfit_page.dart`(占位)
|
||||
- Create: `lib/features/commercial/commercial_page.dart`(占位)
|
||||
|
||||
- [ ] **Step 1: 实现 4 Tab 框架**:底部导航(我的形象/我的衣橱/穿搭方案/门店电商)+ 图标 + IndexedStack 保状态
|
||||
|
||||
- [ ] **Step 2: Widget 测试**:切 Tab 显示对应页面
|
||||
|
||||
- [ ] **Step 3: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 拍照引导 + 照片上传(Tab1)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/profile/photo_guide_page.dart`(引导 + 拍摄)
|
||||
- Create: `lib/features/profile/photo_guide_item.dart`(单张拍摄卡片)
|
||||
- Create: `lib/features/profile/photo_upload_provider.dart`
|
||||
- Create: `lib/features/profile/profile_page.dart`(集成:照片齐备度展示 + 上传入口)
|
||||
- Test: `test/features/profile/photo_upload_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(provider:mock ApiClient.upload 成功 → 状态更新;失败 → error)
|
||||
|
||||
- [ ] **Step 2: 实现 provider**:4 个类型照片上传(type 1-4),逐张上传成功后标记完成;本地压缩(`image_picker` 自带 maxWidth: 2048)
|
||||
|
||||
- [ ] **Step 3: photo_guide_page.dart**:4 张卡片(大头照/全身正面/侧面/背面,各含拍摄示例说明文案 + 相机按钮 image_picker 拍摄)+ 上传进度 + 完成态跳转
|
||||
|
||||
- [ ] **Step 4: profile_page.dart**:显示 4 张照片状态(已传/未传)+ "进入拍摄引导" 按钮 + 身形参数入口(Task 6)
|
||||
|
||||
- [ ] **Step 5: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 身形参数 + 化身状态(Tab1)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/profile/body_tune_page.dart`(滑杆微调)
|
||||
- Create: `lib/features/profile/body_provider.dart`
|
||||
- Create: `lib/features/profile/avatar_provider.dart`
|
||||
- Test: `test/features/profile/avatar_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(avatar provider:get → build → 状态 done + glbUrl 非空)
|
||||
|
||||
- [ ] **Step 2: body_tune_page.dart**:身高(145-200cm)/体重/肤色(1-5) 滑杆,保存 → POST /body-measurement/save
|
||||
|
||||
- [ ] **Step 3: avatar_provider.dart**:页面进入时 GET /avatar/get → 无记录则提示先传照片 → POST /avatar/build → 轮询 build_status(每 2s × 最多 30 次)→ done 后展示 glb_url
|
||||
|
||||
- [ ] **Step 4: profile_page.dart** 集成:身形参数卡片 + 化身构建按钮 + 构建状态展示
|
||||
|
||||
- [ ] **Step 5: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 衣橱管理(Tab2)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/wardrobe/wardrobe_provider.dart`
|
||||
- Create: `lib/features/wardrobe/wardrobe_page.dart`(网格)
|
||||
- Create: `lib/features/wardrobe/wardrobe_upload_page.dart`(上传表单:照片 + 分类 + 季节 + 风格标签)
|
||||
- Test: `test/features/wardrobe/wardrobe_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(provider:upload 成功追加列表;delete 移除;list 加载)
|
||||
|
||||
- [ ] **Step 2: 实现 provider** + 上传表单页(DropdownButton 分类[上衣/下装/鞋/配饰] + 季节 + 标签输入)
|
||||
|
||||
- [ ] **Step 3: 网格页**:GridView 服装照片 + 长按删除确认 + 空态引导("衣橱空空如也,去上传第一件衣服吧")
|
||||
|
||||
- [ ] **Step 4: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 生成入口(Tab3 上半)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/outfit/generate_page.dart`
|
||||
- Create: `lib/features/outfit/outfit_generate_provider.dart`
|
||||
- Test: `test/features/outfit/outfit_generate_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(provider:generate 成功返回 taskId;开始轮询状态)
|
||||
|
||||
- [ ] **Step 2: 生成入口页**:日期范围(showDateRangePicker)+ 地点(TextField + 定位按钮[geolocator 可选,v1 手动输入])+ "生成穿搭" 按钮(校验:日期非空/地点非空/衣橱非空提示)
|
||||
|
||||
- [ ] **Step 3: 生成确认后** → 跳转任务状态页(Task 9)
|
||||
|
||||
- [ ] **Step 4: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 任务轮询 + 方案流(Tab3 核心)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/outfit/task_status_page.dart`
|
||||
- Create: `lib/features/outfit/plan_flow_page.dart`(PageView 横滑)
|
||||
- Create: `lib/features/outfit/plan_provider.dart`
|
||||
- Test: `test/features/outfit/plan_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(provider:task 轮询 done → 加载 plan list;failed → error)
|
||||
|
||||
- [ ] **Step 2: task_status_page.dart**:轮询 GET /outfit/task/status(Timer.periodic 3s),状态文案映射(pending=准备中/planning=方案规划中/scoring=方案评分中/rendering=效果图生成中/done=完成/failed=失败+error 展示);done → 跳转方案流;失败显示重试
|
||||
|
||||
- [ ] **Step 3: plan_provider.dart**:GET /outfit/plan/list + detail(含 items/images/hairstyle)
|
||||
|
||||
- [ ] **Step 4: plan_flow_page.dart**:PageView.builder 每页一张方案卡片:
|
||||
- 3D 化身区(AvatarViewer,Task 10)
|
||||
- 方案摘要(标题/评分 Chip/来源标签:衣橱组合=蓝 / AI 推荐=橙)
|
||||
- 发型切换(横排发型 chip)+ 发色取色(HSV 面板)
|
||||
- 底部条目列表(slot 图标 + 名称 + 描述;推荐条目带"查看商品"入口,v1 占位)
|
||||
- "选为主方案" 按钮 → POST select-main → 效果图页(Task 11)
|
||||
|
||||
- [ ] **Step 5: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 10: AvatarViewer 抽象 + three_dart 实现
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/outfit/viewer/avatar_viewer.dart`(接口 + controller)
|
||||
- Create: `lib/features/outfit/viewer/avatar_viewer_three_dart.dart`(three_dart 实现)
|
||||
- Create: `lib/features/outfit/viewer/avatar_viewer_placeholder.dart`(降级占位:头像图 + 手势提示)
|
||||
- Create: `lib/features/outfit/viewer/viewer_factory.dart`
|
||||
- Test: `test/features/outfit/viewer/avatar_viewer_placeholder_test.dart`
|
||||
|
||||
- [ ] **Step 1: 定义抽象接口**
|
||||
|
||||
```dart
|
||||
/// 渲染层抽象:业务代码只依赖此接口,flutter_scene 进 stable 后提供第二实现
|
||||
abstract class AvatarViewerController {
|
||||
Future<void> loadAvatar(String glbUrl); // 头像+体型主体
|
||||
Future<void> loadHairstyle(String glbUrl); // 发型层
|
||||
void setHairColor(Color color); // 发色 PBR baseColor
|
||||
void rotateBy(double dx, double dy);
|
||||
void zoomBy(double scale);
|
||||
void resetView();
|
||||
}
|
||||
|
||||
class AvatarViewer extends StatefulWidget {
|
||||
final AvatarViewerController Function() controllerFactory;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 three_dart 版**:`three_dart` + `three_dart_jsm` GLTFLoader 加载 GLB → Scene 显示(DirectionalLight + AmbientLight + OrbitControls 式手动手势:onPanUpdate → rotateBy 旋转 Object3D,onScaleUpdate → zoomBy 缩放相机/模型)—— 参考 `three_js_advanced_loaders` 示例;GLB 本地缓存(Task 13)
|
||||
|
||||
- [ ] **Step 3: 降级实现**:加载失败(网络/格式)→ placeholder(用户大头照 Image + "3D 模型加载失败,显示照片效果" 文案)
|
||||
|
||||
- [ ] **Step 4: viewer_factory.dart**:`AvatarViewer createViewer()` → three_dart 实现(有 GLB url 时)/ placeholder(无 url 时)
|
||||
|
||||
- [ ] **Step 5: Widget 测试**(placeholder 渲染)+ `flutter analyze` + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 效果图查看 + 方案详情(Tab3 下半)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/outfit/effect_image_page.dart`
|
||||
- Create: `lib/features/outfit/plan_detail_provider.dart`
|
||||
- Test: `test/features/outfit/plan_detail_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(provider:select-main → 轮询 detail.images 直到 3 张完成)
|
||||
|
||||
- [ ] **Step 2: select-main 后跳转效果图页**:3 视角(正面/侧面/背面)Tab/滑块切换,cached_network_image 加载,生成中展示进度(轮询 plan/detail images status)
|
||||
|
||||
- [ ] **Step 3: 方案详情页**(从列表进入):完整 detail 渲染(items 图片/名称/描述 + 效果图 + 收藏按钮 POST review)
|
||||
|
||||
- [ ] **Step 4: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 门店/电商(Tab4,MVP 列表展示)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/features/commercial/store_page.dart`(附近门店列表)
|
||||
- Create: `lib/features/commercial/store_provider.dart`
|
||||
- Create: `lib/features/commercial/subscription_page.dart`(订阅占位)
|
||||
- Test: `test/features/commercial/store_provider_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(provider:GET /partner-store/list 解析列表)
|
||||
|
||||
- [ ] **Step 2: 门店列表页**:类型筛选 chip(形象设计/服装门店)+ ListView 卡片(名称/类型/地址/距离占位)
|
||||
|
||||
- [ ] **Step 3: 订阅页**:标准版/Pro 权益卡片 + "开通"按钮(v1 占位 toast"支付功能开发中")
|
||||
|
||||
- [ ] **Step 4: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 13: 缓存与性能
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/core/storage/`(新增 glb_cache.dart)
|
||||
- Create: `lib/core/storage/glb_cache.dart`(GLB 本地 LRU)
|
||||
- Test: `test/core/storage/glb_cache_test.dart`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**(缓存:put → hit;LRU 超限淘汰;过期清理)
|
||||
|
||||
- [ ] **Step 2: 实现**:文件缓存目录 `getApplicationSupportDirectory()/glb_cache/`,key=url hash,256MB 上限(超限按最后访问时间淘汰);方案流预取下一页 GLB
|
||||
|
||||
- [ ] **Step 3: 测试通过** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 14: 错误处理与加载状态组件
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/shared/widgets/loading_view.dart`(骨架屏)
|
||||
- Create: `lib/shared/widgets/error_view.dart`(错误 + 重试)
|
||||
- Create: `lib/shared/widgets/empty_view.dart`(空态 + 引导动作)
|
||||
|
||||
- [ ] **Step 1: 三个组件**(loading 骨架、error 文案+重试回调、empty 图标+文案+按钮)
|
||||
|
||||
- [ ] **Step 2: 接入**:profile/wardrobe/outfit/commercial 各页加载态/空态/错误态替换
|
||||
|
||||
- [ ] **Step 3: Widget 测试** + **Commit**
|
||||
|
||||
---
|
||||
|
||||
### Task 15: 集成冒烟(联调 slogan-agent)
|
||||
|
||||
- [ ] **Step 1: 本地起 slogan-agent**(`go run main.go`,3007 端口,mock 效果图供应商)
|
||||
|
||||
- [ ] **Step 2: App 联调**(iOS 模拟器 + Android 模拟器各一遍):
|
||||
1. 登录 → 2. 拍照引导上传 4 张(相册选图代替拍摄)→ 3. 身形参数 → 4. 构建化身 → 5. 衣橱上传 3+ 件 → 6. 生成穿搭(日期+地点)→ 7. 任务轮询 → 8. 方案流(3D 查看/发型切换/横滑)→ 9. 选主方案 → 10. 效果图 3 视角查看
|
||||
|
||||
- [ ] **Step 3: 修复联调问题**(网络/字段/状态映射),`flutter analyze` 0 错误
|
||||
|
||||
- [ ] **Step 4: Commit** `git commit -m "feat: mvp complete, verified against slogan-agent"`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 备注
|
||||
|
||||
- 后端字段命名 snake_case(JSON),Dart 侧解析用 map 取值避免命名映射负担
|
||||
- 效果图生成在后端为异步任务,App 端轮询 plan/detail 的 images 状态(rendering→done)
|
||||
- three_dart 若 iOS 渲染异常(着色器兼容),降级路径 placeholder 保证 MVP 可用;GLB 渲染验证放 Task 10 明确检查
|
||||
@@ -0,0 +1,204 @@
|
||||
# 商业化四支柱设计(客户端)· slogan-app
|
||||
|
||||
> **目标:** 以「个人形象设计」为主流程,把四支柱收入入口从「方案/单品」里长出来:VIP 会员充值、穿山甲广告激励、线下门店引流(美团联盟)、线上商品(京东/淘宝 CPS)。不做泛化场景广场。
|
||||
> **核心原则:** 客户端零硬编码业务配置;所有商业化入口以「接口可用」为开关 —— 后端未配置 key 时接口报错/返回空 → App 自动隐藏对应入口,主流程(生成方案 → 查看)不受影响。
|
||||
|
||||
## 1. 信息架构改造
|
||||
|
||||
```
|
||||
改造前:/commercial 孤立 tab(会员占位卡 + 门店列表)
|
||||
改造后:
|
||||
├─ /commercial 会员中心(P0) 会员状态卡 · 套餐 · 广告激励 · 合作门店 · 最近优惠
|
||||
├─ /plan-viewer 方案页(P0) 发型卡「做同款发型」· 穿衣清单「买同款/到店试穿」· 场合「延伸优惠」
|
||||
├─ /wardrobe 衣橱(P1) 长按「找升级款」
|
||||
└─ 全局(P2) 开屏广告 / 信息流广告位(效果图页底部)
|
||||
```
|
||||
|
||||
**开关原则**:每个商业化入口包一层 `commercialGate`(统一查询后端配置/捕获接口错误),未开通 → 按钮不渲染。启动时不做额外网络调用,**入口可见性由首次打开该页面的接口结果决定**(零新增请求)。
|
||||
|
||||
## 2. 支柱 A:会员中心(/commercial 重构)
|
||||
|
||||
### 2.1 页面结构
|
||||
|
||||
```
|
||||
/ commercial(ConsumerStatefulWidget,保留现有门店列表与类型筛选)
|
||||
├─ 会员状态卡:头像/会员名 · is_vip · expire_at(倒计时)· 权益 chips(无限效果图/优先AI/返现1.5x/门店折扣)
|
||||
│ ├─ 未开通 →「立即开通」按钮 → 打开套餐 bottom sheet
|
||||
│ └─ 已开通 →「会员码」按钮(store_discount 到店出示,P1)
|
||||
├─ 广告激励卡(非会员时显示):
|
||||
│ ├─ 看视频 +1 效果图(每日 2 次,剩余次数显示)
|
||||
│ └─ 看视频 1 天体验会员(每日 1 次)
|
||||
├─ 合作门店列表(现有 /partner-store/list 品牌合作门店保留,含「会员价」角标 P1;
|
||||
│ 美团联盟到店券走 /cps/product/list,两条链路互不替换)
|
||||
└─ 最近优惠(/cps/my/recent,P1,未开通则隐藏整卡)
|
||||
```
|
||||
|
||||
### 2.2 支付时序(App 侧)
|
||||
|
||||
```
|
||||
点击套餐 → POST /member/order/create {plan_id} → 返回 {order_no, pay_url}
|
||||
→ 打开 PayWebViewPage(内嵌 webview_flutter,iOS 用 WKWebView)
|
||||
→ WebView 加载 pay_url,监听 url 变化(payment 完成跳转)
|
||||
→ 同时 Timer 每 2s GET /member/order/status?order_no=... 轮询(上限 60s)
|
||||
→ status=paid → 关闭 WebView → 刷新 memberProvider → 成功 toast
|
||||
→ 超时 → 提示「支付结果确认中,请稍后在会员中心查看」(状态以服务端为准)
|
||||
```
|
||||
|
||||
- 支付页依赖 `webview_flutter`(P0 引入,国内 App 常规做法);若平台编译受限,降级方案:`url_launcher` 唤起系统浏览器支付,返回 App 后仍走轮询(**P0 默认 url_launcher 方案,webview_flutter 留 P1**,减小依赖风险)
|
||||
|
||||
### 2.3 状态与 Provider
|
||||
|
||||
| Provider | 类型 | 数据 | 接口 |
|
||||
|---|---|---|---|
|
||||
| `memberProvider` | AsyncNotifier | isVip, expireAt, planName, benefits[] | GET /member/status |
|
||||
| `memberPlanProvider` | FutureProvider | 套餐列表 | GET /member/plan/list |
|
||||
| `orderCreateProvider` | Notifier.family(planId) | orderNo, payUrl | POST /member/order/create |
|
||||
| `orderStatusProvider` | FutureProvider.family(orderNo) | status | GET /member/order/status |
|
||||
|
||||
- `memberProvider` 缓存登录态期间;`refresh()` 在支付成功、广告领奖后调用
|
||||
- 权益 chips 文案从套餐 `features` JSON 解析 → 本地文案 map(`effect_unlimited→无限效果图` 等)
|
||||
|
||||
## 3. 支柱 B:广告激励(lib/core/ads/ 新建)
|
||||
|
||||
### 3.1 抽象(供应商隔离)
|
||||
|
||||
```dart
|
||||
// lib/core/ads/ads_provider.dart
|
||||
abstract class AdsService {
|
||||
bool get enabled; // appid 未配置 → false,App 隐藏广告入口
|
||||
Future<bool> showRewarded(); // 激励视频,返回是否完整观看
|
||||
}
|
||||
|
||||
// lib/core/ads/pangle_ads_service.dart —— 穿山甲实现(P1 接入 SDK,P0 仅接口 + mock)
|
||||
// P0:MockAdsService —— 本地模拟 3 秒「播放」返回 true,保证主链路可开发可测
|
||||
```
|
||||
|
||||
- **初始化**:`AdsConfig`(AppConfig 常量:pangleAppId 默认空)→ `adsServiceProvider` 单例
|
||||
- **降级**:appid 空 / SDK 初始化失败 → `enabled=false` → 会员中心激励卡、广告位全部不渲染
|
||||
|
||||
### 3.2 激励流程(服务端防刷,客户端只展示)
|
||||
|
||||
```
|
||||
点击「看视频」→ adsService.showRewarded()
|
||||
→ 完整观看 → POST /ad/reward/claim {ad_type: effect_extra | vip_trial}
|
||||
→ 成功 → 展示奖励弹窗(+1 效果图 / 1 天体验会员)→ refresh 会员卡
|
||||
→ 失败(限频/未配置)→ 隐藏式错误(后端返回「今日次数已用完」→ 入口变灰)
|
||||
```
|
||||
|
||||
- 剩余次数展示:`/ad/reward/claim` 响应带回 `{reward: {ad_type, remaining_today}}`,App 本地缓存当日显示;或 P0 简单化 —— 仅在领取失败时提示次数用完
|
||||
- **效果图配额联动**:后端限额 = 基础 3 + 当日额外次数;App 端文案统一显示「今日剩余 X 次」(P1 后端在生成接口响应中带 `remaining` 字段,P0 保持现状提示)
|
||||
|
||||
### 3.3 广告位(P2,本期只留占位)
|
||||
|
||||
- 开屏广告:`/home` 进入时加载(P2)
|
||||
- 信息流广告:`/plan-effect` 效果图 GridView 底部插一条(P2)
|
||||
|
||||
## 4. 支柱 C/D:方案驱动 CPS 入口
|
||||
|
||||
### 4.1 方案页(/plan-viewer)三处入口
|
||||
|
||||
| 位置 | 按钮 | scene | 请求 | 跳转 |
|
||||
|---|---|---|---|---|
|
||||
| 发型卡尾部 | 「做同款发型」 | haircut | GET /cps/plan/recommend {plan_id, scene: haircut} | /cps-product-list(美团丽人/理发券) |
|
||||
| 穿衣清单每项 trailing | 「买同款」 | item_buy | 京东搜索单品名 | /cps-product-list(电商商品) |
|
||||
| 穿衣清单每项 trailing | 「到店试穿」 | item_upgrade | 美团服装类目 | /cps-product-list(门店券) |
|
||||
| 场合卡(P1,detail 有 occasion 字段后) | 「延伸优惠」 | occasion | 映射表推荐 | /cps-product-list |
|
||||
|
||||
- **可见性**:推荐接口返回空列表 / 接口报错(CPS 未开通)→ 该按钮隐藏;发型卡无发型名(默认发型)→ 隐藏「做同款」
|
||||
- **交互**:推荐返回列表 → push `/cps-product-list?source=&category_code=&city=`(带标题「做同款发型 · 丽人」);列表点击 → POST /cps/product/link {product_id, scene, plan_id} → 打开 deeplink
|
||||
- **打开方式**:`url_launcher`(系统浏览器,携 pid 转链 URL;电商商品优先深链 App,P1 增强)
|
||||
|
||||
### 4.2 衣橱升级款(/wardrobe 长按菜单加一项)
|
||||
|
||||
```
|
||||
长按衣物卡 → 菜单:删除 / 找升级款
|
||||
「找升级款」→ GET /cps/wardrobe/upgrade {item_id} → /cps-product-list(标题「升级款 · 上衣」)
|
||||
```
|
||||
|
||||
### 4.3 商品列表页 /cps-product-list
|
||||
|
||||
```
|
||||
AppBar 标题(由入口传入)+ 分类 chips(/cps/category/list,P1)
|
||||
商品卡:封面图(AppConfig.resolveUrl)· 名称 · 价格(分→元)· 店铺 · 佣金标
|
||||
上滑加载更多(page 分页,has_more 判定)
|
||||
点击 → POST /cps/product/link → deeplink → url_launcher 打开
|
||||
```
|
||||
|
||||
### 4.4 Provider
|
||||
|
||||
| Provider | 类型 | 接口 |
|
||||
|---|---|---|
|
||||
| `cpsRecommendProvider` | FutureProvider.family((planId, scene)) | GET /cps/plan/recommend |
|
||||
| `cpsProductProvider` | AsyncNotifierProvider.family((source, categoryCode, city)) | GET /cps/product/list 分页 |
|
||||
| `cpsUpgradeProvider` | FutureProvider.family(itemId) | GET /cps/wardrobe/upgrade |
|
||||
| `cpsLinkAction` | Notifier | POST /cps/product/link |
|
||||
|
||||
## 5. 会员权益在客户端的呈现
|
||||
|
||||
| 权益 | 客户端表现 |
|
||||
|---|---|
|
||||
| effect_unlimited | 效果图页配额提示隐藏(「每日限 3 次」文案在 isVip 时不显示) |
|
||||
| ai_priority | 生成页状态文案「VIP 优先排队」(MVP 仅文案) |
|
||||
| cps_commission_x15 | 商品卡佣金标签「返现加成 1.5x」(VIP 用户) |
|
||||
| store_discount | 门店列表「会员价」角标 + 会员码页(P1) |
|
||||
|
||||
## 6. 配置(AppConfig 常量,均为本地编译期配置)
|
||||
|
||||
```dart
|
||||
// lib/core/config/app_config.dart 追加
|
||||
static const String pangleAppId = ''; // 穿山甲 AppId,空 = 广告功能关闭
|
||||
static const bool cpsEnabled = true; // 兜底开关;最终以接口结果为准
|
||||
```
|
||||
|
||||
- 商业化开关**最终以接口为准**(后端 config 未配置 → 接口错误 → App 隐藏入口),本地常量只控制「广告 SDK 是否初始化」
|
||||
|
||||
## 7. API 映射与错误处理
|
||||
|
||||
| 后端接口 | 客户端方法 | 错误处理 |
|
||||
|---|---|---|
|
||||
| GET /member/status | memberProvider.build | 401 → 重登;其他 → 默认非会员 |
|
||||
| GET /member/plan/list | memberPlanProvider | 错误 → 套餐区显示「暂未开通」 |
|
||||
| POST /member/order/create | 下单动作 | 错误 → toast「支付未开通」 |
|
||||
| GET /member/order/status | 轮询 | 404/错误 → 结束轮询提示稍后查看 |
|
||||
| POST /member/order/notify | -(后端回调,客户端不参与) | - |
|
||||
| POST /ad/reward/claim | 领奖动作 | 错误 → toast 后端 message(限频等) |
|
||||
| GET /cps/plan/recommend | cpsRecommendProvider | 空/错误 → 入口隐藏 |
|
||||
| GET /cps/product/list | cpsProductProvider | 空/错误 → 列表空态「暂未开放」 |
|
||||
| POST /cps/product/link | cpsLinkAction | 错误 → toast「跳转失败」 |
|
||||
| GET /cps/my/recent | recentProvider | 错误 → 整卡隐藏 |
|
||||
|
||||
- 所有新接口走现有 `apiClientProvider`(Dio 封装,自动带 token),无需改动网络层
|
||||
|
||||
## 8. 路由与依赖变更
|
||||
|
||||
```
|
||||
/lib/main.dart 新增 route:
|
||||
/cps-product-list(extra: CpsListArgs{title, source, categoryCode, city})
|
||||
/pay-webview(extra: PayWebviewArgs{orderNo, payUrl},P1 webview_flutter)
|
||||
依赖(P0):url_launcher(打开 deeplink / 系统浏览器支付)
|
||||
依赖(P1):webview_flutter(内嵌收银台)、穿山甲 SDK(pangle 插件)
|
||||
```
|
||||
|
||||
- 穿山甲 SDK Flutter 插件社区维护不稳定 → **P1 先验证 iOS/Android 编译,若插件不可用则改为原生 module 接入(P2)**;P0 用 MockAdsService 保证业务链路先闭环
|
||||
|
||||
## 9. 分期与对齐
|
||||
|
||||
| 分期 | 客户端内容 | 依赖 |
|
||||
|---|---|---|
|
||||
| **P0** | /commercial 重构会员中心(状态/套餐/下单/轮询)+ MockAds + 广告激励入口 + 方案页「做同款发型/买同款/到店试穿」+ /cps-product-list + url_launcher + cps 入口隐藏逻辑 | 后端 P0(会员+广告接口) |
|
||||
| **P1** | 场合卡「延伸优惠」+ 衣橱「找升级款」+ 最近优惠 + 会员码/门店折扣角标 + webview_flutter 内嵌收银台 + 穿山甲 SDK 接入(替换 Mock) | 后端 P1(CPS 引擎) |
|
||||
| **P2** | 开屏/信息流广告位 + 穿山甲插件不可用时的原生 module 兜底 + 收益/返现展示 | 后端 P2 |
|
||||
|
||||
## 10. 合规(客户端侧)
|
||||
|
||||
- **iOS 充值**:App Store 虚拟商品政策风险 → iOS 端隐藏会员套餐充值入口(`Platform.isIOS` 判断),保留广告激励 + 门店引流;「会员价」到店核销不受影响
|
||||
- **广告**:隐私政策文案补充穿山甲 SDK 信息收集披露;提供「个性化广告关闭」设置项(穿山甲 SDK 提供,P1)
|
||||
- **跳转**:CPS 转链一律走联盟 deeplink,不在 App 内二次改链
|
||||
|
||||
## 11. 开发规范约束(沿用 slogan-app 现有规范)
|
||||
|
||||
- Riverpod 3:AsyncNotifier/Notifier/FutureProvider.family;provider 文件放 `lib/features/<feature>/<feature>_provider.dart`
|
||||
- 新页面组件放 `lib/features/commercial/`(会员中心)、`lib/features/cps/`(商品列表);广告抽象放 `lib/core/ads/`
|
||||
- 图片 URL 一律 `AppConfig.resolveUrl()`;价格字段分 → 元转换写死规则(`(fen / 100).toStringAsFixed(0)`)
|
||||
- 所有「隐藏入口」逻辑集中在入口组件内一行判定,不扩散到业务逻辑
|
||||
- 新页面必配 empty/error/loading 三态(复用 shared/widgets)
|
||||
@@ -0,0 +1,174 @@
|
||||
# slogan-app 客户端设计方案
|
||||
|
||||
> 日期:2026-07-31
|
||||
> 关联:slogan-agent 服务端方案(Go + GoFrame)见 slogan-agent 仓库对应文档
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
slogan 是一个"人形象设计"应用:用户上传大头照和全身多角度照片、维护个人服装资产(衣橱),指定日期范围和地点后一键生成最适合的穿搭方案(含发型、发色、服装穿搭),方案以 3D 化身 + 2D 效果图双形态呈现,手指滑动切换方案与查看角度。
|
||||
|
||||
本仓库为客户端(slogan-app),Flutter 实现,一次编写双端(iOS/Android)运行,追求类原生用户操作体验。
|
||||
|
||||
## 2. 技术选型
|
||||
|
||||
| 决策点 | 选择 | 理由 |
|
||||
|--------|------|------|
|
||||
| 语言/框架 | Flutter (Dart) | 一次编写双端;自绘引擎保证体验一致;动画/手势流畅 |
|
||||
| 状态管理 | Riverpod | 类型安全、可测试,适合表单/异步任务/缓存类状态 |
|
||||
| 网络层 | Dio + 拦截器 | JWT 自动注入、统一响应解析 `{code,message,data}`、超时重试 |
|
||||
| 3D 渲染 | three_dart v1 + 渲染抽象层 | 稳定发布版(v0.3.0,GLTF/GLB loader,纯 Dart 双端);flutter_scene 进 stable 后经抽象层无缝替换 |
|
||||
| 图片缓存 | cached_network_image | 效果图/服装照片懒加载缓存 |
|
||||
| 拍照/相册 | camera + image_picker | 大头照/全身多角度拍摄引导 |
|
||||
| 本地存储 | shared_preferences | token/偏好;身形微调参数本地实时生效 |
|
||||
| 手势 | 原生 GestureDetector 组合 | 方案横滑切换(PageView)+ 化身旋转拖拽/捏合缩放 + 惯性滚动 |
|
||||
|
||||
**3D 渲染层风险控制**:`AvatarViewer` 接口抽象(loadGLB / setHairstyle / setHairColor / rotate / zoom / switchOutfit),v1 实现为 three_dart;flutter_scene(官方,基于 Flutter GPU)进入 stable 后提供第二实现,业务代码零改动。
|
||||
|
||||
## 3. 项目结构
|
||||
|
||||
```
|
||||
slogan-app/
|
||||
├── lib/
|
||||
│ ├── main.dart # 入口 + 路由 + 主题
|
||||
│ ├── core/
|
||||
│ │ ├── network/ # Dio 封装:JWT 拦截器/统一响应/错误码映射
|
||||
│ │ ├── auth/ # 登录页 + token 管理(账号密码,复用服务端 /user/login)
|
||||
│ │ ├── config/ # API 地址/环境
|
||||
│ │ ├── storage/ # shared_preferences 封装(token/偏好)
|
||||
│ │ └── router/ # go_router(未登录 → 登录页)
|
||||
│ ├── features/
|
||||
│ │ ├── profile/ # Tab1 我的形象
|
||||
│ │ │ ├── photo_guide/ # 拍照引导页(大头照/全身多角度拍摄指引)
|
||||
│ │ │ ├── avatar_viewer/ # 3D 化身查看(AvatarViewer 抽象层实现)
|
||||
│ │ │ └── body_tune/ # 滑杆微调(身高/胖瘦/肤色,本地实时)
|
||||
│ │ ├── wardrobe/ # Tab2 我的衣橱
|
||||
│ │ │ ├── upload/ # 服装照片上传 + 分类标签
|
||||
│ │ │ └── item_grid/ # 服装资产网格/详情
|
||||
│ │ ├── outfit/ # Tab3 穿搭方案
|
||||
│ │ │ ├── generate/ # 生成入口(日期范围选择器/地点选择)
|
||||
│ │ │ ├── task_status/ # 生成任务进度(轮询)
|
||||
│ │ │ ├── plan_flow/ # 方案流(PageView 横滑切换方案)
|
||||
│ │ │ ├── viewer_3d/ # 3D 方案查看(发型切换/发色取色/旋转/捏合)
|
||||
│ │ │ ├── effect_images/ # 2D 效果图(正面/侧面/背面切换)
|
||||
│ │ │ └── review/ # 收藏/反馈
|
||||
│ │ └── commercial/ # Tab4 门店/电商
|
||||
│ │ ├── stores/ # LBS 附近门店(形象设计/服装)
|
||||
│ │ ├── leads/ # 导流订单/到店核销
|
||||
│ │ ├── products/ # CPS 商品跳转
|
||||
│ │ └── subscription/ # 会员订阅
|
||||
│ └── shared/ # 组件/主题/工具(日期选择器/评分展示等)
|
||||
├── assets/
|
||||
│ ├── avatars/ # 模板/发型 GLB 缓存(与后端 assets 对应,v1 从服务端拉取)
|
||||
│ └── images/ # 图标/占位图
|
||||
└── test/ # 单元/widget 测试
|
||||
```
|
||||
|
||||
## 4. 页面与交互设计(4 Tab 主框架)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Tab 架构(底部导航,类原生体验) │
|
||||
│ ┌────────┬────────┬────────┬─────────┐ │
|
||||
│ │ 我的形象│ 我的衣橱│ 穿搭方案 │ 门店/电商│ │
|
||||
│ └────────┴────────┴────────┴─────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Tab1 我的形象
|
||||
- 首次进入:拍照引导流程(大头照 + 全身正面/侧面/背面 4 张,含姿势示例图)
|
||||
- 化身构建进度(后端任务轮询)
|
||||
- 3D 化身查看:单指拖拽旋转、双指捏合缩放
|
||||
- 滑杆微调:身高/胖瘦/肤色,调参即时反映(本地计算,GLB 缩放参数 + 肤色材质)
|
||||
|
||||
### Tab2 我的衣橱
|
||||
- 服装照片上传(多选)+ 分类(上衣/下装/鞋/配饰)+ 季节/风格标签自动识别(后端 AI 辅助,本地可手改)
|
||||
- 网格展示 + 详情编辑/删除
|
||||
|
||||
### Tab3 穿搭方案(核心)
|
||||
- 生成入口:日期范围选择器 + 地点选择(定位/搜索)
|
||||
- 生成任务进度页(规则规划 → 方案评分 → 完成)
|
||||
- 方案流:PageView 左右滑动切换 3 套方案;每套方案卡片 = 3D 化身 + 方案摘要(评分/来源标签:衣橱组合/AI 推荐)
|
||||
- 3D 查看:发型点击切换 + 发色取色盘(HSV 调色实时渲染)+ 拖拽旋转 + 捏合缩放
|
||||
- 主方案选定 → 触发 2D 效果图生成(3 视角:正面/侧面/背面,切换查看)
|
||||
- 方案条目明细:每件服装(衣橱照片 或 电商商品图)+ 单品操作(跳转商品/加入衣橱)
|
||||
- 收藏/反馈 → 回流 Agent 优化下次生成
|
||||
|
||||
### Tab4 门店/电商
|
||||
- LBS 附近合作门店(类型筛选:形象设计/服装),导航/预约/到店核销
|
||||
- CPS 商品推荐列表(跳转电商)
|
||||
- 会员订阅入口与权益展示
|
||||
|
||||
## 5. 3D 查看器设计(核心模块)
|
||||
|
||||
### AvatarViewer 抽象接口
|
||||
|
||||
```dart
|
||||
abstract class AvatarViewer extends StatelessWidget {
|
||||
// 由具体实现提供(three_dart v1 / flutter_scene v2)
|
||||
}
|
||||
|
||||
abstract class AvatarViewerController {
|
||||
Future<void> loadAvatar(AvatarSpec spec); // 头像 + 体型 + 皮肤贴图
|
||||
Future<void> loadHairstyle(String glbUrl); // 加载发型
|
||||
void setHairColor(Color color); // 发色(PBR baseColor 调色)
|
||||
void setOutfit(List<OutfitLayer> layers); // 换装(简模 GLB 层)
|
||||
void rotateBy(double dx, double dy); // 旋转
|
||||
void zoomBy(double scale); // 缩放
|
||||
void resetView();
|
||||
}
|
||||
```
|
||||
|
||||
- **v1 实现(three_dart)**:加载服务端 `avatar_model.glb`(头部/身体/发型分离分层),发型切换 = 换层 + 发色材质 HSV 调整
|
||||
- **性能**:GLB 经服务端 glTF-Transform 压缩;发型资产首次加载后本地缓存;页面级预取下一套方案
|
||||
- **降级**:GLB 加载失败 → 显示用户大头照 + 方案文本卡片(核心功能不受 3D 影响)
|
||||
|
||||
### 手势实现
|
||||
- 方案切换:`PageView`(水平滑动 + 惯性 + 阻尼边缘效果)
|
||||
- 化身旋转:`GestureDetector.onPanUpdate` → controller.rotateBy,松手无惯性(或轻量衰减)
|
||||
- 缩放:`ScaleGestureRecognizer` 双指捏合,1.0-4.0 范围限制
|
||||
- 发色:`HSVColorPicker` 自定义取色盘,onChanged 实时 setHairColor
|
||||
|
||||
## 6. 网络层与状态管理
|
||||
|
||||
- `ApiClient`(Dio):baseUrl 可配置、token 注入、`code==0` 判定、401 自动登出、超时 30s
|
||||
- 任务轮询:`outfit/task/status` 每 3s 轮询(Riverpod `StreamProvider`),任务完成自动停止
|
||||
- 上传:Multipart,进度条反馈
|
||||
- 缓存:效果图/服装照片 cached_network_image;化身 GLB 本地文件缓存(LRU,256MB 上限)
|
||||
|
||||
## 7. 拍摄引导设计
|
||||
|
||||
- 大头照:正面、面部无遮挡、光线均匀说明图;相机取景框对齐提示
|
||||
- 全身照:距镜 2-3 米、全身入框、正面/侧面/背面 三角度示例图(类原生"手势引导"UI)
|
||||
- 照片本地压缩(宽边 ≤ 2048)后上传
|
||||
|
||||
## 8. 错误处理与加载状态
|
||||
|
||||
- 统一 `ErrorView` / `LoadingView` 组件(骨架屏)
|
||||
- 生成任务失败:明确错误文案("衣橱为空,请先添加服装"等)+ 重试按钮
|
||||
- 网络离线:离线提示 + 本地缓存优先展示(方案历史本地快照)
|
||||
- 轮询超时(>10 分钟):提示"生成时间较长"并提供结果通知路径(v2 推送)
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
- 单元测试:手势计算/发色 HSV 调色/方案缓存 key 逻辑
|
||||
- Widget 测试:方案流滑动切换、3D 查看器骨架降级、拍照引导流程
|
||||
- 集成(v1 手工 + 冒烟脚本):登录 → 上传 → 生成 → 查看全链路
|
||||
- 渲染层测试:AvatarViewer 接口 mock,业务测试不依赖具体 3D 实现
|
||||
|
||||
## 10. 与后端 API 对接清单
|
||||
|
||||
见 slogan-agent 方案第 11 节 API 路由表。App 端关键时序:
|
||||
|
||||
```
|
||||
登录 → 上传照片(4张) → 填写身形 → [build 化身(异步)] → 上传衣橱服装
|
||||
→ 生成穿搭 {日期范围, 地点} → 轮询任务 → 方案流(3D 即时查看)
|
||||
→ 选主方案 → 效果图生成(异步) → 3 视角查看 → 收藏/跳转商品/门店预约
|
||||
```
|
||||
|
||||
## 11. 开发规范约束(App 端)
|
||||
|
||||
- 状态管理统一 Riverpod,禁止 setState 在页面间传递业务状态
|
||||
- 所有网络请求必须走 `ApiClient`,禁止散落 Dio 实例
|
||||
- 3D 渲染只允许通过 `AvatarViewer` 抽象层,禁止业务代码直接依赖 three_dart 类型
|
||||
- 命名:目录 `features/<domain>/`,组件 `shared/`;文件 snake_case,类 PascalCase
|
||||
- 测试随功能同步编写(TDD:先写失败测试再实现)
|
||||
Reference in New Issue
Block a user