08-Flutter 鸿蒙实战 08:故事详情页,编辑、删除、重新生成与底部弹层

📅 2026/7/18 1:47:26
08-Flutter 鸿蒙实战 08:故事详情页,编辑、删除、重新生成与底部弹层
08. Flutter 鸿蒙实战 08故事详情页编辑、删除、重新生成与底部弹层故事详情不只展示正文还要处理编辑、删除、语音、活化和数据同步。详情页职责StoryDetailPage是整个项目里逻辑最复杂的页面之一。它要显示故事、照片、人物、地点、时间、语音讲述、照片活化状态还要支持编辑、删除、重新生成故事。文件位于lib/features/stories/story_detail_page.dart。页面接收预加载数据classStoryDetailPageextendsStatefulWidget{constStoryDetailPage({super.key,requiredthis.storyId,requiredthis.repository,this.initialStory,this.initialPhotoInfo,this.initialVideoFilePath,});}这样从列表进入详情时可以先展示已有内容不必等详情接口全部完成。初始化和刷新详情页initState中先使用初始数据如果缺少再请求_storywidget.initialStory;_photoInfowidget.initialPhotoInfo;_syncAnimationPolling(_photoInfo);if(_storynull||_photoInfonull){_load();}else{unawaited(_load(forceRefresh:false));}这种模式适合移动端详情页先展示缓存再后台校正。用户看到内容更快数据也不会长期停留在旧状态。编辑故事编辑采用底部弹层不跳新页面finaldraftawaitshowModalBottomSheetStoryEditDraft(context:context,isScrollControlled:true,backgroundColor:Colors.white,shape:constRoundedRectangleBorder(borderRadius:BorderRadius.vertical(top:Radius.circular(22)),),builder:(_)_StoryEditSheet(story:story),);移动端表单用底部弹层比新页面轻。故事详情仍在背景里用户知道自己正在编辑哪条故事。保存和同步元数据保存故事时不只更新故事正文还会同步照片的年代、地点、人物等元数据finalshouldOfferRegenerate_shouldOfferRegenerate(story,draft);finalcontentChangedstory.content.trim()!draft.content.trim();finalupdatedawaitwidget.repository.update(story.id,draft);awaitwidget.repository.updatePhotoMetadata(updated.photoId,era:draft.era,location:draft.location,persons:draft.persons,);故事和照片元数据是两套数据但它们在业务上有关联。用户修改地点、人物后照片本身也应该同步更新否则地图、故事详情、照片管理页会出现不一致。是否需要重新生成当年代、地点、日期、人物变化时旧故事正文可能不再准确。项目用_shouldOfferRegenerate判断是否提示重新生成bool_shouldOfferRegenerate(Storystory,StoryEditDraftdraft){returnstory.era.trim()!draft.era.trim()||story.location.trim()!draft.location.trim()||!_sameDate(story.storyDate,draft.storyDate)||!_sameStringList(story.persons,draft.persons);}这个逻辑比“保存后总是重生成”更合理。只改标题或正文时不需要重新生成改影响故事语义的字段时再给用户选择。删除确认故事删除必须二次确认。这个项目里删除故事不等于删除原始照片删除成功后返回列表并刷新缓存。这里的数据边界必须说清楚故事、照片、活化视频、语音缓存不是同一个东西。详情页一致性处理编辑前先保留原始状态详情页编辑不是简单把表单提交出去。保存前要先拿旧story和新draft做对比判断哪些字段真的发生变化。finalshouldOfferRegenerate_shouldOfferRegenerate(story,draft);finalcontentChangedstory.content.trim()!draft.content.trim();contentChanged决定是否清理旧语音状态shouldOfferRegenerate决定是否提示重新生成故事。先比较再保存页面就能根据变化类型做不同处理。正文变化要清理语音状态如果用户编辑了故事正文旧语音已经不再对应新正文。项目里保存后会检查contentChanged然后重置音频状态。这个细节能避免“文字是新的播放还是旧的”的错位体验。详情页状态拆分详情页要同时处理展示和编辑故事详情页不是单纯展示正文。它还要处理编辑、删除、重新生成、语音播放、视频播放、照片元数据同步等动作。为了避免状态混乱页面把关键状态拆成多个布尔值bool _savingfalse;bool _deletingfalse;bool _regeneratingfalse;bool _audioLoadingfalse;bool _audioLoadedfalse;bool _audioPlayingfalse;这些状态分别对应不同按钮和区域。保存中不应该禁用音频播放音频加载中也不应该影响删除确认。状态拆得细页面交互才不会一忙就全灰。编辑故事后要同步照片元数据故事里的年代、地点、人物并不只属于故事正文也会影响地图、照片管理和重新生成逻辑。项目在保存故事后会继续调用updatePhotoMetadata同步照片元数据。finalupdatedawaitwidget.repository.update(story.id,draft);awaitwidget.repository.updatePhotoMetadata(updated.photoId,era:draft.era,location:draft.location,persons:draft.persons,);如果只更新故事不更新照片地图页可能仍然读到旧地点照片列表也可能显示旧人物信息。详情页看起来保存成功了其他模块却不同步这就是跨模块数据边界没处理好。什么时候提示重新生成不是每次编辑都要重新生成故事。正文改了就直接保存正文年代、地点、日期、人物变了旧正文可能不再匹配新的元数据这时才需要询问是否重新生成。bool_shouldOfferRegenerate(Storystory,StoryEditDraftdraft){returnstory.era.trim()!draft.era.trim()||story.location.trim()!draft.location.trim()||!_sameDate(story.storyDate,draft.storyDate)||!_sameStringList(story.persons,draft.persons);}这个判断把“文本编辑”和“业务元数据变化”区分开。用户只是润色一句话时不会被打断用户改了地点和人物时系统才提示重新生成更匹配的故事。删除必须说清数据边界删除故事不等于删除照片。项目里删除故事后会从故事列表移除并清掉故事详情缓存但原始照片仍然可以继续存在。这个边界要清楚否则用户会担心删除故事把照片也删掉。Futurevoiddelete(Stringid)async{await_api.deleteJson(/api/stories/$id);finalcached_storyCache;if(cached!null){_storyCachecached.where((item)item.id!id).toList();}_storyDetailCache.remove(id);}删除动作还需要二次确认。移动端误触概率高尤其详情页按钮区域靠近底部时确认弹层能减少不可逆操作。详情页验证路径详情页至少覆盖这些操作1. 从故事列表进入详情先展示缓存内容再刷新接口数据。 2. 修改正文并保存返回列表后内容同步更新。 3. 修改地点或人物后出现重新生成提示。 4. 删除故事后返回列表被删除项不再显示。 5. 正文变化后音频状态被重置避免播放旧内容。详情页涉及的数据最多验证时要跨页面看结果。只在详情页看到保存成功不够还要返回列表、地图、照片信息确认数据是否一致。详情操作排查底部弹层适合承载编辑表单故事详情页没有跳转到单独编辑页而是用showModalBottomSheet打开编辑表单。这样用户仍然停留在故事上下文里修改完成后能立即看到结果。finaldraftawaitshowModalBottomSheetStoryEditDraft(context:context,isScrollControlled:true,backgroundColor:Colors.white,shape:constRoundedRectangleBorder(borderRadius:BorderRadius.vertical(top:Radius.circular(22)),),builder:(_)_StoryEditSheet(story:story),);isScrollControlled: true对移动端表单很重要。键盘弹起后如果弹层高度不可控输入框容易被遮挡。编辑正文、地点、人物这类字段时弹层要能随键盘调整。保存动作要防止重复提交_saving状态不只是显示 loading还要防止用户连续点击保存。重复提交可能导致两次接口请求交叉返回页面最后显示旧结果。setState(()_savingtrue);try{finalupdatedawaitwidget.repository.update(story.id,draft);...}finally{if(mounted){setState(()_savingfalse);}}项目代码里在成功和失败分支分别收回_saving效果一样。重点是请求发出后按钮状态要进入不可重复提交状态。重新生成会影响多个区域重新生成故事后正文、标题、摘要、语音状态都可能变化。页面不能只替换正文还要重置与旧正文绑定的资源。故事内容变化 - 更新 Story 缓存 正文变化 - 清理旧音频状态 照片元数据变化 - 重新判断是否需要生成如果正文变了但语音状态没重置用户可能播放到旧音频。这个问题在内容类应用里很常见文本和衍生资源要一起维护。删除后的返回值详情页删除成功后需要把“数据已变化”传回上一页。上一页收到结果后刷新列表或使用缓存删除结果。详情页删除故事 - Repository 删除缓存 - Navigator.pop(context, true) - 列表页刷新或移除对应项这种返回值比让列表页盲目刷新更明确。页面之间不用共享太多状态只传递“发生了变化”这个事实。详情页问题定位表现象优先检查对应文件处理方向保存后列表仍是旧标题缓存是否同步更新stories_repository.dart更新_storyCache和_storyDetailCache修改地点后地图不变照片元数据是否同步story_detail_page.dart保存故事后调用updatePhotoMetadata重新生成后还播放旧语音音频状态是否清理story_detail_page.dart正文变化后执行_resetAudioState()删除后返回列表仍显示删除结果是否传回详情页和列表页删除缓存并通知上一页刷新详情页牵动的数据很多。只看当前页面保存成功不够还要确认列表、地图、音频这些相关模块是否跟着更新。编辑草稿的字段边界编辑弹层提交的不是完整 Story而是StoryEditDraft。这样能避免把只读字段、服务端生成字段一起提交回去。可编辑标题、正文、年代、地点、日期、人物 不可编辑故事 id、照片 id、创建时间、视频状态、缓存路径草稿对象越干净接口越不容易收到无关字段。详情页只提交用户真正能修改的内容照片和视频这些关联数据通过专门接口维护边界更清楚。|详情页牵动的数据很多。只看当前页面保存成功不够还要确认列表、地图、音频这些相关模块是否跟着更新。