会议名单已经能够按参会者保存独立草稿,但座位一调整,编辑归属仍可能出错。原来排在第一位的陈思远移到第二位后,如果表单继续按下标读取,就会把周明的资料放进当前编辑区。顺序只是排列,身份才决定内容属于谁。座签编辑器需要一份独立的座位序列,让重排只移动身份引用。

座位顺序改变而编辑身份保持

先把排列从个人资料里拿出来

NameplateDesk继续沿用前篇GuestRecord、EditorState和个人草稿操作。新增SeatPlan保存ID顺序、顺序修订及操作反馈,EditorState仍只负责名单与活动身份。座位号由当前位置计算,既不写进姓名,也不作为个人资料的主键。这样一个人换座位时,saved与draft都不需要复制到另一个下标。

本篇使用上移、下移按钮表达重排,便于手机和较宽窗口共用同一套业务动作。以后接入拖动手势时,手势只需要把操作转成身份和目标位置,再进入相同的顺序服务;不要在手势回调里直接修改姓名数组。输入方式可以变化,数据规则应保持稳定。

顺序修订只记录实际排列变化。边界处继续上移、传入不存在的身份或无效方向,都返回明确反馈并保留原序列。修订号不是人员身份,也不能拿来替代ForEach的条目键;它为后续发布快照判断排列是否变化提供依据。

重排函数只接受身份和方向

新增 model/SeatOrder.ets。EditorState从前篇完整的RosterEditor.ets导入,个人数据结构保持不变。校验函数要求顺序与名单形成一一对应关系,避免重复身份、遗漏人员或残留已经删除的条目进入后续发布。

import { EditorState } from './RosterEditor';
import { GuestRecord } from './RosterSeed';

export interface SeatPlan {
  order: string[];
  revision: number;
  message: string;
}

export function createSeatPlan(editor: EditorState): SeatPlan {
  const plan: SeatPlan = {
    order: editor.guests.map((guest: GuestRecord): string => guest.id), revision: 0, message: ''
  };
  return plan;
}

export function validSeatPlan(editor: EditorState, plan: SeatPlan): boolean {
  const ids: string[] = editor.guests.map((guest: GuestRecord): string => guest.id);
  return plan.order.length === ids.length &&
    plan.order.every((id: string, index: number) =>
      ids.indexOf(id) >= 0 && plan.order.indexOf(id) === index);
}

export function moveGuest(plan: SeatPlan, id: string, direction: number): SeatPlan {
  const from: number = plan.order.indexOf(id);
  const to: number = from + direction;
  let message: string = '';
  if (from < 0 || !Number.isInteger(direction) || Math.abs(direction) !== 1) {
    message = '调整参数无效';
  } else if (to < 0 || to >= plan.order.length) {
    message = '已到座位边界';
  }
  if (message.length > 0) {
    const rejected: SeatPlan = {
      order: plan.order, revision: plan.revision, message: message
    };
    return rejected;
  }
  const order: string[] = plan.order.slice();
  order.splice(from, 1);
  order.splice(to, 0, id);
  const next: SeatPlan = {
    order: order, revision: plan.revision + 1, message: '座位顺序已调整'
  };
  return next;
}

这里虽然使用了数组下标,但下标只在一次移动计算中表示位置。对外接口仍传ID,操作完成后也只返回ID序列。不要把“不能用下标作为身份”误解成完全禁止数组位置运算,关键是位置是否越过了它本应承担的职责。

失败分支可以共享原有order,因为服务没有修改它;成功分支先复制再移动,旧计划仍可用于比较。后续增加撤销时,应保存必要的顺序快照和对应修订,而不是引用一个随后会继续变化的数组。当前不可变更新约定为此保留了基础。

把座位号作为派生视图

新增 model/SeatView.ets。SeatRow只是页面需要的派生结果,包含当前座位、已应用姓名与草稿提示。它不拥有个人资料,也不负责切换活动身份。名单与排列不一致时直接报告数据问题,避免静默跳过人员后形成看似连续却缺人的座位表。

import { EditorState, isDirty } from './RosterEditor';
import { GuestRecord } from './RosterSeed';
import { SeatPlan, validSeatPlan } from './SeatOrder';

export interface SeatRow {
  id: string;
  seat: number;
  name: string;
  dirty: boolean;
}

export function seatRows(editor: EditorState, plan: SeatPlan): SeatRow[] {
  if (!validSeatPlan(editor, plan)) { throw new Error('座位与名单不一致'); }
  return plan.order.map((id: string, index: number): SeatRow => {
    const guest: GuestRecord | undefined =
      editor.guests.find((item: GuestRecord) => item.id === id);
    if (guest === undefined) { throw new Error('座位指向未知参会者'); }
    const row: SeatRow = {
      id: id, seat: index + 1, name: guest.saved.name, dirty: isDirty(guest)
    };
    return row;
  });
}

