课程表的二维建模:ArkTS 为鸿蒙课表设计周几×节次结构


实例:课程表管理(Course)|技术:二维坐标字段、冲突检测、周维度
一、业务需求分析:课程表的数据形态
课程表是**「二维网格」数据**——每门课占据「第几周几的第几节」这个坐标。这与前面的列表型数据(待办、便签)截然不同:数据是坐标定位的(weekday × section),不是顺序排列的。
核心业务需求:
- 课程记录:课程名、老师、教室、周几(17)、节次(18)、色块颜色、教学周;
- 二维网格展示:周几 × 节次的课表——像真实课程表一样按格子摆放;
- 冲突检测:同一天同一节次只能有一门课——新增前检测冲突;
- 按天查询:看周一/周二…的课表与课程列表;
- 周切换:一周 7 天的横向切换。
二、字段设计:坐标式课程
课程表 course
| 字段名 | 类型 | 约束 | 说明 |
|---|---|---|---|
| id | INTEGER | PRIMARY KEY AUTOINCREMENT | 自增主键 |
| name | TEXT | NOT NULL | 课程名 |
| teacher | TEXT | DEFAULT ‘’ | 老师 |
| location | TEXT | DEFAULT ‘’ | 教室 |
| weekday | INTEGER | NOT NULL | 周几 17(周一周日) |
| section | INTEGER | NOT NULL | 节次 1~8 |
| color | TEXT | DEFAULT ‘#3B82F6’ | 色块底色 HEX |
| weeks | TEXT | DEFAULT ‘1-16’ | 教学周(‘1-16’ 或 ‘1,3,5’) |
| remark | TEXT | DEFAULT ‘’ | 备注 |
设计要点拆解:
1. weekday + section 是二维坐标。(weekday, section) 二元组唯一确定课表中的一个格子——这是课程表的空间语义:不是「时间先后」而是「网格位置」。查询「周一第 3 节有没有课」就是 WHERE weekday=1 AND section=3。
2. 为什么用数字 1~7 / 1~8 而不是字符串? weekday 参与等值筛选(equalTo('weekday', 1))与排序(orderByAsc('weekday')),数字编码让筛选与排序直接可靠;界面显示「周一~周日」由 days = ['一','二','三','四','五','六','日'] 数组映射——数字存储 + 数组映射展示。
3. color 用 HEX 字符串。每门课一个色块颜色(10 色板 COURSE_COLORS)——课表的视觉核心(色块区分课程)。存 HEX 字符串,页面直接做 backgroundColor。展示性字段存「最终形态」(HEX 而非颜色名枚举)——不需要参与筛选计算。
4. weeks 教学周用字符串。‘1-16’(连续周)或 ‘1,3,5’(隔周)——真实教务系统的教学周表达。Demo 存字符串不解析(展示用),进阶可解析成周集合做「本周是否上课」判断。
5. 连堂课程是两条记录。高等数学周一 1、2 节连堂——种子里是两条 section=1 和 section=2 的记录(同 name/teacher/location/color)。「连堂 = 相邻节次的多条记录」:查询时按节次分组渲染,同色相邻自然形成「连堂块」。这是课程表的通用建模(不用「起止节次」字段,因为单节也是记录、网格填充更简单)。
三、建表 SQL 与索引
CREATE TABLE IF NOT EXISTS course (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
teacher TEXT DEFAULT '',
location TEXT DEFAULT '',
weekday INTEGER NOT NULL,
section INTEGER NOT NULL,
color TEXT DEFAULT '#3B82F6',
weeks TEXT DEFAULT '1-16',
remark TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_course_grid ON course (weekday, section);
idx_course_grid (weekday, section) 联合索引:WHERE weekday=1 AND section=3(冲突检测)与 WHERE weekday=1 ORDER BY section(按天查询)都命中——二维坐标的联合索引是「坐标查询」的性能保障(两个条件同时过滤)。
四、CourseDao 封装:冲突检测是亮点
数据层核心 CourseDao,本实例的独特方法是冲突检测 checkConflict:
/** 冲突检测:同一天同一节次是否已有课 */
static async checkConflict(context: common.Context, weekday: number, section: number, excludeId: number): Promise<boolean> {
const store = await CourseDao.getStore(context);
const predicates = new relationalStore.RdbPredicates(CourseDao.TABLE);
predicates.equalTo('weekday', weekday).equalTo('section', section);
if (excludeId > 0) {
predicates.notEqualTo('id', excludeId);
}
const result = await store.query(predicates);
const has = result.goToNextRow();
result.close();
return has;
}
冲突检测的本质:查「同坐标是否存在记录」——weekday=1 AND section=3 查到任何一行即冲突。excludeId 排除自身:编辑课程换时间段时,排除当前课程自己(否则自己占的格子永远「冲突」)——notEqualTo('id', excludeId) 让编辑不误报。
为什么冲突检测在数据库层做? 虽然表结构没加 UNIQUE(weekday, section)约束(编辑换时间需要先删约束逻辑),但应用层检测 + 提示比裸约束友好——页面拿到 conflict=true 弹 Toast「该时段已有课程,冲突!」,用户知道为什么失败。**「检测-提示-阻止」**比「违反约束-报错」体验好得多。
其他方法族(与前面实例同构):
queryAll:全部课程按 weekday、section 升序(课表数据源);queryByDay:某天课程按 section 升序(当天列表);insert/update/delete:标准 CRUD(update 全量);count:课程总数(标题栏「共 14 门课程」)。
五、节次时间表与色板常量
/** 节次时间表(第几节 → 起止时间) */
export const SECTION_TIMES: string[] = [
'08:00-08:45', '08:55-09:40', '10:00-10:45', '10:55-11:40',
'14:00-14:45', '14:55-15:40', '16:00-16:45', '16:55-17:40',
];
/** 课程色板 */
export const COURSE_COLORS: string[] = [
'#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6',
'#06B6D4', '#EC4899', '#84CC16', '#F97316', '#6366F1',
];
SECTION_TIMES:节次时间表(第 1 节 08:00-08:45…)——展示层显示「第 3 节 10:00-10:45」用。「坐标 → 人类可读」的映射表,与 days 数组同理。
COURSE_COLORS:10 色课程色板——新增课程时选色(色块圆形选择器),课表渲染用。颜色是课程的视觉标识(扫一眼色块认课程)。
六、技术要点对照表
| 技术点 | 实现方式 | 生产价值 |
|---|---|---|
| 二维坐标 | weekday + section 字段 | 网格定位 |
| 联合索引 | idx_course_grid (weekday, section) | 坐标查询加速 |
| 冲突检测 | 同坐标查重 + excludeId | 防排课冲突 |
| 数字枚举 | weekday 1~7 / section 1~8 | 筛选排序可靠 |
| 色块字段 | color HEX 字符串 | 视觉区分 |
| 连堂建模 | 相邻节次多条记录 | 网格填充简单 |
七、文章小结
课程表的数据层核心是**「二维坐标建模 + 冲突检测」**:weekday × section 二元组定位课表格子(空间语义而非时间顺序),联合索引加速坐标查询,checkConflict(同坐标查重 + excludeId 排除自身)在应用层做「检测-提示-阻止」的友好冲突处理。数字枚举(17/18)保证筛选排序可靠,色块 HEX 与节次时间表是展示层的映射资源。这是「网格/排班类应用」的数据模型范式。
下一篇(15-2)展示课表网格 UI——周切换条 + 节次时间轴 + 色块课表 + 当天课程列表。
八、字段设计详解:从「存什么」到「怎么用」
8.1 weekday × section:一张 7×8 的坐标纸
课程表本质是一张 7 行(周一~周日)× 8 列(第 1~8 节)= 56 个格子的坐标纸,(weekday, section) 就是格子的坐标。这一设计带来三个直接收益:
- 定位语义直接:查询「周三第 4 节有没有课」直接翻译为
WHERE weekday=3 AND section=4——坐标即条件,无需范围运算; - 排序即布局:
ORDER BY weekday, section的输出顺序天然等于「从左到右、从上到下」扫描课表——页面渲染零重排; - 网格与列表双视图:按天列表
WHERE weekday=3是同坐标过滤的特例,一套字段支撑两种 UI。
/** 坐标 → 页面网格位置:第 weekday 行第 section 列 */
function gridCell(weekday: number, section: number): string {
return `周${days[weekday - 1]} · 第${section}节`; // '周三 · 第4节'
}
7×8 网格示意(第 1~5 节):
| 节次 \ 周几 | 周一 | 周二 | 周三 | 周四 | 周五 |
|---|---|---|---|---|---|
| 第1节 | 高等数学 | 数据结构 | 大学物理 | ||
| 第2节 | 高等数学 | 数据结构 | 大学物理 | ||
| 第3节 | 大学英语 | 操作系统 | |||
| 第4节 | 大学英语 | 操作系统 | |||
| 第5节 | 体育 |
8.2 数字枚举 vs 字符串:为什么存 1 而不是「周一」
| 方案 | 存储示例 | 筛选 | 排序 | 界面展示 |
|---|---|---|---|---|
| 数字枚举 | weekday=1 | equalTo('weekday', 1) 直接命中索引 |
orderByAsc 数字序即周序 |
days 数组映射「周一」 |
| 字符串 | weekday=‘周一’ | 等值可筛但易写错(‘周1’/‘一’) | 字典序「周二」在「周一」前 | 无需映射 |
核心结论:weekday 是参与筛选与排序的键,必须用稳定、可比较的编码(数字);「周一」是参与展示的语义,交给映射数组转换。「存储用编码、展示用映射」——展示层不承担存储负担,存储层不承担展示格式。
/** 展示层:数字 → 中文 */
const days: string[] = ['一', '二', '三', '四', '五', '六', '日'];
const label: string = `周${days[course.weekday - 1]}`; // weekday=1 → '周一'
8.3 color 色块与 weeks 教学周:两个「展示形态」字段
- color(HEX 色块):课表是色块驱动的视觉界面——扫一眼颜色认课程。存
#3B82F6这类最终形态,页面直接backgroundColor(course.color)渲染,无需颜色名 → HEX 二次转换。它不参与筛选计算,所以不做枚举约束——HEX 本身就是精确值。 - weeks(教学周):
'1-16'(连续周)与'1,3,5'(隔周)两种表达,来自真实教务系统。Demo 层存字符串不做解析(仅展示「1-16 周」);进阶做法是把 weeks 解析成周集合,判断「本周是否上课」:
/** 进阶:'1,3,5' → {1,3,5},判断第 6 周是否上课 */
function isWeekIn(weeks: string, week: number): boolean {
if (weeks.includes('-')) {
const [s, e] = weeks.split('-').map(Number);
return week >= s && week <= e; // 连续周:区间判断
}
return weeks.split(',').map(Number).includes(week); // 隔周:集合判断
}
九、联合索引 idx_course_grid:二维坐标的加速器
CREATE INDEX IF NOT EXISTS idx_course_grid ON course (weekday, section);
B+ 树按 (weekday, section) 两键有序,把 56 个格子组织成「先周后节」的线性序。命中场景:
| 查询 | SQL 形态 | 是否命中索引 |
|---|---|---|
| 冲突检测 | WHERE weekday=1 AND section=3 |
✅ 双键等值,最优 |
| 按天列表 | WHERE weekday=1 ORDER BY section |
✅ 前缀等值 + 有序扫描 |
| 课表全量 | ORDER BY weekday, section |
✅ 索引即排序,免额外排序 |
为什么是联合索引而不是两个单列索引? 单列 (weekday) 索引按天查询也快,但冲突检测要同时过滤两个条件,两个单列索引需要「索引合并 + 回表」;联合索引一次 B+ 树定位直达叶子——weekday=1 AND section=3 只走树的一条路径。数据量小时(14 门课)索引优势不明显,但这是「网格类数据」的通用范式:坐标查询天然是双键过滤,联合索引从第一天就该建好。
十、连堂课程:相邻多记录建模
问题:高等数学周一 1、2 节连堂,怎么存?
| 方案 | 存储 | 优点 | 缺点 |
|---|---|---|---|
| 起止节次 | section_start=1, section_end=2 |
一条记录 | 单节课要冗余 start=end;网格填充要展开区间;冲突检测要区间相交判断 |
| 相邻多记录 | section=1 与 section=2 两条 |
单节/连堂统一建模;每格一条记录、网格直接填;冲突检测仍是点查询 | 记录数略多(14 门课约 16 条) |
「相邻多记录」是网格应用的通解:连堂 = 同 name/teacher/location/color 的相邻节次记录集合。渲染时同色相邻自动成块:
/** 判断某课是否与上一节同课(连堂块的延续) */
function isConsecutiveBlock(courses: Course[], index: number): boolean {
const cur = courses[index];
const prev = courses[index - 1];
return prev !== undefined && prev.section === cur.section - 1 &&
prev.name === cur.name && prev.color === cur.color;
}
数据已按 ORDER BY weekday, section 排序,同一门连堂课的记录必然相邻,name + color 相同即判定为同一连堂块——数据层无额外逻辑,展示层自动合并成块(圆角、hover 状态只画在块边界)。
十一、冲突检测思路预览
checkConflict 的完整实现将在 15-3 深入,这里先看整体思路:
- 查重:
WHERE weekday=1 AND section=3——同坐标存在记录即冲突; - 排除自身:编辑课程换时段时用
excludeId排除当前课程 id——自己占的格子不算冲突; - 检测-提示-阻止:页面拿到
conflict=true弹 Toast「该时段已有课程,冲突!」,阻止保存。
| 场景 | excludeId | 结果 |
|---|---|---|
| 新增课程,格子已被占 | 0(不排除) | 冲突,阻止 |
| 编辑课程,改到空时段 | 自身 id | 不冲突,放行 |
| 编辑课程,改到他人时段 | 自身 id | 冲突,阻止 |
十二、FAQ
Q1:为什么不用 UNIQUE (weekday, section) 约束直接防冲突?
A:UNIQUE 是数据库硬约束,冲突时抛异常,用户只看到「保存失败」,不知道为什么失败。应用层 checkConflict 能给出「该时段已有课程,冲突!」的友好提示,且编辑换时段可通过 excludeId 灵活放行——「检测-提示-阻止」优于「违反-报错」。
Q2:weekday 为什么从 1 开始而不是 0?
A:与业务语义对齐——周一到周日就是 1~7,days[weekday-1] 映射直观;0 起是程序员习惯,容易和「0 代表周日」的歧义混淆。
Q3:课程表为什么不做「按周」维度(单双周课)?
A:Demo 用 weeks 字符串标记教学周(展示用),未做周维度解析。真实教务系统需要「周 × 周几 × 节次」三维坐标,进阶可在 weeks 解析成集合后按周过滤。
Q4:连堂课为什么不在表里加「连堂组 id」字段?
A:相邻多记录 + 同色判断已能自动成块,加组 id 反而引入额外约束(组内节次必须连续、不能拆开编辑)。数据层越简单,渲染越自由。
Q5:色块颜色存 HEX 还是颜色名?
A:存 HEX 最终形态(页面直接 backgroundColor),不做颜色名枚举——颜色不参与筛选计算,存「最终形态」省一次转换、少一层映射表。
更多推荐




所有评论(0)