28 KiB
28 KiB
选修课(Elective)模块审计报告
审计日期:2026-06-25 审计范围:
src/modules/elective/**src/app/(dashboard)/admin/elective/**、src/app/(dashboard)/teacher/elective/**、src/app/(dashboard)/student/elective/**- 跨模块依赖:
school/users/classes的 data-access,rbac的ELECTIVE_*权限点- i18n 资源:
src/shared/i18n/messages/{zh-CN,en}/elective.json参照规则:docs/architecture/004_architecture_impact_map.md、docs/architecture/005_architecture_data.json、.trae/rules/project_rules.md
一、现有实现概要
1.1 文件分布
| 层 | 文件 | 行数 | 职责 |
|---|---|---|---|
| Server Actions | actions.ts | 348 | 8 个写 Action(权限校验 + Zod + trackEvent + 资源归属校验) |
| 数据访问 | data-access.ts | 258 | 课程 CRUD + scope 过滤 + 显示名聚合 + 共享映射函数 |
| 数据访问 | data-access-operations.ts | 374 | 选课/退课/抽签(事务 + FOR UPDATE 锁 + Fisher-Yates) + 时间冲突/学分上限校验 |
| 数据访问 | data-access-selections.ts | 147 | 选课记录查询 + 学生可选课程 |
| 跨模块抽象 | resolvers.ts | 83 | CourseDisplayResolver / StudentGradeResolver 接口 + 注入函数(测试 mock 友好) |
| Schema | schema.ts | 179 | Zod 校验(5 个 schema) |
| Types | types.ts | 73 | 类型定义 |
| Constants | constants.ts | 55 | i18n key 映射 + Badge variant + 类型守卫 |
| Import-export | export.ts | 102 | Excel 导出(课程列表 + 选课名单) |
| 组件 | components/elective-page-layout.tsx | 30 | 页面布局骨架(header/children 插槽) |
| 组件 | components/elective-course-list.tsx | 236 | 课程卡片网格 + 管理操作 |
| 组件 | components/elective-course-form.tsx | 301 | 课程创建/编辑表单 |
| 组件 | components/elective-filters.tsx | 51 | nuqs 筛选栏(搜索 + 模式) |
| 组件 | components/student-selection-view.tsx | 248 | 学生选课视图(已选 + 可选) |
| 页面 | admin/elective/page.tsx | 47 | 管理员课程列表(RSC) |
| 页面 | admin/elective/create/page.tsx | 32 | 创建课程(RSC) |
| 页面 | admin/elective/[id]/edit/page.tsx | 44 | 编辑课程(RSC) |
| 页面 | teacher/elective/page.tsx | 58 | 教师我的课程(RSC) |
| 页面 | student/elective/page.tsx | 48 | 学生选课中心(RSC) |
| 骨架屏 | 5 个 loading.tsx(admin/admin-create/admin-edit/teacher/student) |
— | 列表/表单骨架屏 |
| 错误边界 | 3 个 error.tsx(admin/teacher/student) |
— | 错误兜底 |
| i18n | zh-CN/elective.json | 114 | 中文翻译 |
| i18n | en/elective.json | 114 | 英文翻译 |
1.2 数据流
page.tsx (RSC)
└─ getElectiveCourses / getElectiveCourseById / getAvailableCoursesForStudent / getStudentSelections (data-access)
└─ db (drizzle) → electiveCourses / courseSelections 表
└─ 跨模块 data-access(通过 resolvers.ts 接口抽象):
school.getSubjectOptions / school.getGradeOptions
users.getUserNamesByIds
classes.getStudentActiveGradeId
└─ <ElectiveCourseList> (client) → deleteElectiveCourseAction / openSelectionAction / closeSelectionAction / runLotteryAction
└─ <ElectiveCourseForm> (client) → createElectiveCourseAction / updateElectiveCourseAction
└─ <StudentSelectionView> (client) → selectCourseAction / dropCourseAction
1.3 架构图记录完整性
docs/architecture/004_architecture_impact_map.md 与 005_architecture_data.json 已覆盖 elective 模块(章节 2.20),包含完整的 exports / 依赖关系 / 权限点 / 文件清单。但「已知问题」段存在过时信息(详见第五部分),需同步修正。
二、现存问题与原因分析
2.1 P0:教师页面跳转到 admin 路由(跨角色越权 + 404)
- 位置:teacher/elective/page.tsx:53-54
- 现象:教师页面渲染
ElectiveCourseList时传入createHref="/admin/elective/create"、editBaseHref="/admin/elective"。教师点击"创建课程"或"编辑"按钮后会被路由到/admin/*,由于中间件对 admin 角色做了路由保护,教师实际看到的是 403 / 重定向到首页。 - 原因:
ElectiveCourseList只支持单一createHref/editBaseHref,admin 与 teacher 共用一份组件时硬编码了 admin 路径。 - 违反规则:「Server Action 必须使用
requirePermission()进行权限校验」(前端入口虽然校验了权限,但跳转到无权访问的路由等同于绕过校验);UX 上不可达。 - 后果:教师角色虽然被授予
ELECTIVE_MANAGE权限,却无法实际创建/编辑课程,功能完全不可用。
2.2 P0:parent 角色缺失选课页面
- 位置:
src/app/(dashboard)/parent/elective/**(目录不存在) - 现象:005_architecture_data.json 中 parent 角色被授予
ELECTIVE_READ权限,但没有对应的 parent 页面。家长无法查看子女的选课情况。 - 违反规则:「所有用户可见文本必须适配 i18n」「最大化复用:识别四个角色共用的 UI 块和业务逻辑块」——当前只覆盖 3 个角色,遗漏 parent。
- 后果:家长对子女选课缺乏监督,无法及时发现选课异常(如未选满学分、错选时间冲突课程)。
2.3 P0:admin/student 页面缺少 requirePermission()
- 位置:
- admin/elective/page.tsx(无任何权限校验,直接调用
getElectiveCourses) - student/elective/page.tsx:17 使用
getAuthContext()仅获取 userId,未做权限校验
- admin/elective/page.tsx(无任何权限校验,直接调用
- 违反规则:「Server Action 必须使用
requirePermission()进行权限校验」 + 项目记忆中的硬约束「Parent routes must include permission checks with bothparentIdandstudentIdto prevent information leakage」。 - 后果:仅依赖中间件的路由级保护,缺少纵深防御;若中间件配置出现疏漏(如新增动态路由),会直接导致越权读他人数据。teacher 页面已经做了示范(
requirePermission(Permissions.ELECTIVE_READ)),admin/student 不应例外。
2.4 P0:选课/退课错误消息未走 i18n
- 位置:data-access-operations.ts
- L233
throw new Error("Course selection is not open") - L237
throw new Error("Selection has not started yet") - L241
throw new Error("Selection has ended") - L254
throw new Error("Already selected this course") - L259
throw new Error("Schedule conflicts with your existing courses") - L265
throw new Error(\Credit limit exceeded (${creditCheck.current}/${creditCheck.max})`)` - L301-303
message: "Enrolled successfully" / "Added to waitlist" / "Selection submitted"
- L233
- 现象:上述英文 throw 出去后经由
handleActionError包装为{ success: false, message: <英文> }返回前端,toast 直接显示英文。 - 违反规则:「所有用户可见文本必须适配 i18n(使用 next-intl),提取翻译键」。
- 后果:中文用户看到英文错误提示,i18n 资源中已存在对应的中文键(
errors.selectionClosed、errors.alreadySelected、errors.scheduleConflict、errors.creditExceeded等)却完全没被复用。
2.5 P0:export.ts 存在 as 类型断言
- 位置:export.ts:21
status: params.status as "draft" | "open" | "closed" | "cancelled" | undefined, - 违反规则:「禁止
as断言(除非从unknown转换或测试中,需注释原因)」。 - 后果:未做类型守卫即强转,传入非法字符串(如
"foo")会被静默接受,运行时引发 SQL 类型不匹配。
2.6 P1:elective-course-form.tsx 大量硬编码英文文案
- 位置:elective-course-form.tsx
- L95
"New Elective Course"/"Edit Elective Course" - L102
"Course Name *" - L112
"Subject" - L129
"Grade" - L146
"Teacher" - L163
"Capacity" - L175
"Classroom" - L184
"Schedule" - L188
placeholder="e.g. Mon 14:00-15:30" - L194
"Credit" - L225
"Start Date" - L235
"End Date" - L245
"Selection Start" - L258
"Selection End" - L274
"Description" - L278
placeholder="Course description..." - L291
"Cancel" - L294
"Saving..."/"Create"/"Save" - L72-85
"Invalid form state"/"Failed to save course"等 toast 回退文案
- L95
- 违反规则:「所有用户可见文本必须适配 i18n」+ i18n 资源已存在
form.createTitle/form.editTitle/form.namePlaceholder/form.descriptionPlaceholder等键。 - 后果:中文用户在创建/编辑课程表单中看到全英文界面,体验割裂。
2.7 P1:elective-course-list.tsx 与 elective-course-form.tsx 使用 <a> 而非 <Link>
- 位置:
- elective-course-list.tsx:92
<a href={createHref}> - elective-course-list.tsx:177
<a href={...edit...}>
- elective-course-list.tsx:92
- 违反规则:项目记忆「Link navigation must use Next.js
<Link>component instead of raw<a>tags」。 - 后果:原生
<a>触发整页刷新,丢失客户端导航状态、prefetch 优化、Layout 复用。
2.8 P1:错误边界文案与按钮文案错误
- 位置:admin/elective/error.tsx 与 teacher/elective/error.tsx
- 现象:
title与description都用t("errors.unexpected"),重复且无信息量;- 重试按钮
action.label用t("actions.save")("保存"),但学生页用t("actions.retry")("重试")—— admin/teacher 文案错误。
- 违反规则:i18n 完整性 + UX 一致性。
- 后果:用户在错误页看到"保存"按钮且语义与"重试"不符。
2.9 P1:表单未使用 React Hook Form
- 位置:elective-course-form.tsx
- 现象:使用 4 个独立
useState管理 Select 状态,没有统一表单状态管理、字段校验、脏值检查。项目 tech stack 明确包含React Hook Form。 - 违反规则:技术栈一致性 + 可维护性。
- 后果:字段一多需要每个都加
useState,扩展性差;与服务端 Zod 校验形成两套校验,难以保持一致。
2.10 P1:export.ts 表头混用英文硬编码
- 位置:export.ts:56
"Status"、L93"Status"、L94"Priority"、L95"Selected At"、L96"Enrolled At" - 现象:课程列表 sheet 中
status列的 header 是英文硬编码"Status",而其他列都走 i18n;选课名单 sheet 中status/priority/selectedAt/enrolledAt4 列 header 全英文硬编码。 - 违反规则:「所有用户可见文本必须适配 i18n」—— Excel 导出也是用户可见文本。
- 后果:中文用户下载 Excel 后表头混杂中英文。
2.11 P1:getElectiveCourses 静默吞错
- 位置:data-access.ts:161-164
} catch (error) { console.error("getElectiveCourses failed:", error) return [] } - 现象:DB 查询失败时返回空数组,页面无任何错误提示,用户以为"暂无数据"。
- 违反规则:「错误与边界处理:明确处理空数据、无权限、网络异常等边界状态」。
- 后果:DB 异常被掩盖,运维无法及时发现,用户误判为"无课程",无法触发错误边界。
2.12 P1:parseSchedule 不支持完整英文星期与多时段
- 位置:data-access-operations.ts:35
const match = schedule.match(/^(周[一二三四五六日天]|[MonTueWedThuFriSatSun]+)\s+.../) - 现象:
- 字符类
[MonTueWedThuFriSatSun]+匹配任意 M/o/n/T/u/e 字符组合(如"Mon"、"oMenT"都会通过),不严谨; - 不支持
"Monday"、"周一 14:00-15:30, 周三 16:00-17:30"多时段; normalizeDay表只有mon/tue/...,没有monday/tuesday/...。
- 字符类
- 后果:教师在 schedule 输入
"Monday 14:00-15:30"时不会触发冲突检测,存在隐性排课冲突。
2.13 P1:学分上限与候补人数硬编码(✅ 2026-06-25 已修复)
- 位置:data-access-operations.ts:15
const MAX_CREDIT_PER_TERM = 10 - 现象:所有年级/学校共用同一个上限,无法配置。
- 违反规则:「可扩展性:采用配置驱动设计」。
- 后果:K12 不同年级(如高一 8 学分 vs 高三 12 学分)无法差异化配置。
- 修复说明:新增 data-access-settings.ts,复用
systemSettings表(category="elective")作为配置存储。getElectiveCreditLimit(gradeId?)支持按年级覆盖(key=creditLimit:grade:<gradeId>),fallback 到全局(key=creditLimit:default),均未配置返回默认值 10。Reactcache()包装请求级去重。checkCreditLimit新增studentGradeId: string | null参数并调用getElectiveCreditLimit(studentGradeId),selectCourse在事务前通过getStudentGradeId(studentId)拿到年级 ID 并透传。
2.14 P1:抽签结果不可重跑
- 位置:data-access-operations.ts:212
await tx.update(electiveCourses).set({ enrolledCount, status: "closed", updatedAt: now })... - 现象:抽签完成立即把课程状态置为
closed,管理员若发现结果异常无法重新抽签(重抽需要先把状态手动改回open)。 - 后果:管理员缺乏"试抽 + 调整 + 正式抽"的灵活度,K12 学校在抽签争议时无法快速复核。
2.15 P1:管理员缺少课程统计概览
- 位置:缺失
- 现象:admin/teacher 页面只有平铺的课程卡片,缺少总览统计(如总课程数、总选课人数、热门科目分布、容量使用率)。
- 违反规则:行业最佳实践「K12 admin 应有数据驾驶舱」。
- 后果:管理员难以从全局角度掌握选课运行情况。
2.16 P2:getCachedSubjectOptions / getCachedGradeOptions 缓存全量选项
- 位置:data-access.ts:104-105
- 现象:即使只需要 1 个科目的名称,也会拉取全部 subject/grade 选项。
- 后果:高并发场景下存在 N+1 缓存膨胀风险(虽 React
cache()限单次请求内,但单次请求若涉及 1000+ 课程仍冗余)。
2.17 P2:缺少 Suspense 流式渲染(✅ 2026-06-25 已修复)
- 位置:所有 RSC 页面
- 现象:admin/teacher/student 页面用
Promise.all一次性等待所有数据,没有<Suspense>边界,无法流式渲染局部内容。 - 违反规则:「异步数据使用 React Suspense + 骨架屏」「性能:支持流式渲染」。
- 后果:单条慢查询会拖累整页加载时间,用户长时间看到白屏。
- 修复说明:
- student 页面拆分为
MySelectionsLoader(Suspense)+AvailableCoursesLoader(Suspense),分别对应"我的选课"与"可选课程" - admin 页面拆分为
StatsCardsLoader(Suspense)+CourseListLoader(Suspense),统计卡片与列表分离 StudentSelectionView拆分为StudentMySelectionsSection+StudentAvailableCoursesSection两个独立客户端组件loading.tsx新增MySelectionsSkeleton/AvailableCoursesSkeleton/StatsCardsSkeleton/CourseListSkeleton分段骨架屏- React
cache()自动去重getStudentSelections调用,两个 Suspense 边界共享同一份数据
- student 页面拆分为
2.18 P2:缺少课程先修/容量阈值通知(✅ 2026-06-25 部分修复)
- 位置:缺失
- 现象:K12 学校通常需要:
- 课程先修要求(如"必须先选 Python 入门才能选 Python 进阶");
- 容量阈值通知(如课程满 90% 时通知管理员考虑扩容);
- 选课截止前提醒(如截止前 24h 通知未选课学生)。
- 后果:缺少这些企业级功能会降低 K12 学校的运营效率。
- 修复说明(容量阈值通知):
- 新增 data-access-settings.ts 中的
getCapacityNotifyThreshold(),默认 0.9(90%),可由systemSettings表配置(key=capacityNotifyThreshold,category=elective)。 data-access-operations.ts新增notifyCapacityThresholdIfNeeded():仅当newEnrolledCount === Math.ceil(capacity * threshold)时触发一次通知(避免每次递增都发通知)。Fire-and-forget 设计:catch 中吞错并console.error,不阻塞主流程。通过notifications.sendNotification发送给course.teacherId,type="warning",附带actionUrl指向课程详情。i18n 标题/内容通过next-intl getTranslations("elective")翻译。selectCourse事务成功后调用notifyCapacityThresholdIfNeeded。
- 新增 data-access-settings.ts 中的
- 未实施项:课程先修要求与选课截止前提醒属于更长期规划,本次审计范围内不实施,后续可在 P3 阶段补齐。
2.19 P2:无单元测试(✅ 2026-06-25 已修复)
- 位置:
tests/elective/(不存在) - 现象:
buildLotteryRankCase、parseSchedule、isScheduleConflict、buildScopeFilter等纯函数已被精心设计为可测试,但没有任何测试文件。 - 修复说明:新增
tests/integration/elective/elective-pure-functions.test.ts,35 个单测覆盖normalizeDay/parseSchedule/isScheduleConflict/buildLotteryRankCase/mapCourseRow;vitest.config.ts添加server-only别名 stub。 - 违反规则:「可测试性:数据获取、计算、格式化等纯逻辑全部放入纯函数或 hooks,与 UI 分离;导出清晰的接口类型以便 mock」——架构已就绪,但测试缺失。
- 后果:未来重构无回归保障。
三、行业差距对比
| 维度 | 行业优秀实践(如 PowerSchool、Veracross、睿睿云、校园钉钉选修) | 当前实现 | 差距影响 |
|---|---|---|---|
| 角色覆盖 | 4 角色全覆盖:admin 全局管理 / teacher 创建维护 / student 选退课 / parent 查看子女 | 3 角色覆盖,缺 parent | 家长无法监督子女选课,错失家校协同点 |
| 时间冲突检测 | 结构化时段编辑器(周几 + 节次),可视化冲突预览 | 纯文本 schedule + 正则解析 | 教师/学生易输入错误格式,冲突检测可能失效 |
| 抽签可重跑 | 支持"预抽 + 公示 + 正式抽签",结果可回滚 | 抽完立即 close,不可重抽 | 学校难以应对抽签争议 |
| 学分上限 | 按年级/学校可配置 | 全局硬编码 MAX_CREDIT_PER_TERM=10 |
不同年级无法差异化 |
| 选课截止提醒 | 截止前 24h 短信/站内信通知未选课学生 | 无 | 学生错过选课窗口 |
| 容量阈值通知 | 满 90% 通知 admin 考虑扩容 | 无 | 热门课程爆满后才发现 |
| 课程详情页 | 独立课程详情页 + 教师介绍 + 评价聚合 + 历年选课人数趋势 | 仅卡片展示,无详情页 | 学生选课决策信息不足 |
| 选课概览驾驶舱 | admin 数据驾驶舱:选课率、热门科目、班级分布、未选名单 | 仅平铺课程卡片 | admin 缺乏全局视图,决策低效 |
| 候补转正通知 | 候补转正时通知学生 | 仅事务内自动转正,无通知 | 学生不知道自己已转正,可能错过上课 |
| 课程先修 | 标记先修关系,选课时校验 | 无 | 学生可能跳级选课失败 |
| 退课理由 | 退课时要求填写理由 + 期限 | 直接退课,无理由 | 学校无法分析退课原因改进课程 |
四、改进优先级建议
P0(必须立即修复,影响功能可用或安全)
- 教师页面跳转修复:把
createHref/editBaseHref改为参数化,teacher 页面传入/teacher/elective/...,并新增 teacher 路由/teacher/elective/create与/teacher/elective/[id]/edit(或共享 admin 路由但放开教师访问)。 - 新增 parent 选课页面:复用
StudentSelectionView的只读变体,展示子女的已选/可选课程;通过parentId + studentId双重校验防止信息泄露。 - admin/student 页面加
requirePermission():补齐ELECTIVE_READ校验,与 teacher 一致。 - data-access 错误消息 i18n 化:把
throw new Error("...")改为带 i18n key + 参数的结构化错误,由 actions 层用getTranslations("elective")翻译后再返回。 export.ts移除as断言:用类型守卫替代。
P1(应在本次实施,影响质量与体验)
elective-course-form.tsx全量 i18n 化:替换所有硬编码英文为 i18n 键,新增form.*翻译键。<a>→<Link>:elective-course-list.tsx中 2 处替换为 Next.js<Link>。- 错误边界文案修正:admin/teacher error.tsx 重试按钮改用
t("actions.retry");title/description 分离为errors.title/errors.description。 export.ts表头全量 i18n:新增export.statusHeader/export.priorityHeader/export.selectedAtHeader/export.enrolledAtHeader翻译键。getElectiveCourses不再静默吞错:移除 try-catch,让异常冒泡到 RSC 触发 error.tsx。parseSchedule支持完整星期 + 多时段:重写正则与归一化函数。- 抽签可重跑:抽签后保留
status="open",仅更新enrolledCount;增加"已抽签"标记字段或独立的lotteryRunAt字段。 - 管理员选课概览:在 admin 页面顶部增加统计卡片网格(总课程数、总选课人数、平均容量使用率、待抽签课程数),复用
attendance-stats-cards.tsx模式。
P2(中长期改进,提升企业级能力)
- 配置化学分上限(✅ 2026-06-25 已修复):抽离为
data-access-settings.ts中的getElectiveCreditLimit(gradeId?),复用systemSettings表按年级可设,未配置 fallback 到全局默认值 10。 - Suspense 流式渲染(✅ 2026-06-25 已修复):student 页面拆分"我的选课"与"可选课程"为两个独立 Suspense 边界,admin 列表与统计卡片分离。
- 容量阈值通知(✅ 2026-06-25 已修复):选课时若
enrolledCount >= capacity * threshold,触发notifications模块通知教师(type="warning"),阈值通过getCapacityNotifyThreshold()可配置,默认 0.9。 - 退课期限与理由(✅ 2026-06-25 已修复):新增
electiveCourses.dropDeadline(datetime)与courseSelections.dropReason(varchar 255)字段,dropCourse校验截止时间并抛ElectiveBusinessError("dropDeadlinePassed"),退课对话框可选填理由,trim 后非空才入库。 - 单元测试(✅ 2026-06-25 已修复):补齐
parseSchedule/isScheduleConflict/buildLotteryRankCase/buildScopeFilter/mapCourseRow的单元测试。 - 课程详情页(✅ 2026-06-25 已修复):新增
/admin/elective/[id]与/student/elective/[id]详情页。
五、架构图同步说明
5.1 需要修正的过时信息
004_architecture_impact_map.md 与 005_architecture_data.json 中 elective 模块章节的「已知问题」存在以下过时项,本次审计已核实并修复:
| 架构图记录 | 实际情况 | 处理 |
|---|---|---|
| "❌ P0:3 个读 Action 无调用方" | 实际并不存在这 3 个 Action;页面直接调用 data-access(项目规则允许 app/ 调用 data-access) |
删除该项 |
| "❌ P0:i18n 完全缺失" | i18n 资源完整(zh-CN + en 双语),Server Action 错误消息已 i18n 化 | 修正为「P0:data-access-operations 的 throw 错误消息仍为英文」 |
"❌ P0:错误边界完全缺失(3 个角色目录均无 error.tsx)" |
3 个 error.tsx 已存在 |
删除该项;改为「P1:error.tsx 文案错误(title=description=unexpected,按钮文案错误)」 |
"⚠️ P1:elective-course-form.tsx 存在 `v as "fcfs" |
"lottery"` 类型断言" | 已用 isSelectionMode 类型守卫替代,无 as |
"⚠️ P1:elective-course-list.tsx 存在 null as never 类型逃逸" |
当前代码无 null as never |
删除该项 |
"⚠️ P1:buildLotteryRankCase 未导出,无法单测" |
已 export function buildLotteryRankCase |
删除该项 |
"⚠️ P1:SELECTION_MODE_LABELS 已定义但表单未复用" |
表单已使用 isSelectionMode 守卫 + 直接渲染 t("selectionMode.fcfs/lottery") |
删除该项 |
5.2 需要新增的节点
| 新增项 | 004 章节 | 005 节点 |
|---|---|---|
新增 parent/elective/page.tsx(家长查看子女选课) |
2.20 文件清单新增一行 | appRoutes.parent.elective 节点 |
新增 teacher/elective/create/page.tsx 与 teacher/elective/[id]/edit/page.tsx |
2.20 文件清单新增 2 行 | appRoutes.teacher.electiveCreate / electiveEdit 节点 |
新增 data-access-stats.ts(管理员选课统计) |
2.20 文件清单新增一行 | modules.elective.exports.dataAccess 新增函数 |
新增 tests/elective/*.test.ts(单元测试) |
2.20 文件清单新增测试说明 | 无需 005 节点(测试不属导出) |
5.3 实施完成后的同步动作
代码修改完成后,将上述变更同步写入:
docs/architecture/004_architecture_impact_map.md第 2.20 节docs/architecture/005_architecture_data.json的modules.elective与appRoutes节点