Compare commits
2
Commits
cbe6955c26
...
2031e6a434
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2031e6a434 | ||
|
|
e62f41f6c7 |
@@ -181,23 +181,27 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
|||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: LEADERBOARD_SIZE })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: LEADERBOARD_SIZE })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
|
|
||||||
const [totalRow] = await db.select({ value: count() }).from(schema.userProfile)
|
// 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset,**不取决于总人数** ——
|
||||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
// 真人不够时数据库自己会少返回。不拿 total 当上限,三段查询就能并发发出去,
|
||||||
.where(leaderboardWhere)
|
// 端点延迟从「四个来回相加」变成「最慢的那个」。越界页一条不剩,直接不发 SQL。
|
||||||
const total = Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE)
|
const pageLimit = Math.max(0, Math.min(limit, LEADERBOARD_SIZE - offset))
|
||||||
|
|
||||||
// 末页可能只剩不足 limit 条,越界页一条不剩 —— 后者直接不发 SQL
|
const [totalRow, rows, me] = await Promise.all([
|
||||||
const pageLimit = Math.max(0, Math.min(limit, total - offset))
|
db.select({ value: count() }).from(schema.userProfile)
|
||||||
const rows = pageLimit === 0 ? [] : await db
|
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||||
|
.where(leaderboardWhere).then(([row]) => row),
|
||||||
|
pageLimit === 0 ? [] : db
|
||||||
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
||||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||||
.where(leaderboardWhere).orderBy(...leaderboardOrder)
|
.where(leaderboardWhere).orderBy(...leaderboardOrder)
|
||||||
.limit(pageLimit).offset(offset)
|
.limit(pageLimit).offset(offset),
|
||||||
|
myLeaderboardRank(c.get("user")?.id),
|
||||||
|
])
|
||||||
|
|
||||||
return success(c, userRankSchema.parse({
|
return success(c, userRankSchema.parse({
|
||||||
results: rows.map(serializeRankRow),
|
results: rows.map(serializeRankRow),
|
||||||
total,
|
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
|
||||||
me: await myLeaderboardRank(c.get("user")?.id),
|
me,
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
classRankItemSchema,
|
classRankItemSchema,
|
||||||
classUserRankSchema,
|
classUserRankSchema,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, eq, gte, inArray, lte, sql } from "drizzle-orm"
|
import { and, eq, gte, inArray, like, lte, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||||
@@ -24,13 +24,19 @@ interface ClassUser {
|
|||||||
submissionNumber: number
|
submissionNumber: number
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadClassUsers(classNames?: string[]) {
|
/**
|
||||||
|
* 入班学生的 AC/提交数。`gradePrefix` 是年级(班号形如 `241` = 24 级 1 班),
|
||||||
|
* 走 SQL 的 like 而不是拉全表再在内存里 startsWith —— 班级榜每换一次年级就要跑一遍,
|
||||||
|
* 没必要每次都把全校一千多号人搬进进程。年级在调用处已校验为纯数字,不含 like 通配符。
|
||||||
|
*/
|
||||||
|
async function loadClassUsers(classNames?: string[], gradePrefix?: string) {
|
||||||
const filters = [
|
const filters = [
|
||||||
eq(schema.user.isDisabled, false),
|
eq(schema.user.isDisabled, false),
|
||||||
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
|
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
|
||||||
sql`${schema.user.className} is not null`,
|
sql`${schema.user.className} is not null`,
|
||||||
]
|
]
|
||||||
if (classNames) filters.push(inArray(schema.user.className, classNames))
|
if (classNames) filters.push(inArray(schema.user.className, classNames))
|
||||||
|
if (gradePrefix) filters.push(like(schema.user.className, `${gradePrefix}%`))
|
||||||
const rows = await db.select({
|
const rows = await db.select({
|
||||||
userId: schema.user.id,
|
userId: schema.user.id,
|
||||||
username: schema.user.username,
|
username: schema.user.username,
|
||||||
@@ -72,7 +78,7 @@ function sampleStdDev(values: number[]) {
|
|||||||
classroomRoutes.get("/rankings/classes", async (c) => {
|
classroomRoutes.get("/rankings/classes", async (c) => {
|
||||||
const grade = c.req.query("grade")?.trim()
|
const grade = c.req.query("grade")?.trim()
|
||||||
if (!grade || !/^\d+$/.test(grade)) return failure(c, 400, "invalid-grade", "grade is required")
|
if (!grade || !/^\d+$/.test(grade)) return failure(c, 400, "invalid-grade", "grade is required")
|
||||||
const users = (await loadClassUsers()).filter((user) => user.className.startsWith(grade))
|
const users = await loadClassUsers(undefined, grade)
|
||||||
const groups = new Map<string, ClassUser[]>()
|
const groups = new Map<string, ClassUser[]>()
|
||||||
for (const user of users) groups.set(user.className, [...(groups.get(user.className) ?? []), user])
|
for (const user of users) groups.set(user.className, [...(groups.get(user.className) ?? []), user])
|
||||||
const result = [...groups].map(([className, members]) => {
|
const result = [...groups].map(([className, members]) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useConfigUpdate } from "shared/composables/configUpdate"
|
|||||||
import { useMaxKB } from "shared/composables/maxkb"
|
import { useMaxKB } from "shared/composables/maxkb"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { useCollabStore } from "shared/store/collab"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
|
import CollabHost from "shared/components/CollabHost.vue"
|
||||||
|
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
@@ -95,6 +96,9 @@ provide("hljs", hljsInstance)
|
|||||||
<n-dialog-provider>
|
<n-dialog-provider>
|
||||||
<n-message-provider>
|
<n-message-provider>
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
|
<!-- 求助提示 / 列表 / 协作弹框。和上面那条常驻连接同级,
|
||||||
|
这样切到 /admin(另一套布局、没有顶栏)也照常收得到 -->
|
||||||
|
<CollabHost />
|
||||||
</n-message-provider>
|
</n-message-provider>
|
||||||
</n-dialog-provider>
|
</n-dialog-provider>
|
||||||
</n-config-provider>
|
</n-config-provider>
|
||||||
|
|||||||
@@ -242,11 +242,13 @@ const columns: DataTableColumn<Rank>[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
watch(() => query.page, init)
|
watch(() => query.page, init)
|
||||||
|
// 改每页条数时,若当前不在第一页,把重新取数交给 page 的 watcher ——
|
||||||
|
// 这里再自己取一次,就是两个一模一样的请求
|
||||||
watch(
|
watch(
|
||||||
() => query.limit,
|
() => query.limit,
|
||||||
() => {
|
() => {
|
||||||
query.page = 1
|
if (query.page === 1) init()
|
||||||
init()
|
else query.page = 1
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
watch(duration, listActivity)
|
watch(duration, listActivity)
|
||||||
@@ -265,12 +267,6 @@ async function listActivity() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 「全服 Top10」就是同一个榜的第一页 —— 上限由服务端定,这里只要前 10 条
|
|
||||||
async function listRank() {
|
|
||||||
const res = await getRank(0, 10)
|
|
||||||
rankChart.value = res.results
|
|
||||||
}
|
|
||||||
|
|
||||||
const options: SelectOption[] = [
|
const options: SelectOption[] = [
|
||||||
{ label: "一周内", value: "weeks:1" },
|
{ label: "一周内", value: "weeks:1" },
|
||||||
{ label: "一个月内", value: "months:1" },
|
{ label: "一个月内", value: "months:1" },
|
||||||
@@ -288,8 +284,10 @@ const subOptions = computed<Duration>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
init()
|
// 「全服 Top10」就是榜单第一页的前 10 条:挂载时 init() 取的正是 offset=0&limit=10,
|
||||||
listRank()
|
// 再单发一次一模一样的 /rankings/users 只会让这张图排在日活后面出来。
|
||||||
|
// 图只在挂载时定一次,翻页/改每页条数不该动它。
|
||||||
|
init().then((results) => (rankChart.value = results.slice(0, 10)))
|
||||||
listActivity()
|
listActivity()
|
||||||
listClassRank()
|
listClassRank()
|
||||||
listMyClassRank()
|
listMyClassRank()
|
||||||
@@ -480,13 +478,12 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 同上:page 改了自会触发下面那个 watcher,别重复取
|
||||||
watch(
|
watch(
|
||||||
() => myClassQuery.limit,
|
() => myClassQuery.limit,
|
||||||
() => {
|
() => {
|
||||||
myClassQuery.page = 1
|
if (myClassQuery.page !== 1) myClassQuery.page = 1
|
||||||
if (myClassScope.value === "all") {
|
else if (myClassScope.value === "all") listMyClassRank()
|
||||||
listMyClassRank()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CollabRequestItem } from "shared/composables/websocket"
|
||||||
|
import { useCollabStore } from "shared/store/collab"
|
||||||
|
import CollabModal from "./CollabModal.vue"
|
||||||
|
import HelpRequestList from "./HelpRequestList.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 课堂求助的全局界面:一次性提示、新求助 toast、求助列表、教师端协作弹框。
|
||||||
|
*
|
||||||
|
* 挂在 App.vue 而不是顶栏或 default.vue 布局里。这些东西跟着**连接**走,
|
||||||
|
* 而连接是全局常驻的(App.vue 按登录态开关)—— 挂在顶栏里的时候,老师一进
|
||||||
|
* /admin 就换成了 admin.vue 布局,顶栏连同这几个消费者一起卸载:求助照收,
|
||||||
|
* 提示、角标、协作弹框全都不出现,正好错过 collab.ts 里写的那句「老师可能
|
||||||
|
* 正在后台改题时收到求助」。放在这里才真的全局。
|
||||||
|
*
|
||||||
|
* 位置要求:n-message-provider 的后代(useMessage 需要)。
|
||||||
|
*/
|
||||||
|
const collabStore = useCollabStore()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次性提示统一在这里消费。
|
||||||
|
*
|
||||||
|
* 学生排着队切去看提交记录,老师这时候取消了他的求助,那条「老师已取消你的
|
||||||
|
* 求助」挂在题目页上就永远没人消费 —— 教师端的 error 提示(比如「请先退出
|
||||||
|
* 当前协作」)同理。
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => collabStore.noticeSeq,
|
||||||
|
() => {
|
||||||
|
const text = collabStore.consumeNotice()
|
||||||
|
if (text) message.info(text)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新求助进来只有角标默默 +1,上课走动的时候根本注意不到,补一条 toast。
|
||||||
|
*
|
||||||
|
* 只在数字**变大**时弹:老师自己接单、拒绝、别的老师接走都会让它变小,那些
|
||||||
|
* 不该打扰人。断线重连后服务端会重推一份全量列表,队里还有人的话这里会再弹
|
||||||
|
* 一次 —— 那正好是「你刚断过线,这些人还等着」,留着。
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
() => collabStore.pendingCount,
|
||||||
|
(count, previous) => {
|
||||||
|
if (count <= previous) return
|
||||||
|
let latest: CollabRequestItem | null = null
|
||||||
|
for (const item of collabStore.requests) {
|
||||||
|
if (item.status !== "pending") continue
|
||||||
|
if (!latest || item.createdAt > latest.createdAt) latest = item
|
||||||
|
}
|
||||||
|
const text = latest
|
||||||
|
? `${latest.studentName} 求助:${latest.problemTitle}`
|
||||||
|
: "有新的求助"
|
||||||
|
// 内容传 render 函数(naive 的 content 支持),这样整条 toast 可点:
|
||||||
|
// 点一下直接开求助列表,省得再去点名字、再点菜单。
|
||||||
|
const notice = message.info(
|
||||||
|
() =>
|
||||||
|
h(
|
||||||
|
"span",
|
||||||
|
{
|
||||||
|
style: { cursor: "pointer" },
|
||||||
|
onClick: () => {
|
||||||
|
collabStore.helpPanelOpen = true
|
||||||
|
notice.destroy()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
`${text} · 点击处理`,
|
||||||
|
),
|
||||||
|
{ duration: 5000 },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<HelpRequestList
|
||||||
|
v-if="collabStore.isTeacher"
|
||||||
|
v-model:show="collabStore.helpPanelOpen"
|
||||||
|
/>
|
||||||
|
<CollabModal v-if="collabStore.isTeacher" />
|
||||||
|
</template>
|
||||||
@@ -2,37 +2,17 @@
|
|||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { RouterLink } from "vue-router"
|
import { RouterLink } from "vue-router"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
import { useDarkTransition } from "shared/composables/darkTransition"
|
||||||
import { useLearnProgress } from "shared/composables/learnProgress"
|
import { useLearnProgress } from "shared/composables/learnProgress"
|
||||||
import { useAuthModalStore } from "shared/store/authModal"
|
import { useAuthModalStore } from "shared/store/authModal"
|
||||||
import { useCollabStore } from "shared/store/collab"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
import { useScreenModeStore } from "shared/store/screenMode"
|
import { useScreenModeStore } from "shared/store/screenMode"
|
||||||
import type { CollabRequestItem } from "shared/composables/websocket"
|
|
||||||
import { logout } from "../api"
|
|
||||||
import CollabModal from "./CollabModal.vue"
|
|
||||||
import HelpRequestList from "./HelpRequestList.vue"
|
|
||||||
import { useConfigStore } from "../store/config"
|
import { useConfigStore } from "../store/config"
|
||||||
import { useUserStore } from "../store/user"
|
import { useUserStore } from "../store/user"
|
||||||
import { trickOrTreat } from "utils/functions"
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
const collabStore = useCollabStore()
|
const collabStore = useCollabStore()
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 课堂求助的一次性提示,统一在这里弹。
|
|
||||||
*
|
|
||||||
* 原来挂在题目页的 Form.vue 上:学生排着队切去看提交记录,老师这时候取消了
|
|
||||||
* 他的求助,那条「老师已取消你的求助」就永远没人消费。顶栏是全局的,放这儿
|
|
||||||
* 才收得全 —— 教师端的 error 提示(比如「请先退出当前协作」)同理。
|
|
||||||
*/
|
|
||||||
watch(
|
|
||||||
() => collabStore.noticeSeq,
|
|
||||||
() => {
|
|
||||||
const text = collabStore.consumeNotice()
|
|
||||||
if (text) message.info(text)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const authStore = useAuthModalStore()
|
const authStore = useAuthModalStore()
|
||||||
const screenModeStore = useScreenModeStore()
|
const screenModeStore = useScreenModeStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -40,106 +20,17 @@ const router = useRouter()
|
|||||||
|
|
||||||
const { isMobile, isDesktop } = useBreakpoints()
|
const { isMobile, isDesktop } = useBreakpoints()
|
||||||
const { learnStep } = useLearnProgress()
|
const { learnStep } = useLearnProgress()
|
||||||
|
const { isDark, toggleDark } = useDarkTransition()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 课堂求助的入口收进姓名下拉里了,顶栏只留姓名按钮上的角标 —— 老师不用展开
|
* 求助的入口收进姓名下拉里,顶栏只留姓名按钮上的角标 —— 老师不用展开菜单
|
||||||
* 菜单也能看见有没有人举手。桌面端限定,教师端接单后要在弹框里替学生写代码。
|
* 也能看见有没有人举手。窄屏同样给:接单之后要在弹框里替学生写代码,那件事
|
||||||
|
* 确实只有桌面端好使,但「有没有人在等」是宽度多少都得知道的。
|
||||||
*/
|
*/
|
||||||
const showHelpRequests = ref(false)
|
|
||||||
const hasHelpEntry = computed(
|
|
||||||
() => isDesktop.value && userStore.isTeacherOrAbove,
|
|
||||||
)
|
|
||||||
const pendingHelpCount = computed(() =>
|
const pendingHelpCount = computed(() =>
|
||||||
hasHelpEntry.value ? collabStore.pendingCount : 0,
|
collabStore.isTeacher ? collabStore.pendingCount : 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* 新求助进来只有角标默默 +1,上课走动的时候根本注意不到,补一条 toast。
|
|
||||||
*
|
|
||||||
* 只在数字**变大**时弹:老师自己接单、拒绝、别的老师接走都会让它变小,那些
|
|
||||||
* 不该打扰人。断线重连后服务端会重推一份全量列表,队里还有人的话这里会再弹
|
|
||||||
* 一次 —— 那正好是「你刚断过线,这些人还等着」,留着。
|
|
||||||
*/
|
|
||||||
watch(
|
|
||||||
() => pendingHelpCount.value,
|
|
||||||
(count, previous) => {
|
|
||||||
if (count <= previous) return
|
|
||||||
let latest: CollabRequestItem | null = null
|
|
||||||
for (const item of collabStore.requests) {
|
|
||||||
if (item.status !== "pending") continue
|
|
||||||
if (!latest || item.createdAt > latest.createdAt) latest = item
|
|
||||||
}
|
|
||||||
const text = latest
|
|
||||||
? `${latest.studentName} 求助:${latest.problemTitle}`
|
|
||||||
: "有新的求助"
|
|
||||||
// 内容传 render 函数(naive 的 content 支持),这样整条 toast 可点:
|
|
||||||
// 点一下直接开求助列表,省得再去点名字、再点菜单。
|
|
||||||
const notice = message.info(
|
|
||||||
() =>
|
|
||||||
h(
|
|
||||||
"span",
|
|
||||||
{
|
|
||||||
style: { cursor: "pointer" },
|
|
||||||
onClick: () => {
|
|
||||||
showHelpRequests.value = true
|
|
||||||
notice.destroy()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
`${text} · 点击处理`,
|
|
||||||
),
|
|
||||||
{ duration: 5000 },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const isDark = useDark()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 圆环从哪儿开始扩散。正常点击就用指针落点;键盘触发(Enter / 空格)时浏览器给的
|
|
||||||
* clientX/clientY 是 0,照用会让圆环从屏幕左上角冒出来——那种情况退回按钮自己的中心。
|
|
||||||
* `event.detail` 是点击次数,键盘触发时为 0,用它区分最省事。
|
|
||||||
*/
|
|
||||||
function revealOrigin(event: MouseEvent) {
|
|
||||||
if (event.detail > 0) return { x: event.clientX, y: event.clientY }
|
|
||||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
|
||||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDark(event: MouseEvent) {
|
|
||||||
if (!document.startViewTransition) {
|
|
||||||
// 机房那批 Chrome 低于 94,没有 View Transitions,直接切、不做动画。
|
|
||||||
isDark.value = !isDark.value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const { x, y } = revealOrigin(event)
|
|
||||||
// 半径要取到**最远**那个角的距离。用 hypot(x, y) 只覆盖到左上角,
|
|
||||||
// 点在偏左上时右下角会有一块旧画面等圆环扩过去,看着像是没刷新。
|
|
||||||
const radius = Math.hypot(
|
|
||||||
Math.max(x, window.innerWidth - x),
|
|
||||||
Math.max(y, window.innerHeight - y),
|
|
||||||
)
|
|
||||||
document
|
|
||||||
.startViewTransition(() => {
|
|
||||||
isDark.value = !isDark.value
|
|
||||||
})
|
|
||||||
.ready.then(() => {
|
|
||||||
document.documentElement.animate(
|
|
||||||
{
|
|
||||||
clipPath: [
|
|
||||||
`circle(0px at ${x}px ${y}px)`,
|
|
||||||
`circle(${radius}px at ${x}px ${y}px)`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
duration: 400,
|
|
||||||
easing: "ease-in-out",
|
|
||||||
pseudoElement: "::view-transition-new(root)",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从 store 中获取屏幕模式状态
|
// 从 store 中获取屏幕模式状态
|
||||||
const { screenMode } = storeToRefs(screenModeStore)
|
const { screenMode } = storeToRefs(screenModeStore)
|
||||||
|
|
||||||
@@ -193,14 +84,12 @@ const titleTags = computed(() =>
|
|||||||
[envVersion.value, userStore.demoMode ? "演示中" : ""].filter(Boolean),
|
[envVersion.value, userStore.demoMode ? "演示中" : ""].filter(Boolean),
|
||||||
)
|
)
|
||||||
|
|
||||||
const active = computed(() => {
|
// 一级路径就是菜单 key,对不上的页面(/user、/setting、/achievement 等)
|
||||||
const path = route.path.split("/")[1] || "problem"
|
// 自然没有一项亮着
|
||||||
return !["user", "setting"].includes(path) ? path : ""
|
const active = computed(() => route.path.split("/")[1] || "problem")
|
||||||
})
|
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await logout()
|
await userStore.signOut()
|
||||||
userStore.clearProfile()
|
|
||||||
router.replace("/")
|
router.replace("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,12 +160,6 @@ const menus = computed<MenuOption[]>(() => [
|
|||||||
key: "rank",
|
key: "rank",
|
||||||
icon: renderIcon("fluent-emoji:trophy"),
|
icon: renderIcon("fluent-emoji:trophy"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: () => h(RouterLink, { to: "/class/pk" }, { default: () => "班级" }),
|
|
||||||
show: false,
|
|
||||||
key: "class",
|
|
||||||
icon: renderIcon("fluent-emoji:crossed-swords"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: () =>
|
label: () =>
|
||||||
h(RouterLink, { to: "/announcement" }, { default: () => "公告" }),
|
h(RouterLink, { to: "/announcement" }, { default: () => "公告" }),
|
||||||
@@ -302,10 +185,10 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
|
|||||||
? `课堂求助(${pendingHelpCount.value})`
|
? `课堂求助(${pendingHelpCount.value})`
|
||||||
: "课堂求助",
|
: "课堂求助",
|
||||||
key: "help",
|
key: "help",
|
||||||
show: hasHelpEntry.value,
|
show: collabStore.isTeacher,
|
||||||
icon: renderIcon("streamline-emojis:raising-hands-2"),
|
icon: renderIcon("streamline-emojis:raising-hands-2"),
|
||||||
props: {
|
props: {
|
||||||
onClick: () => (showHelpRequests.value = true),
|
onClick: () => (collabStore.helpPanelOpen = true),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -316,15 +199,6 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
|
|||||||
onClick: () => router.push("/user"),
|
onClick: () => router.push("/user"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "我的消息",
|
|
||||||
key: "message",
|
|
||||||
show: false,
|
|
||||||
icon: renderIcon("streamline-emojis:herb"),
|
|
||||||
props: {
|
|
||||||
onClick: () => router.push("/message"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "我的提交",
|
label: "我的提交",
|
||||||
key: "status",
|
key: "status",
|
||||||
@@ -368,39 +242,30 @@ const options = computed<Array<DropdownOption | DropdownDividerOption>>(() => [
|
|||||||
function goHome() {
|
function goHome() {
|
||||||
router.push("/")
|
router.push("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMenuSelect(key: string) {
|
|
||||||
if (key === "dont-click") {
|
|
||||||
trickOrTreat()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex justify="space-between" align="center">
|
<n-flex justify="space-between" align="center">
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<n-flex align="center" class="title" @click="goHome">
|
<!-- text 按钮而不是带 @click 的 div:站名要能 tab 到、回车能按 -->
|
||||||
|
<n-button text class="title" @click="goHome">
|
||||||
|
<n-flex align="center">
|
||||||
<Icon icon="streamline-emojis:dog" :height="30"></Icon>
|
<Icon icon="streamline-emojis:dog" :height="30"></Icon>
|
||||||
<div>{{ configStore.config?.websiteName }}</div>
|
<div>{{ configStore.config?.websiteName }}</div>
|
||||||
<div v-if="titleTags.length">({{ titleTags.join(" · ") }})</div>
|
<div v-if="titleTags.length">({{ titleTags.join(" · ") }})</div>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
</n-button>
|
||||||
<div>
|
<div>
|
||||||
<n-menu
|
<n-menu
|
||||||
v-if="isDesktop"
|
v-if="isDesktop"
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
:options="menus"
|
:options="menus"
|
||||||
:value="active"
|
:value="active"
|
||||||
@update:value="handleMenuSelect"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<n-flex align="center">
|
<n-flex align="center">
|
||||||
<n-dropdown
|
<n-dropdown v-if="isMobile" :options="menus" size="large">
|
||||||
v-if="isMobile"
|
|
||||||
:options="menus"
|
|
||||||
size="large"
|
|
||||||
@select="handleMenuSelect"
|
|
||||||
>
|
|
||||||
<n-button>
|
<n-button>
|
||||||
<Icon icon="fluent-emoji:artist-palette" height="20"></Icon>
|
<Icon icon="fluent-emoji:artist-palette" height="20"></Icon>
|
||||||
<span style="padding-left: 8px">菜单</span>
|
<span style="padding-left: 8px">菜单</span>
|
||||||
@@ -450,21 +315,11 @@ function handleMenuSelect(key: string) {
|
|||||||
</template>
|
</template>
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<!--
|
|
||||||
挂在根 n-flex 内部而不是同级:Header.vue 一旦变成多根 fragment,
|
|
||||||
default.vue 里 `<Header class="header" />` 那个 class 就没有任何单一
|
|
||||||
根节点可以落地(Vue 会报 "Extraneous non-props attributes" 警告并把它
|
|
||||||
整个丢弃),header 行随之丢掉 `max-width: 2000px` 那条居中样式。
|
|
||||||
n-modal 默认 teleport 到 body,塞在这里不影响它的实际渲染位置。
|
|
||||||
-->
|
|
||||||
<HelpRequestList v-if="hasHelpEntry" v-model:show="showHelpRequests" />
|
|
||||||
<CollabModal v-if="userStore.isTeacherOrAbove" />
|
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.title {
|
.title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useCollabStore } from "shared/store/collab"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
|
|
||||||
/** 由顶栏的姓名下拉菜单打开 */
|
/** 由顶栏的姓名下拉菜单打开 */
|
||||||
@@ -7,6 +8,10 @@ const show = defineModel<boolean>("show", { default: false })
|
|||||||
|
|
||||||
const collabStore = useCollabStore()
|
const collabStore = useCollabStore()
|
||||||
|
|
||||||
|
// 接单之后要在弹框里替学生写代码,那个编辑器窄屏上没法用 —— 所以窄屏只让看
|
||||||
|
// 「谁在等」(角标、toast、这张列表照常给),接单留到桌面端
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
// 等待时长要每秒走一格,所以自己转一个 now。
|
// 等待时长要每秒走一格,所以自己转一个 now。
|
||||||
// 只在弹框开着时转 —— 这个组件跟着顶栏常驻,关着的时候没人看这个数。
|
// 只在弹框开着时转 —— 这个组件跟着顶栏常驻,关着的时候没人看这个数。
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
@@ -40,7 +45,7 @@ const waited = (createdAt: number) => {
|
|||||||
|
|
||||||
const handleAccept = (studentId: number, status: string) => {
|
const handleAccept = (studentId: number, status: string) => {
|
||||||
// 已被别的老师接走的不能点
|
// 已被别的老师接走的不能点
|
||||||
if (status === "active") return
|
if (status === "active" || !isDesktop.value) return
|
||||||
collabStore.accept(studentId)
|
collabStore.accept(studentId)
|
||||||
// 接单后马上要弹 CollabModal,这个列表得让位
|
// 接单后马上要弹 CollabModal,这个列表得让位
|
||||||
show.value = false
|
show.value = false
|
||||||
@@ -55,7 +60,19 @@ const handleAccept = (studentId: number, status: string) => {
|
|||||||
:style="{ width: '420px' }"
|
:style="{ width: '420px' }"
|
||||||
>
|
>
|
||||||
<div style="max-height: 60vh; overflow: auto">
|
<div style="max-height: 60vh; overflow: auto">
|
||||||
<n-empty v-if="collabStore.groupedRequests.length === 0" description="暂无求助" />
|
<n-alert
|
||||||
|
v-if="!isDesktop"
|
||||||
|
type="info"
|
||||||
|
:bordered="false"
|
||||||
|
style="margin-bottom: 8px"
|
||||||
|
>
|
||||||
|
接单要在电脑上打开
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-empty
|
||||||
|
v-if="collabStore.groupedRequests.length === 0"
|
||||||
|
description="暂无求助"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-for="group in collabStore.groupedRequests" :key="group.problemId">
|
<div v-for="group in collabStore.groupedRequests" :key="group.problemId">
|
||||||
<!-- 同题多人是个教学信号:该停下来全班讲,而不是挨个救 -->
|
<!-- 同题多人是个教学信号:该停下来全班讲,而不是挨个救 -->
|
||||||
@@ -77,14 +94,17 @@ const handleAccept = (studentId: number, status: string) => {
|
|||||||
padding: '6px 8px',
|
padding: '6px 8px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
opacity: item.status === 'active' ? 0.5 : 1,
|
opacity: item.status === 'active' ? 0.5 : 1,
|
||||||
cursor: item.status === 'active' ? 'default' : 'pointer',
|
cursor:
|
||||||
|
item.status === 'active' || !isDesktop ? 'default' : 'pointer',
|
||||||
}"
|
}"
|
||||||
@click="handleAccept(item.studentId, item.status)"
|
@click="handleAccept(item.studentId, item.status)"
|
||||||
>
|
>
|
||||||
<n-flex vertical :size="2">
|
<n-flex vertical :size="2">
|
||||||
<n-text>
|
<n-text>
|
||||||
{{ item.studentName }}
|
{{ item.studentName }}
|
||||||
<n-text depth="3" v-if="item.className">({{ item.className }})</n-text>
|
<n-text depth="3" v-if="item.className"
|
||||||
|
>({{ item.className }})</n-text
|
||||||
|
>
|
||||||
</n-text>
|
</n-text>
|
||||||
<n-text depth="3" style="font-size: 12px">
|
<n-text depth="3" style="font-size: 12px">
|
||||||
{{
|
{{
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useDark } from "@vueuse/core"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 圆环从哪儿开始扩散。正常点击就用指针落点;键盘触发(Enter / 空格)时浏览器给的
|
||||||
|
* clientX/clientY 是 0,照用会让圆环从屏幕左上角冒出来——那种情况退回按钮自己的中心。
|
||||||
|
* `event.detail` 是点击次数,键盘触发时为 0,用它区分最省事。
|
||||||
|
*/
|
||||||
|
function revealOrigin(event: MouseEvent) {
|
||||||
|
if (event.detail > 0) return { x: event.clientX, y: event.clientY }
|
||||||
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 暗黑模式切换 + 圆环扩散过渡。
|
||||||
|
*
|
||||||
|
* 从 Header 里搬出来的:这套动画细节和「顶栏该放什么」没有关系,
|
||||||
|
* 哪个页面想再放一个主题开关都能直接用。
|
||||||
|
*/
|
||||||
|
export function useDarkTransition() {
|
||||||
|
const isDark = useDark()
|
||||||
|
|
||||||
|
function toggleDark(event: MouseEvent) {
|
||||||
|
if (!document.startViewTransition) {
|
||||||
|
// 机房那批 Chrome 低于 94,没有 View Transitions,直接切、不做动画。
|
||||||
|
isDark.value = !isDark.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { x, y } = revealOrigin(event)
|
||||||
|
// 半径要取到**最远**那个角的距离。用 hypot(x, y) 只覆盖到左上角,
|
||||||
|
// 点在偏左上时右下角会有一块旧画面等圆环扩过去,看着像是没刷新。
|
||||||
|
const radius = Math.hypot(
|
||||||
|
Math.max(x, window.innerWidth - x),
|
||||||
|
Math.max(y, window.innerHeight - y),
|
||||||
|
)
|
||||||
|
document
|
||||||
|
.startViewTransition(() => {
|
||||||
|
isDark.value = !isDark.value
|
||||||
|
})
|
||||||
|
.ready.then(() => {
|
||||||
|
document.documentElement.animate(
|
||||||
|
{
|
||||||
|
clipPath: [
|
||||||
|
`circle(0px at ${x}px ${y}px)`,
|
||||||
|
`circle(${radius}px at ${x}px ${y}px)`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
duration: 400,
|
||||||
|
easing: "ease-in-out",
|
||||||
|
pseudoElement: "::view-transition-new(root)",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isDark, toggleDark }
|
||||||
|
}
|
||||||
@@ -26,7 +26,12 @@ watch(
|
|||||||
<template>
|
<template>
|
||||||
<n-layout position="absolute">
|
<n-layout position="absolute">
|
||||||
<n-layout-header bordered style="padding: 8px">
|
<n-layout-header bordered style="padding: 8px">
|
||||||
<Header class="header" />
|
<!-- 居中限宽套在外面,别用 class 传给 Header:那样 Header 就永远只能有
|
||||||
|
一个根节点,多一个根就是 "Extraneous non-props attributes" 警告
|
||||||
|
加样式静默丢失 -->
|
||||||
|
<div class="header">
|
||||||
|
<Header />
|
||||||
|
</div>
|
||||||
</n-layout-header>
|
</n-layout-header>
|
||||||
<n-layout-content
|
<n-layout-content
|
||||||
content-style="padding: 16px; overflow-x: initial; max-width: 2000px; margin: 0 auto;"
|
content-style="padding: 16px; overflow-x: initial; max-width: 2000px; margin: 0 auto;"
|
||||||
|
|||||||
@@ -35,8 +35,13 @@ export const useCollabStore = defineStore("collab", () => {
|
|||||||
const teacherName = ref("")
|
const teacherName = ref("")
|
||||||
/** 双方:当前房间。null 表示不在协作中 */
|
/** 双方:当前房间。null 表示不在协作中 */
|
||||||
const room = ref<RoomInfo | null>(null)
|
const room = ref<RoomInfo | null>(null)
|
||||||
/** 一次性提示,由 Header 统一消费后清空 */
|
/** 一次性提示,由 CollabHost 统一消费后清空 */
|
||||||
const notice = ref("")
|
const notice = ref("")
|
||||||
|
/**
|
||||||
|
* 求助列表弹框开着没有。放在 store 里而不是组件内部:打开它的入口(顶栏的
|
||||||
|
* 姓名下拉、新求助 toast)和弹框本身已经不在同一棵子树里了。
|
||||||
|
*/
|
||||||
|
const helpPanelOpen = ref(false)
|
||||||
/**
|
/**
|
||||||
* 提示序号,每次设置都自增。
|
* 提示序号,每次设置都自增。
|
||||||
*
|
*
|
||||||
@@ -57,7 +62,10 @@ export const useCollabStore = defineStore("collab", () => {
|
|||||||
|
|
||||||
/** 按题目聚合,同题多人时老师能一眼看出该停下来全班讲 */
|
/** 按题目聚合,同题多人时老师能一眼看出该停下来全班讲 */
|
||||||
const groupedRequests = computed(() => {
|
const groupedRequests = computed(() => {
|
||||||
const groups = new Map<string, { problemId: string; problemTitle: string; items: CollabRequestItem[] }>()
|
const groups = new Map<
|
||||||
|
string,
|
||||||
|
{ problemId: string; problemTitle: string; items: CollabRequestItem[] }
|
||||||
|
>()
|
||||||
for (const item of requests.value) {
|
for (const item of requests.value) {
|
||||||
const group = groups.get(item.problemId)
|
const group = groups.get(item.problemId)
|
||||||
if (group) group.items.push(item)
|
if (group) group.items.push(item)
|
||||||
@@ -155,6 +163,7 @@ export const useCollabStore = defineStore("collab", () => {
|
|||||||
teacherName.value = ""
|
teacherName.value = ""
|
||||||
room.value = null
|
room.value = null
|
||||||
notice.value = ""
|
notice.value = ""
|
||||||
|
helpPanelOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestHelp(problemId: string, language: LANGUAGE) {
|
function requestHelp(problemId: string, language: LANGUAGE) {
|
||||||
@@ -211,6 +220,7 @@ export const useCollabStore = defineStore("collab", () => {
|
|||||||
room,
|
room,
|
||||||
notice,
|
notice,
|
||||||
noticeSeq,
|
noticeSeq,
|
||||||
|
helpPanelOpen,
|
||||||
isTeacher: computed(() => userStore.isTeacherOrAbove),
|
isTeacher: computed(() => userStore.isTeacherOrAbove),
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { PROBLEM_PERMISSION, STORAGE_KEY, USER_TYPE } from "utils/constants"
|
import { PROBLEM_PERMISSION, STORAGE_KEY, USER_TYPE } from "utils/constants"
|
||||||
import storage from "utils/storage"
|
import storage from "utils/storage"
|
||||||
import type { Profile, SessionUser } from "utils/types"
|
import type { Profile, SessionUser } from "utils/types"
|
||||||
import { getProfile } from "../api"
|
import { getProfile, logout } from "../api"
|
||||||
import { useConfigStore } from "./config"
|
import { useConfigStore } from "./config"
|
||||||
|
|
||||||
export const useUserStore = defineStore("user", () => {
|
export const useUserStore = defineStore("user", () => {
|
||||||
@@ -60,12 +60,23 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
return flag
|
return flag
|
||||||
})
|
})
|
||||||
|
|
||||||
async function getMyProfile() {
|
// 同一时刻只发一份 /profile。App.vue 挂载时要拉、路由守卫要拉、页面自己也可能
|
||||||
|
// 要拉(子组件的 onMounted 比 App.vue 的先跑),不去重就是同一个请求发好几遍,
|
||||||
|
// 页面里等它的那些请求还得跟着排队。只合并「正在飞的那一次」,不缓存结果 ——
|
||||||
|
// 登录后和改完设置仍然要能重新拉一份。
|
||||||
|
let inflight: Promise<void> | null = null
|
||||||
|
|
||||||
|
function getMyProfile() {
|
||||||
|
if (inflight) return inflight
|
||||||
isFinished.value = false
|
isFinished.value = false
|
||||||
const res = await getProfile()
|
inflight = getProfile()
|
||||||
|
.then((res) => {
|
||||||
profile.value = res
|
profile.value = res
|
||||||
isFinished.value = true
|
isFinished.value = true
|
||||||
storage.set(STORAGE_KEY.AUTHED, !!user.value?.email)
|
storage.set(STORAGE_KEY.AUTHED, !!user.value?.email)
|
||||||
|
})
|
||||||
|
.finally(() => (inflight = null))
|
||||||
|
return inflight
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearProfile() {
|
function clearProfile() {
|
||||||
@@ -73,6 +84,13 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
demoMode.value = false
|
demoMode.value = false
|
||||||
storage.clear()
|
storage.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 退登的两步(吊销服务端会话、清本地状态)绑在一起:只清本地会留一个还活着
|
||||||
|
// 的 cookie,下次进站又被 /profile 认回来。跳转留给调用方,store 里不碰路由。
|
||||||
|
async function signOut() {
|
||||||
|
await logout()
|
||||||
|
clearProfile()
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
profile,
|
profile,
|
||||||
isFinished,
|
isFinished,
|
||||||
@@ -90,5 +108,6 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
showSubmissions,
|
showSubmissions,
|
||||||
getMyProfile,
|
getMyProfile,
|
||||||
clearProfile,
|
clearProfile,
|
||||||
|
signOut,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user