From 91d9c9c6d74235459c3dfcfc32b511edd3270529 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 30 Sep 2026 00:28:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=A6=96=E9=A1=B5):=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=90=8E=E4=B8=8D=E5=86=8D=E5=BC=B9=E3=80=8C=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E9=80=9F=E6=8A=A5=E3=80=8D=EF=BC=8C=E4=B8=8A=E6=AC=A1=E6=9D=A5?= =?UTF-8?q?=E5=81=9A=E4=BA=86=E4=BB=80=E4=B9=88=E3=80=81=E5=93=AA=E9=81=93?= =?UTF-8?q?=E8=BF=98=E6=B2=A1=E5=81=9A=E5=AF=B9=E6=94=BE=E8=BF=9B=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=EF=BC=8C=E5=B8=A6=E3=80=8C=E6=8E=A5=E7=9D=80=E5=81=9A?= =?UTF-8?q?=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新接口 /me/last-visit:上次登录到这次登录之间的题库提交,不调 AI(原来要同步等模型最长 60 秒弹框才出来) - 班里今天在做时让位给班里卡,只在卡底留一句;班里那天就是上次来那天时两张卡合成一张 - 第一次登录说「欢迎来到判题狗」,隔了 30 天以上说「好久不见」 - 删掉弹框、store 和 /ai/login-summary Co-Authored-By: Claude Opus 5.5 --- apps/api/src/auth/session.ts | 6 +- apps/api/src/routes/ai.ts | 104 +-------- apps/api/src/routes/classroom.ts | 131 ++++++++++- apps/web/CLAUDE.md | 2 +- apps/web/src/oj/api.ts | 6 + apps/web/src/oj/home/dashboard.vue | 212 +++++++++++++++++- apps/web/src/shared/api.ts | 8 +- apps/web/src/shared/components/Login.vue | 6 +- .../shared/components/LoginSummaryModal.vue | 70 ------ .../shared/components/LoginSummaryPreview.vue | 10 - apps/web/src/shared/layout/default.vue | 2 - apps/web/src/shared/store/loginSummary.ts | 67 ------ apps/web/src/utils/types.ts | 2 +- packages/contract/src/ai.ts | 15 -- packages/contract/src/classroom.ts | 37 +++ 15 files changed, 389 insertions(+), 289 deletions(-) delete mode 100644 apps/web/src/shared/components/LoginSummaryModal.vue delete mode 100644 apps/web/src/shared/components/LoginSummaryPreview.vue delete mode 100644 apps/web/src/shared/store/loginSummary.ts diff --git a/apps/api/src/auth/session.ts b/apps/api/src/auth/session.ts index 1a9c3f5..78f58c7 100644 --- a/apps/api/src/auth/session.ts +++ b/apps/api/src/auth/session.ts @@ -300,7 +300,9 @@ export async function getContestPassword(c: Context, contestId: number) { return session?.value.contestPasswords[String(contestId)] ?? null } -export async function getPreviousLogin(c: Context) { +/** 上次登录、这次登录的时刻 —— 首页「上次来」卡的窗口就是这两个之间 */ +export async function getLoginWindow(c: Context) { const session = await getStoredSession(c) - return session?.value.previousLogin ?? null + if (!session) return null + return { previousLogin: session.value.previousLogin, loginAt: session.value.createdAt } } diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index 9f354a7..1bf46d9 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -9,20 +9,18 @@ import { type AiHintDone, type HintDiagnosis, type HeatmapItem, - type LoginSummary, } from "@oj2/contract" -import { and, count, countDistinct, eq, gte, inArray, isNull, lte, sql } from "drizzle-orm" +import { and, count, eq, gte, inArray, lte, sql } from "drizzle-orm" import { Hono, type Context } from "hono" import { requireAuth, type AppEnv } from "../auth/middleware" -import { getPreviousLogin } from "../auth/session" import { config } from "../config" import { db, schema } from "../db" import { JudgeStatus } from "../judge/status" import { failure, parseBody, readJson, success } from "../http" -import { completeChat, streamChat, streamWhole } from "../services/ai" +import { streamChat, streamWhole } from "../services/ai" import { generateFilteredHint } from "../services/hint-filter" -import { accepted, buildDetail, buildDuration, listSolved } from "../services/learning-stats" +import { buildDetail, buildDuration, listSolved } from "../services/learning-stats" import { decideHintLevel } from "../services/hint-level" import { hintDiagnosis, hintPrompt, referenceAnswer } from "../services/hint-diagnosis" import { consumeToken } from "../services/throttling" @@ -135,102 +133,6 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => { ) }) -aiRoutes.get("/ai/login-summary", requireAuth, async (c) => { - const user = c.get("user")! - const end = new Date() - const [userRow] = await db - .select({ - createTime: schema.user.createTime, - lastLogin: schema.user.lastLogin, - }) - .from(schema.user) - .where(eq(schema.user.id, user.id)) - .limit(1) - const previous = await getPreviousLogin(c) - let start = new Date( - previous ?? userRow?.lastLogin ?? userRow?.createTime ?? end.getTime() - 7 * 864e5, - ) - if (start >= end) start = new Date(end.getTime() - 864e5) - const range = and( - gte(schema.submission.createTime, start.toISOString()), - lte(schema.submission.createTime, end.toISOString()), - ) - const [newProblems, submissions, acceptedRows, solvedRows, flowRows] = await Promise.all([ - db - .select({ value: count() }) - .from(schema.problem) - .where( - and( - isNull(schema.problem.contestId), - eq(schema.problem.visible, true), - gte(schema.problem.createTime, start.toISOString()), - lte(schema.problem.createTime, end.toISOString()), - ), - ), - db - .select({ value: count() }) - .from(schema.submission) - .where(and(eq(schema.submission.userId, user.id), range)), - db - .select({ value: count() }) - .from(schema.submission) - .where( - and( - eq(schema.submission.userId, user.id), - inArray(schema.submission.result, accepted), - range, - ), - ), - db - .select({ value: countDistinct(schema.submission.problemId) }) - .from(schema.submission) - .where( - and( - eq(schema.submission.userId, user.id), - inArray(schema.submission.result, accepted), - range, - ), - ), - db - .select({ value: count() }) - .from(schema.flowchartSubmission) - .where( - and( - eq(schema.flowchartSubmission.userId, user.id), - gte(schema.flowchartSubmission.createTime, start.toISOString()), - lte(schema.flowchartSubmission.createTime, end.toISOString()), - ), - ), - ]) - const summary = { - start: start.toISOString(), - end: end.toISOString(), - newProblemCount: newProblems[0]?.value ?? 0, - submissionCount: submissions[0]?.value ?? 0, - acceptedCount: acceptedRows[0]?.value ?? 0, - solvedCount: solvedRows[0]?.value ?? 0, - flowchartSubmissionCount: flowRows[0]?.value ?? 0, - } - let analysis = "" - let analysisError: string | undefined - // 这支是登录后自动触发的,没有用户点击 —— 更要过限流,否则反复刷新就是反复调模型。 - // 被限住时安静跳过:analysis 本来就是可选的,弹窗里的统计数字照常显示。 - if ( - summary.submissionCount >= 3 && - (await consumeToken("user", aiThrottleKey(user.id))).allowed - ) { - try { - analysis = await completeChat( - "你是 OnlineJudge 的学习助教。请根据统计数据给出简短分析(1-2句),再给出一行以“结论:”开头的结论。", - JSON.stringify(summary), - ) - } catch (error) { - analysisError = error instanceof Error ? error.message : String(error) - } - } - return success(c, { summary, analysis, analysisError } satisfies LoginSummary) -}) - aiRoutes.get("/ai/pinned", requireAuth, async (c) => { const [row] = await db .select({ analysis: schema.aiAnalysis, username: schema.user.username }) diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index f0de915..87ded98 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -11,15 +11,31 @@ import { type ClassComparisonResponse, type ClassRankItem, type ClassUserRank, + type LastVisit, FLOWCHART_PASS_GRADES, } from "@oj2/contract" -import { and, desc, eq, gte, inArray, isNotNull, isNull, like, lte, sql } from "drizzle-orm" +import { + and, + asc, + desc, + eq, + gte, + inArray, + isNotNull, + isNull, + like, + lt, + lte, + sql, +} from "drizzle-orm" import { Hono } from "hono" import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware" +import { getLoginWindow } from "../auth/session" import { db, schema } from "../db" import { failure, parseBody, success } from "../http" import { JudgeStatus } from "../judge/status" +import { accepted } from "../services/learning-stats" import { calendarDay, dayStart, localTime } from "../time" import { queryInteger, rounded } from "./helpers" @@ -362,6 +378,119 @@ async function classLessonProblems(className: string, lookbackDays: number) { } } +/** 「上次来」超过这么多天就不提那次做了什么,只在问候里说一句隔了多久 */ +const LAST_VISIT_MAX_DAYS = 30 + +/** + * 首页的「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对。 + * 原来是登录后弹的「登录速报」,数字外加 AI 两句话 —— AI 只拿到那五个数,说的就是复述, + * 还要同步等模型(最长 60 秒)弹框才出来;现在拆进首页,没做对的题给「接着做」 + */ +classroomRoutes.get("/me/last-visit", requireAuth, async (c) => { + const user = c.get("user")! + const window = await getLoginWindow(c) + const previousLogin = window?.previousLogin ?? null + if ( + !window || + !previousLogin || + Date.now() - new Date(previousLogin).getTime() > LAST_VISIT_MAX_DAYS * 864e5 + ) { + return success(c, { previousLogin, summary: null } satisfies LastVisit) + } + const rows = await db + .select({ + problemId: schema.submission.problemId, + problemDisplayId: schema.problem.displayId, + title: schema.problem.title, + result: schema.submission.result, + createTime: schema.submission.createTime, + }) + .from(schema.submission) + .innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId)) + .where( + and( + eq(schema.submission.userId, user.id), + isNull(schema.submission.contestId), + gte(schema.submission.createTime, previousLogin), + lt(schema.submission.createTime, window.loginAt), + ), + ) + .orderBy(asc(schema.submission.createTime)) + if (!rows.length) return success(c, { previousLogin, summary: null } satisfies LastVisit) + + // 按题归拢:rows 按时间正序,所以后来的覆盖 lastResult / lastTime,第一次 AC 的先进 solved + const byProblem = new Map< + number, + { + problemDisplayId: string + title: string + attempts: number + lastResult: (typeof rows)[number]["result"] + lastTime: string + solved: boolean + } + >() + const solved: { problemDisplayId: string; title: string }[] = [] + for (const row of rows) { + const item = byProblem.get(row.problemId) ?? { + problemDisplayId: row.problemDisplayId, + title: row.title, + attempts: 0, + lastResult: row.result, + lastTime: row.createTime, + solved: false, + } + item.attempts++ + item.lastResult = row.result + item.lastTime = row.createTime + if (!item.solved && accepted.includes(row.result)) { + item.solved = true + solved.push({ problemDisplayId: row.problemDisplayId, title: row.title }) + } + byProblem.set(row.problemId, item) + } + // 那次没做对的,再看以前、以及这次登录之后有没有做对过 —— 做对过的就不再催「接着做」 + const tried = [...byProblem].filter(([, item]) => !item.solved).map(([id]) => id) + const everSolved = tried.length + ? new Set( + ( + await db + .selectDistinct({ problemId: schema.submission.problemId }) + .from(schema.submission) + .where( + and( + eq(schema.submission.userId, user.id), + isNull(schema.submission.contestId), + inArray(schema.submission.problemId, tried), + inArray(schema.submission.result, accepted), + ), + ) + ).map((row) => row.problemId), + ) + : new Set() + const unsolved = tried + .filter((id) => !everSolved.has(id)) + .map((id) => byProblem.get(id)!) + .sort((a, b) => b.lastTime.localeCompare(a.lastTime)) + .slice(0, 3) + .map(({ problemDisplayId, title, attempts, lastResult }) => ({ + problemDisplayId, + title, + attempts, + lastResult, + })) + return success(c, { + previousLogin, + summary: { + submissionCount: rows.length, + solvedCount: solved.length, + lastSubmitTime: rows.at(-1)!.createTime, + unsolved, + solved: solved.slice(0, 12), + }, + } satisfies LastVisit) +}) + /** * 学生首页的「班里在做」。课上老师报题号、全班去找 —— 这是 2025 秋七成非比赛提交的 * 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。 diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index 31e5860..7ff22be 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -58,7 +58,7 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap 跨端的(登录、资料、标签、验证码)在 `shared/api.ts`。 Shared logic lives in `shared/`: -- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码) +- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (题面「你画的流程图」那张 A/S 图的 mermaid 源码) - `composables/` — `aiStream`, `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `hiddenStudents` (课堂看板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity` - `layout/` — `default.vue` and `admin.vue` layout wrappers - `api.ts` — shared API calls (auth, profile, tags, captcha) diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 1e4b806..ca85995 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -9,6 +9,7 @@ import { type Metrics, type TutorialSummary, type ClassActivity, + type LastVisit, type ClassBoard, type ClassLesson, type KnowledgeMap, @@ -246,6 +247,11 @@ export function getClassActivity() { return api.get("me/class-activity") } +/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */ +export function getLastVisit() { + return api.get("me/last-visit") +} + export function getClassRank(grade?: number | null) { return api.get("rankings/classes", { params: { grade } }) } diff --git a/apps/web/src/oj/home/dashboard.vue b/apps/web/src/oj/home/dashboard.vue index 37853d6..d3be22c 100644 --- a/apps/web/src/oj/home/dashboard.vue +++ b/apps/web/src/oj/home/dashboard.vue @@ -6,6 +6,7 @@ import { getClassActivity, getKnowledgeMap, getContestList, + getLastVisit, getLearnProgress, getSubmissions, getTutorials, @@ -16,13 +17,14 @@ import { useBreakpoints } from "shared/composables/breakpoints" import { useProblemJump } from "shared/composables/problemJump" import { pickHeadline, type Headline } from "oj/user/knowledge" import { useUserStore } from "shared/store/user" -import { ContestStatus, CONTEST_STATUS } from "utils/constants" -import { duration, parseTime, zonedParts } from "utils/functions" +import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS } from "utils/constants" +import { duration, parseTime, zonedParts, zonedYear } from "utils/functions" import type { AnnouncementListItem, ClassActivity, ClassActivityProblem, Contest, + LastVisit, SubmissionListItem, WeeklyRank, } from "utils/types" @@ -51,12 +53,43 @@ const submissions = ref([]) const announcements = ref([]) const weekly = ref(null) const classActivity = ref(null) +const lastVisit = ref(null) /** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */ const knowledgeHeadline = ref(null) const loaded = ref(false) const keyword = ref("") +/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */ +const firstVisit = computed( + () => + !!lastVisit.value && + !lastVisit.value.previousLogin && + (userStore.profile?.submissionNumber ?? 0) === 0, +) + +/** 离上次来多少天。超过 30 天后端不给「上次来」卡,这里只在问候里说一句 */ +const awayDays = computed(() => { + const previous = lastVisit.value?.previousLogin + if (!previous) return 0 + return Math.floor((Date.now() - new Date(previous).getTime()) / 864e5) +}) +const longAway = computed(() => awayDays.value > 30) + +const awayText = computed(() => { + const previous = lastVisit.value?.previousLogin + if (!previous) return "" + const days = awayDays.value + const gap = + days >= 365 + ? `隔了 ${Math.floor(days / 365)} 年多` + : days >= 60 + ? `隔了 ${Math.floor(days / 30)} 个月` + : `隔了 ${days} 天` + return `上次来是 ${dateText(previous)},${gap}` +}) + const greeting = computed(() => { + if (longAway.value) return "好久不见" const hour = zonedParts(new Date())!.hour if (hour < 5) return "夜深了" if (hour < 11) return "早上好" @@ -133,15 +166,69 @@ async function loadClassActivity() { classActivity.value = await getClassActivity() } +async function loadLastVisit() { + lastVisit.value = await getLastVisit() +} + +/** 东八区的 YYYY-MM-DD,和后端 class-activity 的 day 同一个写法 */ +function dayKey(value: Date | string) { + const p = zonedParts(value)! + return `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}` +} + +function dateText(value: string) { + return parseTime(value, zonedParts(value)!.year === zonedYear() ? "M月D日" : "YYYY年M月D日") +} + +const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"] + +/** 「9月27日 周日上午」:机房一个班一周来一两次,说星期几和上下午比说几点几分好认 */ +function visitText(value: string) { + const p = zonedParts(value)! + const weekday = WEEKDAYS[new Date(Date.UTC(p.year, p.month - 1, p.day)).getUTCDay()] + const part = p.hour < 12 ? "上午" : p.hour < 18 ? "下午" : "晚上" + return `${dateText(value)} ${weekday}${part}` +} + +/** 班里的题是今天的(老师布置的就是今天):今天的课排最前,「上次来」只剩卡底一句 */ +const classToday = computed( + () => + classActivity.value?.source === "teacher" || classActivity.value?.day === dayKey(new Date()), +) + +const visit = computed(() => lastVisit.value?.summary ?? null) + +/** + * 班里那天就是上次来的那天:机房一个班一周来一次,上次来做的往往就是班里那节课的题, + * 「上次来」卡已经讲了,班里卡不再把同一批题列一遍,只在卡底说一句 + */ +const visitCoversClass = computed( + () => + !!visit.value && + !!classActivity.value?.problems.length && + !classToday.value && + classActivity.value.day === dayKey(visit.value.lastSubmitTime), +) + +const classSummaryText = computed(() => { + const problems = classActivity.value?.problems ?? [] + const done = problems.filter((item) => item.myStatus === "accepted").length + return done === problems.length + ? `那天班里一起做的 ${problems.length} 道,你都做对了` + : `那天班里一起做了 ${problems.length} 道,你做对了 ${done} 道` +}) + +const solvedText = computed(() => + (visit.value?.solved ?? []).map((item) => `${item.problemDisplayId} ${item.title}`).join("、"), +) + /** 今天的就叫「今天」,往前找到的那天标出日期 —— 周一早上看到的是上周五的课 */ const classActivityTitle = computed(() => { const day = classActivity.value?.day if (!day) return "" - const now = zonedParts(new Date())! - const today = `${now.year}-${String(now.month).padStart(2, "0")}-${String(now.day).padStart(2, "0")}` // 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道 if (classActivity.value?.source === "teacher") return "今天老师布置的题" - if (day === today) return "今天班里在做" + if (classToday.value) return "今天班里在做" const [, month, date] = day.split("-").map(Number) return `${month}月${date}日班里做了` }) @@ -168,6 +255,7 @@ async function load() { loadAnnouncements(), loadWeekly(), loadClassActivity(), + loadLastVisit(), loadKnowledge(), ]) loaded.value = true @@ -226,8 +314,11 @@ async function openAnnouncement(item: AnnouncementListItem) {
-

{{ greeting }},{{ userStore.user?.username }}

- +

欢迎来到判题狗,{{ userStore.user?.username }}

+

{{ greeting }},{{ userStore.user?.username }}

+ 老师报了题号,在右边的框里输入就能找到题 + + 已解决 {{ userStore.profile?.acceptedNumber ?? 0 }} 题 · 共提交 {{ userStore.profile?.submissionNumber ?? 0 }} 次