新增:资料页真实化 + 编辑页——/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>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// PATCH 请求的**三态字段**:契约 v1.4.0 对「昵称 / 头像」两类天生可选的
|
||||
/// 字段定型为三态语义(`UpdateMeRequest` 与 `UpdatePetRequest.avatarAssetId`):
|
||||
///
|
||||
/// | 态 | JSON 表现 | 服务端语义 |
|
||||
/// | --- | --- | --- |
|
||||
/// | [PatchField.absent] | **键不出现** | 不改 |
|
||||
/// | [PatchField.clear] | 键出现且值为 `null` | 清空 |
|
||||
/// | [PatchField.value] | 键出现且有值 | 设置 |
|
||||
///
|
||||
/// Dart 的 `String?` 只有两态(有值 / null),无法区分「不改」与「清空」——
|
||||
/// 若把「不改」也编码为 `null`,未改动的字段会被服务端当成清空指令执行
|
||||
/// (用户只改昵称,头像就被顺手删了)。故三态必须由类型承载,
|
||||
/// 不能靠 `T?` 加约定。
|
||||
///
|
||||
/// 序列化一律经 [writeTo]:它是「absent 不落键」这条纪律的唯一实现处,
|
||||
/// 各请求 DTO 不自行拼 map,避免某处漏写 `isPresent` 判断。
|
||||
@immutable
|
||||
class PatchField<T extends Object> {
|
||||
/// 不改:键不出现在 JSON 里。
|
||||
const PatchField.absent() : _present = false, _value = null;
|
||||
|
||||
/// 清空:键出现且值为 `null`。
|
||||
const PatchField.clear() : _present = true, _value = null;
|
||||
|
||||
/// 设置为 [value]。
|
||||
const PatchField.value(T value) : _present = true, _value = value;
|
||||
|
||||
final bool _present;
|
||||
final T? _value;
|
||||
|
||||
/// 本次 PATCH 是否触及该字段(决定键是否落进 JSON)。
|
||||
bool get isPresent => _present;
|
||||
|
||||
/// 是否为「显式清空」(present 且值为 null)。
|
||||
bool get isClear => _present && _value == null;
|
||||
|
||||
/// present 且有值时的值;absent 与 clear 均为 null(两者不可由此区分)。
|
||||
T? get valueOrNull => _value;
|
||||
|
||||
/// 按三态把自己写进 [json]:absent 不落键;clear 落 `null`;
|
||||
/// 有值时落 [encode] 的产物(缺省原样写入)。
|
||||
void writeTo(
|
||||
Map<String, Object?> json,
|
||||
String key, {
|
||||
Object? Function(T value)? encode,
|
||||
}) {
|
||||
if (!_present) return;
|
||||
final value = _value;
|
||||
json[key] = value == null ? null : (encode?.call(value) ?? value);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PatchField<T> &&
|
||||
other._present == _present &&
|
||||
other._value == _value;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(_present, _value);
|
||||
|
||||
@override
|
||||
String toString() => _present ? 'PatchField($_value)' : 'PatchField.absent()';
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user