介绍
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 得到清楚说明,同时不丢失有效场次。
本单元有意只在内存中保存选择,也不阻止时间重叠。下一实验将独立启动,准备好这里的选择行为,再教授冲突判断和同源浏览器存储。
