Deploy / deploy (push) Canceled after 0s
- 课堂看板「选题」弹框:左边知识点(加必会题),右边搜题号/题名、点选,按点的先后排; 只列这次作业的语言能交的题(/problems、/problem-tags 加 ?language=) - 一天多节课:0040 拿掉 class_lesson (class_name, day) 唯一约束,每次布置一行; 看板「改」只改当前一节,加「新的一节」;这一节布置超过 100 分钟时先问这节课做哪几道题。 不带 lessonId/newLesson 的老请求照旧改今天最后一节 - 数据统计「一行一节课」:布置过的课以布置为准(题目和顺序照布置、做完按全班算、标「布置」), 这些提交不再参与推断切段,也不算零散;详情页「做完」注明是这一天/这节课/这段时间 - 后台「课堂作业」:一节一行,改(班级/日子/时刻/语言/题目)、看统计、删除、补一节; 看板加「布置过的课」入口。题号校验和「做完」口径抽到 services/lesson.ts,三处共用 ⚠️ 机房站点要和服务器一起升级:旧代码布置时 ON CONFLICT (class_name, day) 找不到约束会报错 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
141 lines
6.0 KiB
TypeScript
141 lines
6.0 KiB
TypeScript
import { TIME_ZONE, TIME_ZONE_OFFSET_MINUTES } from '@oj2/contract'
|
||
import { sql, type SQLWrapper } from 'drizzle-orm'
|
||
|
||
/**
|
||
* 后端的日历换算全在这里,锚点是契约里的 `TIME_ZONE`(东八区,固定偏移)。
|
||
*
|
||
* **凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,都必须走这里**;SQL 里按日历切
|
||
* 就用 `localTime()`。不要写 `new Date(x).getHours()` / `setHours(0,0,0,0)` /
|
||
* `getFullYear()` / `new Date(y, m, d)` 这类跟**进程时区**走的代码,也不要依赖数据库
|
||
* 会话时区:容器(UTC)和开发机给出不同答案,而且不报错。
|
||
*/
|
||
export { TIME_ZONE }
|
||
|
||
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
|
||
const DAY_MS = 86_400_000
|
||
|
||
/**
|
||
* 真实时刻 → 「东八区墙上时钟」。平移之后 `getUTC*` 读出来的就是北京时间的年月日时分,
|
||
* 日历运算可以整套用 UTC 那批 API 做。`fromWallClock` 是逆运算。
|
||
*/
|
||
function toWallClock(value: Date | number | string = new Date()): Date {
|
||
return new Date(new Date(value).getTime() + OFFSET_MS)
|
||
}
|
||
|
||
function fromWallClock(wall: Date): Date {
|
||
return new Date(wall.getTime() - OFFSET_MS)
|
||
}
|
||
|
||
/** 北京时间的日历日,形如 `2026-09-14` */
|
||
export function calendarDay(value: Date | number | string = new Date()): string {
|
||
return toWallClock(value).toISOString().slice(0, 10)
|
||
}
|
||
|
||
/** 北京时间的钟点,0–23 */
|
||
export function localHour(value: Date | number | string = new Date()): number {
|
||
return toWallClock(value).getUTCHours()
|
||
}
|
||
|
||
/** 北京时间的年份 */
|
||
export function localYear(value: Date | number | string = new Date()): number {
|
||
return toWallClock(value).getUTCFullYear()
|
||
}
|
||
|
||
/**
|
||
* 日历日序号(1970-01-01 为 0)。
|
||
*
|
||
* 「差几天」一律用它算,别拿两个 Date 相减:夏令时地区的相邻两天可能相差
|
||
* 23 或 25 小时,除 86400000 得到的不是 1,`=== 1` 这种判据会静默失效。
|
||
*/
|
||
export function dayNumber(day: string): number {
|
||
const [year, month, date] = day.split('-').map(Number)
|
||
return Date.UTC(year!, month! - 1, date!) / DAY_MS
|
||
}
|
||
|
||
/** 东八区的「哪天几点」(`2026-10-08`、`10:30`)→ 那一刻的 ISO 字符串。后台补登课堂作业用 */
|
||
export function localDateTime(day: string, time: string): string {
|
||
const [hour, minute] = time.split(':').map(Number)
|
||
return new Date(dayNumber(day) * DAY_MS + (hour! * 60 + minute!) * 60_000 - OFFSET_MS).toISOString()
|
||
}
|
||
|
||
/** 日历日序号 → `YYYY-MM-DD` */
|
||
export function dayText(day: number): string {
|
||
return new Date(day * DAY_MS).toISOString().slice(0, 10)
|
||
}
|
||
|
||
/** 日历日序号是周几,0 = 周日(和 `Date#getDay()` 同一套编号) */
|
||
export function localWeekday(day: number): number {
|
||
return new Date(day * DAY_MS).getUTCDay()
|
||
}
|
||
|
||
/**
|
||
* `instant` 所在东八区日历日的零点,返回 ISO 字符串,缺省是今天。
|
||
* 提交列表、流程图列表的 `?today=1` 和后台「今日提交数」用它
|
||
*/
|
||
export function dayStart(instant: Date | number | string = new Date()): string {
|
||
return new Date(dayNumber(calendarDay(instant)) * DAY_MS - OFFSET_MS).toISOString()
|
||
}
|
||
|
||
/**
|
||
* 「东八区本周一」的零点,返回 ISO 字符串。周榜按自然周清零,周一起算。
|
||
*
|
||
* `localWeekday` 的 0 是周日(跟 `Date#getDay()` 同一套编号),直接拿来减会把周日
|
||
* 算成「本周第一天」,于是周日一整天单独成一周、周一又清零一次 —— 所以先把 0 折成 7,
|
||
* 得到的 `weekday - 1` 才是「从本周一到今天过了几个日历日」。
|
||
*/
|
||
export function weekStart(now: Date | number | string = new Date()): string {
|
||
const today = dayNumber(calendarDay(now))
|
||
const weekday = localWeekday(today) || 7
|
||
return new Date((today - (weekday - 1)) * DAY_MS - OFFSET_MS).toISOString()
|
||
}
|
||
|
||
/**
|
||
* 「这学期」从哪天零点(东八区)算起:秋季学期 9 月 1 日起(到次年 1 月都算),春季学期
|
||
* 2 月 1 日起(7、8 月的暑假也归春季,开学前没人做题,不影响)。返回 ISO 字符串。
|
||
*/
|
||
export function termStart(now: Date | number | string = new Date()): string {
|
||
const wall = toWallClock(now)
|
||
const year = wall.getUTCFullYear()
|
||
const month = wall.getUTCMonth() + 1
|
||
const [termYear, termMonth] = month >= 9 ? [year, 9] : month >= 2 ? [year, 2] : [year - 1, 9]
|
||
return fromWallClock(new Date(Date.UTC(termYear, termMonth - 1, 1))).toISOString()
|
||
}
|
||
|
||
/** 「东八区本月 1 日」的零点,返回 ISO 字符串 */
|
||
export function monthStart(now: Date | number | string = new Date()): string {
|
||
const wall = toWallClock(now)
|
||
return fromWallClock(new Date(Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth(), 1))).toISOString()
|
||
}
|
||
|
||
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
|
||
export function shiftMonthsByCalendar(instant: Date, months: number): Date {
|
||
const wall = toWallClock(instant)
|
||
const date = wall.getUTCDate()
|
||
wall.setUTCDate(1)
|
||
wall.setUTCMonth(wall.getUTCMonth() + months)
|
||
const lastDay = new Date(Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth() + 1, 0)).getUTCDate()
|
||
wall.setUTCDate(Math.min(date, lastDay))
|
||
return fromWallClock(wall)
|
||
}
|
||
|
||
/**
|
||
* 时区名直接拼成 SQL 字面量,**不走参数绑定**:同一个表达式在 select 和 group by 里
|
||
* 各出现一次,绑定成参数会拿到两个不同的占位符,PG 就不认为它们是同一个表达式,报
|
||
* 「must appear in the GROUP BY clause」。常量拼接,没有注入面。
|
||
*/
|
||
const TIME_ZONE_SQL = sql.raw(`'${TIME_ZONE}'`)
|
||
|
||
/**
|
||
* `timestamptz` 列 → 北京墙上时间(`timestamp`),供 `extract(hour from …)` /
|
||
* `date(…)` 这类日历函数用。每次调用渲染出的 SQL 文本相同,select 和 group by
|
||
* 各调一次也能匹配上。
|
||
*/
|
||
export function localTime(column: SQLWrapper) {
|
||
return sql`(${column} at time zone ${TIME_ZONE_SQL})`
|
||
}
|
||
|
||
/** 时刻所在学年的起始年:9 月 1 日起算,2025 = 25–26 学年 */
|
||
export function schoolYearOf(column: SQLWrapper) {
|
||
return sql<number>`extract(year from ${localTime(column)} - interval '8 months')::int`
|
||
}
|