修复用户端个人头像展示

This commit is contained in:
czl231
2026-09-02 22:48:47 +08:00
parent 2033a9407e
commit 7cc56f955f
12 changed files with 334 additions and 23 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
import '../../domain/models/client_models.dart';
import '../services/api_client.dart';
@@ -39,6 +41,9 @@ class ClientRepository {
Future<UserProfile> profile() async =>
UserProfile.fromJson(jsonMap(await _api.get('$root/auth/profile')));
/// 通过用户端鉴权接口读取当前登录用户的头像二进制内容。
Future<Uint8List?> profileAvatar() => _api.getBytes('$root/auth/avatar');
Future<WalletSummary> wallet() async =>
WalletSummary.fromJson(jsonMap(await _api.get('$root/wallet')));
@@ -93,15 +98,17 @@ class ClientRepository {
required String channel,
required String payType,
String openid = '',
}) async => jsonMap(await _api.post(
'$root/$business/orders/$identity/pay',
body: {
'request_no': requestNo,
'channel': channel,
}) async => jsonMap(
await _api.post(
'$root/$business/orders/$identity/pay',
body: {
'request_no': requestNo,
'channel': channel,
'pay_type': payType,
if (openid.isNotEmpty) 'openid': openid,
},
));
},
),
);
Future<void> createRefund({
required String business,
@@ -110,11 +117,14 @@ class ClientRepository {
required String reason,
required List<Map<String, Object?>> items,
}) async {
await _api.post('$root/$business/orders/$identity/refunds', body: {
'request_no': requestNo,
'reason': reason,
'items': items,
});
await _api.post(
'$root/$business/orders/$identity/refunds',
body: {
'request_no': requestNo,
'reason': reason,
'items': items,
},
);
}
Future<Map<String, Object?>?> serviceRelation() async {

View File

@@ -1,6 +1,7 @@
// 功能描述:封装用户端 HTTP 请求,并将服务端错误转换为安全、可读的中文提示。
// 版本1.1.0
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
@@ -104,6 +105,20 @@ class ApiClient {
Future<Object?> get(String path, {bool authenticated = true}) =>
_send('GET', path, authenticated: authenticated);
/// 读取需要鉴权的二进制资源;资源不存在时返回空值。
Future<Uint8List?> getBytes(String path) async {
final request = http.Request('GET', Uri.parse('$baseUrl$path'));
request.headers['accept'] = 'image/jpeg, image/png';
final token = _tokenProvider();
if (token.isNotEmpty) request.headers['authorization'] = token;
final response = await _sendRequest(request);
if (response.statusCode == 404) return null;
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(response.statusCode, '头像加载失败');
}
return response.bodyBytes.isEmpty ? null : response.bodyBytes;
}
Future<Object?> post(
String path, {
Map<String, Object?>? body,

View File

@@ -0,0 +1,77 @@
// 功能描述:展示用户真实头像,并在头像为空或加载失败时回退用户名首字母。
// 版本1.1.0
import 'dart:typed_data';
import 'package:flutter/material.dart';
/// 安全读取头像;失败时返回空值,确保个人中心可继续展示其他资料。
Future<Uint8List?> loadProfileAvatarSafely(
Future<Uint8List?> Function() loader,
) async {
try {
return await loader();
} catch (_) {
return null;
}
}
/// 用户资料头像,保持圆形裁剪并提供稳定的文字占位状态。
class ProfileAvatar extends StatelessWidget {
const ProfileAvatar({
required this.name,
required this.imageBytes,
super.key,
});
final String name;
final Uint8List? imageBytes;
/// 从用户名生成占位字符,空名称统一显示“用”。
String get _fallbackText {
final normalized = name.trim();
return normalized.isEmpty ? '' : normalized.substring(0, 1);
}
@override
Widget build(BuildContext context) {
final bytes = imageBytes;
final hasImage = bytes != null && bytes.isNotEmpty;
final fallback = Center(
child: Text(
_fallbackText,
key: const Key('profile-avatar-fallback'),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
);
return Semantics(
image: true,
label: '${name.trim().isEmpty ? '用户' : name.trim()}头像',
child: ExcludeSemantics(
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Theme.of(context).colorScheme.primaryContainer,
),
clipBehavior: Clip.antiAlias,
child: !hasImage
? fallback
: Image(
key: const Key('profile-avatar-image'),
image: MemoryImage(bytes),
width: 56,
height: 56,
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) =>
wasSynchronouslyLoaded || frame != null ? child : fallback,
errorBuilder: (context, error, stackTrace) => fallback,
),
),
),
);
}
}

View File

@@ -1,3 +1,7 @@
// 功能描述:展示用户资料、头像、钱包概览及账户服务入口。
// 版本1.2.0
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:uuid/uuid.dart';
@@ -6,7 +10,9 @@ import '../../../app/dependencies.dart';
import '../../../data/repositories/client_repository.dart';
import '../../../domain/models/client_models.dart';
import '../../core/widgets.dart';
import 'profile_avatar.dart';
/// 用户端个人中心页面。
class ProfilePage extends StatefulWidget {
const ProfilePage({required this.session, required this.repository, super.key});
@@ -17,8 +23,9 @@ class ProfilePage extends StatefulWidget {
State<ProfilePage> createState() => _ProfilePageState();
}
/// 管理个人资料加载及账户服务操作。
class _ProfilePageState extends State<ProfilePage> {
late Future<(UserProfile, WalletSummary)> _future;
late Future<(UserProfile, WalletSummary, Uint8List?)> _future;
@override
void initState() {
@@ -26,8 +33,16 @@ class _ProfilePageState extends State<ProfilePage> {
_future = _load();
}
Future<(UserProfile, WalletSummary)> _load() async =>
(await widget.repository.profile(), await widget.repository.wallet());
/// 并行加载资料和钱包,并在资料存在头像时读取受保护图片内容。
Future<(UserProfile, WalletSummary, Uint8List?)> _load() async {
final profileFuture = widget.repository.profile();
final walletFuture = widget.repository.wallet();
final profile = await profileFuture;
final avatarFuture = profile.avatar.trim().isEmpty
? Future<Uint8List?>.value()
: loadProfileAvatarSafely(widget.repository.profileAvatar);
return (profile, await walletFuture, await avatarFuture);
}
Future<void> _addAddress() async {
final controller = TextEditingController();
@@ -87,7 +102,7 @@ class _ProfilePageState extends State<ProfilePage> {
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('我的')),
body: FutureBuilder<(UserProfile, WalletSummary)>(
body: FutureBuilder<(UserProfile, WalletSummary, Uint8List?)>(
future: _future,
builder: (context, snapshot) {
if (!snapshot.hasData) {
@@ -100,18 +115,16 @@ class _ProfilePageState extends State<ProfilePage> {
}
return const Center(child: CircularProgressIndicator());
}
final (profile, wallet) = snapshot.data!;
final (profile, wallet, avatarBytes) = snapshot.data!;
return ListView(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 32),
children: [
SurfaceSection(
child: Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
foregroundColor: Theme.of(context).colorScheme.primary,
child: Text(profile.name.isEmpty ? '' : profile.name.substring(0, 1)),
ProfileAvatar(
name: profile.name,
imageBytes: avatarBytes,
),
const SizedBox(width: 16),
Expanded(

View File

@@ -0,0 +1,54 @@
// 功能描述:验证受保护头像二进制接口的鉴权读取和异常处理。
// 版本1.1.0
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:user_app/data/services/api_client.dart';
/// 验证头像字节、鉴权请求头和资源不存在状态。
void main() {
test('头像接口携带令牌并返回二进制内容', () async {
final expectedBytes = Uint8List.fromList(const [1, 2, 3]);
final client = ApiClient(
() => 'JWT test-token',
baseUrl: 'https://api.example.com',
client: MockClient((request) async {
expect(request.url.path, '/heqi/client/v1/user/auth/avatar');
expect(request.headers['authorization'], 'JWT test-token');
return http.Response.bytes(expectedBytes, 200);
}),
);
expect(
await client.getBytes('/heqi/client/v1/user/auth/avatar'),
expectedBytes,
);
});
test('头像不存在时返回空值', () async {
final client = ApiClient(
() => 'JWT test-token',
baseUrl: 'https://api.example.com',
client: MockClient((request) async => http.Response('', 404)),
);
expect(await client.getBytes('/avatar'), isNull);
});
test('头像接口异常时返回中文错误', () async {
final client = ApiClient(
() => 'JWT test-token',
baseUrl: 'https://api.example.com',
client: MockClient((request) async => http.Response('', 500)),
);
expect(
() => client.getBytes('/avatar'),
throwsA(
isA<ApiException>().having((error) => error.message, 'message', '头像加载失败'),
),
);
});
}

View File

@@ -0,0 +1,71 @@
// 功能描述:验证用户头像的真实图片、空值占位和加载失败回退状态。
// 版本1.0.0
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:user_app/data/services/api_client.dart';
import 'package:user_app/ui/features/profile/profile_avatar.dart';
/// 验证头像组件在不同图片状态下保持可用。
void main() {
/// 构建统一头像测试宿主。
Widget testHost({
required String name,
required Uint8List? imageBytes,
}) => MaterialApp(
home: Scaffold(
body: ProfileAvatar(
name: name,
imageBytes: imageBytes,
),
),
);
testWidgets('有头像时使用圆形裁剪图片', (tester) async {
final imageBytes = Uint8List.fromList(
base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+X8l7WQAAAABJRU5ErkJggg==',
),
);
await tester.pumpWidget(
testHost(
name: 'cyyy',
imageBytes: imageBytes,
),
);
await tester.pumpAndSettle();
final image = tester.widget<Image>(find.byKey(const Key('profile-avatar-image')));
expect(image.fit, BoxFit.cover);
expect(image.image, isA<MemoryImage>());
});
testWidgets('头像为空时显示用户名首字母', (tester) async {
await tester.pumpWidget(testHost(name: 'cyyy', imageBytes: null));
expect(find.text('c'), findsOneWidget);
expect(find.byKey(const Key('profile-avatar-image')), findsNothing);
});
testWidgets('头像加载失败时回退用户名首字母', (tester) async {
await tester.pumpWidget(
testHost(
name: 'cyyy',
imageBytes: Uint8List.fromList(const [0]),
),
);
await tester.pumpAndSettle();
expect(find.text('c'), findsOneWidget);
});
test('头像接口失败时安全返回空值', () async {
final result = await loadProfileAvatarSafely(
() => throw const ApiException(500, '头像加载失败'),
);
expect(result, isNull);
});
}