Files
lixi a4a97c03c2 新增:资料页真实化 + 编辑页——/me 读写、PATCH 三态、获赞与作品统计(T3.5-08)
资料页头部三项(展示名 / 头像 / 数字)自此全部来自服务端,176 行硬编码
demo(「萌宠新手(豆豆家长)」/ 24 / 1.8k / 2)退役。

- `PatchField<T>` 承载契约 v1.4.0 的 PATCH 三态(absent 不落键 / clear 落
  显式 null / value 落值)。三态必须由类型承载而非 `T?` 加约定:把「不改」
  也编码成 null,用户只改昵称就会连头像一起被服务端清掉。
- `UserProfile` 补 nickname / avatarUrl,展示回退 `nickname ?? username`
  **做在客户端展示层**——服务端 /me 刻意返回 DB 原值,编辑页因此只用原值
  预填,避免把展示约定固化成真实昵称。
- `ProfileController`:`/me` 主链路四态 + 两块统计独立三态(统计失败只降级
  这一块,不为两个数字丢掉整页);登出 reset 防跨账号泄漏。
- 编辑页维护三态意图而非「当前值整体提交」:昵称 / 头像各有显式清除入口,
  未改字段的键根本不进 JSON;空 patch 直接短路(服务端对空 patch 答 400)。
- 昵称校验按**码点**计 1~32 且先 btrim,与 `ck_users_nickname` 对齐;
  纯空白是校验失败而非隐式清空。
- `MediaUploader` 的 purpose 参数化 + 复用其六态编排的 `AvatarUploadSheet`
  (单图、预览确认后才交付 ready assetId,孤儿防护不变)。
- 修复 `/api/v1/me` 端口线路:该端点由 user 服务(:8082)提供,`ApiAuthRepository`
  此前只挂 auth(:8081)会得到 404。此前无人消费 me(),故这条错线一直没被
  触发;本单是第一个真实消费者,桌面实测即暴露。

测试 526 → 588(+62)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 15:37:00 +08:00

