HarmonyOS 字幕校对台连载04:逐句编辑与原文事务提交【共创稿事节】
HarmonyOS 字幕校对台连载04:逐句编辑与原文事务提交【共创稿事节】
字幕列表能定位和检查冲突以后,编辑一条字幕就不再是改一个文本框。结束时间改变,原文、时间索引、冲突组和重叠对都需要同步更新;如果其中一项计算失败,页面不能停在半新半旧的状态。草稿还可能来自旧版本文档,直接覆盖会把其他入口已经完成的修改丢掉。

一、草稿保存身份和基准版本
继续使用SrtParser、CueIndex与ConflictGroups三个模块,沿用Cue.id作为编辑身份,毫秒区间和左闭右开规则不变。新增model/EditorStore.ets存放前三段代码,第四段替换pages/Index.ets。本文的事务指内存中文档状态的一次整体提交,文件落盘应在此基础上另行接入持久化流程。
草稿包含目标id、开始编辑时的文档版本、起止时间和正文。它独立于正式Cue,输入变化只更新草稿。正式快照则同时持有Cue集合、规范化原文、索引和冲突组,界面读取同一版本,避免每个区域各自解释“最新数据”。
import { Cue, parseSrt, formatTime, normalize } from './SrtParser';
import { IndexedCue, buildIndex } from './CueIndex';
import { ConflictGroup, buildGroups } from './ConflictGroups';
export class CueDraft {
id: string;
baseVersion: number;
startMs: number;
endMs: number;
text: string;
constructor(id: string, baseVersion: number, startMs: number,
endMs: number, text: string) {
this.id = id; this.baseVersion = baseVersion;
this.startMs = startMs; this.endMs = endMs; this.text = text;
}
}
export class EditSnapshot {
version: number;
cues: Cue[];
source: string;
index: IndexedCue[];
groups: ConflictGroup[];
constructor(version: number, cues: Cue[], source: string,
index: IndexedCue[], groups: ConflictGroup[]) {
this.version = version; this.cues = cues; this.source = source;
this.index = index; this.groups = groups;
}
}
export function cloneCues(cues: Cue[]): Cue[] {
const copy: Cue[] = [];
for (let i = 0; i < cues.length; i++) {
const c = cues[i];
copy.push(new Cue(c.id, c.number, c.startMs, c.endMs, c.text));
}
return copy;
}
export function renderSource(cues: Cue[]): string {
const blocks: string[] = [];
for (let i = 0; i < cues.length; i++) {
const c = cues[i];
blocks.push(`${c.number}\n${formatTime(c.startMs)} --> ` +
`${formatTime(c.endMs)}\n${c.text}`);
}
return blocks.join('\n\n');
}
这里保留原序号和原文顺序,不因为某条字幕开始时间变化就把它移到另一个位置。索引负责按时间排序,文档负责保留用户的编辑上下文。生成原文使用规范化换行,不承诺逐字保留最初文件的空格和换行样式;业务字段保持一致才是本节的往返检查目标。
二、先推导完整下一版
deriveSnapshot先复制Cue,再生成原文并重新解析,确认原文能够无歧义地表达这些字段,随后建立索引与冲突组。正文中的空行可能被SRT解释成新的块,不能只检查字符串非空;往返后的数量、序号、时间和正文都要对应。
export function deriveSnapshot(cues: Cue[], version: number): EditSnapshot {
if (!Number.isSafeInteger(version) || version < 1) {
throw new Error('文档版本不合法');
}
const copy = cloneCues(cues);
const source = renderSource(copy);
const parsed = parseSrt(source);
if (parsed.length !== copy.length) {
throw new Error('正文包含无法按当前规则保存的分块');
}
for (let i = 0; i < copy.length; i++) {
const a = copy[i];
const b = parsed[i];
if (a.number !== b.number || a.startMs !== b.startMs ||
a.endMs !== b.endMs || a.text !== b.text) {
throw new Error('原文往返字段不一致');
}
}
const index = buildIndex(copy);
const groups = buildGroups(copy);
return new EditSnapshot(version, copy, source, index, groups);
}
往返解析得到的临时id不用于替换正式身份,因为id属于编辑模型,并没有独立写入SRT。正式快照仍保留copy中的原id;校验比较的是SRT实际承载的字段。后续支持插入、删除或重新编号时,也要把编辑身份与文件序号分开处理。
构建过程只使用局部变量,没有提前更新页面。原文解析、索引或分组任何一步失败,都直接抛出错误,调用者继续保留上一版正式快照。不要先更新Cue,再逐个尝试刷新其他派生结果,那样每增加一种诊断,就会增加一条需要手工回滚的路径。
三、版本检查在修改之前完成
EditorStore保存唯一的正式快照。提交先检查草稿的baseVersion,再按id寻找目标,复制集合后修改其中一条。全部下一版内容推导成功,才执行一次this.current赋值。无变化提交返回false,也不增加版本。
export class EditorStore {
private current: EditSnapshot;
constructor(source: string) {
this.current = deriveSnapshot(parseSrt(source), 1);
}
read(): EditSnapshot {
return deriveSnapshot(this.current.cues, this.current.version);
}
commit(draft: CueDraft): boolean {
if (draft.baseVersion !== this.current.version) {
throw new Error('文档已更新,请重新载入草稿');
}
const nextCues = cloneCues(this.current.cues);
const index = nextCues.findIndex((cue: Cue) => cue.id === draft.id);
if (index < 0) { throw new Error('目标字幕已不存在'); }
const old = nextCues[index];
const text = normalize(draft.text).trim();
if (text.length === 0) { throw new Error('字幕正文不能为空'); }
if (old.startMs === draft.startMs && old.endMs === draft.endMs &&
old.text === text) {
return false;
}
nextCues[index] = new Cue(old.id, old.number,
draft.startMs, draft.endMs, text);
const next = deriveSnapshot(nextCues, this.current.version + 1);
this.current = next;
return true;
}
}
read返回独立快照,使页面或调试代码修改读到的对象时不会碰到正式状态。当前实现为清楚表达隔离关系,会重新建立派生数据;规模增大后可以使用不可变快照或按版本缓存,但必须继续限制外部对正式集合的修改入口。
旧版本草稿不自动覆盖,也不偷偷与新文本合并。发生冲突后可以让用户重新载入最新条目,再决定是否恢复自己的输入。取消草稿只需要丢弃本地字段并重新读取正式快照,不需要构造一个相反事务去修补模型。
四、输入框只维护草稿字段
以下页面继续使用前节四条字幕,固定编辑衔接句。将其结束时间由4000改成3500,并修改正文后提交,第二句与衔接句只在3500端点相接,不再形成重叠;第一句与衔接句仍重叠,因此结果从两对重叠变成一对。界面可同时核对版本与冲突摘要。
import { Cue } from '../model/SrtParser';
import { CueDraft, EditSnapshot, EditorStore } from '../model/EditorStore';
@Entry
@Component
struct Index {
private store: EditorStore = new EditorStore(
'1\n00:00:01,000 --> 00:00:03,000\n第一句字幕\n\n' +
'2\n00:00:03,500 --> 00:00:05,000\n第二句字幕\n\n' +
'3\n00:00:02,500 --> 00:00:04,000\n衔接字幕\n\n' +
'4\n00:00:05,000 --> 00:00:06,500\n收尾字幕');
private baseVersion: number = 1;
@State version: number = 1;
@State startText: string = '';
@State endText: string = '';
@State draftText: string = '';
@State message: string = '';
@State summary: string = '';
aboutToAppear(): void { this.loadDraft(); }
private loadDraft(): void {
const view: EditSnapshot = this.store.read();
const cue = view.cues.find((c: Cue) => c.id === 'cue-3');
if (cue === undefined) { throw new Error('找不到衔接字幕'); }
this.baseVersion = view.version;
this.version = view.version;
this.startText = cue.startMs.toString();
this.endText = cue.endMs.toString();
this.draftText = cue.text;
let pairs: number = 0;
for (let i = 0; i < view.groups.length; i++) {
pairs += view.groups[i].pairs.length;
}
this.summary = `${view.groups.length} 个冲突组 · ${pairs} 对重叠`;
}
private save(): void {
try {
if (!/^\d+$/.test(this.startText.trim()) ||
!/^\d+$/.test(this.endText.trim())) {
throw new Error('时间须为非负整数毫秒');
}
const changed = this.store.commit(new CueDraft('cue-3', this.baseVersion,
Number(this.startText.trim()), Number(this.endText.trim()), this.draftText));
this.loadDraft();
this.message = changed ? '已提交,索引与冲突结果同步更新' : '内容未变化';
} catch (error) { this.message = (error as Error).message; }
}
build() {
Scroll() {
Column({ space: 16 }) {
Text('字幕校对台').fontSize(26).fontWeight(FontWeight.Bold)
Text('逐句编辑').fontColor('#6B8180')
Text(`文档版本 ${this.version}`).fontSize(18)
Text('开始 / ms').width('100%')
TextInput({ text: this.startText }).type(InputType.Number)
.onChange((v: string) => { this.startText = v; this.message = '草稿已修改'; })
Text('结束 / ms').width('100%')
TextInput({ text: this.endText }).type(InputType.Number)
.onChange((v: string) => { this.endText = v; this.message = '草稿已修改'; })
TextArea({ text: this.draftText }).height(160).width('100%')
.onChange((v: string) => { this.draftText = v; this.message = '草稿已修改'; })
Row({ space: 16 }) {
Button('提交修改').backgroundColor('#176C60').onClick(() => this.save())
Button('取消草稿').onClick(() => {
this.loadDraft(); this.message = '草稿已取消';
})
}
Text(this.message).fontColor('#176C60').width('100%')
Text(this.summary).fontColor('#6B8180').width('100%')
}.padding(16).width('100%')
}.width('100%').height('100%').backgroundColor('#F0F9F7')
}
}

