视频拆菜页最难的地方,不是把一段文字拆成几个字段,而是让用户愿意把它改完。很多类似功能做出来以后,看起来像一个结果页:给你一堆识别结果,剩下自己看。中式美食这里更偏向"草稿编辑器":先把视频或字幕整理成一版可修改的菜谱草稿,再让用户逐条确认、补充、保存。
所以这篇重点不放在算法,而放在编辑面板。也就是:因子怎么分类,状态怎么表达,用户怎么保留或删除一条内容,保存以后怎么变成"我的菜谱草稿"。

上图对应的是中式美食里的真实视频拆菜流程:编辑因子、底部保存入口、已保存卡片。它看起来是 UI 问题,但背后其实是数据状态设计。如果状态没拆清楚,页面会很快变成一堆按钮和输入框。
| 章节位置 | 内容 |
|---|---|
| 页面入口 | entry/src/main/ets/view/pages/VideoInsightPage.ets |
| 编辑面板 | entry/src/main/ets/view/components/V3VideoEditorPanels.ets |
| 数据模型 | entry/src/main/ets/model/entity/VideoRecipeInsight.ets |
| 草稿仓储 | entry/src/main/ets/model/repository/VideoInsightRepository.ets |
| 关键状态 | draft、confirmed、detected、uncertain、edited |
本文验证环境
本文对应的是已上架的 HarmonyOS 应用中式美食,视频拆菜页面来自真实工程。
| 项目 | 版本与范围 |
|---|---|
| 开发工具 | DevEco Studio 6.0.0 Release |
| 运行系统 | HarmonyOS NEXT / HarmonyOS 6.0 |
| SDK/API | HarmonyOS SDK API 20 |
| 语言与框架 | ArkTS 6.0、ArkUI V2、Stage 模型 |
| 核心能力 | @ComponentV2、@Param、@Event、TextInput、TextArea、ForEach |
| 验证方式 | 输入字幕文本、生成草稿、修改因子、切换状态、保存后回到草稿列表 |
我验证这块时,不只看页面能不能打开,而是按完整操作走一遍:先输入菜名和一段做菜文字,生成草稿;再修改某条食材、火候或步骤;把不确定内容标出来;最后保存并从历史列表重新打开。
本章导读
中式美食把视频拆菜后的内容叫 VideoRecipeInsight。它不是最终菜谱,而是一份还需要人工确认的草稿。
草稿里最关键的是 factors。每个 VideoFactor 表示一条可编辑信息,比如食材、步骤、火候、成熟信号、容易出错的提醒。每条因子都有自己的状态,用户可以继续改,也可以删掉。
typescript
export type VideoInsightStatus = 'draft' | 'confirmed';
export type VideoFactorStatus = 'detected' | 'uncertain' | 'edited';
export type VideoFactorKind = 'ingredient' | 'step' | 'heat' | 'cue' | 'risk';
export interface VideoFactor {
id: string;
kind: VideoFactorKind;
text: string;
timeLabel: string;
status: VideoFactorStatus;
}
这个模型的好处是直接。用户看到一条内容,不需要猜它属于哪里;开发者保存时,也不需要从一大段富文本里重新解析。
先把草稿建出来
VideoInsightRepository.createDraft() 负责创建一份拆菜草稿。它会把标题、来源、视频地址、原始文字和初始因子放在一起。
typescript
createDraft(title: string, source: string, videoUri: string = '', rawText: string = ''): VideoRecipeInsight {
const now: number = Date.now();
const displayTitle: string = title.trim().length > 0 ? title.trim() : '未命名菜谱';
const cleanedRawText: string = rawText.trim();
const item: VideoRecipeInsight = {
id: 'v' + now,
title: displayTitle,
source: source.trim(),
videoUri,
rawText: cleanedRawText,
dishName: displayTitle,
summary: cleanedRawText.length > 0 ? '已按文字整理成这道菜' : '待你整理的菜谱草稿',
status: 'draft',
factors: this.seedFactors(now, cleanedRawText),
createdAt: now,
updatedAt: now
};
}
这里有两个细节。
第一,标题为空时必须兜底。用户可能只是想先试一下,不一定一开始就把菜名写完整。
第二,rawText 不为空时会走提取逻辑;如果没有输入文字,也会生成几条默认因子,保证编辑面板不是空的。空页面会让用户不知道下一步怎么做,有默认草稿就能直接改。
因子不是结果,是待确认项
仓储里有一段很实用的规则:从原始文字里按关键词拆因子。比如识别到"食材、材料、调料",就倾向归到 ingredient;识别到"大火、中火、小火、收汁",就归到 heat;识别到"不要、避免、小心、容易",就归到 risk。
typescript
private pushIfRoom(out: VideoFactor[], seed: number, kind: VideoFactorKind, text: string, timeLabel: string): void {
const sameKindCount: number = out.filter((it: VideoFactor): boolean => it.kind === kind).length;
if (sameKindCount >= 5 || out.length >= 18) {
return;
}
const existed: VideoFactor | undefined = out.find((it: VideoFactor): boolean => {
return it.kind === kind && it.text === text && it.timeLabel === timeLabel;
});
if (existed !== undefined) {
return;
}
out.push(this.factorWithIndex(seed, out.length, kind, text, timeLabel));
}
这个限制很关键。视频文字可能很长,如果无脑拆出几十条,用户根本没有耐心一条条看。中式美食给每类最多 5 条、总数最多 18 条,目的就是把编辑量控制在可接受范围。
这不是追求"识别越多越好",而是追求"用户愿意确认完"。
编辑面板如何表达状态
V3VideoFactorListPanel 不直接修改仓储,它通过事件把操作抛出去:
typescript
@Event onTextChange: (id: string, text: string) => void = () => {};
@Event onTimeChange: (id: string, timeLabel: string) => void = () => {};
@Event onStatusChange: (id: string, status: VideoFactorStatus) => void = () => {};
@Event onRemove: (id: string) => void = () => {};
这个写法比在组件里直接操作全局数据更干净。面板只负责展示和收集用户动作,真正的数据更新交给页面或 ViewModel。以后如果保存逻辑换成接口、或者加撤销,都不用重写卡片 UI。
每条因子卡片里,用户能改三类东西:
| 操作 | 对应字段 | 作用 |
|---|---|---|
| 改时间 | timeLabel |
保留视频中的时间线索 |
| 改文字 | text |
修正识别不准、补充做法 |
| 改状态 | status |
标记保留、不确定、已修改 |
状态显示也做了差异化:不确定的内容用提醒色,已保留或已修改的内容用更稳定的颜色。这样用户不用读完整段文字,也能知道哪些还没处理。
为什么要有"都留着"
编辑面板顶部有一个 onAcceptAll 事件,对应"都留着"这类操作。这个按钮看起来很小,但体验上很重要。
如果系统已经拆得比较准,用户不应该还要一条条点"保留"。一键保留能把人工确认成本降下来。反过来,如果某条不对,用户仍然可以单独改状态或删除。
typescript
if (this.pendingCount > 0) {
Button('都留着')
.height(32)
.fontSize(AppFonts.xs)
.onClick((): void => this.onAcceptAll());
}
这里的设计原则是:批量操作给轻路径,单条编辑给精修路径。两条路都要有,不然功能会变得很累。
新增一条因子怎么做
识别结果一定会漏。比如字幕里没有说"转小火",但用户知道这一步很关键,就需要手动补一条火候提醒。
V3VideoAddFactorPanel 把新增因子拆成三部分:类型、时间、文字。
typescript
@Param factorKind: VideoFactorKind = 'ingredient';
@Param factorText: string = '';
@Param factorTime: string = '';
@Event onKindChange: (kind: VideoFactorKind) => void = () => {};
@Event onTextChange: (text: string) => void = () => {};
@Event onTimeChange: (timeLabel: string) => void = () => {};
@Event onAdd: () => void = () => {};
这比让用户在一段大文本里继续追加要清楚。后续展示菜谱时,食材、步骤、火候、风险提醒都已经分好了,不需要再二次解析。
已保存卡片给用户一个结果感
用户点保存之后,如果页面只是弹个 toast,很快就没有上下文了。中式美食用了 V3VideoSavedDishCard,告诉用户这份草稿已经进了我的菜谱。
typescript
export struct V3VideoSavedDishCard {
@Param dishName: string = '';
@Event onOpenCustomList: () => void = () => {};
}
卡片里保留菜名,并提供去查看入口。这个动作很重要,因为"视频拆菜"不是一次性功能,它最终要和我的菜谱、收藏、笔记这些长期使用场景连接起来。
草稿列表为什么要显示数量和状态
V3VideoLibraryPanel 展示的是已经创建过的拆菜草稿。每条记录会显示菜名、更新时间和状态。
typescript
Text(item.status === 'confirmed' ? '已保存' : '待整理')
.fontSize(10)
.fontColor(item.status === 'confirmed' ? '#2F5D46' : '#9B2F21');
这类状态不是为了装饰,而是帮助用户快速判断:哪份已经能用了,哪份还需要继续整理。菜谱工具如果没有这种连续性,用户下次回来就很难接上。
工程验收记录
| 检查项 | 结果 |
|---|---|
| 创建草稿 | 菜名为空时有兜底标题,页面不空白 |
| 原始文字拆分 | 能按食材、步骤、火候、成熟信号、风险提醒形成因子 |
| 因子数量 | 单类和总数有限制,避免页面过长 |
| 单条编辑 | 时间、文字、状态都能独立修改 |
| 删除因子 | 删除后列表刷新,剩余内容不丢 |
| 一键保留 | 可快速处理全部待确认内容 |
| 新增因子 | 可选择类型、补时间、写内容 |
| 保存结果 | 显示已保存卡片,并能回到草稿列表查看 |
常见问题复盘
第一个问题是把"识别结果"当成"最终菜谱"。实际使用时,视频字幕经常不完整,用户必须能改。只展示结果不提供编辑,会让这个功能停在玩具阶段。
第二个问题是状态太少。如果只有保存和未保存,用户不知道哪一条是系统识别的、哪一条是自己改过的、哪一条还不确定。detected、uncertain、edited 这三个状态虽然简单,但足够支撑编辑流程。
第三个问题是按钮太散。中式美食把批量保留、单条编辑、新增因子、保存结果分成不同面板,不把所有按钮塞进一个卡片里。这样页面更长一点,但每个区域的任务更清楚。
本章小结
中式美食的视频拆菜页不是单纯做一个"识别结果展示页",而是做成了草稿编辑流程。VideoRecipeInsight 负责承载整份草稿,VideoFactor 负责承载每条可确认内容,V3VideoEditorPanels 负责把这些状态变成用户能操作的面板。
这套设计的核心是把不确定性摆到明面上。系统先整理一版,用户再确认、修改、删除、补充,最后保存成自己的菜谱。对菜谱应用来说,这比一次性给一个看起来很完整但不能改的结果,更接近真实使用。