以单一数据来源维护日程

JavaScriptBeginner
立即练习

介绍

Daylight Festival 在三个舞台安排了六个场次。两个场次可能同名,因此个人日程必须记住实际的场次身份。你将把准备好的响应式 React 页面连接到一份选择数据,再从当前节目目录推导卡片、数量和总分钟数。筛选节目时不能丢失先前选择。

你需要了解 React 组件、JSX、props、useState、事件、条件渲染、map/filter、列表 key 和受控输入,以及 JavaScript 数组、对象、函数和基本 HTML/CSS。本实验教授派生状态、当前记录查找、不可变的选择更新、时长归约与缺失 ID 提示。环境独立启动,不需要之前 VM 的文件。55 分钟是作者估计,并非初学者实测时长。所有场次和身份均为合成数据。

使用 Workspace 终端,通过 Festival 打开真实页面链接。本实验仅编辑 /home/labex/project/festival-schedule/work/src/selection.js 和 src/App.jsx。保留 src/settings.js、src/main.jsx、src/conflicts.js、src/storage.js、准备好的 shared 渲染器和样式、Study、依赖及服务配置。本实验不要求实现冲突判断或浏览器存储。完整 Study 示例包含这些准备好的行为;下一实验才会实现它们。网站标签保持英文。

将选择解析为当前场次

本步骤先使用正常示例,再把稳定 ID 转换为节目目录中的真实当前记录。

在 Festival 中选择 Open Study schedule in a new page。这是真实的独立浏览器页面链接,位于产品嵌入的 Tab 之外。网站操作在新页面进行。按 Tab 到 First Light、Garden、10:00–10:30 的 Add to schedule,再按 Enter。继续找到 Paper Signals、Studio、10:30–11:15 并添加。选择 View my schedule:两张真实卡片与 2 sessions · 75 minutes 出现。日期导航链接跳到日程标题;Back to the programme 返回目录。示例拥有独立选择,不是你的作品或评分结果。布局、卡片反馈和导航已准备好;你将围绕它们构建选择逻辑。

关闭 Study。在产品 Festival 入口页选择 Open Work schedule in a new page,并保持页面打开。它尚未完成。切换到产品的 Workspace Tab 编辑文件。切换产品 Tab 不同于网站链接、页内锚点或浏览器后退按钮。准备好的 Workspace 命令会进入工作目录;下面的可见命令也明确起始路径。cd 改变当前终端目录,以 / 开头的是绝对路径,pwd 打印当前路径,cat 显示小文件内容而不修改文件。

cd /home/labex/project/festival-schedule/work
pwd
cat src/settings.js

目录使用 D11、D13 这样的 ID。它们都叫 First Light,但舞台和时间不同。准备好的 settings.initialIDs 包含 D11 和 RETIRED-07:这是本实验明确提供的一小组合成初始选择,不是上一 VM 留下的存储。D11 仍存在;RETIRED-07 已不存在。只有当前记录才能成为卡片。第 3 步将为缺失 ID 给出明确说明。

小的文件名拼写错误可以恢复。下一命令有意拼错 selection。它应提示文件不存在并以非零状态退出;不会改变任何内容:

cat src/selction.js

阅读错误,对照拼写,然后使用正确路径:

cat src/selection.js

文件中的函数尚未实现。currentRecords 将根据 ID 而非标题或数组位置,从当前目录选取记录。filter 创建新数组;includes 检查某 ID 是否已被选择。目录 ID 唯一,因此重复 ID 不会产生重复卡片。对新数组按数值开始时间排序;开始时间相同时按 ASCII ID 排序。比较器中的 || 仅在时间差为零时采用 ID 比较。condition ? first : second 在两个值之间选择。原始目录保持不变。

用 here-document 保存完整文件。在 cat > path <<'JS' 中,> 替换该文件,<< 将随后各行作为输入,带引号的 JS 防止 shell 展开 JavaScript。结尾的 JS 独占一行。这些命令是一种终端编辑方法;你也可以在 WebIDE 中编辑同名文件并按 Ctrl+S 保存。其余三个函数留待后续步骤完成。

cat > src/selection.js <<'JS'
export function currentRecords(ids, catalog) {
  return catalog.filter(session => ids.includes(session.id)).sort((a, b) =>
    a.start - b.start || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
  );
}

export function totalMinutes(records) {
  return 0;
}

export function visibleRecords(catalog, stage) {
  return catalog;
}

export function missingNotice(ids, catalog) {
  return '';
}
JS

