视频拆菜这个功能第一眼看像是“从视频里提取食材和步骤”。但真做进应用以后会发现更难的不是提取而是用户改完以后怎么留下来。用户可能先贴一段字幕生成一份待确认草稿也可能只改了菜名、补了火候暂时不想保存成正式菜谱还可能确认完以后希望它马上出现在“我的菜谱”里。这里如果只靠页面上的几个Local变量撑着页面一返回用户刚改的内容就容易断掉。所以中式美食这块把链路拆成三层页面负责收集动作VideoInsightRepository负责保存草稿最后再由UserDishRepository把确认后的内容转成我的菜谱。这样写看起来多了一层但后面维护会稳很多。本文验证环境项目版本或范围开发工具DevEco Studio 6.0.0 ReleaseHarmonyOS SDKAPI 20语言与框架ArkTS 6.0 / ArkUI V2工程模型Stage 模型主要页面VideoInsightPage.ets数据模型VideoRecipeInsight.ets草稿仓储VideoInsightRepository.ets验证用例VideoInsight.test.ets这篇只讲视频拆菜里“保存下来”这一段不把重点放在视觉面板。上一篇已经讲过因子编辑面板这篇接着往后走草稿怎么生成、怎么更新、怎么确认、怎么回到列表里继续看。先把草稿和正式菜谱分清楚中式美食里视频拆菜的中间结果叫VideoRecipeInsight。它不是正式菜谱而是一份还可以继续整理的草稿。exporttypeVideoInsightStatusdraft|confirmed;exporttypeVideoFactorStatusdetected|uncertain|edited;exporttypeVideoFactorKindingredient|step|heat|cue|risk;exportinterfaceVideoRecipeInsight{id:string;title:string;source:string;videoUri:string;rawText:string;dishName:string;summary:string;status:VideoInsightStatus;factors:VideoFactor[];createdAt:number;updatedAt:number;}这里最重要的是status。draft表示还只是草稿用户可以继续改confirmed表示这份内容已经被确认过可以进入我的菜谱链路。我不建议一生成拆菜结果就直接写进正式菜谱。因为视频字幕经常有漏字火候也不一定准确。先放到草稿里让用户确认食材、步骤、火候、风险提醒再转成正式菜谱体验会稳得多。Repository 不能只当数组缓存VideoInsightRepository不是简单地放一个数组。它负责三件事职责为什么要放在 Repository创建草稿页面不用自己拼完整对象避免字段漏填更新草稿统一处理updatedAt、默认值和列表顺序本地落盘页面不直接接触存储 key后面迁移更好改核心入口是createDraft()createDraft(title:string,source:string,videoUri:string,rawText:string):VideoRecipeInsight{constnow:numberDate.now();constdisplayTitle:stringtitle.trim().length0?title.trim():未命名菜谱;constcleanedRawText:stringrawText.trim();constitem:VideoRecipeInsight{id:vnow,title:displayTitle,source:source.trim(),videoUri,rawText:cleanedRawText,dishName:displayTitle,summary:cleanedRawText.length0?已按文字整理成这道菜:待你整理的菜谱草稿,status:draft,factors:this.seedFactors(now,cleanedRawText),createdAt:now,updatedAt:now};this.items[item,...this.items];this.save();returnitem;}这里有两个细节。第一标题为空时要兜底。用户刚开始可能只是想试试不一定马上填完整菜名。草稿可以先存在后面再改。第二创建后放到数组最前面。草稿列表通常看的是最近修改内容最新草稿排前面用户回来继续整理时更容易找到。更新草稿时要重建对象不要原地乱改保存草稿时update()会重新组装一份saved对象update(item:VideoRecipeInsight):void{constnext:VideoRecipeInsight[]this.items.slice();constidx:numbernext.findIndex((it:VideoRecipeInsight):booleanit.iditem.id);constsaved:VideoRecipeInsight{id:item.id,title:item.title,source:item.source,videoUri:item.videoUri??,rawText:item.rawText??,dishName:item.dishName,summary:item.summary,status:item.status,factors:item.factors,createdAt:item.createdAt,updatedAt:Date.now()};}这比页面直接把数组里某一项改掉更可靠。尤其是 ArkUI 状态刷新里数组引用、对象引用、字段更新顺序都可能影响页面是否及时重绘。我的习惯是用户动作先进页面本地状态点击保存时再组装一个干净对象交给仓储。仓储负责决定它是替换旧记录还是插入新记录。if(idx0){next[idx]saved;this.itemsnext;}else{this.items[saved,...next];}this.save();这样写还有一个好处如果以后要加字段比如sourcePage、coverImage、lastEditedFactorKind只要集中改仓储的保存逻辑不需要到页面里到处找赋值。页面只说“用户做了什么”VideoInsightPage里保存草稿时不直接处理存储细节而是把当前页面状态转成一个VideoRecipeInsight。privatesaveDraft(status:draft|confirmed):void{constcurrent:VideoRecipeInsight|nullthis.activeItem;if(currentnull||!this.canSave){return;}constsaved:VideoRecipeInsight{id:current.id,title:current.title,source:current.source,videoUri:,rawText:this.rawTextEditor.trim(),dishName:this.dishName.trim(),summary:this.summary.trim(),status,factors:this.factors.slice(),createdAt:current.createdAt,updatedAt:Date.now()};this.repo.update(saved);this.loadItem(saved);}这里页面关心的是当前选中的草稿是谁、用户编辑后的菜名是什么、因子列表是什么、这次保存是draft还是confirmed。它不关心存储 key也不关心怎么序列化。这条边界很重要。页面如果直接碰存储后面一旦改存储结构UI 层就会被拖着一起改。为什么要有“先放着”和“存为菜谱”视频拆菜不是一个一次性动作。用户可能只是先保存素材晚点再确认也可能已经整理完了想马上放进我的菜谱。所以页面里有两个不同动作动作状态用户心智先放着draft我还没整理完下次继续存为菜谱confirmed这份内容已经能用了如果把它们合成一个“保存”按钮用户会不清楚保存后的结果去哪了。中式美食这里把草稿和正式菜谱分开能减少很多误会。正式保存时会先把草稿标成confirmed再转成Dish写入UserDishRepositoryprivatesaveAsDish():void{constcurrent:VideoRecipeInsight|nullthis.activeItem;if(currentnull||!this.canSave){return;}this.saveDraft(confirmed);constdish:Dish{id:manual_Date.now(),name:this.dishName.trim(),summary:this.summary.trim().length0?this.summary.trim():自己整理的家常菜谱草稿,image:,category:DishCategory.CUSTOM,scene:DishScene.DAILY,rating:0,reviewCount:0,cookTime:待补充,serving:待补充,difficulty:简单,ingredients:this.dishIngredients(),steps:this.dishSteps(),nutrition:{calories:0,protein:0,fat:0,carb:0},tip:this.buildTip(),story:current.source.length0?来源current.source:手动整理的菜谱草稿,tags:[我的菜谱,手动整理]};UserDishRepository.getInstance().add(dish);}这段代码的思路很直白视频拆菜草稿负责承接不确定内容正式菜谱负责进入用户长期使用的内容库。两者不混在一起后面列表、搜索、收藏、笔记都更好接。因子怎么变成食材和步骤VideoFactor里已经按类型分好了食材、步骤、火候、判断熟成、风险提醒。转成正式菜谱时不需要再从一大段文字里重新解析。privatedishIngredients():Ingredient[]{constlist:VideoFactor[]this.factorsByKind(ingredient);if(list.length0){return[{name:待补充食材,amount:适量}];}returnlist.map((it:VideoFactor):Ingredient{return{name:it.text,amount:适量};});}步骤也是同样的逻辑privatedishSteps():Step[]{constlist:VideoFactor[]this.factorsByKind(step);if(list.length0){return[{step:1,title:补充做法,desc:可以继续写下这道菜的关键步骤。}];}returnlist.map((it:VideoFactor,index:number):Step{return{step:index1,title:第 (index1).toString() 步,desc:it.text};});}这里没有追求一步到位。比如食材默认适量烹饪时间默认待补充。这比强行猜一个不准确的值要好。菜谱应用最怕“看起来完整实际不可信”。不确定的地方保留给用户补反而更诚实。风险提醒不应该丢掉视频里经常会出现“不要糊锅”“不要大火”“肉不要炒老”这种提醒。它们不是步骤但对做菜很有用。中式美食把这类内容放进risk因子最后汇总到tip里privatebuildTip():string{constheats:stringthis.factorsByKind(heat).map((it:VideoFactor):stringit.text).join();constcues:stringthis.factorsByKind(cue).map((it:VideoFactor):stringit.text).join();constrisks:stringthis.factorsByKind(risk).map((it:VideoFactor):stringit.text).join();return火候(heats.length0?heats:待补充)\n看熟(cues.length0?cues:待补充)\n提醒(risks.length0?risks:待补充);}这样做的好处是正式菜谱不会只剩食材和步骤。用户真正做饭时火候、熟成信号、容易错的地方往往更有价值。删除也要走仓储草稿删除看起来简单但也不要让页面自己改数组。仓储里的remove()会先过滤再判断数量是否变化最后统一保存remove(id:string):void{constnext:VideoRecipeInsight[]this.items.filter((it:VideoRecipeInsight):booleanit.id!id);if(next.length!this.items.length){this.itemsnext;this.save();}}这个判断可以避免无意义写盘。用户点了一个不存在的 id或者页面状态已经过期不会触发多余保存。小地方看起来不起眼但本地存储链路里少做无意义写入就是稳定性。单测要覆盖真实使用路径视频拆菜草稿不是只靠手点页面验证。VideoInsight.test.ets里至少覆盖了几类关键逻辑用例验证点buildFactorsFromTextExtractsCookSignalsAndFiltersNoise从文字里提取食材、步骤、火候、风险提醒buildFactorsFromTextUsesFallbackRecommendationsForEmptyOrUnmatchedInput空输入和无匹配输入有兜底因子createDraftTrimsInputsPreservesRawTextAndKeepsNewestFirst创建草稿时会 trim、保留原文、新草稿排前面getAllReturnsCopyAndRemoveOnlyDeletesMatchingId列表返回副本删除只删匹配项我比较看重第三个用例。它验证的不是算法多复杂而是用户最常见的路径填一点内容、保存成草稿、下次从列表最前面继续打开。expect(repo.count()).assertEqual(2);expect(repo.items[0].title).assertEqual(红烧排骨);expect(repo.items[1].title).assertEqual(番茄炒蛋);expect(first.videoUri).assertEqual(file://first.mp4);expect(first.status).assertEqual(draft);expect(first.factors.length0).assertEqual(true);这种测试能防止后面改仓储时把用户最基础的连续性改坏。这条链路怎么验收我会按下面这组动作验收而不是只看代码编译通过操作预期结果输入菜名和字幕文案可以生成待确认草稿修改某一条关键因子页面状态立即更新点击“先放着”草稿仍是draft回到列表还能继续打开点击“存为菜谱”草稿变成confirmed同时写入我的菜谱删除草稿列表删除对应项其它草稿不受影响重新打开页面已保存草稿仍然存在如果这几个动作都稳定视频拆菜才算真正接进中式美食的内容体系。否则它只是一个看起来能用的临时页面。容易踩的坑1. 把草稿和正式菜谱混成一个模型草稿里有很多不确定内容正式菜谱应该相对稳定。如果一开始就混成一个对象后面会出现很多尴尬字段比如isDraft、maybeIngredient、pendingStep到处散落。分成VideoRecipeInsight和Dish两个阶段代码会清楚很多。2. 页面直接写本地存储页面直接调用存储看起来快但后面要改 key、加版本、做迁移时会很痛。仓储层集中处理页面只负责动作和状态会更稳。3. 没有兜底内容如果空输入生成的是空列表页面会很难引导用户继续。中式美食给空草稿生成默认因子就是为了让用户知道下一步可以补什么。4. 保存后没有结果感用户点击保存以后如果只是一个轻飘飘的提示很容易不知道内容去哪了。保存为我的菜谱以后要让用户明确看到它已经进入自己的菜谱库。小结中式美食的视频拆菜保存链路重点不是把页面做得复杂而是把状态分清楚。VideoRecipeInsight承接不确定的草稿VideoInsightRepository负责创建、更新、删除和落盘VideoInsightPage只表达用户动作UserDishRepository接收确认后的正式菜谱。这样一来用户可以先保存、后整理、再确认整个过程不会因为页面跳转而丢失。对 HarmonyOS 应用来说这类本地内容链路很常见。只要一开始把草稿状态、仓储边界和正式数据入口设计清楚后面加搜索、收藏、笔记、最近浏览都会顺很多。如果你也在做类似的 ArkTS 本地内容功能我建议先问一个问题用户临时保存的东西和用户确认长期使用的东西是不是应该分成两个阶段这个问题想清楚后面的模型会少走很多弯路。