- 新接口 /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:
1 parent
2b4676ae34
commit
91d9c9c6d7
15 files changed
+389
-289
No files matched your search
@@ -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
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user