返回独立 Work 页面,普通刷新浏览器。View my schedule 现在显示实际的 Garden First Light、10:00–10:30 及当前描述。不应为 RETIRED-07 编造卡片。摘要仍是起始值,Add/Remove 也尚未连接;下一步会完成它们。在文件中找出与 session.id 的比较:改变目录顺序不能改变 D11 指向哪个对象。

如果浏览器显示模块或语法错误,返回 Workspace,运行 pwd 和 cat src/selection.js,对照完整代码检查导入、函数名、引号和花括号,在准确路径保存修正文件,再回到 Work 普通刷新。修正文件名或 JavaScript 拼写错误不需要重置 VM 或重装软件包。当前卡片可见后,运行本步骤检查。

推导摘要并更新一份选择

本步骤把 Add 和 Remove 连接到同一份 ID 列表,并计算数量与时长。

从 Workspace 的 /home/labex/project/festival-schedule/work 开始。单独保存的数量或时长可能在移除卡片后偏离实际。用 selectedIDs 作为选择状态,每次渲染都推导当前记录;当前记录数组的长度就是数量。reduce 将多条记录归约为一个值:从零分钟开始,逐项加上 end - start。准备好的时间是同一当地日期上的整数分钟,不需要 Date 解析或时区换算。D11 持续 30 分钟,D12 持续 45 分钟,合计 75 分钟。场次之间的空档不算参加时间。

替换 selection.js,保留相同 ID 查找并完成归约。筛选和缺失 ID 说明仍留作第 3 步的起始函数。

cat > src/selection.js <<'JS'
export function currentRecords(ids, catalog) {
  return catalog.filter(session => ids.includes(session.id)).sort((a, b) =>
    a.start - b.start || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
  );
}

export function totalMinutes(records) {
  return records.reduce((minutes, session) => minutes + session.end - session.start, 0);
}

export function visibleRecords(catalog, stage) {
  return catalog;
}

export function missingNotice(ids, catalog) {
  return '';
}
JS

现在连接 React 组件。useState 提供各个状态值及其 setter。选择 ID、可见舞台和反馈各有职责;切换舞台不能替换 ID 列表。所选卡片和摘要是计算值,不是额外状态。

更新函数 ids => [...ids, id] 接收当前 ID 数组,用展开语法 ... 创建新数组。移除使用 filter 创建新数组;它移除匹配的 ID,而非所有同名场次。检查已经选择的 ID 可防止重复。find 取回被点击 ID 对应的实际记录。feedback 是传给准备好的渲染器的小对象:sessionID 将 Add 结果放到对应卡片旁,text 是可读消息。渲染器会使消息保持可见,而不把键盘焦点移到实时状态区域。

clock 来自准备好的 View.jsx,用于将整数分钟格式化为显示时间。describe 组合标题、舞台和时间,让同名场次可以区分。下面所有组件 props 都有明确的状态或派生记录来源。完整替换 App.jsx:

cat > src/App.jsx <<'JS'
import React, {useState} from 'react';
import View, {clock} from '../../shared/View.jsx';
import {catalog, settings} from './settings.js';
import {currentRecords, totalMinutes, visibleRecords, missingNotice} from './selection.js';

const describe = session => session.title + ' (' + session.stage + ', ' + clock(session.start) + '-' + clock(session.end) + ')';

export default function App() {
  const [selectedIDs, setSelectedIDs] = useState(settings.initialIDs);
  const [stage, setStage] = useState('all');
  const [feedback, setFeedback] = useState({sessionID: null, text: ''});
  const selected = currentRecords(selectedIDs, catalog);
  const visible = visibleRecords(catalog, stage);
  const minutes = totalMinutes(selected);

  function add(id) {
    const session = catalog.find(item => item.id === id);
    if (!session || selectedIDs.includes(id)) return;
    setSelectedIDs(ids => [...ids, id]);
    setFeedback({sessionID: id, text: 'Added ' + describe(session) + '.'});
  }

  function remove(id) {
    setSelectedIDs(ids => ids.filter(existing => existing !== id));
    setFeedback({sessionID: null, text: ''});
  }

  function changeStage(value) {
    setStage(value);
    setFeedback({sessionID: null, text: ''});
  }

  return <View settings={settings} visible={visible} selected={selected}
    count={selected.length} minutes={minutes} selectedIDs={selectedIDs}
    stage={stage} onStage={changeStage} onAdd={add} onRemove={remove}
    feedback={feedback} missingNotice={missingNotice(selectedIDs, catalog)} />;
}
JS