export function seatRow(editor: EditorState, plan: SeatPlan, id: string): SeatRow {
  const row: SeatRow | undefined =
    seatRows(editor, plan).find((item: SeatRow) => item.id === id);
  if (row === undefined) { throw new Error('座位身份不存在'); }
  return row;
}

当前两人样例直接派生整份视图,便于观察关系。名单规模扩大后,可以在名单或顺序修订变化时集中计算,再建立按ID读取的索引,减少每个控件重复查找。优化前应保留相同的校验条件,不能为了加快查找而允许顺序中出现重复身份。

页面排序后仍编辑原来的人

下面替换 pages/Index.ets。保留前篇完整的RosterEditor和前两篇的Nameplate、LayoutFit文件。初始样例将陈思远下移一次,活动身份仍是他,主持称谓仍只保存在草稿中。名单按钮的键使用ID,座位号则每次从当前计划派生。

import { EditorState, createEditor, activeGuest, selectGuest,
  editDraft, applyGuest, isDirty } from '../model/RosterEditor';
import { SeatPlan, createSeatPlan, moveGuest } from '../model/SeatOrder';
import { SeatRow, seatRow } from '../model/SeatView';
import { FitResult, chooseFit } from '../model/LayoutFit';

@Entry
@Component
struct Index {
  @State editor: EditorState = editDraft(createEditor(), 'name', '陈思远(主持)');
  @State seats: SeatPlan = moveGuest(createSeatPlan(this.editor), 'guest-csy', 1);
  @State fit: FitResult = { nameFp: 20, fits: false, reason: '等待版面测量' };
  private panelWidthVp: number = 0;

  private row(id: string): SeatRow {
    return seatRow(this.editor, this.seats, id);
  }

  private recalculate(): void {
    this.fit = chooseFit(this.getUIContext(), activeGuest(this.editor).saved,
      this.panelWidthVp);
  }

  private choose(id: string): void {
    this.editor = selectGuest(this.editor, id);
    this.recalculate();
  }

  private applyCurrent(): void {
    this.editor = applyGuest(this.editor);
    this.recalculate();
  }

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Text('会议座签').fontSize(26).fontWeight(FontWeight.Bold)
        Text('座位与身份分离').fontSize(16)
        ForEach(this.seats.order, (id: string) => {
          Row({ space: 8 }) {
            Text(`座位${this.row(id).seat}`).fontSize(16)
            Button(this.row(id).name).layoutWeight(1)
              .backgroundColor(id === this.editor.activeId ? '#145A44' : '#6F8178')
              .onClick(() => { this.choose(id); })
            Button('上移').fontSize(14).onClick(() => {
              this.seats = moveGuest(this.seats, id, -1);
            })
            Button('下移').fontSize(14).onClick(() => {
              this.seats = moveGuest(this.seats, id, 1);
            })
          }.width('100%')
        }, (id: string) => id)
        Text(`当前人:${activeGuest(this.editor).saved.name}`).fontSize(18)
        TextInput({ text: activeGuest(this.editor).draft.name })
          .onChange((value: string) => {
            this.editor = editDraft(this.editor, 'name', value);
          })
        TextInput({ text: activeGuest(this.editor).draft.role })
          .onChange((value: string) => {
            this.editor = editDraft(this.editor, 'role', value);
          })
        Text(isDirty(activeGuest(this.editor)) ? '草稿尚未应用' : '内容一致')
          .fontSize(14).fontColor('#8C5A10')
        Text('已应用内容').width('100%')
        Column({ space: activeGuest(this.editor).saved.role.length > 0 ? 4 : 0 }) {
          Text(activeGuest(this.editor).saved.name).fontSize(this.fit.nameFp)
            .fontFamily('HarmonyOS Sans').fontWeight(FontWeight.Medium)
            .width('100%').textAlign(TextAlign.Center).fontColor('#145A44')
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          if (activeGuest(this.editor).saved.role.length > 0) {
            Text(activeGuest(this.editor).saved.role).fontSize(14)
              .fontFamily('HarmonyOS Sans').fontWeight(FontWeight.Normal)
              .width('100%').textAlign(TextAlign.Center).maxLines(1)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
          }
        }.width('100%').aspectRatio(3).padding(8).backgroundColor('#FFFFFF')
        .border({ width: 1, color: '#145A44' }).justifyContent(FlexAlign.Center)
        .onAreaChange((_old: Area, area: Area) => {
          const width: number = Number(area.width);
          if (Number.isFinite(width) && width !== this.panelWidthVp) {
            this.panelWidthVp = width;
            this.recalculate();
          }
        })
        Text(this.editor.message || this.seats.message).fontSize(14)
        Button('应用当前人').width('100%').backgroundColor('#145A44')
          .onClick(() => { this.applyCurrent(); })
      }.width('100%').constraintSize({ maxWidth: 640 }).padding(20)
    }.width('100%').height('100%').backgroundColor('#F7F6EF')
  }
}

