摄影棚选片进入批注阶段后,屏幕上画出的矩形和标记点只是意见的落点,真正推动交付的还有每条意见的内容、类别与处理状态。前面的工作流已经从系统相册获取P001~P003,复制工作图到应用目录,并为P001的4000×3000原图建立了一个矩形框:左上角(600,450),右下角(1800,1200);三个标记点分别位于(1020,800)、(1680,620)和(1820,1180)。缩放至1.6倍并平移(-40,-20)后,坐标仍以原图像素保存,撤销重做则以完整的批注文档快照维护。

但当摄影师在点1写下“花瓣边缘需要细化”,在点2写“高光层次再保留一点”,又给矩形区域添加“构图边距过紧”时,仅靠图上的蓝色圆点已无法快速找到待处理意见。摄影师切换到另一张照片、调整检索条件或恢复页面以后,意见还必须跟随原来的标记身份,而非跟随列表行号。第六篇将批注几何、意见文本、筛选条件拆成三个对象,让处理状态与坐标互不干扰。

P001花卉批注意见分组及状态追踪

意见与标记建立稳定的关联关系

每条批注意见都需要自己的noteId,同时引用所属资产assetId和目标targetId。例如,P001的点1保留point-1作为标记身份,意见note-101关联它;点2的note-102写入色彩类意见;矩形的note-103指向region-main。即使点2被移动到新原图坐标(1700,650),它仍然是同一个点,而不是创建新意见。只有用户明确删除标记或者解除绑定时,才需要询问相关意见如何处理。

工程在model/Annotation.ets与model/AnnotationHistory.ets之外增加model/ReviewNote.ets、service/NoteStore.ets和pages/NoteBoardPage.ets。批注文档的修订号与意见自身的修改序号各自递增:前者描述几何内容,后者描述意见文本及状态。用户调整图像的缩放和平移只是视图变化,不应给意见增加新的修订号。

export type NoteGroup = 'COMPOSITION' | 'COLOR' | 'DETAIL';
export type NoteState = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED';

export interface ReviewNote {
  noteId: string;
  assetId: string;
  targetId: string;
  group: NoteGroup;
  state: NoteState;
  text: string;
  noteRevision: number;
  documentRevision: number;
  updatedAt: number;
}

export interface NoteBoard {
  assetId: string;
  notes: ReviewNote[];
  selectedNoteId: string;
}

界面上的“构图”“色彩”“细节”都是意见分类,不是系统相册属性。标签由业务层控制,暂时不允许用户随意拼写新的组名,避免“高光”“亮部”“高亮细节”在列表里被拆成三个含义相近的维度。可在后续加入受控词表,但记录的稳定ID与归属关系不需要因此变化。

处理状态由明确的编辑意图驱动

新建意见进入OPEN,有人开始处理后转为IN_PROGRESS,复核结果被接受后转为RESOLVED。若需要返工,从已解决重新打开应记下原因,而不是静默把状态改回最初值。即使目前只有一位摄影师使用该工具,也应把状态变化写成独立函数,以便将来加入接手人或操作日志时不必重新检查所有按钮。

更新函数至少校验目标意见存在、资产身份一致、修改序号匹配和文本非空。匹配成功后返回新对象,不直接修改@State数组里的原对象;页面通过替换数组接收变化,更容易让ArkUI组件触发界面刷新。对于已经处理完成的意见,文字改动可以单独作为一项内容修订,而不是顺带重置处理状态。

export interface NoteUpdate {
  noteId: string;
  expectedRevision: number;
  text: string;
  nextState: NoteState;
}

export type NoteUpdateResult =
  { kind: 'APPLIED'; note: ReviewNote } |
  { kind: 'CONFLICT' } |
  { kind: 'MISSING' };

export function updateNote(
  source: ReviewNote[], command: NoteUpdate, changedAt: number
): NoteUpdateResult {
  const current = source.find((item: ReviewNote) => item.noteId === command.noteId);
  if (!current) { return { kind: 'MISSING' }; }
  if (current.noteRevision !== command.expectedRevision) {
    return { kind: 'CONFLICT' };
  }
  if (command.text.trim().length === 0) {
    throw new Error('批注意见不能为空');
  }
  return { kind: 'APPLIED', note: {
    ...current, text: command.text.trim(), state: command.nextState,
    noteRevision: current.noteRevision + 1, updatedAt: changedAt
  } };
}

旧修订号与当前修订号不一致时,界面需要保留用户正在编辑的文字并提示重新查看意见,而不能拿旧文本覆盖新记录。批注图形本身已经有撤销重做栈;意见状态不应被任意一次几何撤销连带改写。两个历史轨道可以使用共同的资产ID展示在同一个页面,但提交逻辑与冲突判断保持独立。

筛选追踪依靠ID,不依靠数组位置

列表可以同时按意见分类、状态和关键词过滤。处理照片P001时,摄影师选中note-102并输入“高光”,此时只出现色彩类的待处理意见;将分类改成“全部”后,选中的仍应是note-102。若转而搜索“构图”使它暂时不可见,详情区显示“该意见未包含在当前筛选”,而非自动跳到列表第一条。这个规则与此前港口箱位的稳定ID思想相似,但两个系列各自采用独立模型和工程场景。

export interface NoteFilter {
  group: NoteGroup | 'ALL';
  state: NoteState | 'ALL';
  keyword: string;
}

export function selectNotes(
  source: ReviewNote[], assetId: string, filter: NoteFilter
): ReviewNote[] {
  const key = filter.keyword.trim().toLowerCase();
  return source.filter((note: ReviewNote) => {
    if (note.assetId !== assetId) { return false; }
    if (filter.group !== 'ALL' && note.group !== filter.group) { return false; }
    if (filter.state !== 'ALL' && note.state !== filter.state) { return false; }
    return key.length === 0 || note.text.toLowerCase().includes(key) ||
      note.targetId.toLowerCase().includes(key);
  }).sort((a: ReviewNote, b: ReviewNote) =>
    b.updatedAt - a.updatedAt || a.noteId.localeCompare(b.noteId));
}

