06. Flutter 鸿蒙实战 06:上传后处理流程,照片识别、故事生成和状态刷新

上传成功只是开始,后面还有处理接口、故事生成、照片活化和列表刷新。
上传后的链路
照片上传完成并不代表业务结束。这个项目的核心价值在于"老照片变成可讲述的故事",所以上传后还要触发后端处理:识别照片、提取年代地点人物、生成故事、尝试生成活化视频。前端需要清楚区分"上传中"和"处理中"。
上传页里两个状态分开:
dart
bool _uploading = false;
bool _processing = false;
上传阶段显示具体百分比,处理阶段显示不确定进度:
dart
if (_processing) ...[
const SizedBox(height: 16),
const LinearProgressIndicator(),
const SizedBox(height: 8),
const Text(
'正在识别照片、生成故事并活化视频,请不要离开',
textAlign: TextAlign.center,
),
]
为什么要分阶段
文件上传是客户端可感知的字节传输,进度可以计算;照片处理属于服务端任务,耗时取决于识别、故事生成和视频活化,前端不能伪造百分比。把这两个阶段混在一起,会让用户误以为卡在上传。
项目变更记录里有过真实问题:活化视频失败曾经导致整张照片处理失败。后来修正为故事生成成功后,活化失败只更新照片活化状态,不再删除照片和故事。这里可以沉淀出一个实现原则:非核心附加能力失败,不应该抹掉已经成功的主数据。
处理接口后的状态更新
上传成功后,页面会从返回结果里取出 photo,再触发处理流程并刷新列表。核心思想是:不要只依赖本地临时状态,最终要以服务端照片列表为准。
dart
final response = await apiClient.uploadPhoto(
bytes: bytes,
filename: file.name,
mimeType: mimeType,
onProgress: (value) {
if (mounted) setState(() => _progress = value);
},
);
final photo = response['photo'];
处理完成后刷新上传列表,也可以让首页、故事页拿到新数据。项目里 StoriesRepository.clearCache() 在登录切换时清缓存,上传成功后也应该考虑相关页面刷新。
错误展示
上传页把异常转成用户可理解文案:
dart
String _message(Object exception) => userFacingError(exception);
前端不要把后端错误全部吞掉,也不要用"操作失败"覆盖所有情况。比如文件太大、格式不支持、网络断开、处理失败,这些提示应该不同。用户能根据提示采取下一步动作。
和后端任务状态的关系
从 tasks.md 和 changes.md 可以看到,项目围绕照片处理修过多次问题:活化模型参数、活化状态枚举、视频缓存、额度耗尽提示、403 链接失效等。这些问题最后都指向同一个原则:移动端页面只展示真实后端状态,不假装成功。
例如活化视频生成失败时,照片和故事仍然可以保存;视频额度耗尽时,详情页提示额度问题,而不是让用户重新上传照片。
处理流程图


