Files
ClassHelper/packages/shared/src/utils.ts
T
LoveChengke 9a38f86cd3 feat(server,web,desktop,shared,deploy,docs): Linux 一键安装与 classhelper 运维命令、更新检查、图表去 echarts,版本统一 1.1.0
Linux 交付链(新增)
- deploy/install.sh:一键安装器(CLASSHELPER 大字 TUI、native/docker 双形态、向导选安装目录/
  端口/数据库/初始管理员密码);识别 apt/yum/dnf 只补缺的依赖、建独立系统用户、下载内置 Node
  运行时(glibc < 2.28 的 CentOS 7 自动改用 unofficial-builds 的 glibc-217 构建)、幂等覆盖安装
  (保留 config.env 与 data/,不重置密钥)、全程写 /var/log/classhelper/install.log;
  `--check` 只体检(不需要 root)、`--yes` 无人值守、`--package` 支持内网离线分发。
- deploy/classhelper:运维命令 —— status / doctor / password / backup(list,restore,schedule) /
  upgrade / rollback / start|stop|restart|reload / logs / config / port / key rotate / uninstall /
  completion / help。特权操作一律校验 EUID,关键动作写 /var/log/classhelper/audit.log。
- deploy/tools/admin-cli.mjs:离线账号工具(改密、列账号、VACUUM INTO 一致性快照、哈希回滚),
  直连数据库 —— 服务停机也能救急,不需要先有一个能登录的账号。
- deploy/{classhelper.service,profile.d,logrotate.d,systemd}/ 模板 + Dockerfile.linux-package。
- scripts/dist-server.mjs 增加 --platform linux(根脚本 `pnpm dist:server:linux`),产物
  classhelper-server-linux-x64-<版本>.tar.gz + 同名 .sha256;新增 GitHub Actions workflow 在
  ubuntu runner 上构建并挂 Release(@libsql/linux-* 这类平台相关依赖只能在 Linux 上装)。
- docs/linux-deploy.md:安装 / 运维命令 / 升级回滚 / 备份恢复 / 排障 / 安全清单 /
  Ubuntu 22.04 与 Rocky 9 的验收清单。deploy/install-linux.sh 改为转发壳,不留两份实现。

三处必须知道的取舍(已写进 AGENTS.md §5 第 59 条)
- 配置真身在 /etc/classhelper/config.env,安装目录里的 .env 是指向它的软链:Web 端
  「数据库管理 → 一键切换」改写的是 <安装目录>/.env,而 systemd 注入的是同一份配置 ——
  两处不是同一个文件时,切换会「看起来成功、重启后又连回旧库」。classhelper doctor 有专门断言。
- 升级/回滚替换程序文件用「改名 + 就位」而不是 rm+cp:升级时正在运行的就是 bin/classhelper
  自己,直接删掉同名文件会让 bash 读到半个脚本。
- 交叉构建(在非 Linux 机器上打 Linux 包)会在包内写 .cross-built 标记、doctor 据此提示:
  已核对 @libsql/linux-x64-gnu 与 -musl 就位且无 win32 残留,但 prisma CLI 的原生 schema engine
  是宿主平台的,Web 端「数据库一键切换」那一步存疑;正式产物一律走 CI。

更新检查(此前只在工作区,本次一并提交)
- 服务端 modules/update:由服务端转发 GitHub Releases(Web 端 CSP 不允许直连),离线/限流/超时
  一律收敛成 ok:false + 一句人话,失败结果同样进缓存;?force=1 只对管理员生效(避免刷爆匿名限流)。
- 版本比较放 @classhelper/shared(compareVersions / isNewerVersion),三端共用;服务端版本号
  改由 lib/version.ts 读 package.json,不再在 app.ts 里写字面量。
- Web 端顶栏「检查更新」弹窗、客户端「关于」页的检查更新与诊断信息。

Web 端 / 客户端
- 成绩图表改用自包含的轻量组件(ScoreBarChart / ScoreLineChart),不再全量引入 echarts
  (原先 echarts 占桌面端渲染产物 1.12MB、Web 端 1.13MB);柱状图是纯 HTML/CSS,折线图是
  viewBox SVG,均无 resize 监听。
