Compare commits
5
Commits
0ee5444ede
...
10d2638c23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10d2638c23 | ||
|
|
c8c38553ec | ||
|
|
46b043999b | ||
|
|
30bff2acc4 | ||
|
|
3987ce0ff6 |
No files matched your search
@@ -3,6 +3,7 @@ import {
|
||||
STUDENT_ROLES,
|
||||
classLessonRequestSchema,
|
||||
type ClassActivity,
|
||||
type ClassToday,
|
||||
type ClassBoard,
|
||||
type ClassBoardStudent,
|
||||
type ClassLesson,
|
||||
@@ -762,6 +763,100 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
||||
} satisfies ClassBoard)
|
||||
})
|
||||
|
||||
/**
|
||||
* 老师首页「今天的课」:今天在上课的每个班一行。在上课 = 同班 ≥5 人今天一起交过题(和「班里在做」
|
||||
* 同一个门槛),或者老师今天在看板上布置过题(刚布置、还没人交也要列出来)。每个班的题和人数
|
||||
* 和看板同一套取法(classLessonProblems),「全做对」看这几道题做没做对过,不限今天
|
||||
*/
|
||||
classroomRoutes.get("/classroom/today", requireTeacher, async (c) => {
|
||||
const start = dayStart()
|
||||
const today = calendarDay()
|
||||
const activity = activityRows({ since: start })
|
||||
const users = sql<number>`count(distinct ${activity.userId})::int`
|
||||
const lastAt = sql<string>`max(${activity.createTime})`
|
||||
const [active, lessons] = await Promise.all([
|
||||
db
|
||||
.select({ className: schema.user.className, users, lastAt })
|
||||
.from(activity)
|
||||
.innerJoin(schema.user, eq(schema.user.id, activity.userId))
|
||||
.where(
|
||||
and(
|
||||
isNotNull(schema.user.className),
|
||||
eq(schema.user.isDisabled, false),
|
||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||
),
|
||||
)
|
||||
.groupBy(schema.user.className),
|
||||
db
|
||||
.select({ className: schema.classLesson.className })
|
||||
.from(schema.classLesson)
|
||||
.where(eq(schema.classLesson.day, today)),
|
||||
])
|
||||
const activeByClass = new Map(
|
||||
active
|
||||
.filter((row) => row.className && row.users >= CLASS_ACTIVITY_MIN_USERS)
|
||||
.map((row) => [row.className!, row]),
|
||||
)
|
||||
const names = [...new Set([...activeByClass.keys(), ...lessons.map((row) => row.className)])]
|
||||
|
||||
const classes = await Promise.all(
|
||||
names.map(async (className) => {
|
||||
const [lesson, [roster]] = await Promise.all([
|
||||
classLessonProblems(className, 1),
|
||||
db
|
||||
.select({ value: sql<number>`count(*)::int` })
|
||||
.from(schema.user)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.user.className, className),
|
||||
eq(schema.user.isDisabled, false),
|
||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||
),
|
||||
),
|
||||
])
|
||||
const ids = lesson.problems.map((problem) => problem.id)
|
||||
let allDoneCount = 0
|
||||
if (ids.length) {
|
||||
// 做没做对不限今天(和看板的格子一样:以前做对的也算做完),语言跟着布置走
|
||||
const done = activityRows({
|
||||
since: new Date(0).toISOString(),
|
||||
users: classMemberIds(className),
|
||||
problemIds: ids,
|
||||
language: lesson.language,
|
||||
})
|
||||
const rows = await db
|
||||
.select({
|
||||
userId: done.userId,
|
||||
solved: sql<number>`count(distinct ${done.problemId})::int`,
|
||||
})
|
||||
.from(done)
|
||||
.where(sql`${done.solved}`)
|
||||
.groupBy(done.userId)
|
||||
allDoneCount = rows.filter((row) => row.solved >= ids.length).length
|
||||
}
|
||||
const today = activeByClass.get(className)
|
||||
return {
|
||||
className,
|
||||
source: lesson.source,
|
||||
assignedAt: lesson.assignedAt,
|
||||
problems: lesson.problems.map((problem) => ({
|
||||
problemDisplayId: problem.displayId,
|
||||
title: problem.title,
|
||||
userCount: problem.userCount,
|
||||
acceptedCount: problem.acceptedCount,
|
||||
})),
|
||||
activeCount: today?.users ?? 0,
|
||||
rosterCount: roster?.value ?? 0,
|
||||
allDoneCount,
|
||||
lastSubmitAt: today?.lastAt ? new Date(today.lastAt).toISOString() : null,
|
||||
}
|
||||
}),
|
||||
)
|
||||
// 正在交的在前,只布置了还没人交的放最后
|
||||
classes.sort((a, b) => (b.lastSubmitAt ?? "").localeCompare(a.lastSubmitAt ?? ""))
|
||||
return success(c, { classes } satisfies ClassToday)
|
||||
})
|
||||
|
||||
/**
|
||||
* 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂看板(老师布置的优先,
|
||||
* 否则按今天同班提交推断)。不传班级就猜最近两小时在交题的班
|
||||
|
||||
@@ -141,11 +141,16 @@ onMounted(load)
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 斑马底纹,和后台其它列表一样 */
|
||||
.group:nth-child(even) {
|
||||
background: v-bind("theme.tableColorStriped");
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,11 @@ watchDebounced(
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* 斑马底纹,和后台其它列表一样 */
|
||||
.item:nth-child(even) {
|
||||
background: v-bind("theme.tableColorStriped");
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ watch(query, listAnnouncements, { deep: true })
|
||||
</n-button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
<n-data-table class="ann-table" :columns="columns" :data="announcements" />
|
||||
<n-data-table striped class="ann-table" :columns="columns" :data="announcements" />
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ watchDebounced(() => query.keyword, listContests, {
|
||||
<n-input v-model:value="query.keyword" placeholder="比赛名" clearable style="width: 220px" />
|
||||
</template>
|
||||
</PageHeader>
|
||||
<n-data-table class="contest-table" :columns="columns" :data="contests" />
|
||||
<n-data-table striped class="contest-table" :columns="columns" :data="contests" />
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</template>
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<n-data-table
|
||||
striped
|
||||
:loading="loading"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
|
||||
@@ -348,6 +348,7 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
||||
<n-button size="small" quaternary @click="selectedRowKeys = []">取消选择</n-button>
|
||||
</div>
|
||||
<n-data-table
|
||||
striped
|
||||
class="problem-table"
|
||||
:columns="columns"
|
||||
:data="problems"
|
||||
|
||||
@@ -240,6 +240,11 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* 斑马底纹,和后台其它列表一样(用户要的) */
|
||||
.row:nth-child(even) {
|
||||
background: v-bind("theme.tableColorStriped");
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 0 0 120px;
|
||||
min-width: 0;
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
|
||||
/**
|
||||
* 后台设置(设计稿「后台首页」页的 ConfAdmin):网站 / 班级 / 开关 / 判题机,有无效测试用例时
|
||||
* 再多一块。保存在右上角。
|
||||
* 再多一块。**改了就存**(用户要的实时保存):开关一拨、班级一加一删立刻存,网站名停手半秒再存,
|
||||
* 右上角写「已保存」。
|
||||
*
|
||||
* 2026-10-13 用户定的:网址、简称、页脚三项删了(前后端从来没读过),AI 小助手连同挂件一起删了;
|
||||
* 注册、「学生能看到所有人的提交」两个开关留着。班级不再拖动排序 —— 全站的班级下拉早就按年级
|
||||
@@ -35,15 +36,37 @@ const websiteConfig = reactive<WebsiteConfig>({
|
||||
classList: [],
|
||||
})
|
||||
|
||||
/**
|
||||
* 上一次存进去(或刚读回来)的内容。和它一样就不存 —— 读回配置本身也是一次改动,
|
||||
* 只看「读完没有」的话,那次改动的延迟保存会在读完之后照样发出去
|
||||
*/
|
||||
let savedSnapshot = ""
|
||||
const saveState = ref<"idle" | "saving" | "saved" | "failed">("idle")
|
||||
|
||||
async function getWebsiteConfig() {
|
||||
const res = await getWebsite()
|
||||
websiteConfig.websiteName = res.websiteName
|
||||
websiteConfig.allowRegister = res.allowRegister
|
||||
websiteConfig.submissionListShowAll = res.submissionListShowAll
|
||||
websiteConfig.classList = res.classList
|
||||
savedSnapshot = JSON.stringify(websiteConfig)
|
||||
}
|
||||
|
||||
// 改了就存。网站名打字时一个字一存没意义,停手半秒再存;开关、班级本来就是一下一下的
|
||||
watchDebounced(
|
||||
() => JSON.stringify(websiteConfig),
|
||||
(now) => {
|
||||
if (savedSnapshot && now !== savedSnapshot) saveWebsiteConfig()
|
||||
},
|
||||
{ debounce: 500 },
|
||||
)
|
||||
|
||||
async function saveWebsiteConfig() {
|
||||
if (!websiteConfig.websiteName.trim()) {
|
||||
saveState.value = "failed"
|
||||
message.error("网站名不能空着")
|
||||
return
|
||||
}
|
||||
// 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。后端也会拦
|
||||
const invalid = websiteConfig.classList.filter((c) => !CLASS_NAME_RE.test(c))
|
||||
if (invalid.length) {
|
||||
@@ -52,14 +75,17 @@ async function saveWebsiteConfig() {
|
||||
)
|
||||
return
|
||||
}
|
||||
saveState.value = "saving"
|
||||
const snapshot = JSON.stringify(websiteConfig)
|
||||
try {
|
||||
await editWebsite(websiteConfig)
|
||||
savedSnapshot = snapshot
|
||||
} catch (err) {
|
||||
saveState.value = "failed"
|
||||
message.error("没保存上:" + errorMessage(err))
|
||||
return
|
||||
}
|
||||
message.success("保存好了")
|
||||
getWebsiteConfig()
|
||||
saveState.value = "saved"
|
||||
// 广播由后端在 POST /admin/website 里做,开着页面的人不用刷新
|
||||
configStore.getConfig()
|
||||
}
|
||||
@@ -163,7 +189,9 @@ onMounted(() => {
|
||||
<template>
|
||||
<PageHeader title="设置">
|
||||
<template #actions>
|
||||
<n-button type="primary" @click="saveWebsiteConfig">保存</n-button>
|
||||
<n-text v-if="saveState === 'saving'" depth="3">正在保存…</n-text>
|
||||
<n-text v-else-if="saveState === 'saved'" type="success">已保存</n-text>
|
||||
<n-text v-else-if="saveState === 'failed'" type="error">没保存上</n-text>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
|
||||
@@ -345,6 +345,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
|
||||
</template>
|
||||
</PageHeader>
|
||||
<n-data-table
|
||||
striped
|
||||
class="user-table"
|
||||
:data="users"
|
||||
:columns="columns"
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type TutorialSummary,
|
||||
type Tutorial,
|
||||
type ClassActivity,
|
||||
type ClassToday,
|
||||
type LastVisit,
|
||||
type ClassBoard,
|
||||
type ClassLesson,
|
||||
@@ -305,6 +306,11 @@ export function getClassActivity() {
|
||||
return api.get<ClassActivity>("me/class-activity")
|
||||
}
|
||||
|
||||
/** 老师首页「今天的课」:今天在上课的每个班 */
|
||||
export function getClassToday() {
|
||||
return api.get<ClassToday>("classroom/today")
|
||||
}
|
||||
|
||||
/** 首页「上次来」卡:上次登录到这次登录之间交过什么、哪道还没做对 */
|
||||
export function getLastVisit() {
|
||||
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>
|
||||
+154
-190
@@ -15,7 +15,6 @@ import {
|
||||
} from "oj/api"
|
||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||
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, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||
@@ -56,7 +55,6 @@ 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(
|
||||
@@ -151,9 +149,10 @@ async function loadSubmissions() {
|
||||
submissions.value = res.results
|
||||
}
|
||||
|
||||
/** 首页只放置顶的公告(用户定的),其余从顶栏「公告」进 */
|
||||
async function loadAnnouncements() {
|
||||
const res = await getAnnouncementList(0, 4)
|
||||
announcements.value = res.results
|
||||
const res = await getAnnouncementList(0, 10)
|
||||
announcements.value = res.results.filter((item) => item.top)
|
||||
}
|
||||
|
||||
async function loadKnowledge() {
|
||||
@@ -216,6 +215,24 @@ const classSummaryText = computed(() => {
|
||||
: `那天班里一起做了 ${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(() =>
|
||||
(visit.value?.solved ?? []).map((item) => `${item.problemDisplayId} ${item.title}`).join("、"),
|
||||
)
|
||||
@@ -227,9 +244,9 @@ const classActivityTitle = computed(() => {
|
||||
// 老师在课堂看板布置的,说成「老师布置的」,比「班里在做」更让人知道该做这几道
|
||||
if (classActivity.value?.source === "teacher") {
|
||||
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)
|
||||
return `${month}月${date}日班里做了`
|
||||
})
|
||||
@@ -238,9 +255,9 @@ const MY_STATUS: Record<
|
||||
ClassActivityProblem["myStatus"],
|
||||
{ label: string; type: "success" | "warning" | "default" }
|
||||
> = {
|
||||
accepted: { label: "已通过", type: "success" },
|
||||
tried: { label: "未通过", type: "warning" },
|
||||
none: { label: "没做", type: "default" },
|
||||
accepted: { label: "已做对", type: "success" },
|
||||
tried: { label: "没做对", type: "warning" },
|
||||
none: { label: "还没做", type: "default" },
|
||||
}
|
||||
|
||||
async function loadWeekly() {
|
||||
@@ -272,8 +289,6 @@ function learnLink(track: Track) {
|
||||
return lessonPath(track.type, track.step)
|
||||
}
|
||||
|
||||
const { jump, jumping } = useProblemJump()
|
||||
|
||||
function openSubmission(row: SubmissionListItem) {
|
||||
if (row.showLink) router.push(`/submission/${row.id}`)
|
||||
else router.push(`/problem/${row.problemDisplayId}`)
|
||||
@@ -313,45 +328,87 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
||||
|
||||
<template>
|
||||
<div class="home">
|
||||
<!-- 中间原来还有一个「题号或题目名 找题」框,和顶栏右上角那个重复,拿掉了 -->
|
||||
<section class="hero">
|
||||
<div>
|
||||
<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">
|
||||
·
|
||||
<router-link to="/rank" class="plain-link">{{ weeklyText }}</router-link>
|
||||
</template>
|
||||
</n-text>
|
||||
<div v-if="knowledgeHeadline" class="knowledge-line">
|
||||
<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 }} 题
|
||||
<template v-if="weeklyText">
|
||||
·
|
||||
<router-link to="/rank" class="plain-link">{{ weeklyText }}</router-link>
|
||||
</template>
|
||||
<template v-if="knowledgeHeadline">
|
||||
·
|
||||
<router-link v-if="knowledgeHeadline.to" :to="knowledgeHeadline.to" class="plain-link">
|
||||
{{ knowledgeHeadline.text }}
|
||||
</router-link>
|
||||
<span v-else>{{ knowledgeHeadline.text }}</span>
|
||||
<router-link to="/user" class="more">我的知识点</router-link>
|
||||
</div>
|
||||
</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>
|
||||
</template>
|
||||
</n-text>
|
||||
</section>
|
||||
|
||||
<div class="grid">
|
||||
<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">
|
||||
<template #header>上次来 · {{ visitText(lastVisit!.previousLogin!) }}</template>
|
||||
<template #header>
|
||||
{{ visit.unsolved.length ? "上次没做完" : "上次来" }} ·
|
||||
{{ visitText(lastVisit!.previousLogin!) }}
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-text depth="3" class="row-meta">
|
||||
做对 {{ visit.solvedCount }} 道 · 交了 {{ visit.submissionCount }} 次
|
||||
@@ -363,7 +420,7 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
||||
:to="`/problem/${item.problemDisplayId}`"
|
||||
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">
|
||||
<span class="row-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>
|
||||
</router-link>
|
||||
<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>
|
||||
<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 && !visitCoversClass"
|
||||
v-if="!classToday && classActivity?.problems.length && !visitCoversClass"
|
||||
:title="classActivityTitle"
|
||||
:bordered="false"
|
||||
class="card"
|
||||
@@ -406,59 +460,28 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
||||
{{ 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 }} 人通过
|
||||
{{ 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="继续学习" :bordered="false" class="card">
|
||||
<n-flex vertical :size="12">
|
||||
<router-link
|
||||
v-for="track in tracks"
|
||||
:key="track.type"
|
||||
:to="learnLink(track)"
|
||||
class="track"
|
||||
>
|
||||
<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 ? "继续" : "开始" }}
|
||||
</n-button>
|
||||
</router-link>
|
||||
<n-empty v-if="loaded && !tracks.length" description="还没有公开的教程" />
|
||||
</n-flex>
|
||||
<router-link v-for="track in tracks" :key="track.type" :to="learnLink(track)" class="row">
|
||||
<n-text depth="3" class="track-label">{{ track.label }}</n-text>
|
||||
<span class="row-title">
|
||||
第 {{ track.step }} / {{ track.total }} 课 · {{ track.title }}
|
||||
</span>
|
||||
<n-button :type="track.lastViewedAt ? 'primary' : 'default'" tag="span" size="small">
|
||||
{{ track.lastViewedAt ? "继续" : "开始" }}
|
||||
</n-button>
|
||||
</router-link>
|
||||
<n-empty v-if="loaded && !tracks.length" description="还没有公开的教程" />
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<n-card title="最近提交" :bordered="false" class="card">
|
||||
<template #header-extra>
|
||||
<router-link to="/submission?myself=1" class="more">全部</router-link>
|
||||
@@ -479,10 +502,9 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
||||
</template>
|
||||
</n-empty>
|
||||
</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>
|
||||
<router-link to="/contest" class="more">全部</router-link>
|
||||
</template>
|
||||
@@ -500,47 +522,22 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
||||
</n-flex>
|
||||
<n-text depth="3" class="row-meta">{{ contestTime(contest) }}</n-text>
|
||||
</router-link>
|
||||
<n-text v-if="loaded && !contests.length" depth="3">
|
||||
现在没有进行中或即将开始的比赛
|
||||
</n-text>
|
||||
</n-card>
|
||||
|
||||
<n-card title="公告" :bordered="false" class="card">
|
||||
<template #header-extra>
|
||||
<router-link to="/announcement" class="more">全部</router-link>
|
||||
</template>
|
||||
<div
|
||||
v-for="item in announcements"
|
||||
:key="item.id"
|
||||
class="row clickable"
|
||||
@click="openAnnouncement(item)"
|
||||
>
|
||||
<n-tag v-if="item.top" type="error" size="small" :bordered="false">置顶</n-tag>
|
||||
<span class="row-title">{{ item.title }}</span>
|
||||
<n-text depth="3" class="row-meta">{{ parseTime(item.createTime, "M月D日") }}</n-text>
|
||||
</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
|
||||
v-for="item in announcements"
|
||||
: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>
|
||||
<span class="row-title">{{ item.title }}</span>
|
||||
<router-link to="/announcement" class="more" @click.stop>公告 →</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -564,37 +561,32 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin: 16px 0 28px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 16px 0 24px;
|
||||
}
|
||||
|
||||
.hello {
|
||||
font-size: 28px;
|
||||
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;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
color: #18a058;
|
||||
}
|
||||
|
||||
/* 这一行本身就是链接(去按知识点筛的题目列表),下划线说明它能点 */
|
||||
.knowledge-line .plain-link {
|
||||
color: #18a058;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 340px;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.grid {
|
||||
@@ -629,42 +621,14 @@ async function openAnnouncement(item: AnnouncementListItem) {
|
||||
}
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
.track-label {
|
||||
flex: none;
|
||||
width: 64px;
|
||||
font-size: var(--oj-fs-meta);
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
.row-meta {
|
||||
font-size: var(--oj-fs-meta);
|
||||
}
|
||||
|
||||
.row {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useUserStore } from "shared/store/user"
|
||||
|
||||
// 两边都按需加载:没登录的访客只拿题目列表那一份,登录了的学生只拿首页那一份
|
||||
const Dashboard = defineAsyncComponent(() => import("./dashboard.vue"))
|
||||
// 老师的首页单独一张(设计稿「首页重设计」HomeTeacher):今天哪些班在上课
|
||||
const TeacherHome = defineAsyncComponent(() => import("./TeacherHome.vue"))
|
||||
const ProblemList = defineAsyncComponent(() => import("oj/problem/list.vue"))
|
||||
|
||||
const userStore = useUserStore()
|
||||
@@ -17,6 +19,10 @@ const showDashboard = computed(() =>
|
||||
</script>
|
||||
|
||||
<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 />
|
||||
</template>
|
||||
@@ -79,10 +79,9 @@ function renderIcon(icon: string) {
|
||||
* 顶栏导航的唯一一张表:桌面端渲染成一排纯文字,窄屏收进「菜单」下拉。
|
||||
* `key` 就是一级路径,用来判定高亮(见上面的 active)。
|
||||
*
|
||||
* 顺序是 自学 / 题目 / 提交 / 题单 / 比赛 / 排名。「自学」进自学首页(/learn),
|
||||
* 顺序是 自学 / 题目 / 提交 / 题单 / 比赛 / 排名 / 公告。「自学」进自学首页(/learn),
|
||||
* 两门课、接着学哪一课都在那儿挑,不再是下拉。提交留在一级:学生天天回来看自己交的结果,老师课上翻全班代码也走它。
|
||||
* 公告不上顶栏 —— 一年五六条、基本是版本更新,首页有公告卡片和「全部」入口;
|
||||
* 只有手机菜单里留一项(见 mobileMenus)。
|
||||
* 公告 2026-10-13 回到顶栏(用户要的):首页只放置顶那条了,别的公告得有个地方点得到。
|
||||
* 不用 n-menu:它横排每项固定占 100px,1280 宽的机房屏上会把顶栏挤成两行。
|
||||
*/
|
||||
interface NavLink {
|
||||
@@ -100,18 +99,16 @@ const navLinks = computed<NavLink[]>(() =>
|
||||
{ key: "problemset", label: "题单", to: "/problemset" },
|
||||
{ key: "contest", label: "比赛", to: "/contest" },
|
||||
{ key: "rank", label: "排名", to: "/rank" },
|
||||
{ key: "announcement", label: "公告", to: "/announcement" },
|
||||
].filter((link) => link.show !== false),
|
||||
)
|
||||
|
||||
const announcementLink: NavLink = { key: "announcement", label: "公告", to: "/announcement" }
|
||||
|
||||
const mobileMenus = computed<DropdownOption[]>(() => [
|
||||
...navLinks.value.map((link) => ({ label: link.label, key: link.key })),
|
||||
{ label: announcementLink.label, key: announcementLink.key },
|
||||
])
|
||||
const mobileMenus = computed<DropdownOption[]>(() =>
|
||||
navLinks.value.map((link) => ({ label: link.label, key: link.key })),
|
||||
)
|
||||
|
||||
function handleNavSelect(key: string) {
|
||||
const link = [...navLinks.value, announcementLink].find((item) => item.key === key)
|
||||
const link = navLinks.value.find((item) => item.key === key)
|
||||
if (link) router.push(link.to)
|
||||
}
|
||||
|
||||
|
||||
@@ -185,7 +185,38 @@ export const classLessonSchema = z.object({
|
||||
problems: z.array(z.object({ problemDisplayId: z.string(), title: z.string() })),
|
||||
})
|
||||
|
||||
/**
|
||||
* 老师首页「今天的课」:今天在上课的每个班一行(同班 ≥5 人一起交题,或者老师在看板上布置过)。
|
||||
* 题目的人数是今天的(和看板同一套取法),「全做对」看的是这几道题有没有做对过,不限今天
|
||||
*/
|
||||
export const classTodaySchema = z.object({
|
||||
classes: z.array(
|
||||
z.object({
|
||||
className: z.string(),
|
||||
source: z.enum(["teacher", "inferred"]).nullable(),
|
||||
/** 老师布置的时刻;推断出来的为 null */
|
||||
assignedAt: z.string().nullable(),
|
||||
problems: z.array(
|
||||
z.object({
|
||||
problemDisplayId: z.string(),
|
||||
title: z.string(),
|
||||
userCount: z.number().int(),
|
||||
acceptedCount: z.number().int(),
|
||||
}),
|
||||
),
|
||||
/** 今天交过题的人数 / 全班人数 */
|
||||
activeCount: z.number().int(),
|
||||
rosterCount: z.number().int(),
|
||||
/** 这节课的题全做对了的人数 */
|
||||
allDoneCount: z.number().int(),
|
||||
/** 今天最后一次有人交题;还没人交(只布置了)为 null */
|
||||
lastSubmitAt: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export type ClassActivity = z.infer<typeof classActivitySchema>
|
||||
export type ClassToday = z.infer<typeof classTodaySchema>
|
||||
export type LastVisit = z.infer<typeof lastVisitSchema>
|
||||
export type ClassLessonRequest = z.infer<typeof classLessonRequestSchema>
|
||||
export type ClassBoard = z.infer<typeof classBoardSchema>
|
||||
|
||||
Reference in new issue
Block a user