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
+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),
),