Deploy / deploy (push) Canceled after 0s
- 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>
317 lines
8.0 KiB
Vue
317 lines
8.0 KiB
Vue
<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>
|