feat(首页): 学生上课时首页最上面就是这节课和「接着做」,课后先看上次没做完的;老师有了自己的首页,看今天哪些班在上课、点进课堂看板
Deploy / deploy (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
去掉和顶栏重复的找题框、常年空着的比赛卡、底部快捷入口,公告只留置顶那条。 修了当天上午上课、下午再登录时写「上次(今天的日期)还有几道没过」。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c8c38553ec
commit
10d2638c23
4 files changed
+461
-172
No files matched your search
@@ -13,6 +13,7 @@ import {
|
|||||||
type TutorialSummary,
|
type TutorialSummary,
|
||||||
type Tutorial,
|
type Tutorial,
|
||||||
type ClassActivity,
|
type ClassActivity,
|
||||||
|
type ClassToday,
|
||||||
type LastVisit,
|
type LastVisit,
|
||||||
type ClassBoard,
|
type ClassBoard,
|
||||||
type ClassLesson,
|
type ClassLesson,
|
||||||
@@ -305,6 +306,11 @@ export function getClassActivity() {
|
|||||||
return api.get<ClassActivity>("me/class-activity")
|
return api.get<ClassActivity>("me/class-activity")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 老师首页「今天的课」:今天在上课的每个班 */
|
||||||
|
export function getClassToday() {
|
||||||
|
return api.get<ClassToday>("classroom/today")
|
||||||
|
}
|
||||||
|
|
||||||
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
|
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
|
||||||
export function getLastVisit() {
|
export function getLastVisit() {
|
||||||
return api.get<LastVisit>("me/last-visit")
|
return api.get<LastVisit>("me/last-visit")
|
||||||
|
|||||||
@@ -0,0 +1,313 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ClassToday } from "@oj2/contract"
|
||||||
|
import type { AnnouncementListItem } from "utils/types"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { getAnnouncement, getAnnouncementList, getClassToday } from "oj/api"
|
||||||
|
import { classLabel } from "oj/submission/utils"
|
||||||
|
import { useUserStore } from "shared/store/user"
|
||||||
|
import { parseTime, zonedParts } from "utils/functions"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交),
|
||||||
|
* 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、
|
||||||
|
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂看板」进那个班。
|
||||||
|
* 下面是常用入口和置顶的公告
|
||||||
|
*/
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const today = ref<ClassToday | null>(null)
|
||||||
|
const pinned = ref<AnnouncementListItem[]>([])
|
||||||
|
|
||||||
|
/** 最后一次有人交题在这么久以内,算「正在上课」 */
|
||||||
|
const LIVE_MS = 15 * 60 * 1000
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const [classes, announcements] = await Promise.allSettled([
|
||||||
|
getClassToday(),
|
||||||
|
getAnnouncementList(0, 10),
|
||||||
|
])
|
||||||
|
if (classes.status === "fulfilled") today.value = classes.value
|
||||||
|
if (announcements.status === "fulfilled") {
|
||||||
|
pinned.value = announcements.value.results.filter((item) => item.top)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const greeting = computed(() => {
|
||||||
|
const hour = zonedParts(new Date())!.hour
|
||||||
|
if (hour < 5) return "夜深了"
|
||||||
|
if (hour < 11) return "上午好"
|
||||||
|
if (hour < 13) return "中午好"
|
||||||
|
if (hour < 18) return "下午好"
|
||||||
|
return "晚上好"
|
||||||
|
})
|
||||||
|
|
||||||
|
type ClassRow = ClassToday["classes"][number]
|
||||||
|
|
||||||
|
function stateOf(row: ClassRow) {
|
||||||
|
if (!row.lastSubmitAt) return { live: false, text: "布置了,还没人交" }
|
||||||
|
if (Date.now() - Date.parse(row.lastSubmitAt) < LIVE_MS) return { live: true, text: "正在上课" }
|
||||||
|
return { live: false, text: `${parseTime(row.lastSubmitAt, "HH:mm")} 下课了` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaOf(row: ClassRow) {
|
||||||
|
const parts = [
|
||||||
|
row.source === "teacher" && row.assignedAt
|
||||||
|
? `${parseTime(row.assignedAt, "HH:mm")} 布置了 ${row.problems.length} 道`
|
||||||
|
: `做了 ${row.problems.length} 道`,
|
||||||
|
]
|
||||||
|
if (row.activeCount) parts.push(`${row.activeCount}/${row.rosterCount} 人交过`)
|
||||||
|
if (row.problems.length) parts.push(`全做对 ${row.allDoneCount} 人`)
|
||||||
|
return parts.join(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBoard(row: ClassRow) {
|
||||||
|
router.push({ name: "classroom board", query: { className: row.className } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcuts = computed(() =>
|
||||||
|
[
|
||||||
|
{ label: "课堂看板", to: "/classroom", show: true },
|
||||||
|
{ label: "数据统计", to: "/statistics", show: true },
|
||||||
|
{ label: "新建题目", to: "/admin/problem/create", show: userStore.hasProblemPermission },
|
||||||
|
{ label: "后台", to: userStore.isSuperAdmin ? "/admin" : "/admin/problem/list", show: true },
|
||||||
|
].filter((it) => it.show),
|
||||||
|
)
|
||||||
|
|
||||||
|
const announcementShow = ref(false)
|
||||||
|
const announcementTitle = ref("")
|
||||||
|
const announcementContent = ref("")
|
||||||
|
async function openAnnouncement(item: AnnouncementListItem) {
|
||||||
|
const res = await getAnnouncement(item.id)
|
||||||
|
announcementTitle.value = item.title
|
||||||
|
announcementContent.value = res.content
|
||||||
|
announcementShow.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="home">
|
||||||
|
<section class="hero">
|
||||||
|
<h1 class="hello">{{ greeting }},{{ userStore.user?.username }}</h1>
|
||||||
|
<n-text v-if="today" depth="3" class="tagline">
|
||||||
|
{{
|
||||||
|
today.classes.length ? `今天 ${today.classes.length} 个班上过课` : "今天还没有班在上课"
|
||||||
|
}}
|
||||||
|
</n-text>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="today?.classes.length" class="card">
|
||||||
|
<h3>今天的课</h3>
|
||||||
|
<div v-for="row in today.classes" :key="row.className" class="class-row">
|
||||||
|
<div class="class-head">
|
||||||
|
<b class="class-name">{{ classLabel(row.className) }}</b>
|
||||||
|
<span class="state" :class="{ live: stateOf(row).live }">{{ stateOf(row).text }}</span>
|
||||||
|
<span class="muted">{{ metaOf(row) }}</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<n-button :type="stateOf(row).live ? 'primary' : 'default'" @click="openBoard(row)">
|
||||||
|
课堂看板
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.problems.length" class="minis">
|
||||||
|
<router-link
|
||||||
|
v-for="problem in row.problems"
|
||||||
|
:key="problem.problemDisplayId"
|
||||||
|
:to="`/problem/${problem.problemDisplayId}`"
|
||||||
|
class="mini"
|
||||||
|
:title="`${problem.problemDisplayId} ${problem.title}:${problem.userCount} 人交过,${problem.acceptedCount} 人做对`"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<b class="num">{{ problem.problemDisplayId }}</b>
|
||||||
|
<span class="muted num mini-count">
|
||||||
|
{{ problem.acceptedCount }}/{{ row.rosterCount }}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<i>
|
||||||
|
<b
|
||||||
|
:style="{
|
||||||
|
width: `${row.rosterCount ? (problem.acceptedCount / row.rosterCount) * 100 : 0}%`,
|
||||||
|
}"
|
||||||
|
></b>
|
||||||
|
</i>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="shortcuts">
|
||||||
|
<n-button v-for="it in shortcuts" :key="it.to" size="large" @click="router.push(it.to)">
|
||||||
|
{{ it.label }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="item in pinned"
|
||||||
|
:key="item.id"
|
||||||
|
class="card pinned"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="openAnnouncement(item)"
|
||||||
|
@keyup.enter="openAnnouncement(item)"
|
||||||
|
>
|
||||||
|
<n-tag type="warning" size="small" :bordered="false">置顶</n-tag>
|
||||||
|
<b>{{ item.title }}</b>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<router-link to="/announcement" class="muted" @click.stop>公告 →</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="announcementShow"
|
||||||
|
preset="card"
|
||||||
|
:style="{ maxWidth: '70vw', maxHeight: '80vh' }"
|
||||||
|
:content-style="{ overflow: 'auto' }"
|
||||||
|
:title="announcementTitle"
|
||||||
|
>
|
||||||
|
<div v-html="announcementContent"></div>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home {
|
||||||
|
max-width: var(--oj-page-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
margin: 16px 0 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hello {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: var(--oj-radius);
|
||||||
|
border: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-row:first-of-type {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.class-name {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.state.live {
|
||||||
|
color: v-bind("theme.successColor");
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
font-size: 14px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minis {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 90px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini i {
|
||||||
|
display: block;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini i b {
|
||||||
|
display: block;
|
||||||
|
height: 5px;
|
||||||
|
background: v-bind("theme.successColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-count {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned:hover {
|
||||||
|
border-color: v-bind("theme.primaryColor");
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+135
-171
@@ -15,7 +15,6 @@ import {
|
|||||||
} from "oj/api"
|
} from "oj/api"
|
||||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useProblemJump } from "shared/composables/problemJump"
|
|
||||||
import { pickHeadline, type Headline } from "oj/user/knowledge"
|
import { pickHeadline, type Headline } from "oj/user/knowledge"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
|
import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||||
@@ -56,7 +55,6 @@ const lastVisit = ref<LastVisit | null>(null)
|
|||||||
/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */
|
/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */
|
||||||
const knowledgeHeadline = ref<Headline | null>(null)
|
const knowledgeHeadline = ref<Headline | null>(null)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
const keyword = ref("")
|
|
||||||
|
|
||||||
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
|
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
|
||||||
const firstVisit = computed(
|
const firstVisit = computed(
|
||||||
@@ -151,9 +149,10 @@ async function loadSubmissions() {
|
|||||||
submissions.value = res.results
|
submissions.value = res.results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 首页只放置顶的公告(用户定的),其余从顶栏「公告」进 */
|
||||||
async function loadAnnouncements() {
|
async function loadAnnouncements() {
|
||||||
const res = await getAnnouncementList(0, 4)
|
const res = await getAnnouncementList(0, 10)
|
||||||
announcements.value = res.results
|
announcements.value = res.results.filter((item) => item.top)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadKnowledge() {
|
async function loadKnowledge() {
|
||||||
@@ -216,6 +215,24 @@ const classSummaryText = computed(() => {
|
|||||||
: `那天班里一起做了 ${problems.length} 道,你做对了 ${done} 道`
|
: `那天班里一起做了 ${problems.length} 道,你做对了 ${done} 道`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「上次还有 N 道没过」那一句:上次来就是今天(上午上课、下午又登录)时不说 ——
|
||||||
|
* 原来照样写「上次(10月13日)」,和今天是同一天
|
||||||
|
*/
|
||||||
|
const visitBeforeToday = computed(
|
||||||
|
() =>
|
||||||
|
!!lastVisit.value?.previousLogin &&
|
||||||
|
dayKey(lastVisit.value.previousLogin) !== dayKey(new Date()),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 这节课下一道没做对的:卡底「接着做」那个大按钮 */
|
||||||
|
const nextProblem = computed(
|
||||||
|
() => classActivity.value?.problems.find((item) => item.myStatus !== "accepted") ?? null,
|
||||||
|
)
|
||||||
|
const doneInClass = computed(
|
||||||
|
() => classActivity.value?.problems.filter((item) => item.myStatus === "accepted").length ?? 0,
|
||||||
|
)
|
||||||
|
|
||||||
const solvedText = computed(() =>
|
const solvedText = computed(() =>
|
||||||
(visit.value?.solved ?? []).map((item) => `${item.problemDisplayId} ${item.title}`).join("、"),
|
(visit.value?.solved ?? []).map((item) => `${item.problemDisplayId} ${item.title}`).join("、"),
|
||||||
)
|
)
|
||||||
@@ -227,9 +244,9 @@ const classActivityTitle = computed(() => {
|
|||||||
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
|
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
|
||||||
if (classActivity.value?.source === "teacher") {
|
if (classActivity.value?.source === "teacher") {
|
||||||
const language = classActivity.value.language
|
const language = classActivity.value.language
|
||||||
return `今天老师布置的题${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ""}`
|
return `这节课${language ? ` · 用${LANGUAGE_SHOW_VALUE[language]}做` : ""}`
|
||||||
}
|
}
|
||||||
if (classToday.value) return "今天班里在做"
|
if (classToday.value) return "这节课"
|
||||||
const [, month, date] = day.split("-").map(Number)
|
const [, month, date] = day.split("-").map(Number)
|
||||||
return `${month}月${date}日班里做了`
|
return `${month}月${date}日班里做了`
|
||||||
})
|
})
|
||||||
@@ -238,9 +255,9 @@ const MY_STATUS: Record<
|
|||||||
ClassActivityProblem["myStatus"],
|
ClassActivityProblem["myStatus"],
|
||||||
{ label: string; type: "success" | "warning" | "default" }
|
{ label: string; type: "success" | "warning" | "default" }
|
||||||
> = {
|
> = {
|
||||||
accepted: { label: "已通过", type: "success" },
|
accepted: { label: "已做对", type: "success" },
|
||||||
tried: { label: "未通过", type: "warning" },
|
tried: { label: "没做对", type: "warning" },
|
||||||
none: { label: "没做", type: "default" },
|
none: { label: "还没做", type: "default" },
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWeekly() {
|
async function loadWeekly() {
|
||||||
@@ -272,8 +289,6 @@ function learnLink(track: Track) {
|
|||||||
return lessonPath(track.type, track.step)
|
return lessonPath(track.type, track.step)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { jump, jumping } = useProblemJump()
|
|
||||||
|
|
||||||
function openSubmission(row: SubmissionListItem) {
|
function openSubmission(row: SubmissionListItem) {
|
||||||
if (row.showLink) router.push(`/submission/${row.id}`)
|
if (row.showLink) router.push(`/submission/${row.id}`)
|
||||||
else router.push(`/problem/${row.problemDisplayId}`)
|
else router.push(`/problem/${row.problemDisplayId}`)
|
||||||
@@ -313,45 +328,87 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<div class="home">
|
||||||
|
<!-- 中间原来还有一个「题号或题目名 找题」框,和顶栏右上角那个重复,拿掉了 -->
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div>
|
|
||||||
<h1 v-if="firstVisit" class="hello">欢迎来到判题狗,{{ userStore.user?.username }}</h1>
|
<h1 v-if="firstVisit" class="hello">欢迎来到判题狗,{{ userStore.user?.username }}</h1>
|
||||||
<h1 v-else class="hello">{{ greeting }},{{ 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-if="firstVisit" depth="3">老师报了题号,在右上角的框里输入就能找到题</n-text>
|
||||||
<n-text v-else depth="3">
|
<n-text v-else depth="3">
|
||||||
<template v-if="longAway">{{ awayText }} · </template>
|
<template v-if="longAway">{{ awayText }} · </template>
|
||||||
已解决 {{ userStore.profile?.acceptedNumber ?? 0 }} 题 · 共提交
|
已解决 {{ userStore.profile?.acceptedNumber ?? 0 }} 题
|
||||||
{{ userStore.profile?.submissionNumber ?? 0 }} 次
|
|
||||||
<template v-if="weeklyText">
|
<template v-if="weeklyText">
|
||||||
·
|
·
|
||||||
<router-link to="/rank" class="plain-link">{{ weeklyText }}</router-link>
|
<router-link to="/rank" class="plain-link">{{ weeklyText }}</router-link>
|
||||||
</template>
|
</template>
|
||||||
</n-text>
|
<template v-if="knowledgeHeadline">
|
||||||
<div v-if="knowledgeHeadline" class="knowledge-line">
|
·
|
||||||
<router-link v-if="knowledgeHeadline.to" :to="knowledgeHeadline.to" class="plain-link">
|
<router-link v-if="knowledgeHeadline.to" :to="knowledgeHeadline.to" class="plain-link">
|
||||||
{{ knowledgeHeadline.text }}
|
{{ knowledgeHeadline.text }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<span v-else>{{ knowledgeHeadline.text }}</span>
|
<span v-else>{{ knowledgeHeadline.text }}</span>
|
||||||
<router-link to="/user" class="more">我的知识点</router-link>
|
</template>
|
||||||
</div>
|
</n-text>
|
||||||
</div>
|
|
||||||
<n-input-group class="search">
|
|
||||||
<n-input
|
|
||||||
v-model:value="keyword"
|
|
||||||
placeholder="题号或题目名"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="jump(keyword)"
|
|
||||||
/>
|
|
||||||
<n-button type="primary" :loading="jumping" @click="jump(keyword)">找题</n-button>
|
|
||||||
</n-input-group>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<!-- 「上次来」:上次登录到这次登录之间交过题才有。班里今天在做的时候让位给班里卡,
|
<!-- 上课时最上面是这节课(设计稿 HomeStuClass):几道题做没做对,底下一个「接着做」 -->
|
||||||
只在那张卡底下留一句没做对的 -->
|
<n-card
|
||||||
|
v-if="classToday && classActivity?.problems.length"
|
||||||
|
:bordered="false"
|
||||||
|
class="card lesson"
|
||||||
|
>
|
||||||
|
<template #header>{{ classActivityTitle }}</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-text depth="3" class="row-meta">
|
||||||
|
{{ classActivity.problems.length }} 道做对了 {{ doneInClass }} 道
|
||||||
|
</n-text>
|
||||||
|
</template>
|
||||||
|
<router-link
|
||||||
|
v-for="item in classActivity.problems"
|
||||||
|
:key="item.problemDisplayId"
|
||||||
|
:to="`/problem/${item.problemDisplayId}`"
|
||||||
|
class="row"
|
||||||
|
>
|
||||||
|
<n-tag :type="MY_STATUS[item.myStatus].type" size="small" :bordered="false">
|
||||||
|
{{ MY_STATUS[item.myStatus].label }}
|
||||||
|
</n-tag>
|
||||||
|
<span class="row-title">{{ item.problemDisplayId }} {{ item.title }}</span>
|
||||||
|
<!-- 老师刚布置、还没人交的时候不显示「0/0」 -->
|
||||||
|
<n-text v-if="item.userCount" depth="3" class="row-meta">
|
||||||
|
{{ item.acceptedCount }}/{{ item.userCount }} 人做对
|
||||||
|
</n-text>
|
||||||
|
</router-link>
|
||||||
|
<div class="lesson-foot">
|
||||||
|
<n-button
|
||||||
|
v-if="nextProblem"
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
@click="router.push(`/problem/${nextProblem.problemDisplayId}`)"
|
||||||
|
>
|
||||||
|
接着做 {{ nextProblem.problemDisplayId }} {{ nextProblem.title }} →
|
||||||
|
</n-button>
|
||||||
|
<n-text v-else type="success">这节课的题都做对了</n-text>
|
||||||
|
</div>
|
||||||
|
<div v-if="visitBeforeToday && 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>
|
||||||
|
|
||||||
|
<!-- 不在上课:上次没做完的排最前(设计稿 HomeStuAfter) -->
|
||||||
<n-card v-if="visit && !classToday" :bordered="false" class="card">
|
<n-card v-if="visit && !classToday" :bordered="false" class="card">
|
||||||
<template #header>上次来 · {{ visitText(lastVisit!.previousLogin!) }}</template>
|
<template #header>
|
||||||
|
{{ visit.unsolved.length ? "上次没做完" : "上次来" }} ·
|
||||||
|
{{ visitText(lastVisit!.previousLogin!) }}
|
||||||
|
</template>
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<n-text depth="3" class="row-meta">
|
<n-text depth="3" class="row-meta">
|
||||||
做对 {{ visit.solvedCount }} 道 · 交了 {{ visit.submissionCount }} 次
|
做对 {{ visit.solvedCount }} 道 · 交了 {{ visit.submissionCount }} 次
|
||||||
@@ -363,7 +420,7 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
:to="`/problem/${item.problemDisplayId}`"
|
:to="`/problem/${item.problemDisplayId}`"
|
||||||
class="unsolved"
|
class="unsolved"
|
||||||
>
|
>
|
||||||
<n-tag type="warning" size="small" :bordered="false">未通过</n-tag>
|
<n-tag type="warning" size="small" :bordered="false">没做对</n-tag>
|
||||||
<div class="unsolved-main">
|
<div class="unsolved-main">
|
||||||
<span class="row-title">
|
<span class="row-title">
|
||||||
<n-text depth="3">{{ item.problemDisplayId }}</n-text> {{ item.title }}
|
<n-text depth="3">{{ item.problemDisplayId }}</n-text> {{ item.title }}
|
||||||
@@ -375,20 +432,17 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
<n-button type="primary" tag="span">接着做</n-button>
|
<n-button type="primary" tag="span">接着做</n-button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<div v-if="visit.solvedCount" class="row">
|
<div v-if="visit.solvedCount" class="row">
|
||||||
<n-tag type="success" size="small" :bordered="false">已通过</n-tag>
|
<n-tag type="success" size="small" :bordered="false">已做对</n-tag>
|
||||||
<span class="row-title">{{ visit.solvedCount }} 道:{{ solvedText }}</span>
|
<span class="row-title">{{ visit.solvedCount }} 道:{{ solvedText }}</span>
|
||||||
<router-link to="/submission?myself=1" class="more">
|
|
||||||
看这 {{ visit.solvedCount }} 道 ›
|
|
||||||
</router-link>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="visitCoversClass" class="visit-foot">
|
<div v-if="visitCoversClass" class="visit-foot">
|
||||||
<Icon icon="ph:check-bold" :width="14" />{{ classSummaryText }}
|
<Icon icon="ph:check-bold" :width="14" />{{ classSummaryText }}
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<!-- 只在有的时候出现:没班级、最近一周班里没一起做过题的,这块不占位置 -->
|
<!-- 前几天班里一起做的(不是今天、也不是上次来那天):只在有的时候出现 -->
|
||||||
<n-card
|
<n-card
|
||||||
v-if="classActivity?.problems.length && !visitCoversClass"
|
v-if="!classToday && classActivity?.problems.length && !visitCoversClass"
|
||||||
:title="classActivityTitle"
|
:title="classActivityTitle"
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
class="card"
|
class="card"
|
||||||
@@ -406,59 +460,28 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
{{ MY_STATUS[item.myStatus].label }}
|
{{ MY_STATUS[item.myStatus].label }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
<span class="row-title">{{ item.problemDisplayId }} {{ item.title }}</span>
|
<span class="row-title">{{ item.problemDisplayId }} {{ item.title }}</span>
|
||||||
<!-- 老师刚布置、还没人交的时候不显示「0/0 人通过」 -->
|
|
||||||
<n-text v-if="item.userCount" depth="3" class="row-meta">
|
<n-text v-if="item.userCount" depth="3" class="row-meta">
|
||||||
{{ item.acceptedCount }}/{{ item.userCount }} 人通过
|
{{ item.acceptedCount }}/{{ item.userCount }} 人做对
|
||||||
</n-text>
|
</n-text>
|
||||||
</router-link>
|
</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>
|
||||||
|
|
||||||
|
<!-- 继续学习:一门一行 -->
|
||||||
<n-card title="继续学习" :bordered="false" class="card">
|
<n-card title="继续学习" :bordered="false" class="card">
|
||||||
<n-flex vertical :size="12">
|
<router-link v-for="track in tracks" :key="track.type" :to="learnLink(track)" class="row">
|
||||||
<router-link
|
<n-text depth="3" class="track-label">{{ track.label }}</n-text>
|
||||||
v-for="track in tracks"
|
<span class="row-title">
|
||||||
:key="track.type"
|
第 {{ track.step }} / {{ track.total }} 课 · {{ track.title }}
|
||||||
:to="learnLink(track)"
|
</span>
|
||||||
class="track"
|
<n-button :type="track.lastViewedAt ? 'primary' : 'default'" tag="span" size="small">
|
||||||
>
|
|
||||||
<div class="track-main">
|
|
||||||
<n-text depth="3" class="track-label">
|
|
||||||
{{ track.label }} · 第 {{ track.step }} / {{ track.total }} 课
|
|
||||||
</n-text>
|
|
||||||
<div class="track-title">{{ track.title }}</div>
|
|
||||||
<n-progress
|
|
||||||
type="line"
|
|
||||||
:percentage="Math.round((track.done / track.total) * 100)"
|
|
||||||
status="success"
|
|
||||||
:show-indicator="false"
|
|
||||||
:height="6"
|
|
||||||
/>
|
|
||||||
<n-text depth="3" class="track-meta">
|
|
||||||
学完 {{ track.done }} 课
|
|
||||||
<template v-if="track.lastViewedAt">
|
|
||||||
· 上次 {{ relativeTime(track.lastViewedAt) }}
|
|
||||||
</template>
|
|
||||||
</n-text>
|
|
||||||
</div>
|
|
||||||
<n-button :type="track.lastViewedAt ? 'primary' : 'default'" tag="span">
|
|
||||||
{{ track.lastViewedAt ? "继续" : "开始" }}
|
{{ track.lastViewedAt ? "继续" : "开始" }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</router-link>
|
</router-link>
|
||||||
<n-empty v-if="loaded && !tracks.length" description="还没有公开的教程" />
|
<n-empty v-if="loaded && !tracks.length" description="还没有公开的教程" />
|
||||||
</n-flex>
|
|
||||||
</n-card>
|
</n-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column">
|
||||||
<n-card title="最近提交" :bordered="false" class="card">
|
<n-card title="最近提交" :bordered="false" class="card">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<router-link to="/submission?myself=1" class="more">全部</router-link>
|
<router-link to="/submission?myself=1" class="more">全部</router-link>
|
||||||
@@ -479,10 +502,9 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
</template>
|
</template>
|
||||||
</n-empty>
|
</n-empty>
|
||||||
</n-card>
|
</n-card>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column">
|
<!-- 比赛只在有进行中 / 快开始的时候出现(这学期一场都没办,原来常年一块空卡) -->
|
||||||
<n-card title="比赛" :bordered="false" class="card">
|
<n-card v-if="contests.length" title="比赛" :bordered="false" class="card">
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
<router-link to="/contest" class="more">全部</router-link>
|
<router-link to="/contest" class="more">全部</router-link>
|
||||||
</template>
|
</template>
|
||||||
@@ -500,47 +522,22 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
<n-text depth="3" class="row-meta">{{ contestTime(contest) }}</n-text>
|
<n-text depth="3" class="row-meta">{{ contestTime(contest) }}</n-text>
|
||||||
</router-link>
|
</router-link>
|
||||||
<n-text v-if="loaded && !contests.length" depth="3">
|
|
||||||
现在没有进行中或即将开始的比赛
|
|
||||||
</n-text>
|
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<n-card title="公告" :bordered="false" class="card">
|
<!-- 公告只放置顶的;别的从顶栏「公告」进 -->
|
||||||
<template #header-extra>
|
|
||||||
<router-link to="/announcement" class="more">全部</router-link>
|
|
||||||
</template>
|
|
||||||
<div
|
<div
|
||||||
v-for="item in announcements"
|
v-for="item in announcements"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="row clickable"
|
class="card pinned"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
@click="openAnnouncement(item)"
|
@click="openAnnouncement(item)"
|
||||||
|
@keyup.enter="openAnnouncement(item)"
|
||||||
>
|
>
|
||||||
<n-tag v-if="item.top" type="error" size="small" :bordered="false">置顶</n-tag>
|
<n-tag type="warning" size="small" :bordered="false">置顶</n-tag>
|
||||||
<span class="row-title">{{ item.title }}</span>
|
<span class="row-title">{{ item.title }}</span>
|
||||||
<n-text depth="3" class="row-meta">{{ parseTime(item.createTime, "M月D日") }}</n-text>
|
<router-link to="/announcement" class="more" @click.stop>公告 →</router-link>
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card :bordered="false" class="card">
|
|
||||||
<n-flex :size="8" class="shortcuts">
|
|
||||||
<n-button secondary @click="router.push('/problem')">
|
|
||||||
<template #icon><Icon icon="fluent-emoji:memo" /></template>
|
|
||||||
题库
|
|
||||||
</n-button>
|
|
||||||
<n-button secondary @click="router.push('/problemset')">
|
|
||||||
<template #icon><Icon icon="fluent-emoji:clipboard" /></template>
|
|
||||||
题单
|
|
||||||
</n-button>
|
|
||||||
<n-button secondary @click="router.push('/achievement')">
|
|
||||||
<template #icon><Icon icon="fluent-emoji:sports-medal" /></template>
|
|
||||||
成就
|
|
||||||
</n-button>
|
|
||||||
<n-button secondary @click="router.push('/ai-analysis')">
|
|
||||||
<template #icon><Icon icon="fluent-emoji:crystal-ball" /></template>
|
|
||||||
智能分析
|
|
||||||
</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -564,37 +561,32 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
gap: 6px;
|
||||||
gap: 16px;
|
margin: 16px 0 24px;
|
||||||
flex-wrap: wrap;
|
|
||||||
margin: 16px 0 28px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hello {
|
.hello {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 8px;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.knowledge-line {
|
/* 这节课:卡片描一圈主色,底下一个大按钮 */
|
||||||
margin-top: 6px;
|
.lesson {
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(24, 160, 88, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-foot {
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
align-items: center;
|
||||||
align-items: baseline;
|
gap: 10px;
|
||||||
color: #18a058;
|
padding: 14px 18px;
|
||||||
}
|
cursor: pointer;
|
||||||
|
|
||||||
/* 这一行本身就是链接(去按知识点筛的题目列表),下划线说明它能点 */
|
|
||||||
.knowledge-line .plain-link {
|
|
||||||
color: #18a058;
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search {
|
|
||||||
width: 340px;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
@@ -629,42 +621,14 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.track {
|
.track-label {
|
||||||
display: flex;
|
flex: none;
|
||||||
align-items: center;
|
width: 64px;
|
||||||
gap: 20px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
background-color: rgba(128, 128, 128, 0.06);
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track:hover {
|
|
||||||
background-color: rgba(24, 160, 88, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-label,
|
|
||||||
.track-meta,
|
|
||||||
.row-meta {
|
|
||||||
font-size: var(--oj-fs-meta);
|
font-size: var(--oj-fs-meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-title {
|
.row-meta {
|
||||||
font-size: 17px;
|
font-size: var(--oj-fs-meta);
|
||||||
font-weight: 500;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useUserStore } from "shared/store/user"
|
|||||||
|
|
||||||
// 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份
|
// 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份
|
||||||
const Dashboard = defineAsyncComponent(() => import("./dashboard.vue"))
|
const Dashboard = defineAsyncComponent(() => import("./dashboard.vue"))
|
||||||
|
// 老师的首页单独一张(设计稿「首页重设计」HomeTeacher):今天哪些班在上课
|
||||||
|
const TeacherHome = defineAsyncComponent(() => import("./TeacherHome.vue"))
|
||||||
const ProblemList = defineAsyncComponent(() => import("oj/problem/list.vue"))
|
const ProblemList = defineAsyncComponent(() => import("oj/problem/list.vue"))
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -17,6 +19,10 @@ const showDashboard = computed(() =>
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dashboard v-if="showDashboard" />
|
<template v-if="showDashboard">
|
||||||
|
<!-- 资料回来之前分不出是不是老师,先空着,免得老师先闪一下学生的首页 -->
|
||||||
|
<TeacherHome v-if="userStore.isFinished && userStore.isTeacherOrAbove" />
|
||||||
|
<Dashboard v-else-if="userStore.isFinished" />
|
||||||
|
</template>
|
||||||
<ProblemList v-else />
|
<ProblemList v-else />
|
||||||
</template>
|
</template>
|
||||||
Reference in new issue
Block a user