上传页和后端处理链路必须放在一起理解。它不是单纯 Flutter UI,而是移动端与服务端长任务协作的完整场景。
处理阶段拆分
上传成功不等于处理成功
照片上传接口只说明原图已经到达服务器。后续识别、故事生成、活化视频都可能继续运行。前端如果把"上传成功"当成"全部完成",用户会在故事页看不到新内容,或者误以为处理卡住。
这个项目用 _processing 单独表达后端处理阶段,配合提示文案和刷新逻辑,让用户知道照片已经上传,服务端还在继续生成内容。
非核心能力失败不能删除主数据
项目真实修复过一个问题:活化视频失败导致整张照片处理失败。后续改成故事生成成功后,活化失败只更新照片活化状态,不删除照片和故事。这个处理方式可以拆成三条规则:
text
主链路:照片保存 -> 信息识别 -> 故事生成
增强链路:照片活化 -> 视频缓存 -> 端内播放
增强链路失败时,不应该破坏主链路结果。移动端页面也要按这个边界展示状态。
长任务状态设计
长任务不能用一次请求概括
照片上传完成,只能说明原图已经传到服务器。后面的识别、故事生成、语音准备、视频活化都属于服务端处理链路。移动端如果把上传成功等同于全部完成,用户很容易在故事页看不到内容,误以为数据丢了。
比较清晰的状态语义应该这样拆:
text
selected 本地已选择照片
uploading 字节正在上传
uploaded 原图到达服务端
processing 服务端正在生成业务内容
ready 故事或照片状态可展示
failed 某个阶段失败,需要明确提示
当前项目用 _processing 区分后端处理阶段。页面文案也对应这个阶段,告诉用户照片已经进入服务端处理,而不是简单显示"上传中"。
主数据和附加能力要隔离
项目曾经遇到过一个典型问题:活化视频失败导致整张照片处理失败。后面改成故事生成成功后,视频活化失败只更新照片活化状态,不删除照片和故事。这种处理方式更符合移动端业务优先级。
text
主数据:照片、故事、年代、地点、人物
附加能力:语音、视频活化、缩略图增强
主数据成功后,附加能力失败不能抹掉主数据。否则用户会看到"明明上传成功了,最后什么都没有"的结果。正确做法是保留照片和故事,再在详情页展示视频状态或错误提示。
前端状态刷新要围绕真实接口
移动端不要自己猜服务端处理结果。上传完成后,应该通过照片列表、故事列表、照片详情接口刷新真实状态。
dart
Future<void> _loadPhotos() async {
final rows = await api.getJsonList('/api/photos');
final photos = rows
.whereType<Map<String, dynamic>>()
.map(AlbumPhoto.fromJson)
.toList();
setState(() {
_photos = photos;
_photosLoading = false;
});
}
这段逻辑看起来普通,但它保证页面展示的是服务端最终状态。识别服务是否完成、故事是否生成、视频状态是否失败,都应该从接口拿。
错误提示要分层
上传后处理链路里,错误不能全部归成"操作失败"。比较合理的提示层级如下:
text
文件错误:格式不支持、体积过大
网络错误:断网、超时、服务器不可达
认证错误:token 失效、未登录
业务错误:识别失败、额度耗尽、视频生成失败
数据错误:接口结构异常、缺少必要字段
这些错误的处理动作不同。文件错误要重新选择,网络错误要重试,认证错误要回登录页,业务错误要展示具体状态,数据错误要查接口结构。提示越准确,用户越容易继续操作,开发者也更容易定位问题。
验证处理链路
完整验证不能只看上传接口返回 200,还要继续检查后续页面:
text
1. 上传照片后照片列表出现新照片。
2. 故事列表能看到新生成的故事。
3. 故事详情里的年代、地点、人物字段有内容。
4. 视频活化失败时故事仍然保留。
5. 刷新页面后状态不回退。
这组验证能覆盖"上传成功但业务内容没落地"的问题。移动端项目做长任务时,最怕页面提前报成功,后面状态却跟不上;把状态刷新接到真实接口上,问题会少很多。
处理链路排查
页面文案要反映真实阶段
处理阶段的文案不能继续写"上传中"。上传和处理是两个动作,用户看到不同文案,才知道现在不是网络传输慢,而是服务端正在生成内容。
text
正在上传 35% 字节仍在传输
正在处理照片 服务端已收到照片,正在生成内容
处理失败 服务端任务失败,需要重试或查看错误
文案准确会减少误解。用户如果以为还在上传,可能会重复点击或退出页面;知道已经进入处理阶段,就更容易等待或去别的页面。
状态刷新不要依赖固定等待
移动端常见错误是上传后 Future.delayed 几秒再刷新。这种写法不稳定,网络快时浪费时间,网络慢时仍然拿不到结果。更稳的是围绕接口状态刷新。
text
上传接口返回 -> 刷新照片列表
照片详情接口 -> 查看识别字段和视频状态
故事列表接口 -> 查看故事是否生成
详情页轮询 -> 只针对仍在处理中的视频状态
当前项目里,上传页刷新照片列表,故事详情页针对活化状态做轮询。不同页面只刷新自己需要的数据,避免全局乱刷。
失败隔离的代码边界
处理链路里可以把失败分为"阻断型"和"非阻断型"。
text
阻断型:照片上传失败、故事主内容生成失败
非阻断型:视频活化失败、缩略图加载失败、高清语音生成失败
阻断型失败要阻止进入下一阶段,非阻断型失败只更新局部状态。这个边界能避免一个附加功能失败导致整条业务链路回滚。
数据一致性检查
处理完成后,至少要检查三个接口的数据是否一致:
text
/api/photos 上传列表里有照片
/api/stories 故事列表里有对应故事
/api/photos/{id} 照片详情里有年代、地点、人物、视频状态
如果照片存在但故事不存在,问题在故事生成或列表刷新;如果故事存在但照片详情缺字段,问题在识别结果或元数据保存;如果视频状态失败但故事存在,属于非阻断失败,不应该删除故事。
处理流程问题定位表
| 现象 | 优先检查 | 对应接口或文件 | 处理方向 |
|---|---|---|---|
| 上传成功但故事列表没有内容 | 故事生成是否完成 | /api/stories |
刷新故事列表,检查服务端处理日志 |
| 照片有但元数据为空 | 识别结果是否写入 | /api/photos/{id} |
检查年代、地点、人物字段 |
| 视频失败导致故事消失 | 主数据和附加能力是否耦合 | 后端处理链路、详情页状态 | 视频失败只更新视频状态,不删除故事 |
| 页面一直显示处理中 | 状态刷新是否依赖真实接口 | upload_page.dart、story_detail_page.dart |
通过详情接口刷新状态,不靠固定等待 |
长任务最怕状态含糊。上传、识别、故事生成、视频活化是不同阶段,页面要分别表达。只有这样,用户才能知道现在是网络慢、服务端忙,还是某个附加能力失败。
处理状态字段设计
上传后的处理链路最好不要只用一个布尔值表示。更清楚的做法是给照片维护独立的处理字段,页面根据字段组合展示状态。
text
photo.status uploaded / analyzing / ready / failed
photo.storyStatus pending / generated / failed
photo.animationStatus none / processing / ready / failed
photo.errorMessage 最近一次失败原因
页面展示时可以按优先级判断:
dart
String processingLabel(PhotoInfo photo) {
if (photo.status == 'failed') return '照片识别失败';
if (photo.storyStatus == 'pending') return '故事生成中';
if (photo.animationStatus == 'processing') return '视频活化中';
if (photo.animationStatus == 'failed') return '故事已生成,视频暂不可用';
return '处理完成';
}
这样比"上传成功/失败"更精确。照片识别、故事生成、视频活化可以分别失败、分别恢复,页面也能给出不同提示。