第二期:企业信任风 UI + 打卡科目分类 + 英语语法学习模块
- 全站按 Corporate Trust 设计规范重做:靛蓝/紫罗兰渐变、Plus Jakarta Sans 自托管字体、带色阴影、卡片悬浮、氛围光斑 - 打卡任务增加科目分类(语文/数学/英语/运动/其他),孩子端按科目分组 - 英语语法自学模块:线性解锁课程 + 课后/周/月测验引擎,测验达标 自动联动当日英语打卡;家长概览显示语法进度 - 课程内容:A1→B1 完整大纲,9 周 54 课 + 9 周测 + 2 月测,讲解/例句/ 练习/测验均原创,仅参考两份语法书的主题编排 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b08f6649be
commit
6ed4d5a61e
@@ -0,0 +1,10 @@
|
||||
import type { Cefr } from "@/content/grammar/types";
|
||||
|
||||
// CEFR 级别配色(徽章与进度用)
|
||||
export const CEFR_STYLE: Record<Cefr, { label: string; cls: string }> = {
|
||||
A1: { label: "A1 入门", cls: "bg-emerald-100 text-emerald-700" },
|
||||
A2: { label: "A2 基础", cls: "bg-sky-100 text-sky-700" },
|
||||
B1: { label: "B1 进阶", cls: "bg-indigo-100 text-indigo-700" },
|
||||
B2: { label: "B2 中高", cls: "bg-violet-100 text-violet-700" },
|
||||
C1: { label: "C1 高级", cls: "bg-amber-100 text-amber-700" },
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { grammarProgress, taskInstances } from "@/db/schema";
|
||||
import { todayStr } from "@/lib/dates";
|
||||
import { SYLLABUS, TOTAL, entryBySeq } from "@/content/grammar";
|
||||
|
||||
// 已通过的课程序列集合
|
||||
export async function passedSet(childId: number): Promise<Set<number>> {
|
||||
const rows = await db
|
||||
.select({ seq: grammarProgress.seq })
|
||||
.from(grammarProgress)
|
||||
.where(and(eq(grammarProgress.childUserId, childId), eq(grammarProgress.passed, true)));
|
||||
return new Set(rows.map((r) => r.seq));
|
||||
}
|
||||
|
||||
// 下一个该学的序列(第一个未通过的);全部完成返回 TOTAL+1
|
||||
export async function nextSeq(childId: number): Promise<number> {
|
||||
const passed = await passedSet(childId);
|
||||
for (const e of SYLLABUS) if (!passed.has(e.seq)) return e.seq;
|
||||
return TOTAL + 1;
|
||||
}
|
||||
|
||||
// 解锁规则:第 1 课永远开放;其余需前一课已通过
|
||||
export function isUnlocked(seq: number, passed: Set<number>): boolean {
|
||||
return seq === 1 || passed.has(seq - 1);
|
||||
}
|
||||
|
||||
// 记录某课结果;通过则同步完成当天的英语打卡
|
||||
export async function completeSeq(
|
||||
childId: number,
|
||||
seq: number,
|
||||
score: number,
|
||||
passed: boolean,
|
||||
) {
|
||||
await db
|
||||
.insert(grammarProgress)
|
||||
.values({ childUserId: childId, seq, score, passed })
|
||||
.onDuplicateKeyUpdate({ set: { score, passed, completedAt: sql`CURRENT_TIMESTAMP` } });
|
||||
|
||||
if (!passed) return;
|
||||
|
||||
const entry = entryBySeq(seq);
|
||||
if (!entry) return;
|
||||
const title = entry.type === "lesson" ? `英语语法 · ${entry.title}` : entry.title;
|
||||
const today = todayStr();
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: taskInstances.id, status: taskInstances.status })
|
||||
.from(taskInstances)
|
||||
.where(
|
||||
and(
|
||||
eq(taskInstances.childUserId, childId),
|
||||
eq(taskInstances.date, today),
|
||||
eq(taskInstances.title, title),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
if (existing.status !== "done") {
|
||||
await db
|
||||
.update(taskInstances)
|
||||
.set({ status: "done", doneAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(eq(taskInstances.id, existing.id));
|
||||
}
|
||||
} else {
|
||||
await db.insert(taskInstances).values({
|
||||
childUserId: childId,
|
||||
date: today,
|
||||
title,
|
||||
subject: "english",
|
||||
status: "done",
|
||||
doneAt: new Date(),
|
||||
sortOrder: 99,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端计分:对比提交的答案,返回得分与每题对错,防作弊
|
||||
export function grade(entryQuiz: { answer: number }[], answers: number[]) {
|
||||
let correct = 0;
|
||||
const detail = entryQuiz.map((q, i) => {
|
||||
const right = answers[i] === q.answer;
|
||||
if (right) correct++;
|
||||
return right;
|
||||
});
|
||||
const score = entryQuiz.length ? Math.round((correct / entryQuiz.length) * 100) : 0;
|
||||
return { score, passed: score >= 80, correct, total: entryQuiz.length, detail };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// 极简加粗渲染:把 **文字** 转成 <strong>,其余按段落输出
|
||||
export function renderBold(text: string): (string | { b: string })[] {
|
||||
return text.split(/(\*\*[^*]+\*\*)/g).map((part) =>
|
||||
part.startsWith("**") && part.endsWith("**") ? { b: part.slice(2, -2) } : part,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// 打卡科目元数据:标签 + 配色(色点/文字/浅底),供孩子端分组与家长端徽章共用
|
||||
|
||||
export const SUBJECTS = [
|
||||
{ key: "chinese", label: "语文", dot: "bg-rose-500", text: "text-rose-600", soft: "bg-rose-50", ring: "ring-rose-200" },
|
||||
{ key: "math", label: "数学", dot: "bg-sky-500", text: "text-sky-600", soft: "bg-sky-50", ring: "ring-sky-200" },
|
||||
{ key: "english", label: "英语", dot: "bg-indigo-500", text: "text-indigo-600", soft: "bg-indigo-50", ring: "ring-indigo-200" },
|
||||
{ key: "exercise", label: "运动", dot: "bg-emerald-500", text: "text-emerald-600", soft: "bg-emerald-50", ring: "ring-emerald-200" },
|
||||
{ key: "other", label: "其他", dot: "bg-slate-400", text: "text-slate-500", soft: "bg-slate-100", ring: "ring-slate-200" },
|
||||
] as const;
|
||||
|
||||
export type Subject = (typeof SUBJECTS)[number]["key"];
|
||||
|
||||
export const SUBJECT_ORDER = SUBJECTS.map((s) => s.key);
|
||||
|
||||
// 未知值一律回落"其他",避免崩溃
|
||||
export function subjectMeta(key: string) {
|
||||
return SUBJECTS.find((s) => s.key === key) ?? SUBJECTS[SUBJECTS.length - 1];
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { viewUrl } from "@/lib/oss";
|
||||
export type TaskView = {
|
||||
id: number;
|
||||
title: string;
|
||||
subject: string;
|
||||
requiresPhoto: boolean;
|
||||
status: "pending" | "done";
|
||||
isMakeup: boolean;
|
||||
@@ -36,6 +37,7 @@ export async function ensureDay(childId: number, date: string) {
|
||||
childUserId: childId,
|
||||
date,
|
||||
title: t.title,
|
||||
subject: t.subject,
|
||||
requiresPhoto: t.requiresPhoto,
|
||||
sortOrder: t.sortOrder,
|
||||
sourceTemplateId: t.id,
|
||||
@@ -52,6 +54,7 @@ export async function dayTasks(childId: number, date: string): Promise<TaskView[
|
||||
.select({
|
||||
id: taskInstances.id,
|
||||
title: taskInstances.title,
|
||||
subject: taskInstances.subject,
|
||||
requiresPhoto: taskInstances.requiresPhoto,
|
||||
status: taskInstances.status,
|
||||
isMakeup: taskInstances.isMakeup,
|
||||
|
||||
Reference in New Issue
Block a user