feat(首页): 登录后不再弹「登录速报」,上次来做了什么、哪道还没做对放进首页,带「接着做」
Deploy / deploy (push) Canceled after 0s

- 新接口 /me/last-visit:上次登录到这次登录之间的题库提交,不调 AI(原来要同步等模型最长 60 秒弹框才出来)
- 班里今天在做时让位给班里卡,只在卡底留一句;班里那天就是上次来那天时两张卡合成一张
- 第一次登录说「欢迎来到判题狗」,隔了 30 天以上说「好久不见」
- 删掉弹框、store 和 /ai/login-summary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-30 00:28:14 +08:00
1 parent 2b4676ae34
commit 91d9c9c6d7
15 files changed
+389 -289

No files matched your search

+4 -2
View File
@@ -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 }
}
+3 -101
View File
@@ -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 })
+130 -1
View File
@@ -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<number>()
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 秋七成非比赛提交的
* 来路,但界面上原本没有它的入口,没听清题号的只能问同桌。
+1 -1
View File
@@ -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)
+6
View File
@@ -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<ClassActivity>("me/class-activity")
}
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
export function getLastVisit() {
return api.get<LastVisit>("me/last-visit")
}
export function getClassRank(grade?: number | null) {
return api.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
}
+204 -8
View File
@@ -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<SubmissionListItem[]>([])
const announcements = ref<AnnouncementListItem[]>([])
const weekly = ref<WeeklyRank | null>(null)
const classActivity = ref<ClassActivity | null>(null)
const lastVisit = ref<LastVisit | null>(null)
/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */
const knowledgeHeadline = ref<Headline | null>(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) {
<div class="home">
<section class="hero">
<div>
<h1 class="hello">{{ greeting }},{{ userStore.user?.username }}</h1>
<n-text depth="3">
<h1 v-if="firstVisit" class="hello">欢迎来到判题狗,{{ userStore.user?.username }}</h1>
<h1 v-else class="hello">{{ greeting }},{{ userStore.user?.username }}</h1>
<n-text v-if="firstVisit" depth="3">老师报了题号,在右边的框里输入就能找到题</n-text>
<n-text v-else depth="3">
<template v-if="longAway">{{ awayText }} · </template>
已解决 {{ userStore.profile?.acceptedNumber ?? 0 }} 题 · 共提交
{{ userStore.profile?.submissionNumber ?? 0 }} 次
<template v-if="weeklyText">
@@ -256,9 +347,47 @@ async function openAnnouncement(item: AnnouncementListItem) {
<div class="grid">
<div class="column">
<!-- 「上次来」:上次登录到这次登录之间交过题才有。班里今天在做的时候让位给班里卡,
只在那张卡底下留一句没做对的 -->
<n-card v-if="visit && !classToday" size="small" :bordered="false" class="card">
<template #header>上次来 · {{ visitText(lastVisit!.previousLogin!) }}</template>
<template #header-extra>
<n-text depth="3" class="row-meta">
做对 {{ visit.solvedCount }} 道 · 交了 {{ visit.submissionCount }} 次
</n-text>
</template>
<router-link
v-for="item in visit.unsolved"
:key="item.problemDisplayId"
:to="`/problem/${item.problemDisplayId}`"
class="unsolved"
>
<n-tag type="warning" size="small" :bordered="false">未通过</n-tag>
<div class="unsolved-main">
<span class="row-title">
<n-text depth="3">{{ item.problemDisplayId }}</n-text> {{ item.title }}
</span>
<n-text depth="3" class="row-meta">
交了 {{ item.attempts }} 次,最后一次是{{ JUDGE_STATUS[item.lastResult].name }}
</n-text>
</div>
<n-button type="primary" tag="span">接着做</n-button>
</router-link>
<div v-if="visit.solvedCount" class="row">
<n-tag type="success" size="small" :bordered="false">已通过</n-tag>
<span class="row-title">{{ visit.solvedCount }} 道:{{ solvedText }}</span>
<router-link to="/submission?myself=1" class="more">
看这 {{ visit.solvedCount }} 道 ›
</router-link>
</div>
<div v-if="visitCoversClass" class="visit-foot">
<Icon icon="ph:check-bold" :width="14" />{{ classSummaryText }}
</div>
</n-card>
<!-- 只在有的时候出现:没班级、最近一周班里没一起做过题的,这块不占位置 -->
<n-card
v-if="classActivity?.problems.length"
v-if="classActivity?.problems.length && !visitCoversClass"
:title="classActivityTitle"
size="small"
:bordered="false"
@@ -282,6 +411,17 @@ async function openAnnouncement(item: AnnouncementListItem) {
{{ item.acceptedCount }}/{{ item.userCount }} 人通过
</n-text>
</router-link>
<div v-if="classToday && visit?.unsolved.length" class="visit-foot warn">
<span class="dot"></span>
<span class="row-title">
上次({{ dateText(lastVisit!.previousLogin!) }})还有
{{ visit.unsolved.length }} 道没过: {{ visit.unsolved[0]!.problemDisplayId }}
{{ visit.unsolved[0]!.title }}
</span>
<router-link :to="`/problem/${visit.unsolved[0]!.problemDisplayId}`" class="go">
接着做 ›
</router-link>
</div>
</n-card>
<n-card title="继续学习" size="small" :bordered="false" class="card">
@@ -561,6 +701,62 @@ a.row:hover {
flex-shrink: 0;
}
/* 「上次来」没做对的那几道:一道一个白底块,「接着做」是这张卡唯一的主按钮 */
.unsolved {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
margin-bottom: 8px;
border-radius: 6px;
border: 1px solid rgba(128, 128, 128, 0.18);
background-color: var(--n-color, #fff);
color: inherit;
text-decoration: none;
}
.unsolved:hover {
border-color: rgba(24, 160, 88, 0.5);
}
.unsolved-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.visit-foot {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 10px 4px 2px;
border-top: 1px solid rgba(128, 128, 128, 0.12);
font-size: 13px;
opacity: 0.75;
}
.visit-foot.warn {
opacity: 1;
}
.visit-foot .dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #f0a020;
}
.visit-foot .go {
flex: none;
color: #18a058;
font-weight: 600;
text-decoration: none;
}
.more,
.plain-link {
color: inherit;
+2 -6
View File
@@ -1,4 +1,4 @@
import type { LoginSummary, Quote, WebsiteConfig } from "@oj2/contract"
import type { Quote, WebsiteConfig } from "@oj2/contract"
import api from "utils/api"
import { contractDeferred } from "utils/contract"
import type { Profile, Tag } from "utils/types"
@@ -43,14 +43,10 @@ export function getClassUsernames(classroom: string) {
return api.get<string[]>(`classes/${encodeURIComponent(classroom)}/usernames`)
}
// 下面两个是启动就要调的(config / loginSummary 两个 store),所以放在 shared 而不是
// 下面这个是启动就要调的(config store),所以放在 shared 而不是
// oj/api.ts:oj/api.ts 同步 import 了题目详情、提交详情两个 schema(它们的契约闸门必须
// 同步,submissionDetailSchema 里有 `.catch()`,放行原文和解析结果不等价),入口一旦
// 引到它,zod 整个就进了首屏包。
export function getWebsiteConfig() {
return api.get<WebsiteConfig>("site")
}
export function getAILoginSummary() {
return api.get<LoginSummary>("ai/login-summary")
}
+1 -5
View File
@@ -7,10 +7,8 @@ import { storeToRefs } from "pinia"
import { useAuthModalStore } from "../store/authModal"
import { useConfigStore } from "../store/config"
import { useUserStore } from "../store/user"
import { useLoginSummaryStore } from "../store/loginSummary"
const userStore = useUserStore()
const loginSummaryStore = useLoginSummaryStore()
const configStore = useConfigStore()
const authStore = useAuthModalStore()
@@ -142,10 +140,8 @@ function submit() {
storage.set(STORAGE_KEY.LOGIN_CLASS, form.value.class)
}
authStore.closeLoginModal()
// 「上次登录以来」不再弹框,拆进了首页的「上次来」卡(/me/last-visit)
await userStore.getMyProfile()
// 登录后弹「上次登录以来」的学情小结。移植时漏掉了这一行,
// LoginSummaryModal 一直挂在 layout 里但没人触发,整条链路等于死的
loginSummaryStore.open()
}
})
}
@@ -1,70 +0,0 @@
<script setup lang="ts">
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLoginSummaryStore } from "shared/store/loginSummary"
import { parseTime } from "utils/functions"
// md-editor-v3 按需加载:这个弹框挂在默认布局上,静态 import 的话首页每次都要多拉
// ~160KB JS + ~76KB CSS,而 AI 分析只在登录后、且期间提交够 3 次才有
const MdPreview = defineAsyncComponent(() => import("./LoginSummaryPreview.vue"))
const loginSummaryStore = useLoginSummaryStore()
const { isDesktop } = useBreakpoints()
const lastLoginTime = computed(() => {
const summary = loginSummaryStore.summary
if (!summary?.start) {
return ""
}
return parseTime(summary.start, "YYYY-MM-DD HH:mm")
})
const hasAnalysis = computed(() => !!loginSummaryStore.analysis)
</script>
<template>
<n-modal
v-model:show="loginSummaryStore.show"
preset="card"
title="登录速报"
style="width: min(760px, 92vw)"
>
<n-spin :show="loginSummaryStore.loading" size="small">
<n-flex vertical size="large">
<n-text v-if="lastLoginTime">上次登录时间:{{ lastLoginTime }}</n-text>
<n-grid :cols="isDesktop ? 3 : 1" :x-gap="16" :y-gap="16">
<n-gi>
<n-statistic
label="新增题目"
:value="loginSummaryStore.summary?.newProblemCount ?? 0"
/>
</n-gi>
<n-gi>
<n-statistic
label="提交次数"
:value="loginSummaryStore.summary?.submissionCount ?? 0"
/>
</n-gi>
<n-gi>
<n-statistic label="AC 次数" :value="loginSummaryStore.summary?.acceptedCount ?? 0" />
</n-gi>
<n-gi>
<n-statistic label="AC 题目数" :value="loginSummaryStore.summary?.solvedCount ?? 0" />
</n-gi>
<n-gi>
<n-statistic
label="流程图提交"
:value="loginSummaryStore.summary?.flowchartSubmissionCount ?? 0"
/>
</n-gi>
</n-grid>
<n-divider>AI 分析</n-divider>
<n-alert v-if="loginSummaryStore.analysisError" type="warning" :show-icon="false">
{{ loginSummaryStore.analysisError }}
</n-alert>
<MdPreview v-if="hasAnalysis" :model-value="loginSummaryStore.analysis" />
<n-empty v-else description="期间提交数少于 3 次,暂不生成 AI 分析" />
</n-flex>
</n-spin>
</n-modal>
</template>
@@ -1,10 +0,0 @@
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
defineProps<{ modelValue: string }>()
</script>
<template>
<MdPreview :model-value="modelValue" />
</template>
-2
View File
@@ -3,7 +3,6 @@ import Beian from "../components/Beian.vue"
import Header from "../components/Header.vue"
import Login from "../components/Login.vue"
import Signup from "../components/Signup.vue"
import LoginSummaryModal from "../components/LoginSummaryModal.vue"
import AchievementToast from "../components/AchievementToast.vue"
import { useAchievementStore } from "shared/store/achievement"
import { useUserStore } from "shared/store/user"
@@ -46,7 +45,6 @@ watch(
</n-layout-content>
<Login />
<Signup />
<LoginSummaryModal />
<AchievementToast />
<Beian />
</n-layout>
-67
View File
@@ -1,67 +0,0 @@
import type { LoginSummary as ContractLoginSummary } from "@oj2/contract"
import { getAILoginSummary } from "shared/api"
type LoginSummary = ContractLoginSummary["summary"]
export const useLoginSummaryStore = defineStore("loginSummary", () => {
const show = ref(false)
const loading = ref(false)
const summary = ref<LoginSummary | null>(null)
const analysis = ref("")
const analysisError = ref("")
function shouldShowSummary(nextSummary: LoginSummary | null) {
if (!nextSummary) {
return false
}
const values = [
nextSummary.newProblemCount,
nextSummary.submissionCount,
nextSummary.acceptedCount,
nextSummary.solvedCount,
nextSummary.flowchartSubmissionCount,
]
const zeroCount = values.filter((value) => value === 0).length
return zeroCount < Math.floor(values.length / 2) + 1
}
async function fetchSummary() {
loading.value = true
analysis.value = ""
analysisError.value = ""
try {
const res = await getAILoginSummary()
summary.value = res.summary
analysis.value = res.analysis || ""
analysisError.value = res.analysisError || ""
} catch {
analysisError.value = "获取登录统计失败,请稍后再试"
} finally {
loading.value = false
}
}
async function open() {
await fetchSummary()
if (!summary.value && analysisError.value) {
show.value = true
return
}
show.value = shouldShowSummary(summary.value)
}
function close() {
show.value = false
}
return {
show,
loading,
summary,
analysis,
analysisError,
fetchSummary,
open,
close,
}
})
+1 -1
View File
@@ -292,7 +292,7 @@ export type { WeeklyRank, WeeklyRankItem } from "@oj2/contract"
export type { ClassComparison, ClassRankItem, ClassUserRank } from "@oj2/contract"
/** 班里最近一次一起做的题(课上老师点的那几道),见后端 `/me/class-activity` */
export type { ClassActivity, ClassActivityProblem } from "@oj2/contract"
export type { ClassActivity, ClassActivityProblem, LastVisit } from "@oj2/contract"
/** 课堂看板:一个班今天这节课的题 × 全班学生 */
export type { ClassBoard, ClassBoardStudent, ClassBoardCell, ClassLesson } from "@oj2/contract"
-15
View File
@@ -263,20 +263,6 @@ export const aiAnalysisRecordSchema = z.object({
username: z.string().optional(),
})
export const loginSummarySchema = z.object({
summary: z.object({
start: z.string(),
end: z.string(),
newProblemCount: z.number().int(),
submissionCount: z.number().int(),
acceptedCount: z.number().int(),
solvedCount: z.number().int(),
flowchartSubmissionCount: z.number().int(),
}),
analysis: z.string(),
analysisError: z.string().optional(),
})
export type Grade = z.infer<typeof gradeSchema>
export type DurationData = z.infer<typeof durationDataSchema>
export type SolvedProblem = z.infer<typeof solvedProblemSchema>
@@ -286,7 +272,6 @@ export type AiDetail = z.infer<typeof aiDetailSchema>
export type SolvedList = z.infer<typeof solvedListSchema>
export type HeatmapItem = z.infer<typeof heatmapItemSchema>
export type AiAnalysisRecord = z.infer<typeof aiAnalysisRecordSchema>
export type LoginSummary = z.infer<typeof loginSummarySchema>
export type AiAnalysisRequest = z.infer<typeof aiAnalysisRequestSchema>
export type AiHintRequest = z.infer<typeof aiHintRequestSchema>
+37
View File
@@ -1,5 +1,7 @@
import { z } from "zod"
import { judgeStatusSchema } from "./judge-status"
export const classRankItemSchema = z.object({
className: z.string(),
userCount: z.number().int(),
@@ -86,6 +88,40 @@ export const classActivitySchema = z.object({
problems: z.array(classActivityProblemSchema),
})
/**
* 首页的「上次来」卡(原来登录后弹的「登录速报」并进了首页,AI 那两句不要了)。
* 窗口是**上次登录到这次登录之间**,不是到现在 —— 这次登录期间卡片内容不变,
* 今天做的题不会混进「上次」里。只算题库提交,比赛的题号在题库里点不开。
*/
export const lastVisitSchema = z.object({
/** 上次登录的时刻。null = 头一回登录(或会话早于这个字段) */
previousLogin: z.string().nullable(),
/** null = 上次来已经是 30 天以前,或那次一条代码都没交 */
summary: z
.object({
submissionCount: z.number().int(),
/** 那次做对的题数(去重) */
solvedCount: z.number().int(),
/** 那次最后一条提交的时刻,前端拿它和「班里那天」比是不是同一天 */
lastSubmitTime: z.string(),
/**
* 那次交过、**到现在都还没做对**的题(这次登录后补做对了的不算),
* 按最后一次提交倒序,最多 3 道
*/
unsolved: z.array(
z.object({
problemDisplayId: z.string(),
title: z.string(),
attempts: z.number().int(),
lastResult: judgeStatusSchema,
}),
),
/** 那次做对的题,按第一次做对的先后,最多 12 道(总数看 solvedCount) */
solved: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
})
.nullable(),
})
/** 课堂看板里老师布置这节课的题:按输入顺序给展示题号,最多 20 道,空数组 = 清掉 */
export const classLessonRequestSchema = z.object({
className: z.string().trim().min(1),
@@ -165,6 +201,7 @@ export type ClassComparison = z.infer<typeof classComparisonSchema>
export type ClassComparisonResponse = z.infer<typeof classComparisonResponseSchema>
export type ClassActivity = z.infer<typeof classActivitySchema>
export type LastVisit = z.infer<typeof lastVisitSchema>
export type ClassLessonRequest = z.infer<typeof classLessonRequestSchema>
export type ClassBoard = z.infer<typeof classBoardSchema>
export type ClassLesson = z.infer<typeof classLessonSchema>