Compare commits
2 Commits
9675dfbd42
...
cf1eea0810
| Author | SHA1 | Date | |
|---|---|---|---|
| cf1eea0810 | |||
| 286487acf3 |
12
apps/api/src/db/0006_drop_contest_announcement.sql
Normal file
12
apps/api/src/db/0006_drop_contest_announcement.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- 删掉比赛公告表。旧 Django 栈有「比赛公告」这个功能,OJ2 从头到尾没有搬:
|
||||
-- 没有任何路由、契约或前端页面引用它,表建在那里纯粹是 introspect 0000 时一起拉进来的。
|
||||
--
|
||||
-- 已核实:
|
||||
-- * 没有任何表外键引用 contest_announcement,它只有两条指向 contest / user 的出边,
|
||||
-- 删掉是自洽的;
|
||||
-- * 序列 contest_announcement_id_seq 由本表 owned,随 DROP TABLE 一并消失;
|
||||
-- * 数据:生产快照(db_backup_2026_08_07)里只有 1 行,是 2022 年 4 月挂在 contest 1 上的
|
||||
-- 一条测试公告(「四月月赛」)。OJ2 侧没有写入路径,这个数字不会再增长。
|
||||
--
|
||||
-- 不写 CASCADE,同 0002:万一将来真有别的东西引用了,宁可这里报错,也别被悄悄级联掉。
|
||||
DROP TABLE IF EXISTS contest_announcement;
|
||||
3932
apps/api/src/db/meta/0006_snapshot.json
Normal file
3932
apps/api/src/db/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
||||
"when": 1788272667927,
|
||||
"tag": "0005_add_exercise_attempt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1788402925980,
|
||||
"tag": "0006_drop_contest_announcement",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { relations } from "drizzle-orm/relations";
|
||||
import { user, aiAnalysis, announcement, contest, contestAnnouncement, problem, flowchartSubmission, message, submission, tutorial, exercise, problemset, problemsetProblem, problemsetProgress, problemsetSubmission, reaction, problemTags, problemTag, userStat, achievement, userAchievement, problemsetBadge, userBadge, userProfile, acmContestRank } from "./schema";
|
||||
import { user, aiAnalysis, announcement, contest, problem, flowchartSubmission, message, submission, tutorial, exercise, problemset, problemsetProblem, problemsetProgress, problemsetSubmission, reaction, problemTags, problemTag, userStat, achievement, userAchievement, problemsetBadge, userBadge, userProfile, acmContestRank } from "./schema";
|
||||
|
||||
export const aiAnalysisRelations = relations(aiAnalysis, ({one}) => ({
|
||||
user: one(user, {
|
||||
@@ -12,7 +12,6 @@ export const userRelations = relations(user, ({many}) => ({
|
||||
aiAnalyses: many(aiAnalysis),
|
||||
announcements: many(announcement),
|
||||
contests: many(contest),
|
||||
contestAnnouncements: many(contestAnnouncement),
|
||||
flowchartSubmissions: many(flowchartSubmission),
|
||||
messages_recipientId: many(message, {
|
||||
relationName: "message_recipientId_user_id"
|
||||
@@ -45,23 +44,11 @@ export const contestRelations = relations(contest, ({one, many}) => ({
|
||||
fields: [contest.createdById],
|
||||
references: [user.id]
|
||||
}),
|
||||
contestAnnouncements: many(contestAnnouncement),
|
||||
problems: many(problem),
|
||||
submissions: many(submission),
|
||||
acmContestRanks: many(acmContestRank),
|
||||
}));
|
||||
|
||||
export const contestAnnouncementRelations = relations(contestAnnouncement, ({one}) => ({
|
||||
contest: one(contest, {
|
||||
fields: [contestAnnouncement.contestId],
|
||||
references: [contest.id]
|
||||
}),
|
||||
user: one(user, {
|
||||
fields: [contestAnnouncement.createdById],
|
||||
references: [user.id]
|
||||
}),
|
||||
}));
|
||||
|
||||
export const flowchartSubmissionRelations = relations(flowchartSubmission, ({one}) => ({
|
||||
problem: one(problem, {
|
||||
fields: [flowchartSubmission.problemId],
|
||||
|
||||
@@ -101,29 +101,6 @@ export const contest = pgTable("contest", {
|
||||
}),
|
||||
]);
|
||||
|
||||
export const contestAnnouncement = pgTable("contest_announcement", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
title: text().notNull(),
|
||||
content: text().notNull(),
|
||||
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
contestId: integer("contest_id").notNull(),
|
||||
createdById: integer("created_by_id").notNull(),
|
||||
visible: boolean().notNull(),
|
||||
}, (table) => [
|
||||
index("contest_announcement_contest_id_a8cb419f").using("btree", table.contestId.asc().nullsLast().op("int4_ops")),
|
||||
index("contest_announcement_created_by_id_469a14ce").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.contestId],
|
||||
foreignColumns: [contest.id],
|
||||
name: "contest_announcement_contest_id_a8cb419f_fk_contest_id"
|
||||
}),
|
||||
foreignKey({
|
||||
columns: [table.createdById],
|
||||
foreignColumns: [user.id],
|
||||
name: "contest_announcement_created_by_id_469a14ce_fk_user_id"
|
||||
}),
|
||||
]);
|
||||
|
||||
export const flowchartSubmission = pgTable("flowchart_submission", {
|
||||
id: text().primaryKey().notNull(),
|
||||
mermaidCode: text("mermaid_code").notNull(),
|
||||
|
||||
@@ -245,8 +245,10 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
||||
|
||||
adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
// 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的
|
||||
// PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」
|
||||
const [contest] = await db.select().from(schema.contest)
|
||||
.where(and(eq(schema.contest.id, id), eq(schema.contest.visible, true))).limit(1)
|
||||
.where(eq(schema.contest.id, id)).limit(1)
|
||||
if (!contest || !ownedBy(c.get("user")!, contest)) {
|
||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
TUTORIAL_READ_SECONDS,
|
||||
learnExerciseAttemptSchema,
|
||||
learnExerciseProgressListSchema,
|
||||
learnExerciseProgressSchema,
|
||||
@@ -93,7 +94,9 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
|
||||
username: schema.user.username,
|
||||
realName: schema.userProfile.realName,
|
||||
className: schema.user.className,
|
||||
readCount: count(schema.tutorialProgress.tutorialId),
|
||||
// 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。
|
||||
// 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况
|
||||
readCount: sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
|
||||
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number),
|
||||
lastViewedAt: sql<string | null>`max(${schema.tutorialProgress.lastViewedAt})`,
|
||||
}).from(schema.user)
|
||||
@@ -148,8 +151,11 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
|
||||
// 数的是 user.id 而不是 progress.user_id:join 不上的(老师自己试读的、
|
||||
// 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它,
|
||||
// 而 progress.user_id 那边永远非空,会把过滤当没发生
|
||||
readers: sql<number>`count(distinct ${schema.user.id})`.mapWith(Number),
|
||||
readers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
|
||||
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
|
||||
// 人均时长的分母是 readers(读满 3 分钟的人),分子就得是同一批人的时长,
|
||||
// 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高
|
||||
readSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(Number),
|
||||
}).from(schema.tutorial)
|
||||
.leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id))
|
||||
.leftJoin(schema.user, and(
|
||||
@@ -164,9 +170,9 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
|
||||
|
||||
return success(c, learnTutorialProgressListSchema.parse({
|
||||
studentCount,
|
||||
results: rows.map((row) => learnTutorialProgressSchema.parse({
|
||||
results: rows.map(({ readSeconds, ...row }) => learnTutorialProgressSchema.parse({
|
||||
...row,
|
||||
avgSeconds: row.readers ? Math.round(row.totalSeconds / row.readers) : 0,
|
||||
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
|
||||
})),
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -213,7 +213,10 @@ contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("rank
|
||||
db.select({ rank: schema.acmContestRank, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
||||
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime)).limit(limit).offset(offset),
|
||||
// 末尾的 id 是给排序兜全序用的:同 AC 数同罚时前两列分不出先后,而这条列表是
|
||||
// limit/offset 翻页的,行序不稳定就意味着同一个人在第 2 页出现两次、另一个人
|
||||
// 从此消失。id 本身不参与名次,只保证同分的人每次都按同一个顺序排
|
||||
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime), asc(schema.acmContestRank.id)).limit(limit).offset(offset),
|
||||
])
|
||||
const admin = isContestAdmin(c.get("user"), contest)
|
||||
return success(c, contestRankSchema.parse({
|
||||
|
||||
@@ -432,7 +432,16 @@ function canViewSubmission(
|
||||
const joinTime = problemSetJoinTime?.get(row.problemId)
|
||||
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false
|
||||
}
|
||||
if (row.userId === user.id || isAdminRole(user) || problem.createdById === user.id) return true
|
||||
// 比赛没结束时,学生管理员不吃「管理员看得到所有人代码」这条捷径:他自己也在排行榜里
|
||||
// (contest.ts 的 rank 把 Student Admin 算作参赛者),既参赛又能读别人的提交就是开卷。
|
||||
// 老师和超管不受影响 —— 他们不参赛。旧后端这里是 `not user.is_regular_user()`,
|
||||
// 学生管理员同样放行,所以这条是 OJ2 相对旧栈**收紧**的一处,不是修回归。
|
||||
//
|
||||
// 只掐角色捷径,不掐 `problem.createdById === user.id`:那是这道题的作者本人,
|
||||
// 他早就知道答案了,挡他没有意义。
|
||||
const elevated = isAdminRole(user)
|
||||
&& !(contest && contestStatus(contest) !== "-1" && user.adminType === "Student Admin")
|
||||
if (row.userId === user.id || elevated || problem.createdById === user.id) return true
|
||||
if (!allowShared) return false
|
||||
if (contest && contestStatus(contest) !== "-1") return false
|
||||
return problem.shareSubmission || row.shared
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
|
||||
import { NProgress, NText } from "naive-ui"
|
||||
import {
|
||||
getLearnStudents,
|
||||
@@ -286,6 +287,10 @@ onMounted(load)
|
||||
<n-text depth="3">
|
||||
{{ studentCount }} 名学生,{{ startedCount }} 人已经开始学
|
||||
</n-text>
|
||||
<!-- 口径写在表上方,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了 -->
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
「已读」按累计停留满 {{ TUTORIAL_READ_SECONDS / 60 }} 分钟算,不足的只计时长
|
||||
</n-text>
|
||||
</n-flex>
|
||||
|
||||
<n-tabs v-model:value="tab" type="line" animated>
|
||||
|
||||
@@ -221,11 +221,21 @@ function openExportModal() {
|
||||
async function downloadExcel() {
|
||||
exportLoading.value = true
|
||||
try {
|
||||
// 自己翻页凑齐全量:后端 limit 上限 250,而**超出上限不是截断、是静默回落到
|
||||
// 默认的 10**(routes/helpers.ts 的 queryInteger)。原来这里传 total(或 10000)
|
||||
// 想一次拉完,参赛超过 250 人时只会拿回 10 行,而下面的等级分档仍按真实总人数算 ——
|
||||
// 老师拿到的是一份 10 个人、等级全错的名单,还不报错
|
||||
const PAGE = 250
|
||||
const allRanks: ContestRank[] = []
|
||||
for (;;) {
|
||||
const res = await getContestRank(props.contestID, {
|
||||
limit: total.value || 10000,
|
||||
offset: 0,
|
||||
limit: PAGE,
|
||||
offset: allRanks.length,
|
||||
})
|
||||
const allRanks: ContestRank[] = res.results
|
||||
allRanks.push(...res.results)
|
||||
// 两个出口都要留:拿不满一页说明到底了;比对 total 是防着最后一页正好整除
|
||||
if (res.results.length < PAGE || allRanks.length >= res.total) break
|
||||
}
|
||||
|
||||
const rows = allRanks.map((rank, index) => {
|
||||
const rank1 = index + 1
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
|
||||
import type { TutorialProgress } from "utils/types"
|
||||
import { readableDuration } from "utils/functions"
|
||||
|
||||
@@ -12,6 +13,12 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [lesson: number] }>()
|
||||
|
||||
// 打开过但一秒都没攒够时 readableDuration 给的是 "-",「读了 -」不像人话。
|
||||
// 心跳 15 秒一跳,点开就走确实会落在 0 上
|
||||
function readSoFar(seconds: number) {
|
||||
return seconds > 0 ? readableDuration(seconds) : "不到 1 分钟"
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -31,14 +38,23 @@ const emit = defineEmits<{ select: [lesson: number] }>()
|
||||
{{ index + 1 }}. {{ item.title }}
|
||||
</n-text>
|
||||
<!-- 每篇教程都有一条进度(没读过的是一行零),所以这里判的是读没读过,
|
||||
不是有没有这条记录 -->
|
||||
不是有没有这条记录。
|
||||
满 TUTORIAL_READ_SECONDS 才打 ✓:打开过但没读满的仍然显示时长,
|
||||
只是不带勾、也不是成功色 —— 记是记下了,还没到「已读」 -->
|
||||
<n-text
|
||||
v-if="progress[item.id]?.viewCount"
|
||||
v-if="progress[item.id]?.totalSeconds >= TUTORIAL_READ_SECONDS"
|
||||
type="success"
|
||||
style="font-size: 12px"
|
||||
>
|
||||
✓ 已读 · {{ readableDuration(progress[item.id].totalSeconds) }}
|
||||
</n-text>
|
||||
<n-text
|
||||
v-else-if="progress[item.id]?.viewCount"
|
||||
depth="3"
|
||||
style="font-size: 12px"
|
||||
>
|
||||
读了 {{ readSoFar(progress[item.id].totalSeconds) }}
|
||||
</n-text>
|
||||
<n-text
|
||||
v-if="progress[item.id]?.exerciseTotal"
|
||||
:type="
|
||||
|
||||
@@ -141,3 +141,16 @@ export type Exercise = z.infer<typeof exerciseSchema>
|
||||
export type TutorialProgress = z.infer<typeof tutorialProgressSchema>
|
||||
export type TutorialProgressPing = z.infer<typeof tutorialProgressPingSchema>
|
||||
export type ExerciseAttemptRequest = z.infer<typeof exerciseAttemptRequestSchema>
|
||||
|
||||
/**
|
||||
* 「已读」的门槛:累计停留满 3 分钟才算读过这一课。
|
||||
*
|
||||
* 之前只要打开过(`viewCount > 0`)就记成已读,于是「点开看一眼就退」和「认真读完」
|
||||
* 在老师那张表上长得一模一样,「已读 17/17」并不说明他学了。3 分钟是按最短的一课
|
||||
* 定的下限——读不完还翻不动的课文,扫一眼是到不了这个数的。
|
||||
*
|
||||
* 门槛只管**「已读」这个口径**(学生端的 ✓、后台的已读课数与读过的人数),
|
||||
* 不动累计时长:时长记的是真实停留,不满 3 分钟的那些秒数照样算进去,
|
||||
* 「已读 0 课、累计 25 分钟」正是要让老师看见的一种情况。
|
||||
*/
|
||||
export const TUTORIAL_READ_SECONDS = 180
|
||||
|
||||
Reference in New Issue
Block a user