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

+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;