272 lines
9.0 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/features/community/community_models.dart';
import 'package:patbond_flutter/features/community/community_repository.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
/// 页面侧的构造口:调用页只知道「用途」,仓库与选图/压缩层的装配由
/// `app.dart` 在装配处完成。
///
/// 缺省为 null 时页面**不渲染头像上传入口**:意为「本次构建未装配上传能力」,
/// 而不是「有入口但点了没反应」。生产装配恒注入(见 `app.dart`)。
typedef AvatarUploaderBuilder = MediaUploader Function(MediaPurpose purpose);
/// App 级注入口(沿 `MediaUploaderFactory` 先例:工厂拿到已装配的仓库)。
///
/// 生产缺省 [defaultAvatarUploader]**桌面实测与集成测试**在此替换选图与
/// 压缩两层——Linux 桌面既无 image_picker 也无 flutter_image_compress 的
/// 原生实现,而 createUpload / 预签名 PUT 直传 / confirm 三段仍走生产实现。
typedef AvatarUploaderFactory =
MediaUploader Function(
CommunityRepository repository,
MediaPurpose purpose,
);
/// 生产缺省头像上传器:单图、单并发(头像没有批量语义)。
MediaUploader defaultAvatarUploader(
CommunityRepository repository,
MediaPurpose purpose,
) => MediaUploader(
repository: repository,
purpose: purpose,
maxImages: 1,
maxConcurrentUploads: 1,
);
/// 弹出头像上传流程,返回**ready 的 assetId**;用户取消或未走到 ready
/// 即 null(孤儿防护:未 confirm 的 asset 绝不外露,见 [MediaUploader])。
Future<String?> showAvatarUploadSheet(
BuildContext context, {
required AvatarUploaderBuilder builder,
required MediaPurpose purpose,
String title = '更换头像',
}) {
return showModalBottomSheet<String>(
context: context,
useSafeArea: true,
isScrollControlled: true,
showDragHandle: true,
builder: (context) =>
AvatarUploadSheet(uploader: builder(purpose), title: title),
);
}
/// 头像上传 sheet:复用发布页的 [MediaUploader] 六态编排(queued /
/// compressing / uploading / confirming / ready / failed),单图上限。
///
/// 与九宫格的差异只在呈现:这里一次只有一张图,故用整幅预览 + 线性进度
/// 条,而不是格内 [UploadProgressOverlay];状态机、凭据过期换新、失败可
/// 重试、孤儿防护全部沿用编排器,本组件不复制任何上传逻辑。
///
/// **ready 后仍需用户点「使用这张」**:上传成功不等于用户满意这张图,
/// 自动关闭会剥夺预览确认的机会(头像是长期可见的身份标识)。
class AvatarUploadSheet extends StatefulWidget {
const AvatarUploadSheet({
required this.uploader,
required this.title,
super.key,
});
/// 本次会话专属的上传器(purpose 与 maxImages=1 由构造口设定);
/// 关闭 sheet 即 dispose,在途请求经 [MediaUploader.reset] 作废。
final MediaUploader uploader;
final String title;
@override
State<AvatarUploadSheet> createState() => _AvatarUploadSheetState();
}
class _AvatarUploadSheetState extends State<AvatarUploadSheet> {
MediaUploader get _uploader => widget.uploader;
@override
void initState() {
super.initState();
_uploader.addListener(_onChanged);
// 打开即拉起选择器:sheet 的唯一目的就是选一张图,多一次「选择图片」
// 点击是纯摩擦。用户在系统选择器里取消后回落到空态(可再次选择)。
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _uploader.pickAndAdd();
});
}
void _onChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_uploader.removeListener(_onChanged);
// 在途任务按 cancelled 收敛(未 confirm 的服务端 asset 弃引用,
// 由服务端超时清理兜底)。
_uploader
..reset()
..dispose();
super.dispose();
}
Future<void> _reselect() async {
// 换图前先清空:maxImages=1 时不清空则 remainingSlots=0,选择器不会拉起。
_uploader.reset();
await _uploader.pickAndAdd();
}
@override
Widget build(BuildContext context) {
final items = _uploader.items;
final item = items.isEmpty ? null : items.first;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(widget.title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
if (item != null) _preview(item),
if (item != null) const SizedBox(height: 14),
..._body(item),
],
),
);
}
/// 预览:原图字节直接解码(不落盘),圆形裁切以贴合头像最终形态。
Widget _preview(MediaUploadItem item) {
return Center(
child: ClipOval(
child: Image.memory(
item.previewBytes,
width: 132,
height: 132,
fit: BoxFit.cover,
errorBuilder: (context, error, stack) => Container(
width: 132,
height: 132,
color: AppColors.surfaceTint,
child: const Icon(Icons.image_outlined, color: AppColors.muted),
),
),
),
);
}
List<Widget> _body(MediaUploadItem? item) {
if (item == null) {
// 选择器拉起中 / 用户取消后的空态。
return _uploader.isPicking
? const [_BusyLine(label: '正在打开相册…')]
: [
const Text(
'还没有选择图片',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.inkSoft, fontSize: 13),
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _reselect,
icon: const Icon(Icons.photo_library_outlined, size: 18),
label: const Text('选择图片'),
),
];
}
switch (item.phase) {
case MediaItemPhase.queued:
case MediaItemPhase.compressing:
return const [_BusyLine(label: '正在处理图片…')];
case MediaItemPhase.uploading:
return [
_ProgressLine(progress: item.progress),
const SizedBox(height: 10),
Text(
'上传中 ${(item.progress * 100).round()}%',
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.inkSoft, fontSize: 13),
),
];
case MediaItemPhase.confirming:
return const [
_ProgressLine(progress: 1),
SizedBox(height: 10),
Text(
'正在确认…',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.inkSoft, fontSize: 13),
),
];
case MediaItemPhase.ready:
return [
FilledButton(
onPressed: () => Navigator.of(context).pop(item.assetId),
child: const Text('使用这张'),
),
const SizedBox(height: 8),
TextButton(onPressed: _reselect, child: const Text('重新选择')),
];
case MediaItemPhase.failed:
return [
Text(
item.errorMessage ?? '上传失败',
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.errorDark, fontSize: 13),
),
const SizedBox(height: 12),
// 不可重试(如压缩后仍超 10 MB)只给「重新选择」——重试同一张
// 必然再失败,给重试钮是误导。
if (item.retryable)
FilledButton(
onPressed: () => _uploader.retry(item.localId),
child: const Text('重试'),
),
if (item.retryable) const SizedBox(height: 8),
TextButton(onPressed: _reselect, child: const Text('重新选择')),
];
}
}
}
class _BusyLine extends StatelessWidget {
const _BusyLine({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 10),
Text(
label,
style: const TextStyle(color: AppColors.inkSoft, fontSize: 13),
),
],
);
}
}
class _ProgressLine extends StatelessWidget {
const _ProgressLine({required this.progress});
final double progress;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(AppRadius.pill)),
child: LinearProgressIndicator(
value: progress,
minHeight: 6,
backgroundColor: AppColors.surfaceTint,
color: AppColors.primary,
),
);
}
}