检索仅针对本次会话已授权的资产意见,不扫描整个设备相册。关键词在进入过滤函数之前作空格归一化,中文备注保留原有大小写和标点用于显示,搜索时才转换比较形式。对于大量意见,建议根据资产身份分段加载,避免每次输入都重新生成全部缩略图;列表行也应使用noteId作为ForEach的键,确保输入框、选中颜色和详情弹层不会随排序变动错位。

批注意见筛选服务与列表界面的开发环境

页面既显示批注位置,也显示处理进度

点列表中的意见时,详情需要根据targetId找到对应几何对象,再使用上一篇的sourceToView()将原图坐标投影到当前视图。点2在放大到1.6倍时位置依然来自(1680,620)或最新已提交的(1700,650),而不是来自意见列表的行高。若目标标记已经被删除,意见仍保留在列表中并显示“目标标记已移除”,便于业务人员决定迁移意见还是归档;不能因为绘制层找不到它就顺手删除文字。

@Entry
@Component
struct NoteBoardPage {
  @State private keyword: string = '';
  @State private notes: ReviewNote[] = [];
  @State private selectedNoteId: string = '';
  @State private activeGroup: NoteGroup | 'ALL' = 'ALL';
  private assetId: string = 'P001';

  private getVisible(): ReviewNote[] {
    return selectNotes(this.notes, this.assetId, {
      group: this.activeGroup, state: 'ALL', keyword: this.keyword
    });
  }

  build() {
    Column({ space: 12 }) {
      Text('摄影棚批注意见').fontSize(22).fontWeight(FontWeight.Bold)
      Text('P001 · 花卉静物 · 批注面板')
      TextInput({ placeholder: '搜索意见或标记ID', text: this.keyword })
        .onChange((value: string) => { this.keyword = value; })
      Row({ space: 8 }) {
        Button('全部').onClick(() => { this.activeGroup = 'ALL'; })
        Button('色彩').onClick(() => { this.activeGroup = 'COLOR'; })
        Button('细节').onClick(() => { this.activeGroup = 'DETAIL'; })
      }
      List() {
        ForEach(this.getVisible(), (note: ReviewNote) => {
          ListItem() {
            Column({ space: 4 }) {
              Text(`${note.targetId} · ${note.group}`)
              Text(note.text).fontSize(14)
              Text(note.state).fontColor(Color.Gray)
            }.padding(12).width('100%')
          }.onClick(() => { this.selectedNoteId = note.noteId; })
        }, (note: ReviewNote) => note.noteId)
      }.layoutWeight(1)
      Text(`当前意见:${this.selectedNoteId || '未选择'}`)
    }.padding(16).height('100%')
  }
}

这里的列表是意见的阅读视图,几何编辑仍由之前的批注页负责。实际页面可把色彩、构图、细节三个分类按钮放入横向滚动区域,也可以让平板显示“左侧意见列表、右侧图片定位”的布局。切换窗口尺寸时保持selectedNoteId,但不把“当前第几行”作为持久状态。搜索框内容应留在页面状态中,输入回调只更新查询值;如果后续增加远端索引,再单独控制请求代次以免过期响应污染当前查询。

小型意见板的落盘与恢复

意见数量较少时,可按资产ID将结构化数组存入Preferences,同时保存数据格式版本。持久层只写意见内容和状态,不存放花卉原图,也不把当前缩放倍数写到每条意见中。Preferences适合单进程小型数据;写入后执行flush(),并在读取时检查JSON数组、资产ID和修订号。对异常格式应将当前资产标记为“意见待恢复”,给出重新加载或导入备份的入口,不能用空数组覆盖原来的存储键。

import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export async function storeNotes(
  context: common.UIAbilityContext, assetId: string, notes: ReviewNote[]
): Promise<void> {
  const store = await preferences.getPreferences(context, { name: 'photo_review_notes' });
  const owned = notes.filter((note: ReviewNote) => note.assetId === assetId);
  const payload = JSON.stringify({ schema: 1, assetId: assetId, items: owned });
  await store.put(`notes_${assetId}`, payload);
  await store.flush();
}

export async function loadNotes(
  context: common.UIAbilityContext, assetId: string
): Promise<string> {
  const store = await preferences.getPreferences(context, { name: 'photo_review_notes' });
  return String(await store.get(`notes_${assetId}`, ''));
}

调试路径可以从固定的三条意见开始。选中色彩类note-102,修改关键词后确认焦点仍指向同一条;把点2移动到(1700,650),检查意见关联仍是point-2;改变缩放和平移,检查列表不会生成新意见;把note-101从待处理转为处理中,再用旧修订号提交,观察冲突分支是否保留旧内容;让存储数据格式不正确,检查恢复提示是否避免覆盖。HiLog记录资产ID、筛选命中条数、意见修订号和保存阶段即可,原图URI及完整摄影意见不必写进日志。

P001花卉批注分组、进度与检索的手机界面

到这一阶段,摄影选片工具已经把“图像像素坐标”“批注几何快照”“批注意见”和“检索视图”分成四种稳定的数据。处理人员可以按照构图、色彩或细节查找待办,编辑过程不会因缩放而丢位置,撤销几何修改也不会破坏处理意见。接下来的交付清单和版本导出能够沿用这一组关联身份,不必再凭照片缩略图或列表序号推断意见属于哪里。

参考资料:ArkUI列表创建、TextInput修改数据后的光标位置、Preferences存储接口、ArkTS语言概述。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