Files
OJ2/apps/web/src/oj/home/TeacherHome.vue
T
xuyueandClaude Opus 5.5 309ca0ece2
Deploy / deploy (push) Canceled after 0s
refactor(前端): 全站共用的班级下拉、胶囊配色挪出提交列表目录,编辑器不再偷读题目页的 store
- classLabel / groupClassOptions / classSelectOptions 挪到 utils/classes.ts,useTone 挪到
  shared/composables/tone.ts:原来放在 oj/submission/ 下,被前台、后台、shared 引了 50 多处
- 流程图编辑器的草稿键改由题目页传进来(键名不变,学生存着的草稿照样读得到)。原来编辑器
  自己读题目页的 store 和路由拼,后台出题页也用这个组件,那边 store 是空的
- SQL 表名补全改由题目页传表进来。原来补全直接读题目页的 store,自学页的 SQL 代码块会
  补出上一道看过的题的表名

实跑:提交列表、题目页、排名、后台题目页、SQL 题、流程图题打开无报错;SQL 题输入 stu
补出 students;流程图加一步,草稿照旧存在 flowchart-v2-problem-<题号>。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:59:55 +08:00

317 lines
8.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { ClassToday } from "@oj2/contract"
import type { AnnouncementListItem } from "utils/types"
import { useThemeVars } from "naive-ui"
import { getAnnouncementList, getClassToday } from "oj/api"
import { classLabel } from "utils/classes"
import { useUserStore } from "shared/store/user"
import { parseTime, zonedParts, zonedYear } from "utils/functions"
/**
* 老师的首页(设计稿「首页重设计」HomeTeacher)。原来老师看到的和学生一样(继续学习、最近提交),
* 对老师没用。现在最上面是「今天的课」:今天在上课的每个班一行 —— 正在上课 / 几点下课了、
* 布置了几道、几人交过、全做对几人,每道题一根小条,点「课堂看板」进那个班。
* 下面是常用入口和最新三条公告(和学生首页一样,置顶的排最前)
*/
const theme = useThemeVars()
const router = useRouter()
const userStore = useUserStore()
const today = ref<ClassToday | null>(null)
const announcements = ref<AnnouncementListItem[]>([])
/** 最后一次有人交题在这么久以内,算「正在上课」 */
const LIVE_MS = 15 * 60 * 1000
onMounted(async () => {
const [classes, latest] = await Promise.allSettled([getClassToday(), getAnnouncementList(0, 3)])
if (classes.status === "fulfilled") today.value = classes.value
if (latest.status === "fulfilled") announcements.value = latest.value.results
})
function dateText(value: string) {
return parseTime(value, zonedParts(value)!.year === zonedYear() ? "M月D日" : "YYYY年M月D日")
}
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),
)
</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>
<section v-if="announcements.length" class="card">
<div class="card-head">
<h3>公告</h3>
<span class="spacer"></span>
<router-link to="/announcement" class="muted">全部</router-link>
</div>
<router-link
v-for="item in announcements"
:key="item.id"
:to="{ path: '/announcement', query: { id: item.id } }"
class="ann-row"
>
<n-tag v-if="item.top" type="warning" size="small" :bordered="false">置顶</n-tag>
<span class="ann-title">{{ item.title }}</span>
<span class="muted num">{{ dateText(item.createTime) }}</span>
</router-link>
</section>
</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;
}
.card-head {
display: flex;
align-items: baseline;
}
.ann-row {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
padding: 0 6px;
border-top: 1px solid v-bind("theme.dividerColor");
border-radius: 4px;
color: inherit;
text-decoration: none;
}
.ann-row:first-of-type {
border-top: 0;
}
.ann-row:hover {
background: v-bind("theme.hoverColor");
}
.ann-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>