Compare commits
3
Commits
18b11e3730
...
0ffc405a20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ffc405a20 | ||
|
|
e7b6e27b85 | ||
|
|
395be1a19b |
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
import type { ChainableCommander } from "ioredis"
|
import type { ChainableCommander } from "ioredis"
|
||||||
|
|
||||||
import { redis } from "../redis"
|
import { redis } from "../redis"
|
||||||
|
import { calendarDay } from "../time"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「谁现在在线」。member = userId,score = 最后一次活动的毫秒时间戳。
|
* 「谁现在在线」。member = userId,score = 最后一次活动的毫秒时间戳。
|
||||||
@@ -18,9 +19,24 @@ const PRESENCE_KEY = "online-users"
|
|||||||
*/
|
*/
|
||||||
const ONLINE_WINDOW_MS = 5 * 60 * 1000
|
const ONLINE_WINDOW_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「今天谁上过线、最后一次是几点」,一天一个 key,同样是 userId → 毫秒时间戳。
|
||||||
|
*
|
||||||
|
* 课堂看板拿它判断谁今天没来:机房有两人共用一台电脑的,同一时刻只有一个人登着,
|
||||||
|
* 只看「此刻在不在线」会把坐在旁边的同桌当成没来。上过线的人不管现在登没登着都算来过。
|
||||||
|
* 上面那张会被修剪到最近 5 分钟,留不住这个,所以单记一张,放两天自己过期。
|
||||||
|
*/
|
||||||
|
const seenTodayKey = (day: string) => `online-users:${day}`
|
||||||
|
const SEEN_TODAY_TTL_SECONDS = 2 * 24 * 60 * 60
|
||||||
|
|
||||||
/** 记一笔活动。传 pipeline 而不是自己发命令:调用点都在热路径上 */
|
/** 记一笔活动。传 pipeline 而不是自己发命令:调用点都在热路径上 */
|
||||||
export function markOnline(pipeline: ChainableCommander, userId: number) {
|
export function markOnline(pipeline: ChainableCommander, userId: number) {
|
||||||
pipeline.zadd(PRESENCE_KEY, Date.now(), String(userId))
|
const now = Date.now()
|
||||||
|
const seenKey = seenTodayKey(calendarDay(now))
|
||||||
|
pipeline
|
||||||
|
.zadd(PRESENCE_KEY, now, String(userId))
|
||||||
|
.zadd(seenKey, now, String(userId))
|
||||||
|
.expire(seenKey, SEEN_TODAY_TTL_SECONDS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,3 +67,21 @@ export async function isUserOnline(userId: number) {
|
|||||||
const score = await redis.zscore(PRESENCE_KEY, String(userId))
|
const score = await redis.zscore(PRESENCE_KEY, String(userId))
|
||||||
return score !== null && Number(score) >= Date.now() - ONLINE_WINDOW_MS
|
return score !== null && Number(score) >= Date.now() - ONLINE_WINDOW_MS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这批人今天最后一次上线的时刻(毫秒),今天没上过线的不在结果里。
|
||||||
|
* `online` 和 onlineUserIds 同一个窗口
|
||||||
|
*/
|
||||||
|
export async function seenToday(userIds: number[]) {
|
||||||
|
const seen = new Map<number, { at: number; online: boolean }>()
|
||||||
|
if (!userIds.length) return seen
|
||||||
|
const scores = await redis.zmscore(seenTodayKey(calendarDay()), ...userIds.map(String))
|
||||||
|
const cutoff = Date.now() - ONLINE_WINDOW_MS
|
||||||
|
userIds.forEach((userId, i) => {
|
||||||
|
const score = scores[i]
|
||||||
|
if (score === null || score === undefined) return
|
||||||
|
const at = Number(score)
|
||||||
|
seen.set(userId, { at, online: at >= cutoff })
|
||||||
|
})
|
||||||
|
return seen
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { and, asc, desc, eq, gte, inArray, isNotNull, isNull, lt, sql } from "dr
|
|||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||||
|
import { seenToday } from "../auth/presence"
|
||||||
import { getLoginWindow } from "../auth/session"
|
import { getLoginWindow } from "../auth/session"
|
||||||
import { presenceOf } from "../collab/state"
|
import { presenceOf } from "../collab/state"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
@@ -568,7 +569,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
// 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径
|
// 「来了几次」代码和流程图合起来数:那天只画了流程图的也算来过,和 recentDays 同一个口径
|
||||||
const recentActivity = activityRows({ since: windowStart, users: userIds })
|
const recentActivity = activityRows({ since: windowStart, users: userIds })
|
||||||
const localDay = sql<string>`to_char(${localTime(recentActivity.createTime)}, 'YYYY-MM-DD')`
|
const localDay = sql<string>`to_char(${localTime(recentActivity.createTime)}, 'YYYY-MM-DD')`
|
||||||
const [cells, lastSubmits, lastDrawn, attendance, drawn, latests] = await Promise.all([
|
const [cells, lastSubmits, lastDrawn, attendance, drawn, latests, seen] = await Promise.all([
|
||||||
ids.length && userIds.length
|
ids.length && userIds.length
|
||||||
? db
|
? db
|
||||||
.select({
|
.select({
|
||||||
@@ -694,6 +695,7 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
desc(schema.submission.createTime),
|
desc(schema.submission.createTime),
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
|
seenToday(userIds),
|
||||||
])
|
])
|
||||||
const latestByKey = new Map(latests.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
const latestByKey = new Map(latests.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
||||||
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
const cellByKey = new Map(cells.map((row) => [`${row.userId}:${row.problemId}`, row]))
|
||||||
@@ -757,6 +759,10 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
|
|||||||
drawnToday: drawnTodayUsers.has(student.userId),
|
drawnToday: drawnTodayUsers.has(student.userId),
|
||||||
recentAttended: attendedByUser.get(student.userId) ?? 0,
|
recentAttended: attendedByUser.get(student.userId) ?? 0,
|
||||||
editing: presenceOf(student.userId)?.problemId ?? null,
|
editing: presenceOf(student.userId)?.problemId ?? null,
|
||||||
|
online: seen.get(student.userId)?.online ?? false,
|
||||||
|
seenAt: seen.has(student.userId)
|
||||||
|
? new Date(seen.get(student.userId)!.at).toISOString()
|
||||||
|
: null,
|
||||||
}) satisfies ClassBoardStudent,
|
}) satisfies ClassBoardStudent,
|
||||||
),
|
),
|
||||||
recentLessons: recentDays.length,
|
recentLessons: recentDays.length,
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ const theme = useThemeVars()
|
|||||||
|
|
||||||
const reports = ref<AdminAiReportListItem[]>([])
|
const reports = ref<AdminAiReportListItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const query = reactive({ limit: 10, page: 1, username: "" })
|
// 前台「智能分析」看某个学生时的「AI 报告」按钮带着 ?username= 过来,直接筛到他
|
||||||
|
const route = useRoute()
|
||||||
|
const query = reactive({
|
||||||
|
limit: 10,
|
||||||
|
page: 1,
|
||||||
|
username: typeof route.query.username === "string" ? route.query.username : "",
|
||||||
|
})
|
||||||
|
|
||||||
const selectedId = ref<number | null>(null)
|
const selectedId = ref<number | null>(null)
|
||||||
const detail = ref<AdminAiReport | null>(null)
|
const detail = ref<AdminAiReport | null>(null)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getAchievements } from "oj/achievement/api"
|
|||||||
import { getUserBadges } from "oj/api"
|
import { getUserBadges } from "oj/api"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import UserName from "shared/components/UserName.vue"
|
import UserName from "shared/components/UserName.vue"
|
||||||
|
import AdminLink from "shared/components/AdminLink.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
|
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
|
||||||
import type { AchievementList, AchievementRarity, UserBadge } from "utils/types"
|
import type { AchievementList, AchievementRarity, UserBadge } from "utils/types"
|
||||||
@@ -83,6 +84,9 @@ watch(name, load)
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<router-link v-if="other" to="/achievement" class="mine">看我的 ›</router-link>
|
<router-link v-if="other" to="/achievement" class="mine">看我的 ›</router-link>
|
||||||
|
<div v-if="userStore.isSuperAdmin && isDesktop" class="admin-link">
|
||||||
|
<AdminLink to="/admin/achievement/list" size="small">管理成就</AdminLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 没有班级(老师、自己注册的号)就没有「本班」,「快拿到了」三个横排占满整宽 -->
|
<!-- 没有班级(老师、自己注册的号)就没有「本班」,「快拿到了」三个横排占满整宽 -->
|
||||||
@@ -168,6 +172,16 @@ h2 {
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 两个都在时「看我的」占着 auto,按钮跟在它后面 */
|
||||||
|
.admin-link {
|
||||||
|
margin-left: auto;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine + .admin-link {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.mine {
|
.mine {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: var(--oj-fs-sec);
|
font-size: var(--oj-fs-sec);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { useRouteQuery } from "@vueuse/router"
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
import AdminLink from "shared/components/AdminLink.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import UserName from "shared/components/UserName.vue"
|
import UserName from "shared/components/UserName.vue"
|
||||||
import { classLabel } from "oj/submission/utils"
|
import { classLabel } from "oj/submission/utils"
|
||||||
@@ -137,6 +138,13 @@ function goStudent(username: string | null | undefined) {
|
|||||||
>
|
>
|
||||||
看<UserName :username="viewing" />的提交 ›
|
看<UserName :username="viewing" />的提交 ›
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<AdminLink
|
||||||
|
v-if="userStore.isTeacherOrAbove && isDesktop"
|
||||||
|
:to="{ path: '/admin/ai/reports', query: viewing ? { username: viewing } : {} }"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
AI 报告
|
||||||
|
</AdminLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-spin :show="loading" :delay="150">
|
<n-spin :show="loading" :delay="150">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { getAnnouncement, getAnnouncementList } from "oj/api"
|
import { getAnnouncement, getAnnouncementList } from "oj/api"
|
||||||
|
import AdminLink from "shared/components/AdminLink.vue"
|
||||||
import NotFound from "shared/components/NotFound.vue"
|
import NotFound from "shared/components/NotFound.vue"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
import { useUserStore } from "shared/store/user"
|
||||||
import { errorCode } from "utils/api"
|
import { errorCode } from "utils/api"
|
||||||
import { parseTime, zonedParts, zonedYear } from "utils/functions"
|
import { parseTime, zonedParts, zonedYear } from "utils/functions"
|
||||||
import type { Announcement, AnnouncementListItem } from "utils/types"
|
import type { Announcement, AnnouncementListItem } from "utils/types"
|
||||||
@@ -19,6 +21,7 @@ const LIMIT = 100
|
|||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const announcements = ref<AnnouncementListItem[]>([])
|
const announcements = ref<AnnouncementListItem[]>([])
|
||||||
@@ -105,7 +108,19 @@ const showReader = computed(() => isDesktop.value || queryId.value !== null)
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page oj-page">
|
<div class="page oj-page">
|
||||||
<h2>公告</h2>
|
<div class="head">
|
||||||
|
<h2>公告</h2>
|
||||||
|
<template v-if="userStore.isSuperAdmin">
|
||||||
|
<span class="grow"></span>
|
||||||
|
<AdminLink
|
||||||
|
v-if="showReader && detail"
|
||||||
|
:to="{ name: 'admin announcement edit', params: { announcementID: detail.id } }"
|
||||||
|
>
|
||||||
|
编辑这条
|
||||||
|
</AdminLink>
|
||||||
|
<AdminLink to="/admin/announcement/create">发公告</AdminLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<n-empty v-if="loaded && !announcements.length" description="还没有公告" class="blank" />
|
<n-empty v-if="loaded && !announcements.length" description="还没有公告" class="blank" />
|
||||||
|
|
||||||
@@ -177,6 +192,16 @@ h2 {
|
|||||||
font-size: var(--oj-fs-title);
|
font-size: var(--oj-fs-title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.blank {
|
.blank {
|
||||||
margin-top: 60px;
|
margin-top: 60px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,20 @@ function oftenIdle(student: ClassBoardStudent) {
|
|||||||
return total >= 3 && student.recentAttended <= 1
|
return total >= 3 && student.recentAttended <= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 今天一次都没上过线。机房有两人共用一台电脑的,同桌只有一个登着,所以「谁没来」
|
||||||
|
* 看今天上没上过线,不看此刻在不在线(见契约 seenAt)
|
||||||
|
*/
|
||||||
|
function unseen(student: ClassBoardStudent) {
|
||||||
|
return !student.seenAt
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 没在线的人最后一次上线是多久前,「在写」那格用 */
|
||||||
|
function seenText(seenAt: string) {
|
||||||
|
const minutes = Math.max(0, Math.floor((now.value.getTime() - Date.parse(seenAt)) / 60_000))
|
||||||
|
return minutes < 60 ? `${minutes} 分钟前在` : `${Math.floor(minutes / 60)} 小时前在`
|
||||||
|
}
|
||||||
|
|
||||||
function doneCount(student: ClassBoardStudent) {
|
function doneCount(student: ClassBoardStudent) {
|
||||||
return student.cells.filter((cell) => cell.status === "accepted").length
|
return student.cells.filter((cell) => cell.status === "accepted").length
|
||||||
}
|
}
|
||||||
@@ -251,7 +265,22 @@ watchEffect(() => {
|
|||||||
const leaveDialog = ref(false)
|
const leaveDialog = ref(false)
|
||||||
/** 弹框里能勾的:今天还没交过的(刚上课时就是全班),按名字排 */
|
/** 弹框里能勾的:今天还没交过的(刚上课时就是全班),按名字排 */
|
||||||
const leaveCandidates = computed(() => boardStudents.value.filter(canLeave).sort(byName))
|
const leaveCandidates = computed(() => boardStudents.value.filter(canLeave).sort(byName))
|
||||||
|
/** 今天没上过线的分在前面一堆:请假的人基本在这里面,剩下的是和同桌共用电脑的 */
|
||||||
|
const leaveSections = computed(() =>
|
||||||
|
[
|
||||||
|
{ label: "今天没上线", students: leaveCandidates.value.filter(unseen) },
|
||||||
|
{
|
||||||
|
label: "今天上过线",
|
||||||
|
students: leaveCandidates.value.filter((student) => !unseen(student)),
|
||||||
|
},
|
||||||
|
].filter((section) => section.students.length),
|
||||||
|
)
|
||||||
const leaveSelected = ref<string[]>([])
|
const leaveSelected = ref<string[]>([])
|
||||||
|
function selectAll(list: ClassBoardStudent[]) {
|
||||||
|
leaveSelected.value = [
|
||||||
|
...new Set([...leaveSelected.value, ...list.map((student) => student.username)]),
|
||||||
|
]
|
||||||
|
}
|
||||||
function openLeaveDialog() {
|
function openLeaveDialog() {
|
||||||
leaveSelected.value = leaveStudents.value.map((student) => student.username)
|
leaveSelected.value = leaveStudents.value.map((student) => student.username)
|
||||||
leaveDialog.value = true
|
leaveDialog.value = true
|
||||||
@@ -272,7 +301,8 @@ const students = computed(() =>
|
|||||||
[...boardStudents.value].sort(
|
[...boardStudents.value].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
|
GROUP_ORDER[groupOf(a)] - GROUP_ORDER[groupOf(b)] ||
|
||||||
// 同样是没交过,经常不动手的排前面
|
// 同样是没交过,今天没上过线的排最前(多半没来,好标请假),再是经常不动手的
|
||||||
|
Number(unseen(b)) - Number(unseen(a)) ||
|
||||||
Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
|
Number(oftenIdle(b)) - Number(oftenIdle(a)) ||
|
||||||
// 同样在做,做对得少的排前面(只做了一道的那几个)
|
// 同样在做,做对得少的排前面(只做了一道的那几个)
|
||||||
(groupOf(a) === "working" ? doneCount(a) - doneCount(b) : 0) ||
|
(groupOf(a) === "working" ? doneCount(a) - doneCount(b) : 0) ||
|
||||||
@@ -679,7 +709,7 @@ function shortTitle(title: string, size: number) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="name">学生</th>
|
<th class="name">学生</th>
|
||||||
<th class="narrow-col where-col">在写</th>
|
<th class="narrow-col where-col">在线</th>
|
||||||
<th
|
<th
|
||||||
v-for="(stat, i) in problemStats"
|
v-for="(stat, i) in problemStats"
|
||||||
:key="stat.problem.problemId"
|
:key="stat.problem.problemId"
|
||||||
@@ -728,8 +758,9 @@ function shortTitle(title: string, size: number) {
|
|||||||
举手 {{ waitedText(helpOf(student)!.createdAt) }}
|
举手 {{ waitedText(helpOf(student)!.createdAt) }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<!-- 在写:举手的「帮他」;别人在帮的写谁在帮;开着题目的点题号直接打开他正在写的
|
<!-- 在线:举手的「帮他」;别人在帮的写谁在帮;开着题目的点题号直接打开他正在写的
|
||||||
代码(他没举手也行,他那边会显示「xxx 在看你的代码」) -->
|
代码(他没举手也行,他那边会显示「xxx 在看你的代码」);剩下的写在不在线、
|
||||||
|
多久前在、今天没上过线 -->
|
||||||
<td class="where-col">
|
<td class="where-col">
|
||||||
<n-button
|
<n-button
|
||||||
v-if="helpOf(student)?.status === 'pending'"
|
v-if="helpOf(student)?.status === 'pending'"
|
||||||
@@ -751,7 +782,11 @@ function shortTitle(title: string, size: number) {
|
|||||||
>
|
>
|
||||||
{{ student.editing }}
|
{{ student.editing }}
|
||||||
</button>
|
</button>
|
||||||
<span v-else class="muted">—</span>
|
<span v-else-if="student.online" class="present">在线</span>
|
||||||
|
<span v-else-if="student.seenAt" class="seen">
|
||||||
|
{{ seenText(student.seenAt) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="seen">今天没上线</span>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
v-for="(cell, i) in student.cells"
|
v-for="(cell, i) in student.cells"
|
||||||
@@ -814,14 +849,30 @@ function shortTitle(title: string, size: number) {
|
|||||||
style="width: 640px; max-width: calc(100vw - 32px)"
|
style="width: 640px; max-width: calc(100vw - 32px)"
|
||||||
>
|
>
|
||||||
<n-checkbox-group v-if="leaveCandidates.length" v-model:value="leaveSelected">
|
<n-checkbox-group v-if="leaveCandidates.length" v-model:value="leaveSelected">
|
||||||
<div class="leave-grid">
|
<div v-for="section in leaveSections" :key="section.label" class="leave-section">
|
||||||
<n-checkbox
|
<div v-if="leaveSections.length > 1 || unseen(section.students[0])" class="leave-head">
|
||||||
v-for="student in leaveCandidates"
|
{{ section.label }} {{ section.students.length }} 人
|
||||||
:key="student.userId"
|
<n-button
|
||||||
:value="student.username"
|
v-if="unseen(section.students[0])"
|
||||||
:label="student.username"
|
text
|
||||||
class="leave-option"
|
type="primary"
|
||||||
/>
|
:disabled="
|
||||||
|
section.students.every((student) => leaveSelected.includes(student.username))
|
||||||
|
"
|
||||||
|
@click="selectAll(section.students)"
|
||||||
|
>
|
||||||
|
全勾上
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<div class="leave-grid">
|
||||||
|
<n-checkbox
|
||||||
|
v-for="student in section.students"
|
||||||
|
:key="student.userId"
|
||||||
|
:value="student.username"
|
||||||
|
:label="student.username"
|
||||||
|
class="leave-option"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-checkbox-group>
|
</n-checkbox-group>
|
||||||
<n-text v-else depth="3">请假的人一定是没交过题的,现在没有人可以标。</n-text>
|
<n-text v-else depth="3">请假的人一定是没交过题的,现在没有人可以标。</n-text>
|
||||||
@@ -1305,7 +1356,22 @@ a.cell {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.where::before {
|
.present {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("tone('success').color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.seen {
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.where::before,
|
||||||
|
.present::before {
|
||||||
content: "";
|
content: "";
|
||||||
width: 7px;
|
width: 7px;
|
||||||
height: 7px;
|
height: 7px;
|
||||||
@@ -1323,6 +1389,18 @@ a.cell {
|
|||||||
color: v-bind("tone('info').color");
|
color: v-bind("tone('info').color");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leave-section + .leave-section {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leave-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.leave-grid {
|
.leave-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import ContestTitle from "shared/components/ContestTitle.vue"
|
|||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { useAuthModalStore } from "shared/store/authModal"
|
import { useAuthModalStore } from "shared/store/authModal"
|
||||||
import { usePagination } from "shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
|
import AdminLink from "shared/components/AdminLink.vue"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { ContestStatus } from "utils/constants"
|
import { ContestStatus } from "utils/constants"
|
||||||
import ContestBadge from "./components/ContestBadge.vue"
|
import ContestBadge from "./components/ContestBadge.vue"
|
||||||
@@ -19,6 +21,7 @@ import ContestBadge from "./components/ContestBadge.vue"
|
|||||||
*/
|
*/
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
const authStore = useAuthModalStore()
|
const authStore = useAuthModalStore()
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const tone = useTone()
|
const tone = useTone()
|
||||||
@@ -145,6 +148,10 @@ const warning = computed(() => tone("warning"))
|
|||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</button>
|
</button>
|
||||||
<n-input v-model:value="query.keyword" clearable placeholder="比赛名" style="width: 200px" />
|
<n-input v-model:value="query.keyword" clearable placeholder="比赛名" style="width: 200px" />
|
||||||
|
<!-- 手机上一行放不下,也没人在手机上出题,只给电脑 -->
|
||||||
|
<AdminLink v-if="isDesktop && userStore.isTeacherOrAbove" to="/admin/contest/create"
|
||||||
|
>新建比赛</AdminLink
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -10,8 +10,6 @@ import { explainRuntimeError } from "oj/problem/utils/runtimeError"
|
|||||||
import { useAuthModalStore } from "shared/store/authModal"
|
import { useAuthModalStore } from "shared/store/authModal"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { mainWrapExtensions, wrapMain } from "../wrapMain"
|
import { mainWrapExtensions, wrapMain } from "../wrapMain"
|
||||||
import { hangWrapExtensions } from "shared/extensions/hangIndent"
|
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
|
||||||
|
|
||||||
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
||||||
|
|
||||||
@@ -37,12 +35,8 @@ const message = useMessage()
|
|||||||
const { copy } = useClipboard({ legacy: true })
|
const { copy } = useClipboard({ legacy: true })
|
||||||
|
|
||||||
const original = computed(() => (props.wrapMain ? wrapMain(props.code) : props.code))
|
const original = computed(() => (props.wrapMain ? wrapMain(props.code) : props.code))
|
||||||
const { isDesktop } = useBreakpoints()
|
// 手机上也不折行,长行横着滚:折下来的那截看着像另起了一行代码
|
||||||
// 手机上折行(见 hangIndent.ts);桌面正文 720 宽,长行少,照旧横着滚
|
const extensions = computed(() => (props.wrapMain ? mainWrapExtensions : []))
|
||||||
const extensions = computed(() => [
|
|
||||||
...(props.wrapMain ? mainWrapExtensions : []),
|
|
||||||
...(isDesktop.value ? [] : hangWrapExtensions),
|
|
||||||
])
|
|
||||||
const value = ref(original.value)
|
const value = ref(original.value)
|
||||||
const input = ref("")
|
const input = ref("")
|
||||||
const running = ref(false)
|
const running = ref(false)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import ExerciseResult from "./ExerciseResult.vue"
|
import ExerciseResult from "./ExerciseResult.vue"
|
||||||
import type { Exercise, ExerciseDebugData } from "utils/types"
|
import type { Exercise, ExerciseDebugData } from "utils/types"
|
||||||
import { highlightLines } from "shared/highlight"
|
import { highlightLines } from "shared/highlight"
|
||||||
import { hangStyle } from "shared/extensions/hangIndent"
|
|
||||||
|
|
||||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -92,19 +91,19 @@ function reset() {
|
|||||||
点击你认为有错误的代码行(可多选)。
|
点击你认为有错误的代码行(可多选)。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<n-space vertical :size="6">
|
<!-- 不折行:长行整组一起横着滚,每行一样宽(= 最长那行),和正文的代码块一样 -->
|
||||||
|
<div style="overflow-x: auto">
|
||||||
<div
|
<div
|
||||||
v-for="(line, idx) in data.lines"
|
style="display: flex; flex-direction: column; gap: 6px; width: max-content; min-width: 100%"
|
||||||
:key="idx"
|
|
||||||
:style="lineStyle(idx)"
|
|
||||||
@click="toggle(idx)"
|
|
||||||
>
|
>
|
||||||
<span style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0">
|
<div v-for="(_, idx) in data.lines" :key="idx" :style="lineStyle(idx)" @click="toggle(idx)">
|
||||||
{{ idx + 1 }}
|
<span style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0">
|
||||||
</span>
|
{{ idx + 1 }}
|
||||||
<span v-html="lineHtml[idx]" :style="{ minWidth: 0, ...hangStyle(line) }" />
|
</span>
|
||||||
|
<span v-html="lineHtml[idx]" style="white-space: pre" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-space>
|
</div>
|
||||||
|
|
||||||
<ExerciseResult
|
<ExerciseResult
|
||||||
v-if="submitted"
|
v-if="submitted"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import ExerciseResult from "./ExerciseResult.vue"
|
import ExerciseResult from "./ExerciseResult.vue"
|
||||||
import type { Exercise, ExerciseFillData } from "utils/types"
|
import type { Exercise, ExerciseFillData } from "utils/types"
|
||||||
import { highlightWithHoles } from "shared/highlight"
|
import { highlightWithHoles } from "shared/highlight"
|
||||||
import { hangStyle } from "shared/extensions/hangIndent"
|
|
||||||
|
|
||||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -39,28 +38,21 @@ const segments = computed<Segment[]>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按行切开,每行自己折行、悬挂缩进(见 hangIndent.ts)。原来整段 pre-wrap,
|
* 按行切开,一行一个块;不折行,长行整块横着滚(和正文的代码块一样)。
|
||||||
* 手机上行尾的注释折下来顶格,看着像另起了一行代码。
|
|
||||||
* 高亮的 HTML 在换行处是断开的(见 shared/highlight.ts 的 walk),按 "\n" 切不会切坏标签。
|
* 高亮的 HTML 在换行处是断开的(见 shared/highlight.ts 的 walk),按 "\n" 切不会切坏标签。
|
||||||
*/
|
*/
|
||||||
const lines = computed(() => {
|
const lines = computed(() => {
|
||||||
const rows: { indent: string; parts: Segment[] }[] = [{ indent: "", parts: [] }]
|
const rows: { parts: Segment[] }[] = [{ parts: [] }]
|
||||||
for (const seg of segments.value) {
|
for (const seg of segments.value) {
|
||||||
if (seg.type === "blank") {
|
if (seg.type === "blank") {
|
||||||
rows[rows.length - 1].parts.push(seg)
|
rows[rows.length - 1].parts.push(seg)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seg.html.split("\n").forEach((html, i) => {
|
seg.html.split("\n").forEach((html, i) => {
|
||||||
if (i > 0) rows.push({ indent: "", parts: [] })
|
if (i > 0) rows.push({ parts: [] })
|
||||||
if (html) rows[rows.length - 1].parts.push({ type: "code", html })
|
if (html) rows[rows.length - 1].parts.push({ type: "code", html })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for (const row of rows) {
|
|
||||||
const first = row.parts[0]
|
|
||||||
if (first?.type === "code") {
|
|
||||||
row.indent = first.html.replace(/<[^>]*>/g, "").match(/^[ \t]*/)![0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows
|
return rows
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -114,13 +106,10 @@ function inputWidth(idx: number): string {
|
|||||||
border: '1px solid var(--learn-line)',
|
border: '1px solid var(--learn-line)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
padding: '10px 12px',
|
padding: '10px 12px',
|
||||||
|
overflowX: 'auto',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div v-for="(row, r) in lines" :key="r" style="min-height: 1.9em; white-space: pre">
|
||||||
v-for="(row, r) in lines"
|
|
||||||
:key="r"
|
|
||||||
:style="{ minHeight: '1.9em', ...hangStyle(row.indent) }"
|
|
||||||
>
|
|
||||||
<template v-for="(seg, i) in row.parts" :key="i"
|
<template v-for="(seg, i) in row.parts" :key="i"
|
||||||
><span v-if="seg.type === 'code'" v-html="seg.html" /><input
|
><span v-if="seg.type === 'code'" v-html="seg.html" /><input
|
||||||
v-else
|
v-else
|
||||||
@@ -136,7 +125,6 @@ function inputWidth(idx: number): string {
|
|||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
lineHeight: '1.4',
|
lineHeight: '1.4',
|
||||||
padding: '2px 6px',
|
padding: '2px 6px',
|
||||||
textIndent: 0,
|
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
border: `1.5px solid ${
|
border: `1.5px solid ${
|
||||||
allCorrect
|
allCorrect
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { Exercise, ExerciseSortData } from "utils/types"
|
|||||||
import { shuffle } from "../composables/useShuffle"
|
import { shuffle } from "../composables/useShuffle"
|
||||||
import { highlightLines } from "shared/highlight"
|
import { highlightLines } from "shared/highlight"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { hangStyle } from "shared/extensions/hangIndent"
|
|
||||||
|
|
||||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -100,51 +99,53 @@ const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props
|
|||||||
先点一行,再点它该去的位置。
|
先点一行,再点它该去的位置。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<n-space vertical :size="6">
|
<!-- 不折行:长行整组一起横着滚,每行一样宽(= 最长那行),和正文的代码块一样 -->
|
||||||
|
<div style="overflow-x: auto">
|
||||||
<div
|
<div
|
||||||
v-for="(line, idx) in lines"
|
style="display: flex; flex-direction: column; gap: 6px; width: max-content; min-width: 100%"
|
||||||
:key="line.originalIdx"
|
|
||||||
draggable="true"
|
|
||||||
:style="{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
padding: '8px 12px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
border:
|
|
||||||
pickedIdx === idx
|
|
||||||
? '2px solid #2080f0'
|
|
||||||
: `1.5px ${submitted ? 'solid' : 'dashed'} ${
|
|
||||||
lineStatus(idx) === 'correct'
|
|
||||||
? '#18a058'
|
|
||||||
: lineStatus(idx) === 'wrong'
|
|
||||||
? '#d03050'
|
|
||||||
: 'var(--learn-control-line)'
|
|
||||||
}`,
|
|
||||||
background:
|
|
||||||
pickedIdx === idx
|
|
||||||
? 'rgba(32,128,240,0.08)'
|
|
||||||
: lineStatus(idx) === 'correct'
|
|
||||||
? 'rgba(24,160,88,0.08)'
|
|
||||||
: lineStatus(idx) === 'wrong'
|
|
||||||
? 'rgba(208,48,80,0.07)'
|
|
||||||
: 'transparent',
|
|
||||||
cursor: 'grab',
|
|
||||||
fontFamily: 'var(--learn-code-font)',
|
|
||||||
userSelect: 'none',
|
|
||||||
}"
|
|
||||||
@dragstart="onDragStart(idx)"
|
|
||||||
@dragover.prevent
|
|
||||||
@drop="onDrop(idx)"
|
|
||||||
@click="onLineClick(idx)"
|
|
||||||
>
|
>
|
||||||
<span style="color: #bbb; cursor: grab">⠿</span>
|
<div
|
||||||
<span
|
v-for="(line, idx) in lines"
|
||||||
v-html="lineHtml[line.originalIdx]"
|
:key="line.originalIdx"
|
||||||
:style="{ minWidth: 0, ...hangStyle(line.text) }"
|
draggable="true"
|
||||||
/>
|
:style="{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border:
|
||||||
|
pickedIdx === idx
|
||||||
|
? '2px solid #2080f0'
|
||||||
|
: `1.5px ${submitted ? 'solid' : 'dashed'} ${
|
||||||
|
lineStatus(idx) === 'correct'
|
||||||
|
? '#18a058'
|
||||||
|
: lineStatus(idx) === 'wrong'
|
||||||
|
? '#d03050'
|
||||||
|
: 'var(--learn-control-line)'
|
||||||
|
}`,
|
||||||
|
background:
|
||||||
|
pickedIdx === idx
|
||||||
|
? 'rgba(32,128,240,0.08)'
|
||||||
|
: lineStatus(idx) === 'correct'
|
||||||
|
? 'rgba(24,160,88,0.08)'
|
||||||
|
: lineStatus(idx) === 'wrong'
|
||||||
|
? 'rgba(208,48,80,0.07)'
|
||||||
|
: 'transparent',
|
||||||
|
cursor: 'grab',
|
||||||
|
fontFamily: 'var(--learn-code-font)',
|
||||||
|
userSelect: 'none',
|
||||||
|
}"
|
||||||
|
@dragstart="onDragStart(idx)"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop="onDrop(idx)"
|
||||||
|
@click="onLineClick(idx)"
|
||||||
|
>
|
||||||
|
<span style="color: #bbb; cursor: grab">⠿</span>
|
||||||
|
<span v-html="lineHtml[line.originalIdx]" style="white-space: pre" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-space>
|
</div>
|
||||||
|
|
||||||
<ExerciseResult
|
<ExerciseResult
|
||||||
v-if="submitted"
|
v-if="submitted"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TutorialProgress, TutorialSummary } from "@oj2/contract"
|
|||||||
import { getLearnProgress, getTutorials } from "../api"
|
import { getLearnProgress, getTutorials } from "../api"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useAuthModalStore } from "shared/store/authModal"
|
import { useAuthModalStore } from "shared/store/authModal"
|
||||||
|
import AdminLink from "shared/components/AdminLink.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import {
|
import {
|
||||||
COURSES,
|
COURSES,
|
||||||
@@ -251,6 +252,16 @@ onMounted(load)
|
|||||||
>
|
>
|
||||||
登录后记下学到哪
|
登录后记下学到哪
|
||||||
</button>
|
</button>
|
||||||
|
<div v-if="isDesktop && userStore.isTeacherOrAbove" class="admin-links">
|
||||||
|
<AdminLink to="/admin/learn" size="small">自学情况</AdminLink>
|
||||||
|
<AdminLink
|
||||||
|
v-if="userStore.isSuperAdmin"
|
||||||
|
:to="{ path: '/admin/tutorial/list', query: { type: tab } }"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
管理教程
|
||||||
|
</AdminLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="current && isDesktop" class="map">
|
<div v-if="current && isDesktop" class="map">
|
||||||
@@ -483,6 +494,11 @@ onMounted(load)
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
.admin-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
.login {
|
.login {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { parseLesson } from "./composables/useLessonParse"
|
|||||||
import { useLearnTrace } from "./composables/useLearnTrace"
|
import { useLearnTrace } from "./composables/useLearnTrace"
|
||||||
import { useBackClose } from "shared/composables/backClose"
|
import { useBackClose } from "shared/composables/backClose"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
import AdminLink from "shared/components/AdminLink.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import {
|
import {
|
||||||
COURSES,
|
COURSES,
|
||||||
@@ -293,6 +294,15 @@ function jumpExercise(id: number) {
|
|||||||
<span v-if="tutorial.project">{{ tutorial.project }}</span>
|
<span v-if="tutorial.project">{{ tutorial.project }}</span>
|
||||||
<span v-if="tutorial.project">·</span>
|
<span v-if="tutorial.project">·</span>
|
||||||
<span class="num">第 {{ step }} / {{ titles.length }} 课</span>
|
<span class="num">第 {{ step }} / {{ titles.length }} 课</span>
|
||||||
|
<template v-if="userStore.isSuperAdmin">
|
||||||
|
<span class="grow" />
|
||||||
|
<AdminLink
|
||||||
|
:to="{ name: 'admin tutorial edit', params: { tutorialID: tutorial.id } }"
|
||||||
|
size="tiny"
|
||||||
|
>
|
||||||
|
编辑这一课
|
||||||
|
</AdminLink>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="h1">{{ parts.kp }}</h1>
|
<h1 class="h1">{{ parts.kp }}</h1>
|
||||||
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
|
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
|
||||||
@@ -546,6 +556,7 @@ function jumpExercise(id: number) {
|
|||||||
}
|
}
|
||||||
.crumb {
|
.crumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
|
|||||||
// 一写回去就顺着 Yjs 同步过去,等于一键清空学生的作业,他还没法撤回
|
// 一写回去就顺着 Yjs 同步过去,等于一键清空学生的作业,他还没法撤回
|
||||||
if (!teacherCollab.value) items.push({ label: "重置代码", key: "reset" })
|
if (!teacherCollab.value) items.push({ label: "重置代码", key: "reset" })
|
||||||
}
|
}
|
||||||
if (isDesktop.value && userStore.isSuperAdmin) items.push({ label: "编辑题目", key: "edit" })
|
if (isDesktop.value && problem.value && userStore.canEditProblem(problem.value))
|
||||||
|
items.push({ label: "编辑题目", key: "edit" })
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Pagination from "shared/components/Pagination.vue"
|
|||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { usePagination } from "shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { useAuthModalStore } from "shared/store/authModal"
|
import { useAuthModalStore } from "shared/store/authModal"
|
||||||
|
import AdminLink from "shared/components/AdminLink.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import ProblemStatus from "./components/ProblemStatus.vue"
|
import ProblemStatus from "./components/ProblemStatus.vue"
|
||||||
import ProblemTypeTag from "./components/ProblemTypeTag.vue"
|
import ProblemTypeTag from "./components/ProblemTypeTag.vue"
|
||||||
@@ -430,6 +431,9 @@ const gridColumns = computed(() =>
|
|||||||
</div>
|
</div>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<Hitokoto class="hitokoto" />
|
<Hitokoto class="hitokoto" />
|
||||||
|
<AdminLink v-if="userStore.hasProblemPermission" to="/admin/problem/create" size="small">
|
||||||
|
新建题目
|
||||||
|
</AdminLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="titlebar">
|
<div class="titlebar">
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import AdminLink from "shared/components/AdminLink.vue"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
import { useUserStore } from "shared/store/user"
|
||||||
import { getProblemSetList } from "oj/api"
|
import { getProblemSetList } from "oj/api"
|
||||||
import { rgba, useTone } from "oj/submission/composables/tone"
|
import { rgba, useTone } from "oj/submission/composables/tone"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
@@ -13,6 +16,8 @@ import { badgeCondition, ladder, nextBadge } from "./badges"
|
|||||||
* 题单一共十几个,一次全取回来在前端分组,不分页
|
* 题单一共十几个,一次全取回来在前端分组,不分页
|
||||||
*/
|
*/
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const { isDesktop } = useBreakpoints()
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const tone = useTone()
|
const tone = useTone()
|
||||||
|
|
||||||
@@ -88,6 +93,10 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
|
|||||||
<h2>题单</h2>
|
<h2>题单</h2>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<n-input v-model:value="keyword" placeholder="搜题单" clearable class="search" />
|
<n-input v-model:value="keyword" placeholder="搜题单" clearable class="search" />
|
||||||
|
<!-- 手机上一行放不下,也没人在手机上出题,只给电脑 -->
|
||||||
|
<AdminLink v-if="isDesktop && userStore.isTeacherOrAbove" to="/admin/problemset/create"
|
||||||
|
>新建题单</AdminLink
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="assigning.length">
|
<template v-if="assigning.length">
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink, type RouteLocationRaw } from "vue-router"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前台页面上直达后台对应功能的按钮(「编辑这一课」「新建比赛」……),和题单页的
|
||||||
|
* 「编辑题单」、比赛页的「比赛设置」同一种样子。渲染成真的 <a>:老师常要一边看前台
|
||||||
|
* 一边改,中键 / Ctrl 点开新标签页得能用。给谁看由调用方用 v-if 管,要和
|
||||||
|
* routes.ts 里目标路由的 meta 对齐,否则点下去被守卫静默踢回首页。
|
||||||
|
*/
|
||||||
|
withDefaults(defineProps<{ to: RouteLocationRaw; size?: "tiny" | "small" | "medium" }>(), {
|
||||||
|
size: "medium",
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterLink v-slot="{ href, navigate }" :to="to" custom>
|
||||||
|
<n-button tag="a" :href="href" :size="size" @click="navigate"><slot /></n-button>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
@@ -8,9 +8,9 @@ import {
|
|||||||
} from "@codemirror/view"
|
} from "@codemirror/view"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手机上代码折行(自学的代码块和练一练、题目页的编辑器)。原来是横着滚,可没有滚动条
|
* 手机上代码折行(题目页的编辑器)。原来是横着滚,可没有滚动条也看不出右边还有。
|
||||||
* 也看不出右边还有,自学综合练习那种长 print 整整半行看不见。折下来的那截缩到这一行的
|
* 折下来的那截缩到这一行的代码下面再进两格,一眼看得出还是同一行。
|
||||||
* 代码下面再进两格,一眼看得出还是同一行。
|
* 自学的代码块和练一练也这么折过,用户嫌不好看,又改回横着滚了。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 行首空白占几格,tab 按 4 */
|
/** 行首空白占几格,tab 按 4 */
|
||||||
@@ -24,19 +24,6 @@ function leadingWidth(text: string): number {
|
|||||||
return width
|
return width
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 静态的代码行(练一练的排序、找错、填空):折行,接着的那截悬挂缩进 */
|
|
||||||
export function hangStyle(text: string): Record<string, string> {
|
|
||||||
const width = leadingWidth(text) + 2
|
|
||||||
return {
|
|
||||||
whiteSpace: "pre-wrap",
|
|
||||||
// 中文连着的一串别从中间断(「类」「型」各在一行),实在放不下才随便断
|
|
||||||
wordBreak: "keep-all",
|
|
||||||
overflowWrap: "anywhere",
|
|
||||||
paddingLeft: `${width}ch`,
|
|
||||||
textIndent: `-${width}ch`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const glue = Decoration.mark({ attributes: { style: "white-space: nowrap" } })
|
const glue = Decoration.mark({ attributes: { style: "white-space: nowrap" } })
|
||||||
|
|
||||||
function hangDecorations(view: EditorView): DecorationSet {
|
function hangDecorations(view: EditorView): DecorationSet {
|
||||||
@@ -82,5 +69,5 @@ const hangPlugin = ViewPlugin.fromClass(
|
|||||||
{ decorations: (plugin) => plugin.decorations },
|
{ decorations: (plugin) => plugin.decorations },
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 编辑器(正文里能运行的代码块):折行 + 悬挂缩进 */
|
/** 编辑器:折行 + 悬挂缩进 */
|
||||||
export const hangWrapExtensions = [EditorView.lineWrapping, hangPlugin]
|
export const hangWrapExtensions = [EditorView.lineWrapping, hangPlugin]
|
||||||
@@ -49,6 +49,22 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
|
|
||||||
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
|
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前台「编辑题目」入口给谁看,对齐后端 admin/problem.ts 的 canEdit:
|
||||||
|
* 公开题看题目的创建者(或题目权限是「全部」),比赛题看比赛的创建者。
|
||||||
|
* 题目页拿不到比赛的创建者,比赛题就按题目的创建者估 —— 比赛题基本是出题老师
|
||||||
|
* 自己建或克隆的;估错了后台会拦,只是多一个点进去 404 的入口。
|
||||||
|
*/
|
||||||
|
function canEditProblem(problem: { contestId: number | null; createdBy: { id: number } }) {
|
||||||
|
if (isSuperAdmin.value) return true
|
||||||
|
const mine = problem.createdBy.id === user.value?.id
|
||||||
|
if (problem.contestId !== null) return isTeacherOrAbove.value && mine
|
||||||
|
return (
|
||||||
|
hasProblemPermission.value &&
|
||||||
|
(mine || user.value?.problemPermission === PROBLEM_PERMISSION.ALL)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function toggleDemoMode() {
|
function toggleDemoMode() {
|
||||||
demoSwitch.value = !demoSwitch.value
|
demoSwitch.value = !demoSwitch.value
|
||||||
storage.set(STORAGE_KEY.DEMO_MODE, demoSwitch.value)
|
storage.set(STORAGE_KEY.DEMO_MODE, demoSwitch.value)
|
||||||
@@ -124,6 +140,7 @@ export const useUserStore = defineStore("user", () => {
|
|||||||
hasProblemPermission,
|
hasProblemPermission,
|
||||||
demoMode,
|
demoMode,
|
||||||
canToggleDemoMode,
|
canToggleDemoMode,
|
||||||
|
canEditProblem,
|
||||||
toggleDemoMode,
|
toggleDemoMode,
|
||||||
isAuthed,
|
isAuthed,
|
||||||
showSubmissions,
|
showSubmissions,
|
||||||
|
|||||||
@@ -144,6 +144,13 @@ export const classBoardStudentSchema = z.object({
|
|||||||
* 代码,不用等他举手。只认连着**本站**的(和课堂求助一样,在 serve 进程的内存里)
|
* 代码,不用等他举手。只认连着**本站**的(和课堂求助一样,在 serve 进程的内存里)
|
||||||
*/
|
*/
|
||||||
editing: z.string().nullable(),
|
editing: z.string().nullable(),
|
||||||
|
/**
|
||||||
|
* 此刻在线(5 分钟内有动静)、今天最后一次上线的时刻(今天没上过线为 null)。
|
||||||
|
* 机房两人共用一台电脑时同桌只有一个登着,所以「谁没来」看 seenAt,不看 online。
|
||||||
|
* 和 editing 一样只认**本站**:服务器和机房各记各的(在各自的 Redis 里)
|
||||||
|
*/
|
||||||
|
online: z.boolean(),
|
||||||
|
seenAt: z.string().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user