座位序列与编辑身份的开发视图

列表条目移动后,已有ID对应的控件可以继续复用。座位数字通过读取seats派生,不能在条目首次创建时缓存index再长期使用。稳定身份和动态位置需要同时表达,否则即使姓名没有串位,座位标注仍可能停留在旧位置。

用相反方向检查状态是否被误改

先把陈思远从第二位上移,再下移回去,预期只有顺序和顺序修订变化。活动ID、个人草稿和已应用姓名保持原样。随后选择周明修改职务,再调整座位,返回周明时草稿仍归他所有。这个操作组合比只检查一个上移按钮更容易暴露位置与身份混用。

边界动作也要观察。第一位上移应提示到达边界,不增加修订;未知ID应保持整份顺序;传入零或非整数方向应被拒绝。失败不是一次有效排列,若仍增加修订,会让发布侧误以为内容发生变化,增加不必要的确认和同步。

名单变更和座位变更采用不同动作

重排后的座签编辑页

删除人员时,名单和座位序列必须同步协调,不能仅过滤可见列表。若保留一个指向已删除人的活动ID,下一次编辑就会遇到不存在的资料。新增人员时也应明确插入哪个座位,再将新的完整计划交给校验函数,不能依靠缺失项自动追加来掩盖输入问题。

用身份链路解释一次重排

样例中,调整前的序列是陈思远、周明,调整后是周明、陈思远。陈思远的ID始终相同,草稿中的主持称谓也始终归他所有。当前编辑区继续读取activeId对应的记录,因此不需要在移动结束后猜测“刚才编辑的人现在在哪一行”。位置查找只用于座位显示,内容读取只使用身份。

若把整个GuestRecord数组直接排序,也可以得到新的显示顺序,但它会同时改变编辑存储的排列,迫使更多代码理解同一次变化。独立序列让名单存储保持稳定,也方便为同一份参会资料准备不同会场的座位计划。多个计划可以引用同一身份集合,但应各自拥有顺序修订和发布确认。

座位标签应来自当前序列,参会者身份应来自名单。两者同时出现在一行,并不意味着可以互相替代。尤其不要把“座位二”写成条目的ID,否则调换座位就变成给两个人交换身份,草稿、收藏或后续发布回执都会面临归属混乱。

把操作反馈和业务结果分开

到达边界时仍然返回一份带反馈的计划,但其order和revision保持原值。界面可以更新提示,后续比较则能判断排列没有变化。若仅用对象引用判断是否需要发布,这类反馈更新也会被当成内容变化,因此发布侧应比较明确的业务修订或序列内容,而不是整份界面状态对象。

移动成功后,如果个人内容刚好也在编辑,两个状态仍分别推进。座位计划增加顺序修订,个人草稿保持待确认;用户可以只确认内容,也可以继续调整座位。把两项变化拆开后,发布入口才能清楚列出本次究竟变更了哪些人的内容、哪些人的位置。

如果两个窗口都能调整同一份计划,保存时需要检查各自依据的顺序修订。来自旧修订的移动不能直接覆盖较新的完整序列,可以拒绝并刷新,也可以在明确规则下重新应用单次移动。当前页面只维护本地计划,修订字段则让后续同步可以表达这个前提,而不必重新设计身份模型。

名单规模变大后的显示与操作

较窄窗口中,名单行需要为姓名和移动按钮分配稳定空间。长姓名可在选择区采用截断,但已应用预览仍保留原文。不能为了容纳按钮而把姓名字段改成缩略文本;导航标签和业务内容可以采用不同显示策略,但都应继续指向同一身份。

较宽窗口可以把座位列表和编辑区并排呈现,单一活动ID仍保持不变。键盘或辅助操作也应调用choose和moveGuest,而不是维护另一份选中下标。这样视觉布局改变时,输入方式与业务动作仍然是一一对应的,排查交互问题也能回到同一条身份链路。

对于大量名单,优先减少重复派生和无关区域刷新,再考虑更复杂的移动交互。当前按钮方案已经能覆盖顺序变更的核心规则,后续拖动只增加位置输入,不应重新实现校验、复制和修订更新。把规则放在独立服务中,可以让不同交互共享同样的失败处理。

座位序列只保存ID,使排序、个人编辑和排版各有归属。重排改变位置,应用改变个人内容,布局变化改变字号选择。等到发布快照加入后,系统就可以分别比较这些变化,而不必把一次换座位解释成所有人的资料都被重新编辑。

相关资料:ForEach循环渲染、避免使用下标作为条目键、状态管理。

Logo

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

更多推荐