返回 Work 并刷新。合成初始选择显示 1 session · 30 minutes 和 Garden First Light 卡片。添加 Paper Signals:变为 2 个场次、75 分钟。添加 Loft, 12:00–12:30 的 First Light:两个 First Light 应同时保留,舞台和时间不同,合计 3 个场次、105 分钟。仅移除 Garden First Light。Paper Signals 与较晚的 Loft First Light 保留:2 个场次、75 分钟。这也证明时长是实际参加时间的总和,而非从最早开始到最晚结束的时间跨度。

提供的渲染器用场次 ID 作为 React key。Key 在列表变化时跟踪身份,并不替代选择状态。Add 按钮变为 Added,重复选择不能创建另一份副本。本单元的舞台控件仍使用起始辅助函数,也有意不阻止重叠。不要声称任何选择组合都已是无冲突日程。卡片和摘要关系正确后,运行本步骤检查。

筛选节目而不丢失选择

本步骤完成舞台筛选,并在保留有效场次的同时说明一个已不可用的早期 ID。

从 Workspace 开始。visibleRecords 只根据 stage === 'all' 或舞台匹配条件筛选节目数组,再将新数组按开始时间和 ID 排序。它从不编辑 selectedIDs。missingNotice 判断是否有已选 ID 找不到当前记录。some 在至少一个元素满足条件时返回 true;内部的 some 检查匹配的当前 ID,! 表示没有匹配。提示是普通反馈,不是要求丢弃整个日程的错误。没有缺失项时,函数可以返回空字符串。

保存完整辅助文件:

cat > src/selection.js <<'JS'
export function currentRecords(ids, catalog) {
  return catalog.filter(session => ids.includes(session.id)).sort((a, b) =>
    a.start - b.start || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
  );
}

export function totalMinutes(records) {
  return records.reduce((minutes, session) => minutes + session.end - session.start, 0);
}

export function visibleRecords(catalog, stage) {
  return catalog.filter(session => stage === 'all' || session.stage === stage).sort((a, b) =>
    a.start - b.start || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
  );
}

export function missingNotice(ids, catalog) {
  const missing = ids.some(id => !catalog.some(session => session.id === id));
  return missing ? 'Some earlier choices are no longer listed. Your available sessions are kept.' : '';
}
JS

返回 Work 并刷新。准备好的 D11 仍是真实的 30 分钟卡片。可见提示说明部分早期选择已不再列出;它不会为 RETIRED-07 编造过时标题,也不会删除 D11。数据仍来自本独立单元公开的 settings.initialIDs,不是 localStorage。再次添加 Paper Signals,得到两个选择、合计 75 分钟。

将 Stage 设为 Garden。节目区现在只包含 Garden 场次,View my schedule 中仍有 Garden First Light 和 Studio Paper Signals,共 2 个场次、75 分钟。将 Stage 改回 All stages:Paper Signals 仍是 Added。通过 My schedule 移除它,确认只有对应 ID 离开列表。关于已退出目录的合成 ID 的提示可以保留;它不贡献卡片或分钟数。普通刷新后,本单元会回到准备好的初始 ID,因为这里尚未实现持久化。

尝试用 Tab 和 Enter 操作舞台控件、页内导航链接及可用的 Add/Remove 按钮。窄屏中,View my schedule 保留在日期导航内,Add 反馈出现在被点击的卡片旁。对照标题、舞台、时间和描述,而不只看数量。保留渲染器提供的可见焦点和有意义标签。运行最后的步骤检查。现在你已得到一致的选择与当前数据视图;下一独立实验将增加时间冲突与浏览器恢复。

要重建下图状态,返回独立 Work 页面并普通刷新。这会恢复独立初始的 Garden First Light 选择和已退出目录的 ID 提示。将 Stage 设为 All stages。如果 Paper Signals 尚不是 Added,选择其 Add to schedule 按钮。然后对照两张当前卡片、75 分钟摘要和独立的不可用选择提示。已退出目录的 ID 不产生卡片或分钟数。

当前选择与不可用选择提示

总结

你围绕稳定 ID 构建了 React 选择逻辑,将其解析为当前目录记录,并推导出卡片、数量和安排的分钟数。同名记录保持独立,筛选保留选择,已退出目录的合成 ID 得到清楚说明,同时不丢失有效场次。

本单元有意只在内存中保存选择,也不阻止时间重叠。下一实验将独立启动,准备好这里的选择行为,再教授冲突判断和同源浏览器存储。