- Element Plus 图标改为按需注册(两端各一份 icons.ts),新增 scripts/check-icons.mjs 静态门禁
  并挂进两端 build —— 漏注册的后果是界面静默渲染空白,只在控制台一条 warning。

版本号统一 1.1.0
- 5 份 package.json、deploy/package.runtime.json、.env.example 的 APP_VERSION、
  docker-compose 与两个 Dockerfile 的镜像标签、Linux 安装包名示例、README 首页版本;
- ClassIsland 插件 manifest.yml 与 BridgeService.PluginVersion 同步(verify:classisland-plugin
  有 manifest ↔ code 的一致性断言);
- 刻意保持不变的是**历史性表述与举例**:docs/mysql.md 的「自 v1.0.0 起内置 MySQL 适配器」、
  shared/utils.ts 讲版本比较用的 1.0.0 / 1.0.0-fix、README 验收表里的实测记录。
2026-10-05 16:39:38 +08:00

420 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { WEEKDAY_LABELS, WEEKDAYS, WEEK_PARITY_LABELS } from './constants.js';
import type { ClassPeriod, GradeLevel, ScheduleDto, ScheduleWeekView, WeekParity } from './types.js';
/* ------------------------------------------------------------------ 日期时间 */
export function toDate(value: string | number | Date): Date {
return value instanceof Date ? value : new Date(value);
}
function pad(input: number): string {
return input < 10 ? `0${input}` : String(input);
}
/**
* 本地日期串(YYYY-MM-DD)。
*
* 「作业属于哪一天」用它是**本地**日期,不是 UTC 日期:教室在 UTC+8,晚上 8 点录的作业
* 应该算今天,而 `toISOString().slice(0, 10)` 会把它算成明天(UTC 已跨天)。
*/
export function dayKeyLocal(value: string | number | Date): string {
const date = toDate(value);
if (Number.isNaN(date.getTime())) return '';
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/** 判断字符串是否形如 YYYY-MM-DD(并校验是真实存在的日期) */
export function isDayKey(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
const [year, month, day] = value.split('-').map(Number);
const date = new Date(year, month - 1, day);
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
}
/** 在 YYYY-MM-DD 上加减天数(用于日期选择器的区间与"昨天/明天") */
export function shiftDayKey(value: string, deltaDays: number): string {
const [year, month, day] = value.split('-').map(Number);
const date = new Date(year, month - 1, day + deltaDays);
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
/** 格式化为 YYYY-MM-DD,withTime=true 时追加 HH:mm */
export function formatDate(value: string | number | Date | null | undefined, withTime = false): string {
if (value === null || value === undefined || value === '') return '';
const date = toDate(value);
if (Number.isNaN(date.getTime())) return '';
const day = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
return withTime ? `${day} ${pad(date.getHours())}:${pad(date.getMinutes())}` : day;
}
export function formatTime(value: string | number | Date | null | undefined): string {
if (value === null || value === undefined || value === '') return '';
const date = toDate(value);
if (Number.isNaN(date.getTime())) return '';
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
/** 周几,1=周一 ... 7=周日 */
export function weekdayOf(value: string | number | Date): number {
const day = toDate(value).getDay();
return day === 0 ? 7 : day;
}
/** ISO 周次(与学期周次解耦,仅用于展示当前自然周) */
export function getIsoWeek(value: string | number | Date): number {
const date = toDate(value);
const target = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNumber = target.getUTCDay() || 7;
target.setUTCDate(target.getUTCDate() + 4 - dayNumber);
const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 1));
return Math.ceil(((target.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
/** 相对时间描述,例如"3 分钟前" */
export function relativeTime(value: string | number | Date, now: Date = new Date()): string {
const date = toDate(value);
if (Number.isNaN(date.getTime())) return '';
const diffSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffSeconds < 60) return '刚刚';
const minutes = Math.floor(diffSeconds / 60);
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} 天前`;
return formatDate(date);
}
/* ------------------------------------------------------------------ 课表 */
/** 第 week 周是单周还是双周(第 1 周记为单周,与 ClassIsland 的 WeekCountDiv 一致) */
export function weekParityOf(week: number): Exclude<WeekParity, 'ALL'> {
return week % 2 === 0 ? 'EVEN' : 'ODD';
}
/**
* ClassIsland 的 `WeekCountDiv` / `WeekCountDivTotal` → 本系统的单双周。
*
* 语义(与 ClassIsland 源码一致):`WeekCountDivTotal = n` 时,第 `WeekCountDiv` 周启用(1-based);
* `WeekCountDiv <= 0` 或 `Total <= 1` 表示每周都上。
*
* 注意:本系统只用单双周(2 周循环),3 周及以上的循环**无法表达**,此时降级为「每周」
* 并由调用方向用户提示(导入模块记 warning)。这里集中为一处,避免导入与插件上报各写一套。
*/
export function weekParityFromDiv(weekCountDiv: number, weekCountDivTotal: number): WeekParity {
if (weekCountDiv <= 0 || weekCountDivTotal <= 1) return 'ALL';
const position = ((weekCountDiv - 1) % weekCountDivTotal) + 1;
if (weekCountDivTotal === 2) return position === 1 ? 'ODD' : 'EVEN';
return 'ALL';
}
/** `weekParityFromDiv` 的逆运算:本系统的单双周 → ClassIsland 的 Div/Total */
export function weekDivFromParity(parity: WeekParity | null | undefined): {
weekCountDiv: number;
weekCountDivTotal: number;
} {
if (parity === 'ODD') return { weekCountDiv: 1, weekCountDivTotal: 2 };
if (parity === 'EVEN') return { weekCountDiv: 2, weekCountDivTotal: 2 };
return { weekCountDiv: 0, weekCountDivTotal: 0 };
}
/** 多周循环(3 周及以上)无法用单双周表达:告知调用方是否被降级 */
export function isWeekCycleDegraded(weekCountDiv: number, weekCountDivTotal: number): boolean {
return weekCountDivTotal > 2;
}
/** 判断某个课表条目在第 week 周是否上课(周次区间 + 单双周) */
export function isScheduleActiveInWeek(
schedule: Pick<ScheduleDto, 'weekStart' | 'weekEnd'> & { weekParity?: WeekParity | null },
week: number,
): boolean {
if (week < schedule.weekStart || week > schedule.weekEnd) return false;
const parity = schedule.weekParity ?? 'ALL';
if (parity === 'ALL') return true;
return parity === weekParityOf(week);
}
/** 格式化单双周标签(课表卡片用) */
export function formatWeekParity(parity: WeekParity | null | undefined): string {
return WEEK_PARITY_LABELS[(parity ?? 'ALL') as WeekParity] ?? '每周';
}
/** 格式化周次范围,例如"1-20 周" / "单周" */
export function formatWeekRange(weekStart: number, weekEnd: number): string {
if (weekStart === weekEnd) return `第 ${weekStart} 周`;
return `${weekStart}-${weekEnd} 周`;
}
/** 按时间排序(HH:mm 字符串可直接字典序比较) */
export function sortScheduleByTime<T extends Pick<ScheduleDto, 'dayOfWeek' | 'startTime'>>(items: T[]): T[] {
return [...items].sort((a, b) =>
a.dayOfWeek === b.dayOfWeek ? a.startTime.localeCompare(b.startTime) : a.dayOfWeek - b.dayOfWeek,
);
}
/** 组装周视图:7 列固定输出,便于前端直接渲染 */
export function buildScheduleWeekView(items: ScheduleDto[], week: number): ScheduleWeekView {
const active = sortScheduleByTime(items.filter((item) => isScheduleActiveInWeek(item, week)));
return {
week,
columns: WEEKDAYS.map((dayOfWeek) => ({
dayOfWeek,
label: WEEKDAY_LABELS[dayOfWeek] ?? `周${dayOfWeek}`,
items: active.filter((item) => item.dayOfWeek === dayOfWeek),
})),
};
}
/** 计算两个 HH:mm 之间的时长(分钟),非法输入返回 0 */
export function durationMinutes(startTime: string, endTime: string): number {
const [sh, sm] = startTime.split(':').map(Number);
const [eh, em] = endTime.split(':').map(Number);
if ([sh, sm, eh, em].some((n) => Number.isNaN(n))) return 0;
return (eh as number) * 60 + (em as number) - ((sh as number) * 60 + (sm as number));
}
/* ------------------------------------------------------------------ 成绩 */
/** 得分率(0-100),总分非法时返回 0 */
export function gradePercent(score: number, totalScore: number): number {
if (!Number.isFinite(score) || !Number.isFinite(totalScore) || totalScore <= 0) return 0;
return round((score / totalScore) * 100, 1);
}
/** 等级换算,学生端图表与标签复用 */
export function gradeLevel(percent: number): GradeLevel {
if (percent >= 90) return 'A';
if (percent >= 80) return 'B';
if (percent >= 70) return 'C';
if (percent >= 60) return 'D';
return 'E';
}
export function averageOf(numbers: number[]): number {
const valid = numbers.filter((n) => Number.isFinite(n));
if (valid.length === 0) return 0;
return round(valid.reduce((sum, n) => sum + n, 0) / valid.length, 1);
}
export function round(value: number, digits = 2): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
/* ------------------------------------------------------------------ 通用 */
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function groupBy<T, K extends string | number>(items: T[], keyOf: (item: T) => K): Map<K, T[]> {
const map = new Map<K, T[]>();
for (const item of items) {
const key = keyOf(item);
const bucket = map.get(key);
if (bucket) bucket.push(item);
else map.set(key, [item]);
}
return map;
}
export function uniqueBy<T, K>(items: T[], keyOf: (item: T) => K): T[] {
const seen = new Set<K>();
const result: T[] = [];
for (const item of items) {
const key = keyOf(item);
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
/** 生成 [1, max] 的周次选项,课表周次切换与表单下拉复用 */
export function buildWeekOptions(max = 20, min = 1): number[] {
const safeMax = Math.max(min, Math.floor(max));
return Array.from({ length: safeMax - min + 1 }, (_, index) => min + index);
}
/** 安全的数字转换,表单输入(字符串)到接口字段(number)之间使用 */
export function toNumber(value: unknown, fallback = 0): number {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return fallback;
}
/** 截断长文本(列表页展示摘要) */
export function truncate(text: string | null | undefined, max = 60): string {
if (!text) return '';
return text.length <= max ? text : `${text.slice(0, max)}…`;
}
/** HH:mm 校验 */
export function isValidTimeString(value: string): boolean {
return /^([01]\d|2[0-3]):[0-5]\d$/.test(value);
}
/* ------------------------------------------------------------------ 上课时段判定 */
/** HH:mm -> 分钟数(非法输入返回 -1) */
export function timeToMinutes(value: string): number {
const [hour, minute] = value.split(':').map(Number);
if (hour === undefined || minute === undefined || Number.isNaN(hour) || Number.isNaN(minute)) return -1;
return hour * 60 + minute;
}
const MS_PER_DAY = 86_400_000;
export const MAX_TERM_WEEK = 30;
/**
* 依据学期起始日(第 1 教学周的周一)计算当前教学周。
* 服务端与三端共用同一份实现,避免"周次"口径不一致。
*/
export function resolveCurrentWeek(
termStartDate: string,
now: Date = new Date(),
maxWeek: number = MAX_TERM_WEEK,
): number {
if (!termStartDate) return 1;
const start = toDate(termStartDate);
if (Number.isNaN(start.getTime())) return 1;
const weekday = start.getDay() === 0 ? 7 : start.getDay();
const monday = new Date(start.getTime() - (weekday - 1) * MS_PER_DAY);
monday.setHours(0, 0, 0, 0);
const diffDays = Math.floor((now.getTime() - monday.getTime()) / MS_PER_DAY);
const week = Math.floor(diffDays / 7) + 1;
return clamp(week, 1, maxWeek);
}
/** 把课表条目转换成时段信息 */
export function toClassPeriod(schedule: ScheduleDto): ClassPeriod {
return {
scheduleId: schedule.id,
courseId: schedule.courseId,
courseName: schedule.course?.name ?? '课程',
startTime: schedule.startTime,
endTime: schedule.endTime,
location: schedule.location,
dayOfWeek: schedule.dayOfWeek,
};
}
/**
* 判断某一时刻是否处于上课时间段,并给出正在上的课与下一节课。
*
* @param schedules 该班级的课表(可含全部周次)
* @param now 判定时刻
* @param week 当前教学周;传 0 / 省略表示不按周次过滤(仅按星期与时间)
*/
export function resolveClassStatus(
schedules: ScheduleDto[],
now: Date = new Date(),
week?: number,
): { inClass: boolean; current: ClassPeriod | null; next: ClassPeriod | null } {
const weekday = weekdayOf(now);
const nowMinutes = now.getHours() * 60 + now.getMinutes();
const weekFiltered =
week && week > 0 ? schedules.filter((item) => isScheduleActiveInWeek(item, week)) : schedules;
const today = weekFiltered.filter((item) => item.dayOfWeek === weekday);
const current = today
.filter((item) => {
const start = timeToMinutes(item.startTime);
const end = timeToMinutes(item.endTime);
return start >= 0 && end > start && nowMinutes >= start && nowMinutes < end;
})
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime))[0];
const next = today
.filter((item) => timeToMinutes(item.startTime) > nowMinutes)
.sort((a, b) => timeToMinutes(a.startTime) - timeToMinutes(b.startTime))[0];
return {
inClass: Boolean(current),
current: current ? toClassPeriod(current) : null,
next: next ? toClassPeriod(next) : null,
};
}
/** 时段的可读文案,例如 "08:00-08:45 · 数学 · 教学楼 A301" */
export function formatClassPeriod(period: ClassPeriod | null): string {
if (!period) return '';
const location = period.location ? ` · ${period.location}` : '';
return `${period.startTime}-${period.endTime} · ${period.courseName}${location}`;
}
/** 距离某个 HH:mm 还有多少分钟(已过则为负) */
export function minutesUntil(time: string, now: Date = new Date()): number {
const target = timeToMinutes(time);
if (target < 0) return 0;
return target - (now.getHours() * 60 + now.getMinutes());
}
/* ------------------------------------------------------------------ 版本比较(更新检查) */
/**
* 版本号归一化:去掉首尾空白与前导的 `v` / `V`。
*
* 之所以需要:GitHub 的 tag 是 `v1.0.0`,而 package.json / `app.getVersion()` 给的是 `1.0.0`,
* 两边不归一化就没法比。三端共用这一个实现,避免"客户端说有新版、服务端说没有"。
*/
export function normalizeVersion(value: string | null | undefined): string {
return (value ?? '').trim().replace(/^[vV]/, '');
}
/**
* 比较两个版本号:`a` 比 `b` 新返回正数,旧返回负数,相同返回 0。
*
* 规则:
* 1. 按 `.` 分段比数字(每段只取开头的连续数字,因此 `1.0.0-fix` 的第三段仍是 `0`);
* 2. 数字全部相同、只有后缀不同时,**带后缀的一方视为更新**。
*
* 第 2 条是为 `v0.1.0-fix` 这种"同一个版本号的修复发布"定的:按标准 semver,
* `0.1.0-fix` 是 `0.1.0` 的**预发布**(应当更旧),但本项目是拿它当"修完再补发一版"用的,
* 照 semver 判会让那条修复版永远提示不出来。另外 `/releases/latest` 本身会跳过预发布,
* 所以不用担心把 `-beta` 当成正式版。
*/
export function compareVersions(a: string | null | undefined, b: string | null | undefined): number {
const left = normalizeVersion(a);
const right = normalizeVersion(b);
if (left === right) return 0;
const parse = (value: string): { numbers: number[]; suffix: string } => {
const [core = '', ...rest] = value.split('-');
const numbers = core.split('.').map((part) => {
const matched = /^\d+/.exec(part.trim());
return matched ? Number(matched[0]) : 0;
});
return { numbers, suffix: rest.join('-') };
};
const l = parse(left);
const r = parse(right);
const length = Math.max(l.numbers.length, r.numbers.length);
for (let index = 0; index < length; index += 1) {
const diff = (l.numbers[index] ?? 0) - (r.numbers[index] ?? 0);
if (diff !== 0) return diff > 0 ? 1 : -1;
}
if (l.suffix === r.suffix) return 0;
// 数字相同时:裸版本更旧(1.0.0 < 1.0.0-fix),两个都有后缀才按字典序
if (!l.suffix) return -1;
if (!r.suffix) return 1;
return l.suffix.localeCompare(r.suffix);
}
/** `latest` 是否比 `current` 新(更新检查的判定入口;latest 为空一律视为"没有新版") */
export function isNewerVersion(
current: string | null | undefined,
latest: string | null | undefined,
): boolean {
if (!normalizeVersion(latest)) return false;
return compareVersions(latest, current) > 0;
}