From aa7e0a6eb8d5557044d75c001b20f371ddf1514b Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 3 Nov 2026 16:33:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=9C=8B=E6=9D=BF=E5=A8=98):=20=E6=8E=A5?= =?UTF-8?q?=E4=B8=8A=E5=85=A8=E7=8F=AD=E8=BF=9B=E5=BA=A6=E3=80=81=E9=9A=BE?= =?UTF-8?q?=E5=BA=A6=E3=80=81=E6=8F=90=E7=A4=BA=E3=80=81=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E7=82=B9=E3=80=81=E6=8E=92=E5=90=8D=E7=AD=89=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=EF=BC=8C=E8=AF=B4=E7=9A=84=E8=AF=9D=E6=9B=B4=E8=B4=B4=E6=AF=8F?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 题目页:这节课同班做对的人多就说「全班已经 N 个人做对了」、都卡着就安慰、第一个做对的夸一句; 通过率不到三成的题错了说「本来就难」、高于七成的说「再看看题」;AI 提示开了没用就提一句; 做对了还有挑战就问敢不敢;流程图分低、拿 S 各有话 - 常点「太难了」的学生错了换软话;这周交得多、例子跑得少的念一句;卡住却从不举手的劝举手; 这周要提示多的劝先自己想 - 首页(这节课没要做的时)每次戳随机说一件:知识点差几道升级、周榜差几道超过前一名、 今天明天的比赛、三天内的新公告;两周以上没来说「好久不见」,只说一回 - 排名页第一名、差前一名几道;成就页差一点的;自学没全对的练一练;专题差最后一道拿奖章 - 运行例子、自己输入、举手也记进 mascot_event(trial、help),GET /mascot/profile 多给「太难了」次数和这一周交、跑例子、举手、要提示的次数 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/mascot.ts | 34 +++++++- apps/web/src/oj/achievement/index.vue | 10 ++- apps/web/src/oj/home/student.vue | 86 +++++++++++++++++-- apps/web/src/oj/learn/index.vue | 11 ++- .../src/oj/problem/composables/mascotGuide.ts | 73 +++++++++++++++- apps/web/src/oj/problemset/detail.vue | 5 +- apps/web/src/oj/rank/list.vue | 8 +- apps/web/src/shared/components/Mascot.vue | 8 +- apps/web/src/shared/mascot/behavior.ts | 25 +++++- apps/web/src/shared/mascot/guide.ts | 11 +++ apps/web/src/shared/mascot/lines.ts | 33 +++++++ packages/contract/src/mascot.ts | 17 +++- 12 files changed, 296 insertions(+), 25 deletions(-) diff --git a/apps/api/src/routes/mascot.ts b/apps/api/src/routes/mascot.ts index 3a92a8b..c7428d1 100644 --- a/apps/api/src/routes/mascot.ts +++ b/apps/api/src/routes/mascot.ts @@ -1,11 +1,11 @@ import { mascotEventSchema, type MascotProfile } from '@oj2/contract' -import { and, count, countDistinct, desc, eq, gte, isNull, sql } from 'drizzle-orm' +import { and, count, countDistinct, desc, eq, gte, inArray, isNull, sql } from 'drizzle-orm' import { Hono } from 'hono' import { requireAuth, type AppEnv } from '../auth/middleware' import { db, schema } from '../db' import { parseBody, success } from '../http' -import { dayStart } from '../time' +import { dayStart, weekStart } from '../time' export const mascotRoutes = new Hono() @@ -49,7 +49,9 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => { const userId = c.get('user')!.id const s = schema.submission - const [pokes, today, recent] = await Promise.all([ + const week = weekStart() + const e = schema.mascotEvent + const [pokes, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([ db .select({ value: count() }) .from(schema.mascotEvent) @@ -64,7 +66,26 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => { .where(and(eq(s.userId, userId), isNull(s.contestId))) .orderBy(desc(s.createTime)) .limit(RECENT_SUBMISSIONS), + db + .select({ value: count() }) + .from(schema.reaction) + .where(and(eq(schema.reaction.userId, userId), eq(schema.reaction.type, 'too_hard'))), + db + .select({ value: count() }) + .from(s) + .where(and(eq(s.userId, userId), isNull(s.contestId), gte(s.createTime, week))), + db + .select({ kind: e.kind, value: count() }) + .from(e) + .where(and(eq(e.userId, userId), inArray(e.kind, ['trial', 'help']), gte(e.createdAt, week))) + .groupBy(e.kind), + db + .select({ value: count() }) + .from(schema.aiHint) + .innerJoin(s, eq(s.id, schema.aiHint.submissionId)) + .where(and(eq(s.userId, userId), gte(schema.aiHint.createTime, week))), ]) + const eventCount = (kind: string) => weekEvents.find((row) => row.kind === kind)?.value ?? 0 const types = new Map() for (const row of recent) if (row.result === 4 && row.errorType) types.set(row.errorType, (types.get(row.errorType) ?? 0) + 1) @@ -74,6 +95,13 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => { return success(c, { pokes: pokes[0]?.value ?? 0, todaySolved: today[0]?.value ?? 0, + tooHard: tooHard[0]?.value ?? 0, + week: { + submits: weekSubmits[0]?.value ?? 0, + trials: eventCount('trial'), + helps: eventCount('help'), + aiHints: weekHints[0]?.value ?? 0, + }, recent: { total: recent.length, wrongAnswer: tally(-1), diff --git a/apps/web/src/oj/achievement/index.vue b/apps/web/src/oj/achievement/index.vue index 448b2bc..480e9d3 100644 --- a/apps/web/src/oj/achievement/index.vue +++ b/apps/web/src/oj/achievement/index.vue @@ -61,8 +61,14 @@ async function load() { onMounted(load) watch(name, load) -// 看板娘:只在看自己的成就时说话 -useMascotGuide(() => (data.value && !other.value && !userStore.isTeacherOrAbove ? { situation: 'achievement' } : null)) +// 看板娘:只在看自己的成就时说话。「快拿到了」里最近的一个能说成「再做对 3 道」的,报它的名字; +// 「还差 3 次」「一天做对 5 道」这种前面加不了「再」,往后找 +useMascotGuide(() => { + if (!data.value || other.value || userStore.isTeacherOrAbove) return null + const item = near.value.find((it) => it.remain.startsWith('再')) + if (!item) return { situation: 'achievement' } + return { situation: 'achievement.near', vars: { name: item.achievement.name, need: item.remain.slice(1) } } +})