五、围绕失败路径检查一致性
先保存一次合法修改,再用相同baseVersion提交另一份旧草稿,应得到版本冲突,正式原文与全部派生结果保持不变。再分别输入空正文、结束早于开始、超出时间格式范围和带分块空行的文本,确认失败不会增加版本,也不会改变索引中的命中内容。
合法路径则需要同时检查原文重新解析后的字段、locate返回的字幕、冲突组成员与重叠对数量。只检查输入框已显示新文字不足以判断提交完整。特别是把4000改成3500的例子,能够检查端点规则是否在编辑、定位和冲突诊断三个位置保持一致。

六、避免把界面同步当作文档提交
TextArea的onChange应直接保存组件返回的新值,不根据旧字符串长度自行猜测删除了哪个字符。用户可能在任意光标位置修改文本,也可能粘贴多行内容。输入层保持原样接收,规范化和结构检查放到提交边界,才能减少光标跳动及草稿被过早重写的问题。
异步保存接入后,还应区分内存文档版本和持久化完成版本。界面可以在内存提交成功后更新结果,再显示文件保存状态;写入失败时保留当前待保存版本并允许重试,不能把旧文件重新解析后直接覆盖新编辑。两个阶段各自拥有明确状态,比把所有操作塞进一个布尔值更容易恢复。
七、明确草稿的生命周期
打开条目时读取正式快照,记录baseVersion并初始化输入框;输入期间保留草稿,提交成功后再用新的正式条目刷新基准。提交失败时保留用户输入,让用户能够修正具体字段。取消则重新读取最新正式条目,因此即使文档已被另一个入口更新,也不会把过期内容作为新的草稿起点。
界面上的文档版本与baseVersion含义接近,但职责不同。文档版本用于说明当前已经提交的数据,baseVersion用于判断这份草稿从哪里开始。若以后增加自动保存,不能在后台保存完成时随意修改草稿基准,否则用户尚未提交的输入可能被错误地认为已经基于最新文档。版本变更必须伴随明确的快照切换。
原文往返检查还提供了一个有用的诊断点:若Cue集合正确而重新解析的内容不一致,应先检查序列化分隔符、正文换行和空白规则,随后才排查索引。索引只是消费整数区间,不应负责修补文本格式。把错误留在发生的层级,能够避免为了让定位结果正常而悄悄改写用户正文。
对于只改文字而时间完全不变的操作,可以进一步复用时间索引的排序结果,但需要更新正文所属的数据版本。冲突关系虽然不变,卡片显示的内容仍必须来自新Cue集合。性能优化应建立在明确依赖关系上,不能因为区间没变化就把整个旧快照保留下来。
编辑事务把一次修改的影响范围固定下来:身份用于找到条目,版本用于拒绝过期草稿,完整推导用于确保原文与派生结果一致。后续加入大屏对照与键盘选择时,可以继续共用这个入口,而不必让每一种界面各自维护文档更新规则。
更多推荐

所有评论(0)