feat(app): 商业化门店页 + 收尾联调

- commercial_page:形象会员订阅占位 + 合作门店列表(造型/服装筛选,佣金政策展示)
- avatar_viewer 精简为 MVP 占位实现(移除 three_dart/flutter_gl 依赖:flutter_gl 0.0.21 在 Flutter 3.44 下无法编译,v2 接入 GLB 时引入,接入方式注释保留)
- 全链路冒烟验证通过:注册→照片→衣橱→身形→化身(172cm→体型模板4)→生成(LLM 预筛→全低分触发兜底→3 套方案)→选定主方案→3 视角效果图→收藏→门店列表
This commit is contained in:
2026-07-31 12:56:41 +08:00
parent c4fd49bd7e
commit 139f9970c6
4 changed files with 184 additions and 340 deletions
+170 -2
View File
@@ -1,10 +1,178 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class CommercialPage extends StatelessWidget {
import '../../core/auth/auth_provider.dart';
import '../../shared/widgets/error_view.dart';
import '../../shared/widgets/loading_view.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: '服装'};
/// 商业化:订阅权益 + 合作门店(佣金导流)
class CommercialPage extends ConsumerStatefulWidget {
const CommercialPage({super.key});
@override
ConsumerState<CommercialPage> createState() => _CommercialPageState();
}
class _CommercialPageState extends ConsumerState<CommercialPage> {
int? _typeFilter;
@override
Widget build(BuildContext context) {
return const Center(child: Text('门店电商(开发中)'));
final stores = ref.watch(storeProvider);
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('门店与服务')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// 订阅卡片(MVP 占位,支付接入后启用)
Card(
color: scheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.workspace_premium,
size: 36, color: scheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('形象会员',
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(
'会员专享:无限次效果图生成 · 优先 AI 方案 · 门店专属折扣(接入支付后开通)',
style: TextStyle(
fontSize: 12, color: scheme.primary),
),
],
),
),
],
),
),
),
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),
],
],
);
},
),
],
),
);
}
}
+12 -191
View File
@@ -1,19 +1,13 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gl/flutter_gl.dart';
import 'package:three_dart/three_dart.dart' as three;
import 'package:three_dart_jsm/three_dart_jsm.dart' as three_jsm;
import '../../core/config/app_config.dart';
/// 是否启用 three_dart 3D 渲染
/// MVP 阶段服务端尚无预烘焙 GLB 资产(/workspace/templates/*.glb 为构建产物),
/// 默认关闭展示信息占位;接入真实 GLB 资源后置为 true 即可启用。
const bool kEnable3D = false;
/// 3D 化身查看组件:three_dart 渲染 GLB,加载失败/未启用时展示信息占位
/// 3D 化身查看组件(MVP 占位实现)
///
/// v2 接入真实 3D 渲染的方式:
/// 1. 服务端提供预烘焙 GLB 资产(/workspace/templates/avatar_f{face}_b{body}_s{skin}.glb
/// 2. pubspec 引入 three_dart ^0.0.16 + three_dart_jsm ^0.0.10 + flutter_gl ^0.0.20
/// (注意 flutter_gl 0.0.21 在 Flutter 3.44 下无法编译,需锁 0.0.20
/// 3. 用 flutter_gl 初始化 GL 上下文 + GLTFLoader 加载 GLB + 自动旋转渲染,
/// 参考 three_dart 0.0.16 的 example/lib/webgl_loader_glb.dart
class AvatarViewer extends StatelessWidget {
final String? glbUrl;
final String title;
@@ -28,182 +22,6 @@ class AvatarViewer extends StatelessWidget {
this.height = 360,
});
@override
Widget build(BuildContext context) {
if (!kEnable3D || glbUrl == null || glbUrl!.isEmpty) {
return _AvatarPlaceholder(
title: title, subtitle: subtitle, height: height);
}
return _ThreeDAvatarViewer(
url: AppConfig.resolveUrl(glbUrl!),
title: title,
subtitle: subtitle,
height: height,
);
}
}
/// three_dart 实现:flutter_gl 初始化 + GLTFLoader 加载 + 自动旋转渲染
class _ThreeDAvatarViewer extends StatefulWidget {
final String url;
final String title;
final String subtitle;
final double height;
const _ThreeDAvatarViewer({
required this.url,
required this.title,
required this.subtitle,
required this.height,
});
@override
State<_ThreeDAvatarViewer> createState() => _ThreeDAvatarViewerState();
}
class _ThreeDAvatarViewerState extends State<_ThreeDAvatarViewer> {
FlutterGlPlugin? _gl;
three.WebGLRenderer? _renderer;
three.Scene? _scene;
three.Camera? _camera;
three.Object3D? _model;
dynamic _sourceTexture;
double _width = 300;
double _dpr = 1;
bool _ready = false;
bool _failed = false;
bool _disposed = false;
Timer? _timer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
}
@override
void dispose() {
_disposed = true;
_timer?.cancel();
super.dispose();
}
Future<void> _init() async {
final mq = MediaQuery.of(context);
_width = mq.size.width;
_dpr = mq.devicePixelRatio;
try {
final gl = FlutterGlPlugin();
await gl.initialize(options: {
'antialias': true,
'alpha': false,
'width': _width.toInt(),
'height': widget.height.toInt(),
'dpr': _dpr,
});
await gl.prepareContext();
final renderer = three.WebGLRenderer({
'width': _width,
'height': widget.height,
'gl': gl.gl,
'antialias': true,
'canvas': gl.element,
});
renderer.setPixelRatio(_dpr);
renderer.setSize(_width, widget.height, false);
final target = three.WebGLMultisampleRenderTarget(
(_width * _dpr).toInt(), (widget.height * _dpr).toInt(),
three.WebGLRenderTargetOptions({'format': three.RGBAFormat}));
target.samples = 4;
renderer.setRenderTarget(target);
final sourceTexture = renderer.getRenderTargetGLTexture(target);
await _loadModel(gl, renderer);
_gl = gl;
_renderer = renderer;
_sourceTexture = sourceTexture;
if (mounted) setState(() => _ready = true);
_timer = Timer.periodic(
const Duration(milliseconds: 33), (_) => _render());
} catch (e) {
debugPrint('3D 渲染不可用,回退占位:$e');
if (mounted) setState(() => _failed = true);
}
}
Future<void> _loadModel(
FlutterGlPlugin gl, three.WebGLRenderer renderer) async {
final scene = three.Scene();
final camera = three.PerspectiveCamera(45, _width / widget.height, 0.1, 100);
camera.position.set(0, 1.4, 3.2);
scene.add(three.AmbientLight(0xffffff, 0.9));
final keyLight = three.DirectionalLight(0xffffff, 0.9);
keyLight.position.set(2, 4, 3);
scene.add(keyLight);
scene.add(camera);
camera.lookAt(three.Vector3(0, 1, 0));
final loader = three_jsm.GLTFLoader(null);
final result = await loader.loadAsync(widget.url);
final model = result['scene'] as three.Object3D?;
if (model != null) {
model.rotation.y = 0.6;
scene.add(model);
}
_scene = scene;
_camera = camera;
_model = model;
}
void _render() {
if (_disposed || _renderer == null || _scene == null || _camera == null) {
return;
}
final model = _model;
if (model != null) {
model.rotation.y += 0.01; // 自动旋转
}
_renderer!.render(_scene!, _camera!);
_gl!.gl.flush();
if (!kIsWeb) {
_gl!.updateTexture(_sourceTexture);
}
}
@override
Widget build(BuildContext context) {
if (_failed) {
return _AvatarPlaceholder(
title: widget.title, subtitle: widget.subtitle, height: widget.height);
}
return SizedBox(
height: widget.height,
width: double.infinity,
child: _ready
? (kIsWeb
? HtmlElementView(viewType: _gl!.textureId!.toString())
: Texture(textureId: _gl!.textureId!))
: const Center(child: CircularProgressIndicator()),
);
}
}
/// 占位:GLB 资产未接入或加载失败时展示化身信息
class _AvatarPlaceholder extends StatelessWidget {
final String title;
final String subtitle;
final double height;
const _AvatarPlaceholder({
required this.title,
required this.subtitle,
required this.height,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
@@ -214,7 +32,10 @@ class _AvatarPlaceholder extends StatelessWidget {
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [scheme.primaryContainer, scheme.primary.withValues(alpha: 0.3)],
colors: [
scheme.primaryContainer,
scheme.primary.withValues(alpha: 0.3),
],
),
borderRadius: BorderRadius.circular(16),
),
-144
View File
@@ -17,14 +17,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "12.1.0"
archive:
dependency: transitive
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
url: "https://pub.dev"
source: hosted
version: "3.6.1"
args:
dependency: transitive
description:
@@ -57,14 +49,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.1"
charcode:
dependency: transitive
description:
name: charcode
sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a
url: "https://pub.dev"
source: hosted
version: "1.4.0"
cli_config:
dependency: transitive
description:
@@ -121,14 +105,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -222,46 +198,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_gl:
dependency: "direct main"
description:
name: flutter_gl
sha256: de74c88f77228f47dd280e2092b50eb49fe1fc008de74047d195a27f2db0b491
url: "https://pub.dev"
source: hosted
version: "0.0.21"
flutter_gl_macos:
dependency: transitive
description:
name: flutter_gl_macos
sha256: "62aa244d4aa9127115df651baec070893718dd27571c8dbca5374dbcb9fd4849"
url: "https://pub.dev"
source: hosted
version: "0.0.5"
flutter_gl_platform_interface:
dependency: transitive
description:
name: flutter_gl_platform_interface
sha256: "03062491fac26d0fda80703e4a01894ecc4fec4f7e5cec131ef021fbf46b2fda"
url: "https://pub.dev"
source: hosted
version: "0.0.4"
flutter_gl_web:
dependency: transitive
description:
name: flutter_gl_web
sha256: "005dc72618ee14659dff7dfe72d6b1fce0efdda745cfebfe4618ce7ac2044af9"
url: "https://pub.dev"
source: hosted
version: "0.0.5"
flutter_gl_windows:
dependency: transitive
description:
name: flutter_gl_windows
sha256: cd9259fb8178863de9e667129f73447fa174447a3c4abd16defa07af648850e4
url: "https://pub.dev"
source: hosted
version: "0.0.4"
flutter_lints:
dependency: "direct dev"
description:
@@ -328,14 +264,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "17.3.0"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: transitive
description:
@@ -360,14 +288,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "8e9d133755c3e84c73288363e6343157c383a0c6c56fc51afcc5d4d7180306d6"
url: "https://pub.dev"
source: hosted
version: "3.3.0"
image_picker:
dependency: "direct main"
description:
@@ -528,14 +448,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.2"
opentype_dart:
dependency: transitive
description:
name: opentype_dart
sha256: "4bd96aeed494289a87e92bde20afe60f59648dcef253c0a7159b65ffa23899dc"
url: "https://pub.dev"
source: hosted
version: "0.0.1"
package_config:
dependency: transitive
description:
@@ -576,14 +488,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
platform:
dependency: transitive
description:
@@ -805,22 +709,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.17"
three_dart:
dependency: "direct main"
description:
name: three_dart
sha256: "102ff2cc65c2dcb805166c7dc5fa00a9a3252b5aec34fb435cc558a7487dc40b"
url: "https://pub.dev"
source: hosted
version: "0.0.16"
three_dart_jsm:
dependency: "direct main"
description:
name: three_dart_jsm
sha256: "26a71aff4aa842ac8178d2ba8e64b997c434ce9f604fa4fa3ee49f343d2c84b4"
url: "https://pub.dev"
source: hosted
version: "0.0.10"
typed_data:
dependency: transitive
description:
@@ -829,30 +717,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
typr_dart:
dependency: transitive
description:
name: typr_dart
sha256: e8aa717c1445ceccd77bd6ba471683c8e17a9b14797ac09db3bd52d6fbd2fe7b
url: "https://pub.dev"
source: hosted
version: "0.0.2"
universal_html:
dependency: transitive
description:
name: universal_html
sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4
url: "https://pub.dev"
source: hosted
version: "2.3.0"
universal_io:
dependency: transitive
description:
name: universal_io
sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2
url: "https://pub.dev"
source: hosted
version: "2.3.1"
uuid:
dependency: transitive
description:
@@ -925,14 +789,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
+2 -3
View File
@@ -40,9 +40,8 @@ dependencies:
image_picker: ^1.2.3
shared_preferences: ^2.5.5
fluttertoast: ^9.1.0
three_dart: ^0.0.16
three_dart_jsm: ^0.0.10
flutter_gl: ^0.0.20
# v2 3D 渲染接入时引入:three_dart ^0.0.16 + three_dart_jsm ^0.0.10 + flutter_gl ^0.0.20
# flutter_gl 0.0.21 在 Flutter 3.44 下无法编译:platformViewRegistry 已移除)
dev_dependencies:
flutter_test: