Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151bbdcb50 | ||
|
|
b94e028ff4 |
No files matched your search
@@ -2,7 +2,6 @@ import {
|
|||||||
createContestRequestSchema,
|
createContestRequestSchema,
|
||||||
updateAcmHelperRequestSchema,
|
updateAcmHelperRequestSchema,
|
||||||
updateContestRequestSchema,
|
updateContestRequestSchema,
|
||||||
type AcmHelperItem,
|
|
||||||
type AdminContest,
|
type AdminContest,
|
||||||
type AdminContestList,
|
type AdminContestList,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
@@ -268,60 +267,9 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
|||||||
return success(c, await serialize(row!), 201)
|
return success(c, await serialize(row!), 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- ACM 赛后核查
|
// ---------------------------------------------------------------- 赛后核查
|
||||||
|
// 「看过了」的标记。列表原来是后台单独一页(审核),2026-10 并进前台比赛页老师的「成绩」,
|
||||||
adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
// 数据从 GET /contests/:id/class-view 来,这里只剩写标记
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
|
||||||
// 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的
|
|
||||||
// PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」
|
|
||||||
const [contest] = await db.select().from(schema.contest).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")
|
|
||||||
}
|
|
||||||
|
|
||||||
const [problems, ranks] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
|
|
||||||
.from(schema.problem)
|
|
||||||
.where(eq(schema.problem.contestId, id)),
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
id: schema.acmContestRank.id,
|
|
||||||
username: schema.user.username,
|
|
||||||
realName: schema.userProfile.realName,
|
|
||||||
submissionInfo: schema.acmContestRank.submissionInfo,
|
|
||||||
acceptedNumber: schema.acmContestRank.acceptedNumber,
|
|
||||||
})
|
|
||||||
.from(schema.acmContestRank)
|
|
||||||
.innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
|
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
|
||||||
.where(eq(schema.acmContestRank.contestId, id)),
|
|
||||||
])
|
|
||||||
const displayIds = new Map(problems.map((problem) => [String(problem.id), problem.displayId]))
|
|
||||||
|
|
||||||
const results = []
|
|
||||||
for (const rank of ranks) {
|
|
||||||
if (rank.acceptedNumber <= 0) continue
|
|
||||||
for (const [problemId, info] of Object.entries(rank.submissionInfo)) {
|
|
||||||
if (info.is_ac !== true) continue
|
|
||||||
results.push({
|
|
||||||
id: rank.id,
|
|
||||||
username: rank.username,
|
|
||||||
// 真名在这里是**有意下发**的:核查页就是老师对着名单一个个确认谁抄了。
|
|
||||||
// 接口已由 requireTeacher + ownedBy 双重把关。
|
|
||||||
realName: rank.realName,
|
|
||||||
problemId,
|
|
||||||
problemDisplayId: displayIds.get(problemId) ?? problemId,
|
|
||||||
acInfo: info,
|
|
||||||
checked: info.checked === true,
|
|
||||||
_acTime: typeof info.ac_time === "number" ? info.ac_time : 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
|
|
||||||
results.sort((left, right) => right._acTime - left._acTime)
|
|
||||||
return success(c, results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[])
|
|
||||||
})
|
|
||||||
|
|
||||||
adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
||||||
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
addContestProblemRequestSchema,
|
addContestProblemRequestSchema,
|
||||||
|
reorderContestProblemsRequestSchema,
|
||||||
astCheckRequestSchema,
|
astCheckRequestSchema,
|
||||||
generateTestInputsRequestSchema,
|
generateTestInputsRequestSchema,
|
||||||
isSqlProblem,
|
isSqlProblem,
|
||||||
@@ -797,6 +798,61 @@ adminProblemRoutes.post(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比赛题按给定顺序重新编号 1、2、3…(新建比赛页拖动排序用)。
|
||||||
|
* 榜单、提交、草稿都按题目 id 记,改编号不影响已经交的东西;比赛结束后就不让动了,
|
||||||
|
* 免得赛后对着「第 3 题」讲评时题号变了。
|
||||||
|
* 唯一约束是 (_id, contest_id),直接改会和还没改的那道撞,所以先统一挪到临时编号再写回来
|
||||||
|
*/
|
||||||
|
adminProblemRoutes.put(
|
||||||
|
"/contests/:contestId/problems/order",
|
||||||
|
requireProblemPermission,
|
||||||
|
async (c) => {
|
||||||
|
const contestId = queryInteger(c.req.param("contestId"), 0, { min: 1 })
|
||||||
|
const parsed = await parseBody(c, reorderContestProblemsRequestSchema)
|
||||||
|
if (!parsed.success) return parsed.response
|
||||||
|
const [contest] = await db
|
||||||
|
.select()
|
||||||
|
.from(schema.contest)
|
||||||
|
.where(eq(schema.contest.id, contestId))
|
||||||
|
.limit(1)
|
||||||
|
const user = c.get("user")!
|
||||||
|
if (!contest || (user.adminType !== "Super Admin" && contest.createdById !== user.id)) {
|
||||||
|
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
|
}
|
||||||
|
if (contestStatus(contest) === "-1")
|
||||||
|
return failure(c, 409, "contest-ended", "比赛已经结束,不能再改题号")
|
||||||
|
const existing = await db
|
||||||
|
.select({ id: schema.problem.id })
|
||||||
|
.from(schema.problem)
|
||||||
|
.where(eq(schema.problem.contestId, contestId))
|
||||||
|
const ids = parsed.data.problemIds
|
||||||
|
const all = new Set(existing.map((row) => row.id))
|
||||||
|
if (
|
||||||
|
new Set(ids).size !== ids.length ||
|
||||||
|
ids.length !== all.size ||
|
||||||
|
ids.some((id) => !all.has(id))
|
||||||
|
) {
|
||||||
|
return failure(c, 400, "problem-list-mismatch", "题目列表和比赛里的对不上,请刷新后再排")
|
||||||
|
}
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
await tx
|
||||||
|
.update(schema.problem)
|
||||||
|
.set({ displayId: `__${id}` })
|
||||||
|
.where(eq(schema.problem.id, id))
|
||||||
|
}
|
||||||
|
for (const [index, id] of ids.entries()) {
|
||||||
|
await tx
|
||||||
|
.update(schema.problem)
|
||||||
|
.set({ displayId: String(index + 1) })
|
||||||
|
.where(eq(schema.problem.id, id))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return success(c, null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 测试用例
|
// ---------------------------------------------------------------- 测试用例
|
||||||
|
|
||||||
adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
|
adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
|
||||||
|
|||||||
+111
-63
@@ -1,12 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
|
EXAM_TAGS,
|
||||||
isSqlProblem,
|
isSqlProblem,
|
||||||
contestPasswordRequestSchema,
|
contestPasswordRequestSchema,
|
||||||
STUDENT_ROLES,
|
STUDENT_ROLES,
|
||||||
type Contest,
|
type Contest,
|
||||||
type ContestAccess,
|
type ContestAccess,
|
||||||
type ContestList,
|
type ContestList,
|
||||||
type ContestRank,
|
|
||||||
type ContestRankItem,
|
|
||||||
type ProblemDetail,
|
type ProblemDetail,
|
||||||
type ProblemListItem,
|
type ProblemListItem,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
@@ -23,8 +22,11 @@ import {
|
|||||||
checkContestPassword,
|
checkContestPassword,
|
||||||
contestDetailsAllowed,
|
contestDetailsAllowed,
|
||||||
contestStatus,
|
contestStatus,
|
||||||
|
buildClassView,
|
||||||
|
buildScoreboard,
|
||||||
findAccessibleContest,
|
findAccessibleContest,
|
||||||
isContestAdmin,
|
isContestTeacher,
|
||||||
|
rankHiddenFor,
|
||||||
requireContestAccess,
|
requireContestAccess,
|
||||||
type ContestEnv,
|
type ContestEnv,
|
||||||
} from "../services/contest"
|
} from "../services/contest"
|
||||||
@@ -71,7 +73,59 @@ function serializeContest(
|
|||||||
} satisfies Contest
|
} satisfies Contest
|
||||||
}
|
}
|
||||||
|
|
||||||
contestRoutes.get("/contests", async (c) => {
|
/**
|
||||||
|
* 列表上要的几个数:每场几道题、几个学生交过,以及「我」在每场的名次和做对数。
|
||||||
|
* 名次和榜单同一个口径(学生、没禁用、做对数 ↓ 罚时 ↑ id ↑),期中期末进行中不给名次
|
||||||
|
*/
|
||||||
|
async function listExtras(
|
||||||
|
rows: (typeof schema.contest.$inferSelect)[],
|
||||||
|
userId: number | undefined,
|
||||||
|
) {
|
||||||
|
const ids = rows.map((row) => row.id)
|
||||||
|
if (ids.length === 0) return { problems: new Map(), people: new Map(), mine: new Map() }
|
||||||
|
const idList = sql.join(
|
||||||
|
ids.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)
|
||||||
|
const studentList = sql.join(
|
||||||
|
STUDENT_ROLES.map((role) => sql`${role}`),
|
||||||
|
sql`, `,
|
||||||
|
)
|
||||||
|
const [problemRows, peopleRows, mineRows] = await Promise.all([
|
||||||
|
db.execute<{ contest_id: number; n: number }>(sql`
|
||||||
|
select contest_id, count(*)::int as n from ${schema.problem}
|
||||||
|
where contest_id in (${idList}) and visible group by contest_id
|
||||||
|
`),
|
||||||
|
db.execute<{ contest_id: number; n: number }>(sql`
|
||||||
|
select r.contest_id, count(*)::int as n from ${schema.acmContestRank} r
|
||||||
|
join ${schema.user} u on u.id = r.user_id
|
||||||
|
where r.contest_id in (${idList}) and u.admin_type in (${studentList}) and not u.is_disabled
|
||||||
|
group by r.contest_id
|
||||||
|
`),
|
||||||
|
userId
|
||||||
|
? db.execute<{ contest_id: number; solved: number; rank: number }>(sql`
|
||||||
|
select r.contest_id, r.accepted_number as solved,
|
||||||
|
1 + (select count(*)::int from ${schema.acmContestRank} o
|
||||||
|
join ${schema.user} ou on ou.id = o.user_id
|
||||||
|
where o.contest_id = r.contest_id and ou.admin_type in (${studentList})
|
||||||
|
and not ou.is_disabled and o.user_id <> r.user_id
|
||||||
|
and (o.accepted_number > r.accepted_number
|
||||||
|
or (o.accepted_number = r.accepted_number and o.total_time < r.total_time)
|
||||||
|
or (o.accepted_number = r.accepted_number and o.total_time = r.total_time
|
||||||
|
and o.id < r.id))) as rank
|
||||||
|
from ${schema.acmContestRank} r
|
||||||
|
where r.user_id = ${userId} and r.contest_id in (${idList})
|
||||||
|
`)
|
||||||
|
: Promise.resolve([]),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
problems: new Map(problemRows.map((row) => [row.contest_id, row.n])),
|
||||||
|
people: new Map(peopleRows.map((row) => [row.contest_id, row.n])),
|
||||||
|
mine: new Map(mineRows.map((row) => [row.contest_id, row])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
contestRoutes.get("/contests", optionalAuth, async (c) => {
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const keyword = c.req.query("keyword")?.trim()
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
@@ -80,7 +134,21 @@ contestRoutes.get("/contests", async (c) => {
|
|||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const filters = [eq(schema.contest.visible, true)]
|
const filters = [eq(schema.contest.visible, true)]
|
||||||
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
|
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
|
||||||
if (tag) filters.push(eq(schema.contest.tag, tag))
|
if (tag === "考试") filters.push(inArray(schema.contest.tag, [...EXAM_TAGS]))
|
||||||
|
else if (tag) filters.push(eq(schema.contest.tag, tag))
|
||||||
|
const user = c.get("user")
|
||||||
|
// 「我参加过的」:交过题的才算(榜单行是第一次交题时建的)
|
||||||
|
if (c.req.query("joined") === "1" && user) {
|
||||||
|
filters.push(
|
||||||
|
inArray(
|
||||||
|
schema.contest.id,
|
||||||
|
db
|
||||||
|
.select({ id: schema.acmContestRank.contestId })
|
||||||
|
.from(schema.acmContestRank)
|
||||||
|
.where(eq(schema.acmContestRank.userId, user.id)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (status === "1") filters.push(gte(schema.contest.startTime, now))
|
if (status === "1") filters.push(gte(schema.contest.startTime, now))
|
||||||
else if (status === "-1") filters.push(lte(schema.contest.endTime, now))
|
else if (status === "-1") filters.push(lte(schema.contest.endTime, now))
|
||||||
else if (status === "0")
|
else if (status === "0")
|
||||||
@@ -96,14 +164,28 @@ contestRoutes.get("/contests", async (c) => {
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset),
|
.offset(offset),
|
||||||
])
|
])
|
||||||
const byId = await creators([...new Set(rows.map((row) => row.createdById))])
|
const [byId, extras] = await Promise.all([
|
||||||
|
creators([...new Set(rows.map((row) => row.createdById))]),
|
||||||
|
listExtras(rows, user?.id),
|
||||||
|
])
|
||||||
return success(c, {
|
return success(c, {
|
||||||
results: rows.map((row) =>
|
results: rows.map((row) => {
|
||||||
serializeContest(
|
const mine = extras.mine.get(row.id)
|
||||||
|
const total = extras.people.get(row.id) ?? 0
|
||||||
|
return {
|
||||||
|
...serializeContest(
|
||||||
row,
|
row,
|
||||||
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
|
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
|
||||||
),
|
),
|
||||||
),
|
problemCount: extras.problems.get(row.id) ?? 0,
|
||||||
|
participantCount: total,
|
||||||
|
mine: !user
|
||||||
|
? undefined
|
||||||
|
: mine
|
||||||
|
? { rank: rankHiddenFor(user, row) ? null : mine.rank, total, solved: mine.solved }
|
||||||
|
: null,
|
||||||
|
} satisfies Contest
|
||||||
|
}),
|
||||||
total: totalRow[0]?.value ?? 0,
|
total: totalRow[0]?.value ?? 0,
|
||||||
} satisfies ContestList)
|
} satisfies ContestList)
|
||||||
})
|
})
|
||||||
@@ -224,7 +306,8 @@ contestRoutes.get(
|
|||||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true)))
|
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true)))
|
||||||
.orderBy(asc(schema.problem.displayId))
|
// 先按长度再按字面:编号是文本,直接排会变成 1、10、11…、2
|
||||||
|
.orderBy(sql`length(${schema.problem.displayId})`, asc(schema.problem.displayId))
|
||||||
const tags = await contestProblemTags(rows.map((row) => row.problem.id))
|
const tags = await contestProblemTags(rows.map((row) => row.problem.id))
|
||||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||||
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||||
@@ -323,58 +406,23 @@ contestRoutes.get(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => {
|
contestRoutes.get(
|
||||||
|
"/contests/:id/scoreboard",
|
||||||
|
optionalAuth,
|
||||||
|
requireContestAccess("ranks"),
|
||||||
|
async (c) => success(c, await buildScoreboard(c.get("contest")!, c.get("user"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
contestRoutes.get(
|
||||||
|
"/contests/:id/class-view",
|
||||||
|
optionalAuth,
|
||||||
|
requireContestAccess("ranks"),
|
||||||
|
async (c) => {
|
||||||
const contest = c.get("contest")!
|
const contest = c.get("contest")!
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const user = c.get("user")
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
if (!user || !isContestTeacher(user, contest)) {
|
||||||
const where = and(
|
return failure(c, 403, "teacher-only", "只有老师能看全班情况")
|
||||||
eq(schema.acmContestRank.contestId, contest.id),
|
}
|
||||||
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
return success(c, await buildClassView(contest, user))
|
||||||
eq(schema.user.isDisabled, false),
|
},
|
||||||
)
|
)
|
||||||
const [totalRows, rows] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select({ value: count() })
|
|
||||||
.from(schema.acmContestRank)
|
|
||||||
.innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
|
|
||||||
.where(where),
|
|
||||||
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)
|
|
||||||
// 末尾的 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, {
|
|
||||||
results: rows.map(
|
|
||||||
({ rank, user, realName }) =>
|
|
||||||
({
|
|
||||||
id: rank.id,
|
|
||||||
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
|
|
||||||
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
|
|
||||||
user: sampleUser(user, realName, { includeRealName: admin }),
|
|
||||||
submissionNumber: rank.submissionNumber,
|
|
||||||
acceptedNumber: rank.acceptedNumber,
|
|
||||||
totalTime: rank.totalTime,
|
|
||||||
submissionInfo: rank.submissionInfo,
|
|
||||||
contestId: rank.contestId,
|
|
||||||
}) satisfies ContestRankItem,
|
|
||||||
),
|
|
||||||
total: totalRows[0]?.value ?? 0,
|
|
||||||
} satisfies ContestRank)
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
import { eq } from "drizzle-orm"
|
import {
|
||||||
|
isExamTag,
|
||||||
|
STUDENT_ROLES,
|
||||||
|
TEACHER_ROLES,
|
||||||
|
type ContestClassView,
|
||||||
|
type ContestScoreboard,
|
||||||
|
type ContestScoreRow,
|
||||||
|
type ContestSubmissionInfo,
|
||||||
|
} from "@oj2/contract"
|
||||||
|
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm"
|
||||||
import type { Context, MiddlewareHandler } from "hono"
|
import type { Context, MiddlewareHandler } from "hono"
|
||||||
|
|
||||||
import type { AppEnv } from "../auth/middleware"
|
import type { AppEnv } from "../auth/middleware"
|
||||||
@@ -128,3 +137,247 @@ export function requireContestAccess(
|
|||||||
await next()
|
await next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 老师(含出题人、超管):看得到真名、全班情况,期中期末进行中也看得到排名 */
|
||||||
|
export function isContestTeacher(user: AuthUser | null | undefined, contest: ContestRow) {
|
||||||
|
return Boolean(user && (isContestAdmin(user, contest) || TEACHER_ROLES.includes(user.adminType)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 期中、期末进行中,学生看不到排名和每题做对人数(见契约 isExamTag) */
|
||||||
|
export function rankHiddenFor(user: AuthUser | null | undefined, contest: ContestRow) {
|
||||||
|
return (
|
||||||
|
isExamTag(contest.tag) && contestStatus(contest) === "0" && !isContestTeacher(user, contest)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PENALTY_SECONDS = 20 * 60
|
||||||
|
/** 「5 分钟里上升 3 名」的 5 分钟 */
|
||||||
|
const RANK_MOVE_WINDOW_SECONDS = 5 * 60
|
||||||
|
|
||||||
|
type RankRow = {
|
||||||
|
rankId: number
|
||||||
|
userId: number
|
||||||
|
username: string
|
||||||
|
realName: string | null
|
||||||
|
className: string | null
|
||||||
|
solved: number
|
||||||
|
totalTime: number
|
||||||
|
info: Record<string, ContestSubmissionInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只算 `cutoff` 秒之前做对的题,得到那一刻的名次(用户 id → 名次)。
|
||||||
|
* 做对前的错误次数都发生在做对之前,所以「那时做对的题 + 它们的罚时」就是那一刻的榜;
|
||||||
|
* 那时还没做对的题当时错了几次不影响名次(ACM 只罚做对的题)
|
||||||
|
*/
|
||||||
|
function ranksAt(rows: RankRow[], cutoff: number) {
|
||||||
|
const scored = rows.map((row) => {
|
||||||
|
let solved = 0
|
||||||
|
let time = 0
|
||||||
|
for (const info of Object.values(row.info)) {
|
||||||
|
if (info.is_ac && info.ac_time <= cutoff) {
|
||||||
|
solved += 1
|
||||||
|
time += info.ac_time + info.error_number * PENALTY_SECONDS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { row, solved, time }
|
||||||
|
})
|
||||||
|
scored.sort((a, b) => b.solved - a.solved || a.time - b.time || a.row.rankId - b.row.rankId)
|
||||||
|
return new Map(scored.map((item, index) => [item.row.userId, index + 1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整张榜。名次按 做对数 ↓、罚时 ↑ 排,同分的按 rank 行 id 定个稳定顺序(不并列名次,和原来一样)。
|
||||||
|
* 只算学生、不算禁用的号;老师赛中试做的提交不上榜,也不占「最先做对」。
|
||||||
|
*/
|
||||||
|
export async function buildScoreboard(
|
||||||
|
contest: ContestRow,
|
||||||
|
viewer: AuthUser | null | undefined,
|
||||||
|
): Promise<ContestScoreboard> {
|
||||||
|
const teacher = isContestTeacher(viewer, contest)
|
||||||
|
const hidden = rankHiddenFor(viewer, contest)
|
||||||
|
const [problems, rankRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: schema.problem.id,
|
||||||
|
displayId: schema.problem.displayId,
|
||||||
|
title: schema.problem.title,
|
||||||
|
})
|
||||||
|
.from(schema.problem)
|
||||||
|
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true)))
|
||||||
|
.orderBy(sql`length(${schema.problem.displayId})`, asc(schema.problem.displayId)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
rankId: schema.acmContestRank.id,
|
||||||
|
userId: schema.user.id,
|
||||||
|
username: schema.user.username,
|
||||||
|
realName: schema.userProfile.realName,
|
||||||
|
className: schema.user.className,
|
||||||
|
solved: schema.acmContestRank.acceptedNumber,
|
||||||
|
totalTime: schema.acmContestRank.totalTime,
|
||||||
|
info: schema.acmContestRank.submissionInfo,
|
||||||
|
})
|
||||||
|
.from(schema.acmContestRank)
|
||||||
|
.innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
|
||||||
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.acmContestRank.contestId, contest.id),
|
||||||
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
desc(schema.acmContestRank.acceptedNumber),
|
||||||
|
asc(schema.acmContestRank.totalTime),
|
||||||
|
asc(schema.acmContestRank.id),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 每题学生里最早做对的那一秒
|
||||||
|
const firstAcTime = new Map<string, number>()
|
||||||
|
for (const row of rankRows) {
|
||||||
|
for (const [problemId, info] of Object.entries(row.info)) {
|
||||||
|
if (!info.is_ac) continue
|
||||||
|
const best = firstAcTime.get(problemId)
|
||||||
|
if (best === undefined || info.ac_time < best) firstAcTime.set(problemId, info.ac_time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Math.floor((Date.now() - Date.parse(contest.startTime)) / 1000)
|
||||||
|
const prev =
|
||||||
|
contestStatus(contest) === "0" ? ranksAt(rankRows, elapsed - RANK_MOVE_WINDOW_SECONDS) : null
|
||||||
|
|
||||||
|
const rows: ContestScoreRow[] = rankRows.map((row, index) => ({
|
||||||
|
rankId: row.rankId,
|
||||||
|
userId: row.userId,
|
||||||
|
username: row.username,
|
||||||
|
realName: teacher ? row.realName : null,
|
||||||
|
className: row.className,
|
||||||
|
rank: index + 1,
|
||||||
|
prevRank: prev?.get(row.userId) ?? null,
|
||||||
|
solved: row.solved,
|
||||||
|
totalTime: row.totalTime,
|
||||||
|
cells: Object.fromEntries(
|
||||||
|
Object.entries(row.info).map(([problemId, info]) => [
|
||||||
|
problemId,
|
||||||
|
{
|
||||||
|
isAc: info.is_ac,
|
||||||
|
acTime: info.ac_time,
|
||||||
|
errors: info.error_number,
|
||||||
|
firstAc: info.is_ac && info.ac_time === firstAcTime.get(problemId),
|
||||||
|
checked: info.checked === true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const problemRows = problems.map((problem) => {
|
||||||
|
const key = String(problem.id)
|
||||||
|
let solvedUsers = 0
|
||||||
|
let triedUsers = 0
|
||||||
|
let firstSolver: { username: string; acTime: number } | null = null
|
||||||
|
for (const row of rows) {
|
||||||
|
const cell = row.cells[key]
|
||||||
|
if (!cell) continue
|
||||||
|
triedUsers += 1
|
||||||
|
if (!cell.isAc) continue
|
||||||
|
solvedUsers += 1
|
||||||
|
if (cell.firstAc && !firstSolver)
|
||||||
|
firstSolver = { username: row.username, acTime: cell.acTime }
|
||||||
|
}
|
||||||
|
return hidden
|
||||||
|
? {
|
||||||
|
id: problem.id,
|
||||||
|
_id: problem.displayId,
|
||||||
|
title: problem.title,
|
||||||
|
solvedUsers: 0,
|
||||||
|
triedUsers: 0,
|
||||||
|
firstSolver: null,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: problem.id,
|
||||||
|
_id: problem.displayId,
|
||||||
|
title: problem.title,
|
||||||
|
solvedUsers,
|
||||||
|
triedUsers,
|
||||||
|
firstSolver,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hidden) {
|
||||||
|
const mine = rows.filter((row) => row.userId === viewer?.id)
|
||||||
|
return {
|
||||||
|
hidden,
|
||||||
|
problems: problemRows,
|
||||||
|
// 「全班最先做对」也是别人的信息,考完再说
|
||||||
|
rows: mine.map((row) => ({
|
||||||
|
...row,
|
||||||
|
rank: null,
|
||||||
|
prevRank: null,
|
||||||
|
cells: Object.fromEntries(
|
||||||
|
Object.entries(row.cells).map(([key, cell]) => [key, { ...cell, firstAc: false }]),
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { hidden, problems: problemRows, rows }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 老师的「全班情况 / 成绩」:榜单 + 每人最后一次交 + 没进来的人。
|
||||||
|
* 比赛不绑班(2026-10 用户定的「都能进」),名单按参赛的人推:一个班来了一半以上,
|
||||||
|
* 就把这个班算进来,班里没交过题的人列成「没进来」。技能周那种七个班各来几个人的,一个班也不算
|
||||||
|
*/
|
||||||
|
export async function buildClassView(
|
||||||
|
contest: ContestRow,
|
||||||
|
viewer: AuthUser,
|
||||||
|
): Promise<ContestClassView> {
|
||||||
|
const board = await buildScoreboard(contest, viewer)
|
||||||
|
const last = await db.execute<{ user_id: number; create_time: string; problem_id: number }>(sql`
|
||||||
|
select distinct on (user_id) user_id, create_time, problem_id
|
||||||
|
from submission
|
||||||
|
where contest_id = ${contest.id}
|
||||||
|
order by user_id, create_time desc
|
||||||
|
`)
|
||||||
|
const lastSubmit: ContestClassView["lastSubmit"] = {}
|
||||||
|
for (const row of last) {
|
||||||
|
lastSubmit[String(row.user_id)] = { time: row.create_time, problemId: row.problem_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
const came = new Map<string, number>()
|
||||||
|
for (const row of board.rows) {
|
||||||
|
if (row.className) came.set(row.className, (came.get(row.className) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
const candidates = [...came.keys()]
|
||||||
|
const roster =
|
||||||
|
candidates.length === 0
|
||||||
|
? []
|
||||||
|
: await db
|
||||||
|
.select({
|
||||||
|
userId: schema.user.id,
|
||||||
|
username: schema.user.username,
|
||||||
|
realName: schema.userProfile.realName,
|
||||||
|
className: schema.user.className,
|
||||||
|
})
|
||||||
|
.from(schema.user)
|
||||||
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(schema.user.className, candidates),
|
||||||
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(schema.user.username))
|
||||||
|
const size = new Map<string, number>()
|
||||||
|
for (const user of roster) size.set(user.className!, (size.get(user.className!) ?? 0) + 1)
|
||||||
|
const classes = candidates
|
||||||
|
.filter((name) => (came.get(name) ?? 0) * 2 >= (size.get(name) ?? 0))
|
||||||
|
.sort()
|
||||||
|
const joined = new Set(board.rows.map((row) => row.userId))
|
||||||
|
const absent = roster.filter(
|
||||||
|
(user) => classes.includes(user.className!) && !joined.has(user.userId),
|
||||||
|
)
|
||||||
|
return { ...board, lastSubmit, classes, absent }
|
||||||
|
}
|
||||||
@@ -7,8 +7,6 @@ import type {
|
|||||||
GenerateSqlTestCaseResponse,
|
GenerateSqlTestCaseResponse,
|
||||||
RenameTagResponse,
|
RenameTagResponse,
|
||||||
SqlTestCaseScript,
|
SqlTestCaseScript,
|
||||||
AcmHelperItem,
|
|
||||||
ContestSubmissionInfo,
|
|
||||||
AdminAiReport,
|
AdminAiReport,
|
||||||
AdminAiReportList,
|
AdminAiReportList,
|
||||||
StuckProblem,
|
StuckProblem,
|
||||||
@@ -357,6 +355,11 @@ export function addProblemForContest(contestID: string, problemID: number, displ
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 比赛题按这个顺序重新编号 1、2、3…(要给全这场比赛的题) */
|
||||||
|
export function reorderContestProblems(contestID: string, problemIds: number[]) {
|
||||||
|
return api.put(`admin/contests/${contestID}/problems/order`, { problemIds })
|
||||||
|
}
|
||||||
|
|
||||||
export function getWebsite() {
|
export function getWebsite() {
|
||||||
return api.get<WebsiteConfig>("admin/website")
|
return api.get<WebsiteConfig>("admin/website")
|
||||||
}
|
}
|
||||||
@@ -478,14 +481,6 @@ export function makeProblemPublic(id: number, displayId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 比赛辅助检查
|
// 比赛辅助检查
|
||||||
export function getACMHelperList(contestId: number) {
|
|
||||||
// acInfo 在契约里是 Record<string, unknown>(acm_contest_rank 的 JSONB 原文),
|
|
||||||
// 组件侧按 ContestSubmissionInfo 读,收窄放在这里
|
|
||||||
return api.get<Array<Omit<AcmHelperItem, "acInfo"> & { acInfo: ContestSubmissionInfo }>>(
|
|
||||||
`admin/contests/${contestId}/acm-helper`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateACMHelperChecked(
|
export function updateACMHelperChecked(
|
||||||
contest_id: number,
|
contest_id: number,
|
||||||
rank_id: number,
|
rank_id: number,
|
||||||
|
|||||||
@@ -16,18 +16,9 @@ function goEdit() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function goEditProblems() {
|
// 原来的「审核」页并进了前台比赛页老师的「全班情况 / 成绩」:点格子看代码、标看过了
|
||||||
router.push({
|
function goClass() {
|
||||||
name: "admin contest problem list",
|
window.open(`/contest/${props.contest.id}/class`, "_blank")
|
||||||
params: { contestID: props.contest.id },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function goACMHelper() {
|
|
||||||
router.push({
|
|
||||||
name: "admin contest helper",
|
|
||||||
params: { contestID: props.contest.id },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clone() {
|
async function clone() {
|
||||||
@@ -45,9 +36,10 @@ async function clone() {
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<n-flex>
|
<n-flex>
|
||||||
<n-button size="small" type="primary" secondary @click="goEditProblems"> 题目 </n-button>
|
<n-button size="small" type="primary" secondary @click="goEdit"> 编辑 </n-button>
|
||||||
<n-button size="small" type="warning" secondary @click="goACMHelper"> 审核 </n-button>
|
<n-button size="small" type="info" secondary @click="goClass">
|
||||||
<n-button size="small" type="info" secondary @click="goEdit"> 编辑 </n-button>
|
{{ contest.status === "-1" ? "成绩" : "全班情况" }}
|
||||||
|
</n-button>
|
||||||
<n-button size="small" secondary @click="clone"> 复制 </n-button>
|
<n-button size="small" secondary @click="clone"> 复制 </n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,57 +1,40 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
import PageHeader from "admin/components/PageHeader.vue"
|
||||||
import { errorMessage } from "utils/api"
|
import { errorCode, errorMessage } from "utils/api"
|
||||||
import { formatISO } from "date-fns"
|
|
||||||
import TextEditor from "shared/components/TextEditor.vue"
|
import TextEditor from "shared/components/TextEditor.vue"
|
||||||
|
import { DIFFICULTY } from "utils/constants"
|
||||||
import { fromPickerValue, parseTime, toPickerValue } from "utils/functions"
|
import { fromPickerValue, parseTime, toPickerValue } from "utils/functions"
|
||||||
import type { BlankContest } from "utils/types"
|
import type { AdminProblemRow, BlankContest } from "utils/types"
|
||||||
import { createContest, editContest, getContest } from "../api"
|
import {
|
||||||
|
addProblemForContest,
|
||||||
|
createContest,
|
||||||
|
deleteContestProblem,
|
||||||
|
editContest,
|
||||||
|
getContest,
|
||||||
|
getLibraryProblemList,
|
||||||
|
getProblemList,
|
||||||
|
reorderContestProblems,
|
||||||
|
} from "../api"
|
||||||
|
|
||||||
interface Props {
|
/**
|
||||||
contestID?: string
|
* 新建 / 编辑比赛,一屏做完(设计稿「比赛重设计」后台那块):左边名字、类型、时间、密码,
|
||||||
}
|
* 右边题目 —— 搜题号把题库的题加进来,编号自动按 1、2、3 排,拖动换顺序。
|
||||||
|
*
|
||||||
|
* 开始时间默认 **10 分钟后**(用户定的:建比赛、加题要花时间,不能是「现在」)。
|
||||||
|
* 新建时题目先攒在页面上,保存时建好比赛再按顺序一道道复制进去;
|
||||||
|
* 编辑时加、删、排顺序都是当场生效的
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ contestID?: string }>()
|
||||||
|
|
||||||
function getTimes() {
|
|
||||||
const timestamp = Date.now()
|
|
||||||
const rounded = timestamp - (timestamp % 60000) // 确保秒数为0
|
|
||||||
const t1 = rounded + waitMins.value * 60000
|
|
||||||
const t2 = t1 + durationMins.value * 60000
|
|
||||||
return [t1, t2]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建的时候
|
|
||||||
const waitMins = ref(5) // 顺延5分钟
|
|
||||||
const durationMins = ref(10) // 比赛默认时长10分钟
|
|
||||||
|
|
||||||
watch([waitMins, durationMins], () => {
|
|
||||||
const times = getTimes()
|
|
||||||
contest.startTime = formatISO(times[0])
|
|
||||||
contest.endTime = formatISO(times[1])
|
|
||||||
})
|
|
||||||
|
|
||||||
// 编辑的时候。这两个 ref 绑给 n-date-picker,值要平移过(见 utils/functions.ts
|
|
||||||
// 的 toPickerValue)—— 选择器按浏览器本地渲染,不换算的话非东八区的老师看到的是
|
|
||||||
// 自己时区的钟点,存进去就成了另一个时刻。
|
|
||||||
const startTime = ref(0)
|
|
||||||
const endTime = ref(0)
|
|
||||||
|
|
||||||
watch([startTime, endTime], (values) => {
|
|
||||||
contest.startTime = formatISO(fromPickerValue(values[0]))
|
|
||||||
contest.endTime = formatISO(fromPickerValue(values[1]))
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const props = defineProps<Props>()
|
const dialog = useDialog()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
const [ready, toggleReady] = useToggle()
|
const editing = computed(() => !!props.contestID)
|
||||||
|
const ready = ref(false)
|
||||||
const tags: SelectOption[] = [
|
const saving = ref(false)
|
||||||
{ label: "练习", value: "练习" },
|
|
||||||
{ label: "期中", value: "期中" },
|
|
||||||
{ label: "期末", value: "期末" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const contest = reactive<BlankContest & { id: number }>({
|
const contest = reactive<BlankContest & { id: number }>({
|
||||||
id: 0,
|
id: 0,
|
||||||
@@ -61,103 +44,628 @@ const contest = reactive<BlankContest & { id: number }>({
|
|||||||
startTime: "",
|
startTime: "",
|
||||||
endTime: "",
|
endTime: "",
|
||||||
password: "",
|
password: "",
|
||||||
visible: false,
|
// 不列出来学生就进不去(隐藏比赛对学生是 404),新建默认就列出来
|
||||||
|
visible: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function getContestDetail() {
|
// ---------------------------------------------------------------- 时间
|
||||||
|
|
||||||
|
const WAIT_CHOICES = [10, 20, 30]
|
||||||
|
const DURATION_CHOICES = [40, 60, 90]
|
||||||
|
|
||||||
|
/** 新建时「几分钟后开始」;null = 自己选了具体时刻 */
|
||||||
|
const waitMins = ref<number | null>(10)
|
||||||
|
const durationMins = ref(60)
|
||||||
|
const customDuration = ref(false)
|
||||||
|
/** 自己选的开始时刻,已平移给 n-date-picker(见 utils/functions.ts 的 toPickerValue) */
|
||||||
|
const pickedStart = ref(toPickerValue(Date.now() + 10 * 60_000))
|
||||||
|
|
||||||
|
// 「10 分钟后」跟着钟走:老师在这页磨蹭几分钟再保存,开始时间也还是保存时的 10 分钟后
|
||||||
|
const now = useNow({ interval: 15_000 })
|
||||||
|
function roundedNow() {
|
||||||
|
const ms = now.value.getTime()
|
||||||
|
return ms - (ms % 60_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMs = computed(() =>
|
||||||
|
waitMins.value !== null
|
||||||
|
? roundedNow() + waitMins.value * 60_000
|
||||||
|
: fromPickerValue(pickedStart.value),
|
||||||
|
)
|
||||||
|
const endMs = computed(() => startMs.value + durationMins.value * 60_000)
|
||||||
|
|
||||||
|
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]
|
||||||
|
const timeHint = computed(() => {
|
||||||
|
const start = new Date(startMs.value).toISOString()
|
||||||
|
const end = new Date(endMs.value).toISOString()
|
||||||
|
// 东八区的星期几:平移 8 小时后读 UTC
|
||||||
|
const weekday = WEEKDAYS[new Date(startMs.value + 8 * 3600_000).getUTCDay()]
|
||||||
|
const sameDay = parseTime(start, "YYYY-MM-DD") === parseTime(end, "YYYY-MM-DD")
|
||||||
|
return `${parseTime(start, "M月D日")} 周${weekday} ${parseTime(start, "HH:mm")} 开始,${parseTime(end, sameDay ? "HH:mm" : "M月D日 HH:mm")} 结束`
|
||||||
|
})
|
||||||
|
|
||||||
|
function pickWait(minutes: number | null) {
|
||||||
|
if (minutes === null) pickedStart.value = toPickerValue(startMs.value)
|
||||||
|
waitMins.value = minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDuration(minutes: number | null) {
|
||||||
|
customDuration.value = minutes === null
|
||||||
|
if (minutes !== null) durationMins.value = minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 题目
|
||||||
|
|
||||||
|
type Item = { id: number; displayId: string; title: string; difficulty: string }
|
||||||
|
/** 新建时是题库里的题(id 是公开题的),编辑时是比赛里的题(id 是比赛题自己的) */
|
||||||
|
const items = ref<Item[]>([])
|
||||||
|
/** 新建:题库里已经选进来的公开题 id,用来标「已加入」 */
|
||||||
|
const pickedPublic = computed(() => new Set(items.value.map((item) => item.id)))
|
||||||
|
|
||||||
|
async function loadContestProblems() {
|
||||||
|
const res = await getProblemList(0, 250, "", undefined, props.contestID)
|
||||||
|
items.value = res.results
|
||||||
|
.map((row: AdminProblemRow) => ({
|
||||||
|
id: row.id,
|
||||||
|
displayId: row._id,
|
||||||
|
title: row.title,
|
||||||
|
difficulty: row.difficulty,
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(a, b) => a.displayId.length - b.displayId.length || a.displayId.localeCompare(b.displayId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = ref("")
|
||||||
|
const results = ref<AdminProblemRow[]>([])
|
||||||
|
const searching = ref(false)
|
||||||
|
watchDebounced(
|
||||||
|
keyword,
|
||||||
|
async (value) => {
|
||||||
|
if (!value.trim()) {
|
||||||
|
results.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
searching.value = true
|
||||||
|
try {
|
||||||
|
results.value = (await getLibraryProblemList(0, 8, value.trim())).results
|
||||||
|
} finally {
|
||||||
|
searching.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ debounce: 300 },
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 编辑时比赛题是复制品,没法知道它从哪道公开题来,按题名比一下防重复 */
|
||||||
|
function added(row: AdminProblemRow) {
|
||||||
|
return editing.value
|
||||||
|
? items.value.some((item) => item.title === row.title)
|
||||||
|
: pickedPublic.value.has(row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add(row: AdminProblemRow) {
|
||||||
|
if (!editing.value) {
|
||||||
|
items.value.push({
|
||||||
|
id: row.id,
|
||||||
|
displayId: String(items.value.length + 1),
|
||||||
|
title: row.title,
|
||||||
|
difficulty: row.difficulty,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const next = Math.max(0, ...items.value.map((item) => Number(item.displayId) || 0)) + 1
|
||||||
|
try {
|
||||||
|
await addProblemForContest(props.contestID!, row.id, String(next))
|
||||||
|
await loadContestProblems()
|
||||||
|
} catch (err) {
|
||||||
|
const code = errorCode(err)
|
||||||
|
message.error(
|
||||||
|
code === "contest-ended"
|
||||||
|
? "比赛已经结束,不能再加题"
|
||||||
|
: code === "display-id-exists"
|
||||||
|
? "题号重了,刷新一下再加"
|
||||||
|
: errorMessage(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(item: Item) {
|
||||||
|
if (!editing.value) {
|
||||||
|
items.value = items.value.filter((it) => it !== item)
|
||||||
|
renumber()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialog.warning({
|
||||||
|
title: "从比赛里删掉这道题?",
|
||||||
|
content: `第 ${item.displayId} 题「${item.title}」。已经有人交过的题删不掉。`,
|
||||||
|
positiveText: "删掉",
|
||||||
|
negativeText: "不删",
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try {
|
||||||
|
await deleteContestProblem(item.id)
|
||||||
|
await loadContestProblems()
|
||||||
|
if (items.value.length) await saveOrder()
|
||||||
|
} catch (err) {
|
||||||
|
message.error(
|
||||||
|
errorCode(err) === "problem-has-submissions"
|
||||||
|
? "已经有人交过这道题,删不掉;不想让学生看到可以在题目里关掉「可见」"
|
||||||
|
: errorMessage(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renumber() {
|
||||||
|
items.value.forEach((item, index) => (item.displayId = String(index + 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOrder() {
|
||||||
|
renumber()
|
||||||
|
if (!editing.value) return
|
||||||
|
try {
|
||||||
|
await reorderContestProblems(
|
||||||
|
props.contestID!,
|
||||||
|
items.value.map((item) => item.id),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
message.error(errorMessage(err))
|
||||||
|
await loadContestProblems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖动排序:原生 drag & drop,够用了
|
||||||
|
const dragging = ref<number | null>(null)
|
||||||
|
function onDragStart(index: number) {
|
||||||
|
dragging.value = index
|
||||||
|
}
|
||||||
|
function onDragEnter(index: number) {
|
||||||
|
const from = dragging.value
|
||||||
|
if (from === null || from === index) return
|
||||||
|
const list = [...items.value]
|
||||||
|
const [moved] = list.splice(from, 1)
|
||||||
|
list.splice(index, 0, moved!)
|
||||||
|
items.value = list
|
||||||
|
dragging.value = index
|
||||||
|
}
|
||||||
|
function onDragEnd() {
|
||||||
|
if (dragging.value === null) return
|
||||||
|
dragging.value = null
|
||||||
|
saveOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
function editProblem(item: Item) {
|
||||||
|
router.push({
|
||||||
|
name: "admin contest problem edit",
|
||||||
|
params: { contestID: props.contestID, problemID: item.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 载入 / 保存
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
keyword.value = ""
|
||||||
if (!props.contestID) {
|
if (!props.contestID) {
|
||||||
const times = getTimes()
|
ready.value = true
|
||||||
contest.startTime = formatISO(times[0])
|
|
||||||
contest.endTime = formatISO(times[1])
|
|
||||||
toggleReady(true)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const data = await getContest(props.contestID)
|
const data = await getContest(props.contestID)
|
||||||
toggleReady(true)
|
Object.assign(contest, {
|
||||||
contest.id = data.id
|
id: data.id,
|
||||||
contest.title = data.title
|
title: data.title,
|
||||||
contest.description = data.description
|
description: data.description,
|
||||||
contest.tag = data.tag
|
tag: data.tag,
|
||||||
contest.startTime = data.startTime
|
startTime: data.startTime,
|
||||||
contest.endTime = data.endTime
|
endTime: data.endTime,
|
||||||
contest.password = data.password
|
password: data.password,
|
||||||
contest.visible = data.visible
|
visible: data.visible,
|
||||||
|
})
|
||||||
// 显示:交给选择器之前先平移成「北京墙上时间」
|
waitMins.value = null
|
||||||
startTime.value = toPickerValue(Date.parse(data.startTime))
|
pickedStart.value = toPickerValue(Date.parse(data.startTime))
|
||||||
endTime.value = toPickerValue(Date.parse(data.endTime))
|
const minutes = Math.round((Date.parse(data.endTime) - Date.parse(data.startTime)) / 60_000)
|
||||||
|
durationMins.value = minutes
|
||||||
|
customDuration.value = !DURATION_CHOICES.includes(minutes)
|
||||||
|
await loadContestProblems()
|
||||||
|
ready.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (contest.description === "<p><br></p>") {
|
if (!contest.title.trim()) {
|
||||||
|
message.error("比赛还没起名字")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (durationMins.value <= 0) {
|
||||||
|
message.error("时长要大于 0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contest.startTime = new Date(startMs.value).toISOString()
|
||||||
|
contest.endTime = new Date(endMs.value).toISOString()
|
||||||
|
if (!contest.description || contest.description === "<p><br></p>") {
|
||||||
contest.description = contest.title
|
contest.description = contest.title
|
||||||
}
|
}
|
||||||
const api = {
|
saving.value = true
|
||||||
"admin contest create": createContest,
|
|
||||||
"admin contest edit": editContest,
|
|
||||||
}[route.name as string]
|
|
||||||
try {
|
try {
|
||||||
await api!(contest)
|
if (editing.value) {
|
||||||
if (route.name === "admin contest create") {
|
await editContest(contest)
|
||||||
message.success("成功新建比赛 💐")
|
message.success("改好了")
|
||||||
} else {
|
return
|
||||||
message.success("修改已保存")
|
|
||||||
}
|
}
|
||||||
router.push({ name: "admin contest list" })
|
const created = await createContest(contest)
|
||||||
|
for (const [index, item] of items.value.entries()) {
|
||||||
|
await addProblemForContest(String(created.id), item.id, String(index + 1))
|
||||||
|
}
|
||||||
|
message.success(
|
||||||
|
items.value.length ? `比赛建好了,${items.value.length} 道题都加进去了` : "比赛建好了",
|
||||||
|
)
|
||||||
|
router.replace({ name: "admin contest edit", params: { contestID: created.id } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error(errorMessage(err))
|
message.error(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(getContestDetail)
|
watch(() => props.contestID, load)
|
||||||
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHeader
|
<PageHeader :title="editing ? '编辑比赛' : '新建比赛'" :back="{ name: 'admin contest list' }">
|
||||||
:title="route.name === 'admin contest create' ? '新建比赛' : '编辑比赛'"
|
<template v-if="editing" #actions>
|
||||||
:back="{ name: 'admin contest list' }"
|
<n-button tag="a" :href="`/contest/${contestID}/class`" target="_blank">
|
||||||
>
|
到前台看这场比赛
|
||||||
<template v-if="!props.contestID" #extra>
|
</n-button>
|
||||||
<n-tag type="success" round>
|
|
||||||
开始 {{ parseTime(contest.startTime, "YYYY年M月D日 HH:mm:ss") }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag type="warning" round>
|
|
||||||
结束 {{ parseTime(contest.endTime, "YYYY年M月D日 HH:mm:ss") }}
|
|
||||||
</n-tag>
|
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<n-form inline>
|
|
||||||
<n-form-item label="标题">
|
<div v-if="ready" class="cols">
|
||||||
<n-input style="width: 300px" v-model:value="contest.title" />
|
<section class="card form">
|
||||||
</n-form-item>
|
<div class="field">
|
||||||
<n-form-item label="标签">
|
<span class="label">名字</span>
|
||||||
<n-select style="width: 100px" :options="tags" v-model:value="contest.tag" />
|
<n-input v-model:value="contest.title" placeholder="比如 24计算机1班期末考试" />
|
||||||
</n-form-item>
|
</div>
|
||||||
<template v-if="props.contestID">
|
|
||||||
<n-form-item label="开始">
|
<div class="field top">
|
||||||
<n-date-picker style="width: 200px" v-model:value="startTime" type="datetime" />
|
<span class="label">类型</span>
|
||||||
</n-form-item>
|
<div class="stack">
|
||||||
<n-form-item label="结束">
|
<n-radio-group v-model:value="contest.tag">
|
||||||
<n-date-picker style="width: 200px" v-model:value="endTime" type="datetime" />
|
<n-radio-button value="练习">练习</n-radio-button>
|
||||||
</n-form-item>
|
<n-radio-button value="期中">期中</n-radio-button>
|
||||||
|
<n-radio-button value="期末">期末</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
|
<span class="hint">
|
||||||
|
练习:学生边做边看排名。期中、期末:考试中学生看不到排名和每题做对人数,考完一起公布。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field top">
|
||||||
|
<span class="label">开始</span>
|
||||||
|
<div class="stack">
|
||||||
|
<n-flex :size="6">
|
||||||
|
<template v-if="!editing">
|
||||||
|
<button
|
||||||
|
v-for="m in WAIT_CHOICES"
|
||||||
|
:key="m"
|
||||||
|
class="chip"
|
||||||
|
:class="{ on: waitMins === m }"
|
||||||
|
@click="pickWait(m)"
|
||||||
|
>
|
||||||
|
{{ m }} 分钟后
|
||||||
|
</button>
|
||||||
|
<button class="chip" :class="{ on: waitMins === null }" @click="pickWait(null)">
|
||||||
|
选时间…
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<n-date-picker
|
||||||
<n-form-item label="几分钟后开始">
|
v-if="waitMins === null"
|
||||||
<n-input-number style="width: 120px" v-model:value="waitMins" />
|
v-model:value="pickedStart"
|
||||||
</n-form-item>
|
type="datetime"
|
||||||
<n-form-item label="比赛时长">
|
format="yyyy-MM-dd HH:mm"
|
||||||
<n-input-number style="width: 120px" step="5" v-model:value="durationMins" />
|
style="width: 200px"
|
||||||
</n-form-item>
|
/>
|
||||||
</template>
|
|
||||||
<n-form-item label="密码">
|
|
||||||
<n-input style="width: 160px" v-model:value="contest.password" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="可见">
|
|
||||||
<n-switch v-model:value="contest.visible" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<TextEditor v-if="ready" title="描述" v-model:value="contest.description" :min-height="200" />
|
|
||||||
<n-flex style="margin-bottom: 100px" justify="end">
|
|
||||||
<n-button type="primary" @click="submit">保存</n-button>
|
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field top">
|
||||||
|
<span class="label">时长</span>
|
||||||
|
<div class="stack">
|
||||||
|
<n-flex :size="6" align="center">
|
||||||
|
<button
|
||||||
|
v-for="m in DURATION_CHOICES"
|
||||||
|
:key="m"
|
||||||
|
class="chip"
|
||||||
|
:class="{ on: !customDuration && durationMins === m }"
|
||||||
|
@click="pickDuration(m)"
|
||||||
|
>
|
||||||
|
{{ m === 60 ? "1 小时" : `${m} 分钟` }}
|
||||||
|
</button>
|
||||||
|
<button class="chip" :class="{ on: customDuration }" @click="pickDuration(null)">
|
||||||
|
自己填
|
||||||
|
</button>
|
||||||
|
<n-input-number
|
||||||
|
v-if="customDuration"
|
||||||
|
v-model:value="durationMins"
|
||||||
|
:min="1"
|
||||||
|
:step="5"
|
||||||
|
style="width: 130px"
|
||||||
|
>
|
||||||
|
<template #suffix>分钟</template>
|
||||||
|
</n-input-number>
|
||||||
|
</n-flex>
|
||||||
|
<span class="hint">{{ timeHint }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">密码</span>
|
||||||
|
<n-input v-model:value="contest.password" placeholder="不填,谁都能进" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">列出来</span>
|
||||||
|
<n-switch v-model:value="contest.visible" />
|
||||||
|
<span class="hint">{{
|
||||||
|
contest.visible ? "学生在比赛列表里看得到" : "藏起来,学生进不去"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field top">
|
||||||
|
<span class="label">说明</span>
|
||||||
|
<div class="stack grow">
|
||||||
|
<TextEditor v-model:value="contest.description" title="" mini :min-height="80" />
|
||||||
|
<span class="hint">选填,学生在「比赛信息」里看得到。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="save">
|
||||||
|
<n-button type="primary" :loading="saving" @click="submit">保存</n-button>
|
||||||
|
<span class="hint">{{ items.length }} 道题 · {{ timeHint }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card picker">
|
||||||
|
<div class="picker-head">
|
||||||
|
<b>题目</b>
|
||||||
|
<span class="hint">{{ items.length }} 道 · 拖动排顺序,题号自动按 1、2、3 排</span>
|
||||||
|
</div>
|
||||||
|
<div class="search">
|
||||||
|
<n-input
|
||||||
|
v-model:value="keyword"
|
||||||
|
clearable
|
||||||
|
:loading="searching"
|
||||||
|
placeholder="输入题号或题目名,从题库里加"
|
||||||
|
/>
|
||||||
|
<n-button
|
||||||
|
v-if="editing"
|
||||||
|
@click="router.push({ name: 'admin contest problem create', params: { contestID } })"
|
||||||
|
>
|
||||||
|
+ 新出一道
|
||||||
|
</n-button>
|
||||||
|
<n-tooltip v-else>
|
||||||
|
<template #trigger>
|
||||||
|
<n-button disabled>+ 新出一道</n-button>
|
||||||
</template>
|
</template>
|
||||||
|
先保存比赛,再出只在比赛里用的题
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
<div v-if="results.length" class="results">
|
||||||
|
<div v-for="row in results" :key="row.id" class="result">
|
||||||
|
<span class="muted num">{{ row._id }}</span>
|
||||||
|
<span class="ell grow">{{ row.title }}</span>
|
||||||
|
<span class="muted tiny">{{ DIFFICULTY[row.difficulty] }}</span>
|
||||||
|
<span v-if="added(row)" class="muted tiny">已加入</span>
|
||||||
|
<n-button v-else size="small" text type="primary" @click="add(row)">+ 加入</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="items.length" class="list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="item.id"
|
||||||
|
class="item"
|
||||||
|
:class="{ dragging: dragging === index }"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="onDragStart(index)"
|
||||||
|
@dragenter.prevent="onDragEnter(index)"
|
||||||
|
@dragover.prevent
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
>
|
||||||
|
<span class="grip" aria-hidden="true">⋮⋮</span>
|
||||||
|
<b class="num">{{ item.displayId }}</b>
|
||||||
|
<span class="ell grow">{{ item.title }}</span>
|
||||||
|
<span class="muted tiny">{{
|
||||||
|
DIFFICULTY[item.difficulty as keyof typeof DIFFICULTY]
|
||||||
|
}}</span>
|
||||||
|
<n-button v-if="editing" size="small" text type="primary" @click="editProblem(item)"
|
||||||
|
>改</n-button
|
||||||
|
>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
:aria-label="`删掉第 ${item.displayId} 题`"
|
||||||
|
@click="remove(item)"
|
||||||
|
>✕</n-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty hint">还没加题。在上面输入题号,比如 2033</div>
|
||||||
|
<span class="hint">
|
||||||
|
从题库加的题会复制一份进比赛:比赛里改它不影响原题,学生在题库里也搜不到比赛题。
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cols {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 6px;
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
flex: 1 1 460px;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
flex: 999 1 480px;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.top {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.top .label {
|
||||||
|
line-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
width: 52px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiny {
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
min-width: 28px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ell {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.on {
|
||||||
|
border-color: v-bind("theme.primaryColor");
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results,
|
||||||
|
.list {
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
margin-top: -4px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result,
|
||||||
|
.item {
|
||||||
|
min-height: 38px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result + .result,
|
||||||
|
.item + .item {
|
||||||
|
border-top: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
cursor: grab;
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grip {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
letter-spacing: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 18px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
|
||||||
import { errorMessage } from "utils/api"
|
|
||||||
import type { AcmHelperItem, ContestSubmissionInfo } from "utils/types"
|
|
||||||
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
|
|
||||||
import { parseTime } from "utils/functions"
|
|
||||||
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
|
|
||||||
import { getSubmission, getSubmissions } from "oj/api"
|
|
||||||
import SubmissionDetail from "oj/submission/detail.vue"
|
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
contestID: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ACM 助手行。`acInfo` 的**内容**是 acm_contest_rank.submission_info 的 JSONB 原文,
|
|
||||||
* 键名保持 snake_case —— 判题写进去的就是这套键名,见 utils/types.ts 的 ContestSubmissionInfo。
|
|
||||||
*/
|
|
||||||
type HelperItem = Omit<AcmHelperItem, "acInfo"> & {
|
|
||||||
acInfo: ContestSubmissionInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
|
||||||
|
|
||||||
const submissions = ref<HelperItem[]>([])
|
|
||||||
const contestStartTime = ref<Date | null>(null)
|
|
||||||
const query = reactive({
|
|
||||||
username: "",
|
|
||||||
problemId: "",
|
|
||||||
checked: "all",
|
|
||||||
})
|
|
||||||
|
|
||||||
// 检查状态选项
|
|
||||||
const checkedOptions = [
|
|
||||||
{ label: "全部", value: "all" },
|
|
||||||
{ label: "已检查", value: "checked" },
|
|
||||||
{ label: "未检查", value: "unchecked" },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 代码查看模态框
|
|
||||||
const [codePanel, toggleCodePanel] = useToggle(false)
|
|
||||||
const currentSubmission = ref<any>(null)
|
|
||||||
|
|
||||||
// 格式化 AC 时间(ac_time 是相对于比赛开始的秒数)
|
|
||||||
function formatACTime(relativeSeconds: number) {
|
|
||||||
if (!contestStartTime.value) return "-"
|
|
||||||
const acTime = new Date(contestStartTime.value.getTime() + relativeSeconds * 1000)
|
|
||||||
return parseTime(acTime, "YYYY-MM-DD HH:mm:ss")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换检查状态
|
|
||||||
async function toggleChecked(item: HelperItem) {
|
|
||||||
const newChecked = !item.checked
|
|
||||||
try {
|
|
||||||
await updateACMHelperChecked(Number(props.contestID), item.id, item.problemId, newChecked)
|
|
||||||
// 更新本地状态
|
|
||||||
item.checked = newChecked
|
|
||||||
item.acInfo.checked = newChecked
|
|
||||||
|
|
||||||
// 强制触发响应式更新
|
|
||||||
submissions.value = [...submissions.value]
|
|
||||||
|
|
||||||
message.success(newChecked ? "已标记为已检查" : "已取消标记")
|
|
||||||
} catch (err) {
|
|
||||||
message.error(errorMessage(err, "操作失败"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量标记为已检查
|
|
||||||
async function markAllAsChecked() {
|
|
||||||
const unchecked = filteredSubmissions.value.filter((item) => !item.checked)
|
|
||||||
if (unchecked.length === 0) {
|
|
||||||
message.info("没有需要标记的提交")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadingMsg = message.loading("正在标记...", { duration: 0 })
|
|
||||||
try {
|
|
||||||
for (const item of unchecked) {
|
|
||||||
await updateACMHelperChecked(Number(props.contestID), item.id, item.problemId, true)
|
|
||||||
item.checked = true
|
|
||||||
item.acInfo.checked = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 强制触发响应式更新
|
|
||||||
submissions.value = [...submissions.value]
|
|
||||||
|
|
||||||
loadingMsg.destroy()
|
|
||||||
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
|
|
||||||
} catch (err) {
|
|
||||||
loadingMsg.destroy()
|
|
||||||
message.error(errorMessage(err, "批量操作失败"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 过滤后的提交列表
|
|
||||||
const filteredSubmissions = computed(() => {
|
|
||||||
return submissions.value.filter((item) => {
|
|
||||||
if (query.username && !item.username.includes(query.username)) return false
|
|
||||||
if (query.problemId && !item.problemDisplayId.includes(query.problemId)) return false
|
|
||||||
if (query.checked === "checked" && !item.checked) return false
|
|
||||||
if (query.checked === "unchecked" && item.checked) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 统计信息
|
|
||||||
const stats = computed(() => {
|
|
||||||
const total = submissions.value.length
|
|
||||||
const checked = submissions.value.filter((item) => item.checked).length
|
|
||||||
const unchecked = total - checked
|
|
||||||
return { total, checked, unchecked }
|
|
||||||
})
|
|
||||||
|
|
||||||
// 查看代码 - 获取该用户在该题目的 AC 提交
|
|
||||||
async function viewSubmission(item: HelperItem) {
|
|
||||||
try {
|
|
||||||
// 查询该用户在该竞赛该题目的 AC 提交
|
|
||||||
const res = await getSubmissions({
|
|
||||||
username: item.username,
|
|
||||||
problemDisplayId: item.problemDisplayId,
|
|
||||||
contestId: props.contestID,
|
|
||||||
result: "0", // ACCEPTED
|
|
||||||
language: "",
|
|
||||||
page: 1,
|
|
||||||
offset: 0,
|
|
||||||
limit: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (res.results.length === 0) {
|
|
||||||
message.warning("未找到该用户的 AC 提交")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取提交详情
|
|
||||||
const submissionListItem = res.results[0]
|
|
||||||
const detailRes = await getSubmission(submissionListItem.id)
|
|
||||||
|
|
||||||
// 手动添加 contest 字段(ACM模式下后端不返回此字段)
|
|
||||||
currentSubmission.value = {
|
|
||||||
...detailRes,
|
|
||||||
contest: Number(props.contestID),
|
|
||||||
problem_display_id: item.problemDisplayId,
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleCodePanel(true)
|
|
||||||
} catch (err) {
|
|
||||||
message.error(errorMessage(err, "加载提交失败"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载数据
|
|
||||||
async function loadData() {
|
|
||||||
try {
|
|
||||||
// 先获取比赛信息,获取开始时间
|
|
||||||
const contestRes = await getContest(props.contestID)
|
|
||||||
contestStartTime.value = new Date(contestRes.startTime)
|
|
||||||
|
|
||||||
// 再获取 AC 提交列表
|
|
||||||
const data = await getACMHelperList(Number(props.contestID))
|
|
||||||
submissions.value = data
|
|
||||||
} catch (err) {
|
|
||||||
message.error(errorMessage(err, "加载失败"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: DataTableColumn<HelperItem>[] = [
|
|
||||||
{
|
|
||||||
title: "用户名",
|
|
||||||
key: "username",
|
|
||||||
width: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "题目",
|
|
||||||
key: "problem_display_id",
|
|
||||||
width: 100,
|
|
||||||
render: (row) => h(NTag, { type: "info" }, () => row.problemDisplayId),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "AC时间",
|
|
||||||
key: "ac_time",
|
|
||||||
width: 180,
|
|
||||||
render: (row) => formatACTime(row.acInfo.ac_time),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "错误次数",
|
|
||||||
key: "error_number",
|
|
||||||
width: 100,
|
|
||||||
render: (row) =>
|
|
||||||
h(
|
|
||||||
NTag,
|
|
||||||
{
|
|
||||||
type: row.acInfo.error_number > 0 ? "warning" : "success",
|
|
||||||
size: "small",
|
|
||||||
},
|
|
||||||
() => row.acInfo.error_number,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "已检查",
|
|
||||||
key: "checked",
|
|
||||||
width: 100,
|
|
||||||
render: (row) =>
|
|
||||||
h(NCheckbox, {
|
|
||||||
checked: row.checked,
|
|
||||||
onUpdateChecked: () => toggleChecked(row),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "操作",
|
|
||||||
key: "actions",
|
|
||||||
width: 100,
|
|
||||||
render: (row) =>
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
size: "small",
|
|
||||||
type: "primary",
|
|
||||||
secondary: true,
|
|
||||||
onClick: () => viewSubmission(row),
|
|
||||||
},
|
|
||||||
() => "查看代码",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(loadData)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<n-flex vertical>
|
|
||||||
<PageHeader
|
|
||||||
title="比赛辅助检查"
|
|
||||||
description="赛后人工审核代码,检查是否存在抄袭、作弊等行为。请逐个查看通过(AC)的提交代码,检查完成后勾选「已检查」。"
|
|
||||||
:back="{ name: 'admin contest list' }"
|
|
||||||
>
|
|
||||||
<template #extra>
|
|
||||||
<n-tag type="info" round>总计 {{ stats.total }}</n-tag>
|
|
||||||
<n-tag type="success" round>已检查 {{ stats.checked }}</n-tag>
|
|
||||||
<n-tag type="warning" round>未检查 {{ stats.unchecked }}</n-tag>
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<n-button type="primary" :disabled="stats.unchecked === 0" @click="markAllAsChecked">
|
|
||||||
标记全部为已检查
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
<template #filters>
|
|
||||||
<n-input
|
|
||||||
v-model:value="query.username"
|
|
||||||
placeholder="筛选用户名"
|
|
||||||
style="width: 150px"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
<n-input
|
|
||||||
v-model:value="query.problemId"
|
|
||||||
placeholder="筛选题目"
|
|
||||||
style="width: 150px"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
<n-select v-model:value="query.checked" :options="checkedOptions" style="width: 120px" />
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
:columns="columns"
|
|
||||||
:data="filteredSubmissions"
|
|
||||||
:pagination="{ pageSize: 20 }"
|
|
||||||
:bordered="false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-modal
|
|
||||||
v-model:show="codePanel"
|
|
||||||
preset="card"
|
|
||||||
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
|
|
||||||
:content-style="{ overflow: 'auto' }"
|
|
||||||
title="代码详情"
|
|
||||||
>
|
|
||||||
<SubmissionDetail
|
|
||||||
v-if="currentSubmission"
|
|
||||||
:submission="currentSubmission"
|
|
||||||
:submissionID="currentSubmission.id"
|
|
||||||
hideList
|
|
||||||
@copied="toggleCodePanel(false)"
|
|
||||||
/>
|
|
||||||
</n-modal>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
:deep(.n-data-table) {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+14
-9
@@ -4,6 +4,8 @@ import {
|
|||||||
type Contest,
|
type Contest,
|
||||||
type ContestAccess,
|
type ContestAccess,
|
||||||
type ContestList,
|
type ContestList,
|
||||||
|
type ContestClassView,
|
||||||
|
type ContestScoreboard,
|
||||||
type ActivityRankItem,
|
type ActivityRankItem,
|
||||||
type FormatCodeResponse,
|
type FormatCodeResponse,
|
||||||
type Metrics,
|
type Metrics,
|
||||||
@@ -52,7 +54,6 @@ import { toProblemRow } from "oj/transforms"
|
|||||||
import type {
|
import type {
|
||||||
Announcement,
|
Announcement,
|
||||||
AnnouncementListItem,
|
AnnouncementListItem,
|
||||||
ContestRank,
|
|
||||||
Profile,
|
Profile,
|
||||||
Message,
|
Message,
|
||||||
SubmissionListItem,
|
SubmissionListItem,
|
||||||
@@ -303,9 +304,14 @@ export function getContestList(query: {
|
|||||||
limit: number
|
limit: number
|
||||||
keyword: string
|
keyword: string
|
||||||
status: string
|
status: string
|
||||||
|
/** 练习 / 期中 / 期末,或「考试」= 期中和期末 */
|
||||||
tag: string
|
tag: string
|
||||||
|
/** 只看我交过题的 */
|
||||||
|
joined?: boolean
|
||||||
}) {
|
}) {
|
||||||
return api.get<ContestList>("contests", { params: query })
|
return api.get<ContestList>("contests", {
|
||||||
|
params: { ...query, joined: query.joined ? "1" : undefined },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContest(id: string) {
|
export function getContest(id: string) {
|
||||||
@@ -328,13 +334,12 @@ export async function getContestProblems(contestID: string) {
|
|||||||
return res.map(toProblemRow)
|
return res.map(toProblemRow)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContestRank(contestID: string, query: { limit: number; offset: number }) {
|
export function getContestScoreboard(contestID: string) {
|
||||||
// submissionInfo 在契约里是 Record<string, unknown>(JSONB 原文),
|
return api.get<ContestScoreboard>(`contests/${encodeURIComponent(contestID)}/scoreboard`)
|
||||||
// 前端在这里收窄成 ContestSubmissionInfo,见 utils/types 的 ContestRank
|
}
|
||||||
return api.get<{ results: ContestRank[]; total: number }>(
|
|
||||||
`contests/${encodeURIComponent(contestID)}/rank`,
|
export function getContestClassView(contestID: string) {
|
||||||
{ params: query },
|
return api.get<ContestClassView>(`contests/${encodeURIComponent(contestID)}/class-view`)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadAvatar(file: File) {
|
export function uploadAvatar(file: File) {
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ContestRank } from "utils/types"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
rank: ContestRank
|
|
||||||
}
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
function goto() {
|
|
||||||
router.push({
|
|
||||||
name: "contest submissions",
|
|
||||||
query: { username: props.rank.user.username },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
{{ rank.acceptedNumber }} /
|
|
||||||
<n-button text type="primary" @click="goto">
|
|
||||||
{{ rank.submissionNumber }}
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { isExamTag } from "@oj2/contract"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
|
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比赛的两个小标:状态(进行中 / 未开始 / 已结束)和类型(练习 / 期中 / 期末)。
|
||||||
|
* 进行中绿、未开始琥珀、已结束灰;期中期末用浅红底 —— 学生一眼知道「这场是考试,排名考完才有」
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ status?: ContestStatus; tag?: string }>()
|
||||||
|
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const tone = useTone()
|
||||||
|
|
||||||
|
const style = computed(() => {
|
||||||
|
if (props.status !== undefined) {
|
||||||
|
const kind =
|
||||||
|
props.status === ContestStatus.underway
|
||||||
|
? "success"
|
||||||
|
: props.status === ContestStatus.not_started
|
||||||
|
? "warning"
|
||||||
|
: "default"
|
||||||
|
const t = tone(kind)
|
||||||
|
return { color: t.color, background: t.background, border: "1px solid transparent" }
|
||||||
|
}
|
||||||
|
if (props.tag && isExamTag(props.tag)) {
|
||||||
|
const t = tone("error")
|
||||||
|
return { color: t.color, background: t.background, border: "1px solid transparent" }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
color: theme.value.textColor3,
|
||||||
|
background: "transparent",
|
||||||
|
border: `1px solid ${theme.value.borderColor}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const text = computed(() =>
|
||||||
|
props.status !== undefined ? CONTEST_STATUS[props.status]?.name : props.tag,
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="badge" :class="{ strong: status === ContestStatus.underway }" :style="style">{{
|
||||||
|
text
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import ContestAccessTag from "shared/components/ContestAccessTag.vue"
|
import ContestAccessTag from "shared/components/ContestAccessTag.vue"
|
||||||
@@ -10,12 +9,7 @@ const contestStore = useContestStore()
|
|||||||
<template>
|
<template>
|
||||||
<n-popover v-if="contestStore.contest" placement="bottom-end" :show-arrow="false">
|
<n-popover v-if="contestStore.contest" placement="bottom-end" :show-arrow="false">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-button>
|
<n-button>比赛信息</n-button>
|
||||||
<template #icon>
|
|
||||||
<Icon icon="streamline-emojis:exclamation-mark"></Icon>
|
|
||||||
</template>
|
|
||||||
比赛信息
|
|
||||||
</n-button>
|
|
||||||
</template>
|
</template>
|
||||||
<div v-html="contestStore.contest.description"></div>
|
<div v-html="contestStore.contest.description"></div>
|
||||||
<n-descriptions bordered label-placement="left" :column="1">
|
<n-descriptions bordered label-placement="left" :column="1">
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useContestStore } from "oj/store/contest"
|
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
|
||||||
import { ContestStatus } from "utils/constants"
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const contestStore = useContestStore()
|
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
|
||||||
|
|
||||||
const contestMenuVisible = computed(() => {
|
|
||||||
if (contestStore.isContestAdmin) return true
|
|
||||||
if (!contestStore.isPrivate) {
|
|
||||||
return contestStore.contestStatus !== ContestStatus.not_started
|
|
||||||
}
|
|
||||||
return contestStore.access
|
|
||||||
})
|
|
||||||
|
|
||||||
function goto(name: string) {
|
|
||||||
router.push({ name: "contest " + name })
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentType(name: string): "primary" | "default" {
|
|
||||||
if (route.name === "contest " + name) return "primary"
|
|
||||||
return "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
const options: DropdownOption[] = [
|
|
||||||
{ label: "比赛题目", key: "problems" },
|
|
||||||
{ label: "提交信息", key: "submissions" },
|
|
||||||
{ label: "比赛排名", key: "rank" },
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<div v-if="contestMenuVisible">
|
|
||||||
<n-flex v-if="isDesktop">
|
|
||||||
<n-button :type="getCurrentType('problems')" @click="goto('problems')"> 比赛题目 </n-button>
|
|
||||||
<n-button :type="getCurrentType('submissions')" @click="goto('submissions')">
|
|
||||||
提交信息
|
|
||||||
</n-button>
|
|
||||||
<n-button :type="getCurrentType('rank')" @click="goto('rank')"> 比赛排名 </n-button>
|
|
||||||
</n-flex>
|
|
||||||
<n-dropdown v-else :options="options" @select="goto">
|
|
||||||
<n-button>菜单</n-button>
|
|
||||||
</n-dropdown>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<style scoped></style>
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="chart" v-if="showChart">
|
|
||||||
<Line :data="chartData" :options="chartOptions" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Line } from "vue-chartjs"
|
|
||||||
import {
|
|
||||||
Chart as ChartJS,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
} from "chart.js"
|
|
||||||
import type { ContestRank } from "utils/types"
|
|
||||||
|
|
||||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend)
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
ranks: ContestRank[]
|
|
||||||
problems: Array<{ id: number; title: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const PENALTY_SECONDS = 20 * 60
|
|
||||||
|
|
||||||
const showChart = computed(() => {
|
|
||||||
const hasRanks = props.ranks.length > 0
|
|
||||||
const hasProblems = props.problems.length >= 3
|
|
||||||
return hasProblems && hasRanks
|
|
||||||
})
|
|
||||||
|
|
||||||
const colorPalette = [
|
|
||||||
"#3B82F6",
|
|
||||||
"#EF4444",
|
|
||||||
"#10B981",
|
|
||||||
"#F59E0B",
|
|
||||||
"#8B5CF6",
|
|
||||||
"#EC4899",
|
|
||||||
"#06B6D4",
|
|
||||||
"#84CC16",
|
|
||||||
"#F97316",
|
|
||||||
"#6366F1",
|
|
||||||
]
|
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
|
||||||
const h = Math.floor(seconds / 3600)
|
|
||||||
const m = Math.floor((seconds % 3600) / 60)
|
|
||||||
if (h > 0) return `${h}h${m}m`
|
|
||||||
return `${m}m`
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AcEvent {
|
|
||||||
time: number
|
|
||||||
userIndex: number
|
|
||||||
problemId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const chartData = computed(() => {
|
|
||||||
if (!props.ranks || props.ranks.length === 0) {
|
|
||||||
return { labels: [], datasets: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const topUsers = props.ranks.slice(0, 10)
|
|
||||||
|
|
||||||
// 收集所有AC事件并按时间排序
|
|
||||||
const events: AcEvent[] = []
|
|
||||||
topUsers.forEach((rank, userIndex) => {
|
|
||||||
Object.entries(rank.submissionInfo).forEach(([problemId, info]) => {
|
|
||||||
if (info.is_ac) {
|
|
||||||
events.push({ time: info.ac_time, userIndex, problemId })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
events.sort((a, b) => a.time - b.time)
|
|
||||||
|
|
||||||
if (events.length === 0) {
|
|
||||||
return { labels: [], datasets: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在每个时间点计算所有人的排名
|
|
||||||
// 状态: 每个用户当前已AC题数和罚时
|
|
||||||
const userState = topUsers.map(() => ({
|
|
||||||
solved: 0,
|
|
||||||
penalty: 0,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 用于记录每个用户每道题的错误次数
|
|
||||||
const userErrors: Map<string, number>[] = topUsers.map(() => new Map())
|
|
||||||
topUsers.forEach((rank, i) => {
|
|
||||||
Object.entries(rank.submissionInfo).forEach(([problemId, info]) => {
|
|
||||||
if (info.error_number > 0) {
|
|
||||||
userErrors[i].set(problemId, info.error_number)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function calcRanks(): number[] {
|
|
||||||
const indexed = userState.map((s, i) => ({ ...s, i }))
|
|
||||||
indexed.sort((a, b) => {
|
|
||||||
if (b.solved !== a.solved) return b.solved - a.solved
|
|
||||||
return a.penalty - b.penalty
|
|
||||||
})
|
|
||||||
const ranks = Array.from({ length: topUsers.length }, () => 0)
|
|
||||||
indexed.forEach((item, pos) => {
|
|
||||||
ranks[item.i] = pos + 1
|
|
||||||
})
|
|
||||||
return ranks
|
|
||||||
}
|
|
||||||
|
|
||||||
// 时间轴上的数据点: [时间标签, 各用户排名]
|
|
||||||
const timePoints: number[] = [0]
|
|
||||||
const rankSnapshots: number[][] = [calcRanks()]
|
|
||||||
|
|
||||||
// 按时间处理事件(合并同一时刻的事件)
|
|
||||||
let i = 0
|
|
||||||
while (i < events.length) {
|
|
||||||
const currentTime = events[i].time
|
|
||||||
// 处理同一时刻的所有事件
|
|
||||||
while (i < events.length && events[i].time === currentTime) {
|
|
||||||
const ev = events[i]
|
|
||||||
userState[ev.userIndex].solved++
|
|
||||||
const errors = userErrors[ev.userIndex].get(ev.problemId) || 0
|
|
||||||
userState[ev.userIndex].penalty =
|
|
||||||
userState[ev.userIndex].penalty + ev.time + errors * PENALTY_SECONDS
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
timePoints.push(currentTime)
|
|
||||||
rankSnapshots.push(calcRanks())
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels = timePoints.map((t) => formatTime(t))
|
|
||||||
|
|
||||||
const datasets = topUsers.map((rank, userIndex) => {
|
|
||||||
const color = colorPalette[userIndex % colorPalette.length]
|
|
||||||
const finalRank = rankSnapshots[rankSnapshots.length - 1][userIndex]
|
|
||||||
return {
|
|
||||||
label: `#${finalRank} ${rank.user.username}`,
|
|
||||||
data: rankSnapshots.map((snapshot) => snapshot[userIndex]),
|
|
||||||
borderColor: color,
|
|
||||||
backgroundColor: color,
|
|
||||||
tension: 0.3,
|
|
||||||
fill: false,
|
|
||||||
pointRadius: 3,
|
|
||||||
pointHoverRadius: 6,
|
|
||||||
pointBackgroundColor: color,
|
|
||||||
pointBorderColor: "#fff",
|
|
||||||
pointBorderWidth: 1,
|
|
||||||
borderWidth: 2.5,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return { labels, datasets }
|
|
||||||
})
|
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
interaction: {
|
|
||||||
mode: "index" as const,
|
|
||||||
intersect: false,
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
display: true,
|
|
||||||
position: "top" as const,
|
|
||||||
maxHeight: 80,
|
|
||||||
labels: {
|
|
||||||
boxWidth: 14,
|
|
||||||
boxHeight: 3,
|
|
||||||
padding: 10,
|
|
||||||
font: { size: 12 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
mode: "index" as const,
|
|
||||||
intersect: false,
|
|
||||||
itemSort: (a: any, b: any) => a.parsed.y - b.parsed.y,
|
|
||||||
callbacks: {
|
|
||||||
title: (context: any) => `比赛进行: ${context[0].label}`,
|
|
||||||
label: (context: any) => {
|
|
||||||
const rank = context.parsed.y
|
|
||||||
const name = context.dataset.label
|
|
||||||
return ` 第${rank}名 — ${name}`
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "比赛时间",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: "排名",
|
|
||||||
},
|
|
||||||
reverse: true,
|
|
||||||
min: 1,
|
|
||||||
max: 10,
|
|
||||||
ticks: {
|
|
||||||
stepSize: 1,
|
|
||||||
callback: (value: any) => `第${value}名`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.chart {
|
|
||||||
height: 420px;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { ContestScoreRow } from "@oj2/contract"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
|
import { secondsToDuration } from "utils/functions"
|
||||||
|
import UserName from "./UserName.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题目页右边的小榜:前三名,然后是我和我前后各两个人(中间隔一个「⋯」)。
|
||||||
|
* 我在前八里、或者没上榜(老师、还没交题),就直接列前八
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ rows: ContestScoreRow[]; me?: number }>()
|
||||||
|
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const tone = useTone()
|
||||||
|
const success = computed(() => tone("success"))
|
||||||
|
const danger = computed(() => tone("error"))
|
||||||
|
|
||||||
|
const shown = computed(() => {
|
||||||
|
const rows = props.rows
|
||||||
|
const index = rows.findIndex((row) => row.userId === props.me)
|
||||||
|
if (index < 8) return { head: rows.slice(0, 8), tail: [] }
|
||||||
|
return { head: rows.slice(0, 3), tail: rows.slice(index - 2, index + 3) }
|
||||||
|
})
|
||||||
|
|
||||||
|
function move(row: ContestScoreRow) {
|
||||||
|
return row.rank && row.prevRank ? row.prevRank - row.rank : 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mini">
|
||||||
|
<template v-for="(group, gi) in [shown.head, shown.tail]" :key="gi">
|
||||||
|
<div v-if="gi === 1 && group.length" class="gap">⋯</div>
|
||||||
|
<div v-for="row in group" :key="row.userId" class="line" :class="{ me: row.userId === me }">
|
||||||
|
<span class="rank">{{ row.rank }}</span>
|
||||||
|
<span class="move">
|
||||||
|
<template v-if="move(row) > 0"
|
||||||
|
><span class="up">↑{{ move(row) }}</span></template
|
||||||
|
>
|
||||||
|
<template v-else-if="move(row) < 0"
|
||||||
|
><span class="down">↓{{ -move(row) }}</span></template
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<UserName
|
||||||
|
class="who"
|
||||||
|
:username="row.username"
|
||||||
|
:class-name="row.className"
|
||||||
|
:strong="row.userId === me"
|
||||||
|
/>
|
||||||
|
<span class="solved">{{ row.solved }} 道</span>
|
||||||
|
<span class="time">{{ secondsToDuration(row.totalTime) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mini {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
height: 30px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.me {
|
||||||
|
background: v-bind("success.background");
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
width: 24px;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.me .rank {
|
||||||
|
color: v-bind("success.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.move {
|
||||||
|
width: 28px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.up {
|
||||||
|
color: v-bind("success.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.down {
|
||||||
|
color: v-bind("danger.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.who {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.solved {
|
||||||
|
width: 44px;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
width: 62px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap {
|
||||||
|
height: 14px;
|
||||||
|
padding-left: 44px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 8px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 榜单上的名字:`ks241` 前缀淡一点,后面的名字是重点(和提交列表一个写法)。
|
||||||
|
* 前缀对不上班级号就整个照常显示
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ username: string; className?: string | null; strong?: boolean }>()
|
||||||
|
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
|
const parts = computed(() => {
|
||||||
|
const prefix = props.className ? `ks${props.className}` : ""
|
||||||
|
if (prefix && props.username.startsWith(prefix) && props.username.length > prefix.length) {
|
||||||
|
return [prefix, props.username.slice(prefix.length)]
|
||||||
|
}
|
||||||
|
return ["", props.username]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="user" :title="username"
|
||||||
|
><span class="prefix">{{ parts[0] }}</span
|
||||||
|
><span class="name" :class="{ strong }">{{ parts[1] }}</span></span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefix {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
color: v-bind("theme.textColor1");
|
||||||
|
}
|
||||||
|
|
||||||
|
.name.strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,18 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { ContestStatus } from "utils/constants"
|
||||||
|
import { parseTime, secondsToDuration } from "utils/functions"
|
||||||
import { useContestStore } from "../store/contest"
|
import { useContestStore } from "../store/contest"
|
||||||
|
import ContestBadge from "./components/ContestBadge.vue"
|
||||||
import ContestInfo from "./components/ContestInfo.vue"
|
import ContestInfo from "./components/ContestInfo.vue"
|
||||||
import ContestMenu from "./components/ContestMenu.vue"
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比赛页的外壳(设计稿「比赛重设计」):一条顶栏 + 下面三个页之一。
|
||||||
|
* 顶栏左边是状态、名字、类型,右边是倒计时和去别的页的按钮:
|
||||||
|
* 学生只有「题目 / 我的提交 / 比赛信息」,排名从题目页右边的名次卡进;
|
||||||
|
* 老师是一排页签「全班情况 · 排名 · 提交 · 题目」,出题人另有「比赛设置」
|
||||||
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
contestID: string
|
contestID: string
|
||||||
}>()
|
}>()
|
||||||
const contestStore = useContestStore()
|
const contestStore = useContestStore()
|
||||||
|
const route = useRoute()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const theme = useThemeVars()
|
||||||
const { isDesktop } = useBreakpoints()
|
const tone = useTone()
|
||||||
|
const success = computed(() => tone("success"))
|
||||||
|
|
||||||
const password = ref("")
|
const password = ref("")
|
||||||
|
|
||||||
@@ -21,7 +30,7 @@ async function check() {
|
|||||||
if (error === "too-many-password-attempts") {
|
if (error === "too-many-password-attempts") {
|
||||||
message.error("密码错误次数太多,请 10 分钟后再试")
|
message.error("密码错误次数太多,请 10 分钟后再试")
|
||||||
} else if (!contestStore.access) {
|
} else if (!contestStore.access) {
|
||||||
message.error("密码错误")
|
message.error("密码不对")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,45 +49,233 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(contestStore.clear)
|
onBeforeUnmount(contestStore.clear)
|
||||||
|
|
||||||
|
const status = computed(() => contestStore.contestStatus)
|
||||||
|
const contest = computed(() => contestStore.contest)
|
||||||
|
|
||||||
const passwordFormVisible = computed(
|
const passwordFormVisible = computed(
|
||||||
() => contestStore.isPrivate && !contestStore.access && !contestStore.isContestAdmin,
|
() => contestStore.isPrivate && !contestStore.access && !contestStore.isContestAdmin,
|
||||||
)
|
)
|
||||||
|
/** 学生在开始前进来:题目和榜都还拿不到,整页就是「几点开始、还有多久」 */
|
||||||
|
const waiting = computed(
|
||||||
|
() =>
|
||||||
|
status.value === ContestStatus.not_started &&
|
||||||
|
!contestStore.isContestAdmin &&
|
||||||
|
!passwordFormVisible.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const untilStart = computed(() => {
|
||||||
|
if (!contest.value) return ""
|
||||||
|
const seconds = Math.floor((Date.parse(contest.value.startTime) - contestStore.now) / 1000)
|
||||||
|
if (seconds <= 0) return ""
|
||||||
|
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} 天`
|
||||||
|
return secondsToDuration(seconds)
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeRange = computed(() => {
|
||||||
|
if (!contest.value) return ""
|
||||||
|
const start = parseTime(contest.value.startTime, "M月D日 HH:mm")
|
||||||
|
const sameDay =
|
||||||
|
parseTime(contest.value.startTime, "YYYY-MM-DD") ===
|
||||||
|
parseTime(contest.value.endTime, "YYYY-MM-DD")
|
||||||
|
const end = parseTime(contest.value.endTime, sameDay ? "HH:mm" : "M月D日 HH:mm")
|
||||||
|
return `${start} – ${end}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const routeName = computed(() => String(route.name ?? ""))
|
||||||
|
const teacherTabs = computed(() => [
|
||||||
|
{
|
||||||
|
name: "contest class",
|
||||||
|
label: status.value === ContestStatus.finished ? "成绩" : "全班情况",
|
||||||
|
},
|
||||||
|
{ name: "contest rank", label: "排名" },
|
||||||
|
{ name: "contest submissions", label: "提交" },
|
||||||
|
{ name: "contest problems", label: "题目" },
|
||||||
|
])
|
||||||
|
const to = (name: string) => ({ name, params: { contestID: props.contestID } })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex vertical size="large" v-if="contestStore.contest">
|
<div v-if="contest" class="contest-page">
|
||||||
<n-flex align="center" justify="space-between">
|
<header class="bar">
|
||||||
<n-flex align="center">
|
<ContestBadge :status="status" />
|
||||||
<n-tag :type="CONTEST_STATUS[contestStore.contestStatus]['type']">
|
<h2 class="title">{{ contest.title }}</h2>
|
||||||
{{ contestStore.countdown }}
|
<ContestBadge :tag="contest.tag" />
|
||||||
</n-tag>
|
<div class="spacer"></div>
|
||||||
<Icon
|
<span v-if="status === ContestStatus.underway" class="countdown">
|
||||||
v-if="contestStore.isPrivate"
|
<span class="countdown-label">还剩</span>
|
||||||
icon="streamline-ultimate-color:shield-lock"
|
<span class="countdown-num">{{ contestStore.remaining }}</span>
|
||||||
:height="30"
|
</span>
|
||||||
></Icon>
|
<span v-else-if="status === ContestStatus.not_started" class="countdown">
|
||||||
<h2 class="contestTitle">{{ contestStore.contest.title }}</h2>
|
<span class="countdown-label">{{ parseTime(contest.startTime, "M月D日 HH:mm") }} 开始</span>
|
||||||
</n-flex>
|
<span v-if="untilStart" class="countdown-num small">还有 {{ untilStart }}</span>
|
||||||
<n-flex align="center">
|
</span>
|
||||||
<ContestInfo />
|
<span v-else class="range">{{ timeRange }}</span>
|
||||||
<ContestMenu />
|
<template v-if="contestStore.canEnter">
|
||||||
</n-flex>
|
<i class="divider"></i>
|
||||||
</n-flex>
|
<nav v-if="contestStore.isTeacher" class="tabs" aria-label="比赛">
|
||||||
<n-form :inline="isDesktop" label-placement="left" v-if="passwordFormVisible">
|
<RouterLink
|
||||||
<n-form-item label="需要输入密码才能看到题目">
|
v-for="tab in teacherTabs"
|
||||||
<n-input name="ContestPassword" type="password" v-model:value="password" />
|
:key="tab.name"
|
||||||
</n-form-item>
|
:to="to(tab.name)"
|
||||||
<n-form-item>
|
class="tab"
|
||||||
<n-button @click="check" :disabled="!password">确认</n-button>
|
:class="{ on: routeName === tab.name }"
|
||||||
</n-form-item>
|
>{{ tab.label }}</RouterLink
|
||||||
</n-form>
|
>
|
||||||
<router-view></router-view>
|
</nav>
|
||||||
|
<template v-else>
|
||||||
|
<RouterLink v-if="routeName !== 'contest problems'" :to="to('contest problems')">
|
||||||
|
<n-button>‹ 题目</n-button>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="routeName !== 'contest submissions'"
|
||||||
|
:to="{ ...to('contest submissions'), query: { myself: '1' } }"
|
||||||
|
>
|
||||||
|
<n-button>我的提交</n-button>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
<RouterLink
|
||||||
|
v-if="contestStore.isContestAdmin"
|
||||||
|
:to="{ name: 'admin contest edit', params: { contestID } }"
|
||||||
|
>
|
||||||
|
<n-button>比赛设置</n-button>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
<ContestInfo v-if="!contestStore.isTeacher" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="passwordFormVisible" class="gate">
|
||||||
|
<b>这场比赛要输入密码才能进</b>
|
||||||
|
<span class="gate-sub">密码问老师</span>
|
||||||
|
<n-flex :wrap="false">
|
||||||
|
<n-input
|
||||||
|
v-model:value="password"
|
||||||
|
name="ContestPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="比赛密码"
|
||||||
|
style="width: 220px"
|
||||||
|
@keyup.enter="password && check()"
|
||||||
|
/>
|
||||||
|
<n-button type="primary" :disabled="!password" @click="check">进入</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="waiting" class="gate">
|
||||||
|
<b>比赛还没开始</b>
|
||||||
|
<span class="gate-sub">
|
||||||
|
{{ parseTime(contest.startTime, "M月D日 HH:mm") }} 开始,到时候题目会自己出来,不用刷新
|
||||||
|
</span>
|
||||||
|
<span v-if="untilStart" class="gate-count">{{ untilStart }}</span>
|
||||||
|
</div>
|
||||||
|
<router-view v-else></router-view>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.contestTitle {
|
.contest-page {
|
||||||
font-weight: 500;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: -16px -16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
min-height: 60px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
color: v-bind("theme.warningColorPressed");
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-num {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-num.small {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range {
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
margin: 0 4px;
|
||||||
|
background: v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab + .tab {
|
||||||
|
border-left: 1px solid v-bind("theme.borderColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.on {
|
||||||
|
background: v-bind("success.background");
|
||||||
|
color: v-bind("success.color");
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate {
|
||||||
|
padding: 80px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-sub {
|
||||||
|
font-size: 14px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.gate-count {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: v-bind("theme.warningColorPressed");
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
+397
-175
@@ -1,247 +1,469 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouteQuery } from "@vueuse/router"
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
import { NTag } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { getContestList } from "oj/api"
|
import { getContestList } from "oj/api"
|
||||||
import { duration, parseTime } from "utils/functions"
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
|
import { contestLength, parseTime, secondsToDuration } from "utils/functions"
|
||||||
import type { Contest } from "utils/types"
|
import type { Contest } from "utils/types"
|
||||||
import ContestTitle from "shared/components/ContestTitle.vue"
|
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 { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { CONTEST_STATUS, ContestStatus, ContestType } from "utils/constants"
|
import { ContestStatus } from "utils/constants"
|
||||||
|
import ContestBadge from "./components/ContestBadge.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比赛列表(设计稿「比赛重设计」):进行中的单独一大块「接着比」,未开始的一行一个,
|
||||||
|
* 下面是全部比赛,每场写「我」第几名 / 做对几道 —— 进过前三的带奖杯
|
||||||
|
*/
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const authStore = useAuthModalStore()
|
const authStore = useAuthModalStore()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const tone = useTone()
|
||||||
|
|
||||||
interface ContestQuery {
|
interface ContestQuery {
|
||||||
keyword: string
|
keyword: string
|
||||||
status: string
|
/** "" 全部 / joined 我参加过的 / 练习 / 考试 */
|
||||||
tag: string
|
scope: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用分页 composable
|
const { query } = usePagination<ContestQuery>({
|
||||||
const { query, clearQuery } = usePagination<ContestQuery>({
|
|
||||||
keyword: useRouteQuery("keyword", "").value,
|
keyword: useRouteQuery("keyword", "").value,
|
||||||
status: useRouteQuery("status", "").value,
|
scope: useRouteQuery("scope", "").value,
|
||||||
tag: useRouteQuery("tag", "").value,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = ref<Contest[]>([])
|
const data = ref<Contest[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
const active = ref<Contest[]>([])
|
||||||
|
|
||||||
const options: SelectOption[] = [
|
const scopes = computed(() => [
|
||||||
{ label: "全部", value: "" },
|
{ value: "", label: "全部" },
|
||||||
{ label: "未开始", value: "1" },
|
...(userStore.isAuthed ? [{ value: "joined", label: "我参加过的" }] : []),
|
||||||
{ label: "进行中", value: "0" },
|
{ value: "练习", label: "练习" },
|
||||||
{ label: "已结束", value: "-1" },
|
{ value: "考试", label: "期中 · 期末" },
|
||||||
]
|
])
|
||||||
|
|
||||||
const tags: SelectOption[] = [
|
|
||||||
{ label: "全部", value: "" },
|
|
||||||
{ label: "练习", value: "练习" },
|
|
||||||
{ label: "期中", value: "期中" },
|
|
||||||
{ label: "期末", value: "期末" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const columns: DataTableColumn<Contest>[] = [
|
|
||||||
{
|
|
||||||
title: "状态",
|
|
||||||
key: "status",
|
|
||||||
width: 100,
|
|
||||||
render: (row) =>
|
|
||||||
h(
|
|
||||||
NTag,
|
|
||||||
{ type: CONTEST_STATUS[row.status]["type"] },
|
|
||||||
() => CONTEST_STATUS[row.status]["name"],
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "比赛",
|
|
||||||
key: "title",
|
|
||||||
minWidth: 360,
|
|
||||||
render: (row) => h(ContestTitle, { contest: row }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "标签",
|
|
||||||
key: "tag",
|
|
||||||
width: 100,
|
|
||||||
render: (row) => h(NTag, () => row.tag),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "开始时间",
|
|
||||||
key: "start_time",
|
|
||||||
width: 180,
|
|
||||||
render: (row) => parseTime(row.startTime),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "比赛时长",
|
|
||||||
key: "duration",
|
|
||||||
width: 180,
|
|
||||||
render: (row) => duration(row.startTime, row.endTime),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
async function listContests() {
|
async function listContests() {
|
||||||
const offset = (query.page - 1) * query.limit
|
|
||||||
const res = await getContestList({
|
const res = await getContestList({
|
||||||
offset,
|
offset: (query.page - 1) * query.limit,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
keyword: query.keyword,
|
keyword: query.keyword,
|
||||||
status: query.status,
|
status: "",
|
||||||
tag: query.tag,
|
tag: query.scope === "joined" ? "" : query.scope,
|
||||||
|
joined: query.scope === "joined",
|
||||||
})
|
})
|
||||||
data.value = res.results
|
data.value = res.results
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
}
|
}
|
||||||
|
|
||||||
function search(value: string) {
|
// 进行中 / 即将开始的单独拎到最上面:表格按开始时间倒序,一场开了一周的练习赛
|
||||||
query.keyword = value
|
// 会被后来建的比赛压到第二页去,学生找不着正在比的那一场
|
||||||
}
|
|
||||||
|
|
||||||
function clear() {
|
|
||||||
clearQuery()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(listContests)
|
|
||||||
|
|
||||||
// 监听搜索关键词变化(防抖)
|
|
||||||
watchDebounced(() => query.keyword, listContests, {
|
|
||||||
debounce: 500,
|
|
||||||
maxWait: 1000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听其他查询条件变化
|
|
||||||
watch(() => [query.page, query.limit, query.status, query.tag], listContests)
|
|
||||||
|
|
||||||
function openContest(row: Contest) {
|
|
||||||
if (!userStore.isAuthed && row.contestType === ContestType.private) {
|
|
||||||
authStore.openLoginModal()
|
|
||||||
} else {
|
|
||||||
router.push("/contest/" + row.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowProps(row: Contest) {
|
|
||||||
return {
|
|
||||||
style: "cursor: pointer",
|
|
||||||
onClick: () => openContest(row),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 进行中 / 即将开始的比赛单独拎到最上面:表格按开始时间倒序,一场开了一周的
|
|
||||||
// 练习赛会被后来建的比赛压到第二页去,学生找不着正在比的那一场
|
|
||||||
const active = ref<Contest[]>([])
|
|
||||||
const showActive = computed(
|
|
||||||
() =>
|
|
||||||
active.value.length > 0 && query.page === 1 && !query.keyword && !query.status && !query.tag,
|
|
||||||
)
|
|
||||||
|
|
||||||
async function listActive() {
|
async function listActive() {
|
||||||
const base = { offset: 0, limit: 6, keyword: "", tag: "" }
|
const base = { offset: 0, limit: 6, keyword: "", tag: "" }
|
||||||
const [underway, upcoming] = await Promise.all([
|
const [underway, upcoming] = await Promise.all([
|
||||||
getContestList({ ...base, status: ContestStatus.underway }),
|
getContestList({ ...base, status: ContestStatus.underway }),
|
||||||
getContestList({ ...base, status: ContestStatus.not_started }),
|
getContestList({ ...base, status: ContestStatus.not_started }),
|
||||||
])
|
])
|
||||||
active.value = [...underway.results, ...upcoming.results].slice(0, 6)
|
active.value = [...underway.results, ...upcoming.results.reverse()]
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(listActive)
|
const plain = computed(() => query.page === 1 && !query.keyword && !query.scope)
|
||||||
|
const running = computed(() =>
|
||||||
|
plain.value ? active.value.filter((c) => c.status === ContestStatus.underway) : [],
|
||||||
|
)
|
||||||
|
const upcoming = computed(() =>
|
||||||
|
plain.value ? active.value.filter((c) => c.status === ContestStatus.not_started) : [],
|
||||||
|
)
|
||||||
|
/** 上面单独列过的,下面表里不再重复 */
|
||||||
|
const rows = computed(() => {
|
||||||
|
const shown = new Set([...running.value, ...upcoming.value].map((c) => c.id))
|
||||||
|
return data.value.filter((c) => !shown.has(c.id))
|
||||||
|
})
|
||||||
|
|
||||||
function activeTime(row: Contest) {
|
onMounted(() => {
|
||||||
if (row.status === ContestStatus.underway) {
|
listContests()
|
||||||
return `${parseTime(row.endTime, "M月D日 HH:mm")} 结束`
|
listActive()
|
||||||
|
})
|
||||||
|
watchDebounced(() => query.keyword, listContests, { debounce: 500, maxWait: 1000 })
|
||||||
|
watch(() => [query.page, query.limit, query.scope], listContests)
|
||||||
|
|
||||||
|
const now = useNow({ interval: 1000 })
|
||||||
|
function remaining(contest: Contest) {
|
||||||
|
const seconds = Math.floor((Date.parse(contest.endTime) - now.value.getTime()) / 1000)
|
||||||
|
const text = secondsToDuration(Math.max(0, seconds))
|
||||||
|
return text.startsWith("0:") ? text.slice(2) : text
|
||||||
}
|
}
|
||||||
return `${parseTime(row.startTime, "M月D日 HH:mm")} 开始`
|
function untilStart(contest: Contest) {
|
||||||
|
const ms = Date.parse(contest.startTime) - now.value.getTime()
|
||||||
|
const days = Math.floor(ms / 86_400_000)
|
||||||
|
if (days >= 1) return `还有 ${days} 天`
|
||||||
|
return `还有 ${secondsToDuration(Math.max(0, Math.floor(ms / 1000)))}`
|
||||||
}
|
}
|
||||||
|
function range(contest: Contest) {
|
||||||
|
const sameDay =
|
||||||
|
parseTime(contest.startTime, "YYYY-MM-DD") === parseTime(contest.endTime, "YYYY-MM-DD")
|
||||||
|
return `${parseTime(contest.startTime, "HH:mm")} – ${parseTime(contest.endTime, sameDay ? "HH:mm" : "M月D日 HH:mm")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(contest: Contest) {
|
||||||
|
if (!userStore.isAuthed) {
|
||||||
|
authStore.openLoginModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 老师直接进「全班情况」
|
||||||
|
router.push(
|
||||||
|
userStore.isTeacherOrAbove ? `/contest/${contest.id}/class` : `/contest/${contest.id}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mineText(contest: Contest) {
|
||||||
|
const mine = contest.mine
|
||||||
|
if (!mine) return ""
|
||||||
|
const solved = `做对 ${mine.solved}/${contest.problemCount ?? "?"}`
|
||||||
|
return mine.rank ? `第 ${mine.rank} 名 / ${mine.total} · ${solved}` : solved
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = computed(() => tone("success"))
|
||||||
|
const warning = computed(() => tone("warning"))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex vertical size="large">
|
<div class="page">
|
||||||
<n-space>
|
<div class="top">
|
||||||
<n-form :show-feedback="false" label-placement="left" inline>
|
<h2>比赛</h2>
|
||||||
<n-form-item label="比赛状态">
|
<div class="spacer"></div>
|
||||||
<n-select style="width: 120px" :options="options" v-model:value="query.status" />
|
<button
|
||||||
</n-form-item>
|
v-for="item in scopes"
|
||||||
<n-form-item label="标签">
|
:key="item.value"
|
||||||
<n-select style="width: 120px" :options="tags" v-model:value="query.tag" />
|
class="chip"
|
||||||
</n-form-item>
|
:class="{ on: query.scope === item.value }"
|
||||||
</n-form>
|
@click="query.scope = item.value"
|
||||||
<n-form :show-feedback="false" label-placement="left" inline>
|
|
||||||
<n-form-item>
|
|
||||||
<n-input
|
|
||||||
style="width: 180px"
|
|
||||||
clearable
|
|
||||||
v-model:value="query.keyword"
|
|
||||||
placeholder="比赛标题"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item>
|
|
||||||
<n-flex :wrap="false">
|
|
||||||
<n-button @click="search(query.keyword)">搜索</n-button>
|
|
||||||
<n-button @click="clear" quaternary>重置</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-space>
|
|
||||||
<div v-if="showActive" class="active-grid">
|
|
||||||
<div
|
|
||||||
v-for="contest in active"
|
|
||||||
:key="contest.id"
|
|
||||||
class="active-card"
|
|
||||||
:class="{ underway: contest.status === ContestStatus.underway }"
|
|
||||||
@click="openContest(contest)"
|
|
||||||
>
|
>
|
||||||
<n-flex align="center" :size="8">
|
{{ item.label }}
|
||||||
<n-tag :type="CONTEST_STATUS[contest.status].type" size="small" :bordered="false">
|
</button>
|
||||||
{{ CONTEST_STATUS[contest.status].name }}
|
<n-input v-model:value="query.keyword" clearable placeholder="比赛名" style="width: 200px" />
|
||||||
</n-tag>
|
</div>
|
||||||
<n-text depth="3" class="active-meta">{{ contest.tag }}</n-text>
|
|
||||||
</n-flex>
|
<div
|
||||||
<ContestTitle :contest="contest" class="active-title" />
|
v-for="contest in running"
|
||||||
<n-text depth="3" class="active-meta">
|
:key="contest.id"
|
||||||
{{ activeTime(contest) }} · 时长 {{ duration(contest.startTime, contest.endTime) }}
|
class="running"
|
||||||
</n-text>
|
role="link"
|
||||||
|
tabindex="0"
|
||||||
|
@click="open(contest)"
|
||||||
|
@keyup.enter="open(contest)"
|
||||||
|
>
|
||||||
|
<ContestBadge :status="ContestStatus.underway" />
|
||||||
|
<div class="running-main">
|
||||||
|
<div class="running-title">
|
||||||
|
<ContestTitle :contest="contest" />
|
||||||
|
<ContestBadge :tag="contest.tag" />
|
||||||
|
</div>
|
||||||
|
<span class="muted small">
|
||||||
|
{{ range(contest) }} · {{ contest.problemCount }} 道题 ·
|
||||||
|
{{ contest.participantCount }} 人在比
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="running-right">
|
||||||
|
<span v-if="contest.mine" class="small sec">
|
||||||
|
<template v-if="contest.mine.rank"
|
||||||
|
>你第 <b class="green">{{ contest.mine.rank }}</b> 名 · </template
|
||||||
|
>做对 {{ contest.mine.solved }} 道
|
||||||
|
</span>
|
||||||
|
<span class="countdown"
|
||||||
|
><span class="small">还剩</span> <b>{{ remaining(contest) }}</b></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<n-button type="primary" size="large" @click.stop="open(contest)">
|
||||||
|
{{ contest.mine ? "接着比 ›" : "进去比 ›" }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="contest in upcoming"
|
||||||
|
:key="contest.id"
|
||||||
|
class="upcoming"
|
||||||
|
role="link"
|
||||||
|
tabindex="0"
|
||||||
|
@click="open(contest)"
|
||||||
|
@keyup.enter="open(contest)"
|
||||||
|
>
|
||||||
|
<ContestBadge :status="ContestStatus.not_started" />
|
||||||
|
<ContestTitle :contest="contest" class="upcoming-title" />
|
||||||
|
<ContestBadge :tag="contest.tag" />
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span class="small sec">
|
||||||
|
{{ parseTime(contest.startTime, "M月D日 HH:mm") }} 开始 · 比
|
||||||
|
{{ contestLength(contest.startTime, contest.endTime) }}
|
||||||
|
</span>
|
||||||
|
<span class="small amber">{{ untilStart(contest) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table">
|
||||||
|
<div class="tr th">
|
||||||
|
<span>{{ plain ? "已结束" : "比赛" }}</span>
|
||||||
|
<span>类型</span>
|
||||||
|
<span>日期</span>
|
||||||
|
<span>时长</span>
|
||||||
|
<span>我</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="contest in rows"
|
||||||
|
:key="contest.id"
|
||||||
|
class="tr"
|
||||||
|
:class="{ done: contest.status === ContestStatus.finished }"
|
||||||
|
role="link"
|
||||||
|
tabindex="0"
|
||||||
|
@click="open(contest)"
|
||||||
|
@keyup.enter="open(contest)"
|
||||||
|
>
|
||||||
|
<span class="name">
|
||||||
|
<ContestBadge
|
||||||
|
v-if="contest.status !== ContestStatus.finished"
|
||||||
|
:status="contest.status as ContestStatus"
|
||||||
|
/>
|
||||||
|
<ContestTitle :contest="contest" />
|
||||||
|
</span>
|
||||||
|
<span><ContestBadge :tag="contest.tag" /></span>
|
||||||
|
<span class="muted small num">{{ parseTime(contest.startTime, "YYYY-MM-DD") }}</span>
|
||||||
|
<span class="muted small">{{ contestLength(contest.startTime, contest.endTime) }}</span>
|
||||||
|
<span class="mine small">
|
||||||
|
<template v-if="contest.mine">
|
||||||
|
<svg
|
||||||
|
v-if="contest.mine.rank && contest.mine.rank <= 3"
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="cup"
|
||||||
|
>
|
||||||
|
<path d="M8 4h8v5a4 4 0 0 1-8 0z" />
|
||||||
|
<path d="M16 6h3v1a3 3 0 0 1-3 3" />
|
||||||
|
<path d="M8 6H5v1a3 3 0 0 0 3 3" />
|
||||||
|
<path d="M12 13v4" />
|
||||||
|
<path d="M8 20h8" />
|
||||||
|
</svg>
|
||||||
|
<span class="sec">{{ mineText(contest) }}</span>
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-else-if="userStore.isAuthed && contest.status === ContestStatus.finished"
|
||||||
|
class="faint"
|
||||||
|
>没参加</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!rows.length" class="empty muted">
|
||||||
|
{{ query.scope === "joined" ? "你还没参加过比赛" : "没有符合的比赛" }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<n-data-table :bordered="false" :columns="columns" :data="data" :row-props="rowProps" />
|
|
||||||
</n-flex>
|
|
||||||
<Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" />
|
<Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.active-grid {
|
.page {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-card {
|
.top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.faint {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec {
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.green {
|
||||||
|
color: v-bind("success.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.amber {
|
||||||
|
color: v-bind("warning.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.on {
|
||||||
|
border-color: transparent;
|
||||||
|
background: v-bind("success.background");
|
||||||
|
color: v-bind("success.color");
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.running {
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid v-bind("theme.successColor");
|
||||||
|
background: v-bind("success.background");
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.running-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.running-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 14px 16px;
|
font-size: 20px;
|
||||||
border-radius: 8px;
|
font-weight: 700;
|
||||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
}
|
||||||
|
|
||||||
|
.running-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown {
|
||||||
|
color: v-bind("warning.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown b {
|
||||||
|
font-size: 22px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming {
|
||||||
|
padding: 12px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-card.underway {
|
.upcoming-title {
|
||||||
border-color: rgba(24, 160, 88, 0.5);
|
font-size: 15px;
|
||||||
background-color: rgba(24, 160, 88, 0.06);
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-card:hover {
|
.table {
|
||||||
border-color: #18a058;
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-title {
|
.tr {
|
||||||
font-size: 16px;
|
display: grid;
|
||||||
font-weight: 500;
|
grid-template-columns: minmax(200px, 1fr) 70px 110px 130px minmax(160px, 260px);
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-meta {
|
.tr:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tr:not(.th):hover {
|
||||||
|
background: v-bind("theme.hoverColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.th {
|
||||||
|
min-height: 32px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tr.done .name {
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cup {
|
||||||
|
color: #b07d00;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.tr {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tr > span:nth-child(3),
|
||||||
|
.tr > span:nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,60 +1,553 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ProblemRow } from "utils/types"
|
import type { ContestScoreRow } from "@oj2/contract"
|
||||||
import ProblemStatus from "oj/problem/components/ProblemStatus.vue"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
|
import { useUserStore } from "shared/store/user"
|
||||||
|
import { ContestStatus } from "utils/constants"
|
||||||
|
import { parseTime, secondsToDuration } from "utils/functions"
|
||||||
|
import RankMini from "../components/RankMini.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比赛页「题目」(设计稿「比赛重设计」B,用户选的):一页放下。
|
||||||
|
* 左边是题目,每题带几个人做对了、谁最先做对 —— 练习就是要比;
|
||||||
|
* 右边是「我」现在第几名、5 分钟里升了几名、再做对 1 道能到第几名、我前后的人。
|
||||||
|
* 期中期末进行中右边只剩自己的进度,排名和人数考完公布(后端也不给)
|
||||||
|
*/
|
||||||
const props = defineProps<{ contestID: string }>()
|
const props = defineProps<{ contestID: string }>()
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const contestStore = useContestStore()
|
const contestStore = useContestStore()
|
||||||
const problemsColumns: DataTableColumn<ProblemRow>[] = [
|
const userStore = useUserStore()
|
||||||
{
|
const theme = useThemeVars()
|
||||||
title: "状态",
|
const tone = useTone()
|
||||||
key: "status",
|
|
||||||
width: 100,
|
|
||||||
render: (row) => h(ProblemStatus, { status: row.status }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "编号",
|
|
||||||
key: "_id",
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "题目",
|
|
||||||
key: "title",
|
|
||||||
minWidth: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "提交数",
|
|
||||||
key: "submissionCount",
|
|
||||||
align: "center",
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "通过率",
|
|
||||||
key: "acRate",
|
|
||||||
align: "center",
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function rowProps(row: ProblemRow) {
|
const running = computed(() => contestStore.contestStatus === ContestStatus.underway)
|
||||||
return {
|
const ended = computed(() => contestStore.contestStatus === ContestStatus.finished)
|
||||||
style: "cursor: pointer",
|
|
||||||
onClick() {
|
const { pause, resume } = useIntervalFn(
|
||||||
router.push(`/contest/${props.contestID}/problem/${row._id}`)
|
() => contestStore.loadScoreboard(props.contestID),
|
||||||
|
10_000,
|
||||||
|
{
|
||||||
|
immediate: false,
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
watch(
|
||||||
|
() => [contestStore.canEnter, contestStore.contestStatus] as const,
|
||||||
|
([enter, status]) => {
|
||||||
|
if (!enter) return
|
||||||
|
contestStore.loadScoreboard(props.contestID)
|
||||||
|
if (status === ContestStatus.underway) resume()
|
||||||
|
else pause()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const board = computed(() => contestStore.scoreboard)
|
||||||
|
const rows = computed(() => board.value?.rows ?? [])
|
||||||
|
const total = computed(() => rows.value.length)
|
||||||
|
const me = computed<ContestScoreRow | undefined>(() =>
|
||||||
|
rows.value.find((row) => row.userId === userStore.user?.id),
|
||||||
|
)
|
||||||
|
/** 排名页、题目条都按题目 id 找格子;题目列表那边是 contestStore.problems(带我的状态) */
|
||||||
|
const stats = computed(() => new Map((board.value?.problems ?? []).map((p) => [p.id, p])))
|
||||||
|
|
||||||
|
function when(seconds: number) {
|
||||||
|
const minute = Math.floor(seconds / 60)
|
||||||
|
return minute === 0 ? "1 分钟内做对" : `第 ${minute} 分钟做对`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const items = computed(() =>
|
||||||
|
contestStore.problems.map((problem) => {
|
||||||
|
const cell = me.value?.cells[String(problem.id)]
|
||||||
|
const stat = stats.value.get(problem.id)
|
||||||
|
let mine = ""
|
||||||
|
if (cell?.isAc) mine = when(cell.acTime) + (cell.errors ? `,错 ${cell.errors} 次` : "")
|
||||||
|
else if (cell) mine = cell.errors ? `交了 ${cell.errors} 次,还没对` : "交过,还没对"
|
||||||
|
else if (problem.status === "failed") mine = "交过,还没对"
|
||||||
|
return {
|
||||||
|
problem,
|
||||||
|
done: cell?.isAc || problem.status === "passed",
|
||||||
|
tried: !!cell || problem.status === "failed",
|
||||||
|
mine,
|
||||||
|
solved: stat?.solvedUsers ?? 0,
|
||||||
|
first: stat?.firstSolver ?? null,
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const solvedCount = computed(() => items.value.filter((item) => item.done).length)
|
||||||
|
|
||||||
|
/** 现在再做对一道,按此刻算用时,能排到第几 */
|
||||||
|
const nextRank = computed(() => {
|
||||||
|
if (!running.value) return null
|
||||||
|
const elapsed = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((contestStore.now - Date.parse(contestStore.contest!.startTime)) / 1000),
|
||||||
|
)
|
||||||
|
const solved = (me.value?.solved ?? 0) + 1
|
||||||
|
if (solved > contestStore.problems.length) return null
|
||||||
|
const time = (me.value?.totalTime ?? 0) + elapsed
|
||||||
|
const ahead = rows.value.filter(
|
||||||
|
(row) =>
|
||||||
|
row.userId !== userStore.user?.id &&
|
||||||
|
(row.solved > solved || (row.solved === solved && row.totalTime < time)),
|
||||||
|
).length
|
||||||
|
const target = ahead + 1
|
||||||
|
return me.value?.rank && target >= me.value.rank ? null : target
|
||||||
|
})
|
||||||
|
|
||||||
|
const move = computed(() => {
|
||||||
|
const row = me.value
|
||||||
|
if (!row?.rank || !row.prevRank || !running.value) return 0
|
||||||
|
return row.prevRank - row.rank
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedAgo = computed(() => {
|
||||||
|
if (!contestStore.scoreboardAt) return ""
|
||||||
|
const seconds = Math.max(0, Math.floor((contestStore.now - contestStore.scoreboardAt) / 1000))
|
||||||
|
return seconds < 5 ? "刚刚更新" : `${seconds} 秒前更新`
|
||||||
|
})
|
||||||
|
|
||||||
|
function firstMinute(seconds: number) {
|
||||||
|
const minute = Math.floor(seconds / 60)
|
||||||
|
return minute === 0 ? "不到 1 分" : `${minute} 分`
|
||||||
|
}
|
||||||
|
|
||||||
|
const examLeft = computed(() =>
|
||||||
|
items.value.filter((item) => !item.done).map((item) => `第 ${item.problem._id} 题`),
|
||||||
|
)
|
||||||
|
|
||||||
|
const to = (name: string) => ({ name, params: { contestID: props.contestID } })
|
||||||
|
const success = computed(() => tone("success"))
|
||||||
|
const warning = computed(() => tone("warning"))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-data-table
|
<div class="layout">
|
||||||
striped
|
<section class="problems">
|
||||||
:data="contestStore.problems"
|
<div class="head">
|
||||||
:columns="problemsColumns"
|
<b>题目</b>
|
||||||
:row-props="rowProps"
|
<span class="muted">
|
||||||
/>
|
{{ contestStore.problems.length }} 道<template v-if="me || solvedCount">
|
||||||
|
· 你做对 {{ solvedCount }} 道</template
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span v-if="running && !contestStore.rankHidden" class="muted small">做对人数随时更新</span>
|
||||||
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.problem.id"
|
||||||
|
:to="`/contest/${contestID}/problem/${item.problem._id}`"
|
||||||
|
class="row"
|
||||||
|
>
|
||||||
|
<span v-if="item.done" class="dot done">
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="3"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M5 12l5 5 9-10" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span v-else-if="item.tried" class="dot tried" title="交过,还没对">!</span>
|
||||||
|
<span v-else class="dot"></span>
|
||||||
|
<span class="pid">{{ item.problem._id }}</span>
|
||||||
|
<span class="name">{{ item.problem.title }}</span>
|
||||||
|
<span class="mine" :class="{ warn: item.tried && !item.done }">{{ item.mine }}</span>
|
||||||
|
<template v-if="!contestStore.rankHidden && board">
|
||||||
|
<span class="count">
|
||||||
|
<span class="track"
|
||||||
|
><span
|
||||||
|
class="fill"
|
||||||
|
:style="{ width: total ? `${(item.solved / total) * 100}%` : '0' }"
|
||||||
|
></span
|
||||||
|
></span>
|
||||||
|
<span class="count-num">{{ item.solved }} 人做对</span>
|
||||||
|
</span>
|
||||||
|
<span class="first">
|
||||||
|
<template v-if="item.first"
|
||||||
|
>最先:{{ item.first.username }} · {{ firstMinute(item.first.acTime) }}</template
|
||||||
|
>
|
||||||
|
<template v-else>还没人做对</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</RouterLink>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="side">
|
||||||
|
<!-- 期中期末进行中:只有自己的进度 -->
|
||||||
|
<template v-if="contestStore.rankHidden">
|
||||||
|
<div class="card">
|
||||||
|
<div class="big-line">
|
||||||
|
<span class="muted small">你做对</span>
|
||||||
|
<span class="big">{{ solvedCount }}</span>
|
||||||
|
<span class="muted">/ {{ items.length }} 道</span>
|
||||||
|
</div>
|
||||||
|
<div class="segments">
|
||||||
|
<span v-for="item in items" :key="item.problem.id" :class="{ on: item.done }"></span>
|
||||||
|
</div>
|
||||||
|
<div v-if="examLeft.length" class="small sec">还没做:{{ examLeft.join("、") }}</div>
|
||||||
|
<div v-else class="small sec">全做对了,可以再检查一遍</div>
|
||||||
|
</div>
|
||||||
|
<div class="card lock">
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="muted"
|
||||||
|
>
|
||||||
|
<rect x="5" y="11" width="14" height="9" rx="2" />
|
||||||
|
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
|
||||||
|
</svg>
|
||||||
|
<div class="lock-text">
|
||||||
|
<b>{{ contestStore.contest?.tag }}考试,排名考完公布</b>
|
||||||
|
<span class="muted small">
|
||||||
|
{{ parseTime(contestStore.contest!.endTime, "HH:mm") }}
|
||||||
|
结束后这里会出现排名,也能看到每道题有多少人做对。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<template v-else-if="board">
|
||||||
|
<div v-if="me && me.rank" class="card">
|
||||||
|
<div class="big-line">
|
||||||
|
<span class="muted small">{{ ended ? "最后" : "你现在" }}</span>
|
||||||
|
<span class="big">第 {{ me.rank }} 名</span>
|
||||||
|
<span class="muted">/ {{ total }} 人</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span v-if="move > 0" class="move up">5 分钟里上升 {{ move }} 名</span>
|
||||||
|
<span v-else-if="move < 0" class="move down">5 分钟里下降 {{ -move }} 名</span>
|
||||||
|
</div>
|
||||||
|
<div class="small sec">
|
||||||
|
做对 <b>{{ me.solved }}</b> 道 · 用时 {{ secondsToDuration(me.totalTime) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="nextRank" class="nudge">
|
||||||
|
再做对 1 道,就能到<b>第 {{ nextRank }} 名</b>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="running && me.rank === 1" class="nudge">你现在第一,守住</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="running && !contestStore.isTeacher" class="card">
|
||||||
|
<b>你还没交题</b>
|
||||||
|
<div v-if="nextRank" class="nudge">
|
||||||
|
做对 1 道,就能到<b>第 {{ nextRank }} 名</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mini">
|
||||||
|
<div class="mini-head">
|
||||||
|
<b>排名</b>
|
||||||
|
<span v-if="running" class="muted small">{{ updatedAgo }}</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<RouterLink :to="to('contest rank')" class="link">完整排名 ›</RouterLink>
|
||||||
|
</div>
|
||||||
|
<RankMini :rows="rows" :me="me?.userId" />
|
||||||
|
<div v-if="!rows.length" class="muted small empty">还没有人交题</div>
|
||||||
|
</div>
|
||||||
|
<div class="muted small rule">
|
||||||
|
先比做对几道;一样多的,比用时:每道题做对时是第几分钟,加起来;错一次多算 20 分钟。
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: calc(100vh - 140px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.problems {
|
||||||
|
flex: 999 1 560px;
|
||||||
|
min-width: 0;
|
||||||
|
border-right: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
flex: 1 1 400px;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 16px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.side {
|
||||||
|
flex: 0 0 470px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
height: 40px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec {
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
min-height: 34px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 4px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
color: v-bind("theme.textColor1");
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:hover {
|
||||||
|
background: v-bind("theme.hoverColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px dashed v-bind("theme.borderColor");
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.done {
|
||||||
|
border: 0;
|
||||||
|
background: v-bind("theme.successColor");
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot.tried {
|
||||||
|
border: 0;
|
||||||
|
background: v-bind("warning.background");
|
||||||
|
color: v-bind("warning.color");
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pid {
|
||||||
|
width: 22px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine.warn {
|
||||||
|
color: v-bind("warning.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
width: 150px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
width: 64px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
background: v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
background: v-bind("theme.successColor");
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-num {
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.first {
|
||||||
|
width: 180px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 6px;
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
color: v-bind("success.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.move {
|
||||||
|
align-self: center;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 11px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move.up {
|
||||||
|
background: v-bind("success.background");
|
||||||
|
color: v-bind("success.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.move.down {
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.nudge {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: v-bind("warning.background");
|
||||||
|
color: v-bind("warning.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.segments {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.segments span {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.segments span.on {
|
||||||
|
background: v-bind("theme.successColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 8px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule {
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 手机:题目名要留得下,人数和「最先」只在右边的排名里看 */
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.count,
|
||||||
|
.first {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine {
|
||||||
|
max-width: 45%;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,334 +1,592 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import type { ContestScoreCell, ContestScoreRow } from "@oj2/contract"
|
||||||
import { NButton, useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { getContestProblems, getContestRank } from "oj/api"
|
|
||||||
import { secondsToDuration } from "utils/functions"
|
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
import { usePagination } from "shared/composables/pagination"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { ContestStatus } from "utils/constants"
|
import { ContestStatus } from "utils/constants"
|
||||||
import type { ContestRank, ProblemRow } from "utils/types"
|
import { secondsToDuration } from "utils/functions"
|
||||||
import AcAndSubmission from "../components/AcAndSubmission.vue"
|
import { classLabel } from "oj/submission/utils"
|
||||||
import LineChart from "../components/LineChart.vue"
|
import UserName from "../components/UserName.vue"
|
||||||
|
|
||||||
interface Props {
|
/**
|
||||||
contestID: string
|
* 比赛排名(设计稿「比赛重设计」排行榜两块)。竞争感是用户要的(「我需要竞争」):
|
||||||
}
|
* 名次变化箭头、这题最先做对的深绿格、按班筛、自己那行一直看得见;结束后前三名上领奖台。
|
||||||
|
* 原来的前十名排名曲线不要了(用户定的),学生的「导出数据」也拿掉了 —— 导出在老师的成绩页
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ contestID: string }>()
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const theme = useThemeVars()
|
|
||||||
|
|
||||||
const contestStore = useContestStore()
|
const contestStore = useContestStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const tone = useTone()
|
||||||
|
|
||||||
const total = ref(0)
|
const running = computed(() => contestStore.contestStatus === ContestStatus.underway)
|
||||||
const data = ref<ContestRank[]>([])
|
const ended = computed(() => contestStore.contestStatus === ContestStatus.finished)
|
||||||
const chart = ref<ContestRank[]>([])
|
|
||||||
const problems = ref<ProblemRow[]>([])
|
const autoRefresh = ref(true)
|
||||||
const [autoRefresh] = useToggle(true)
|
const { pause, resume } = useIntervalFn(
|
||||||
const { resume, pause } = useIntervalFn(
|
() => contestStore.loadScoreboard(props.contestID),
|
||||||
() => {
|
10_000,
|
||||||
query.page = 1
|
|
||||||
listRanks()
|
|
||||||
},
|
|
||||||
10000,
|
|
||||||
{
|
{
|
||||||
immediate: false,
|
immediate: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 使用分页 composable
|
|
||||||
const { query } = usePagination({}, { defaultLimit: 50 })
|
|
||||||
|
|
||||||
const columns = ref<DataTableColumn<ContestRank>[]>([
|
|
||||||
{
|
|
||||||
title: "编号",
|
|
||||||
key: "id",
|
|
||||||
width: 80,
|
|
||||||
fixed: "left",
|
|
||||||
align: "center",
|
|
||||||
render: (_, index) => index + (query.page - 1) * query.limit + 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "用户",
|
|
||||||
key: "username",
|
|
||||||
width: 120,
|
|
||||||
fixed: "left",
|
|
||||||
align: "center",
|
|
||||||
render: (row) =>
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
text: true,
|
|
||||||
type: "info",
|
|
||||||
onClick: () => router.push("/user?name=" + row.user.username),
|
|
||||||
},
|
|
||||||
() => row.user.username,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "正确数/总提交",
|
|
||||||
key: "submission",
|
|
||||||
width: 140,
|
|
||||||
align: "center",
|
|
||||||
render: (row) => h(AcAndSubmission, { rank: row }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "总时间",
|
|
||||||
key: "total_time",
|
|
||||||
width: 120,
|
|
||||||
align: "center",
|
|
||||||
render: (row) => secondsToDuration(row.totalTime),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
async function listRanks() {
|
|
||||||
const res = await getContestRank(props.contestID, {
|
|
||||||
limit: query.limit,
|
|
||||||
offset: query.limit * (query.page - 1),
|
|
||||||
})
|
|
||||||
total.value = res.total
|
|
||||||
data.value = res.results
|
|
||||||
if (query.page === 1) {
|
|
||||||
chart.value = data.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addColumns() {
|
|
||||||
try {
|
|
||||||
problems.value = await getContestProblems(props.contestID)
|
|
||||||
problems.value.map((problem) => {
|
|
||||||
columns.value.push({
|
|
||||||
align: "center",
|
|
||||||
title: () =>
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
text: true,
|
|
||||||
type: "primary",
|
|
||||||
onClick: () => {
|
|
||||||
const data = router.resolve({
|
|
||||||
name: "contest problem",
|
|
||||||
params: {
|
|
||||||
contestID: route.params.contestID,
|
|
||||||
problemID: problem._id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
window.open(data.href, "_blank")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
() => problem.title,
|
|
||||||
),
|
|
||||||
render: (row) => {
|
|
||||||
if (row.submissionInfo[problem.id]) {
|
|
||||||
const status = row.submissionInfo[problem.id]
|
|
||||||
let acTime
|
|
||||||
let errorNumber
|
|
||||||
if (status.is_ac) {
|
|
||||||
acTime = h("span", secondsToDuration(status.ac_time))
|
|
||||||
}
|
|
||||||
if (status.is_first_ac) {
|
|
||||||
acTime = [
|
|
||||||
h(Icon, {
|
|
||||||
icon: "fluent-emoji:1st-place-medal",
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
}),
|
|
||||||
h("span", secondsToDuration(status.ac_time)),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
if (status.error_number) {
|
|
||||||
errorNumber = h("span", { style: "margin: 0" }, `(-${status.error_number})`)
|
|
||||||
}
|
|
||||||
return h("div", { class: "oj-time-with-modal" }, [acTime, errorNumber])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cellProps: (row) => {
|
|
||||||
let backgroundColor = ""
|
|
||||||
let color = theme.value.textColorBase
|
|
||||||
if (row.submissionInfo[problem.id]) {
|
|
||||||
const status = row.submissionInfo[problem.id]
|
|
||||||
if (status.is_first_ac) {
|
|
||||||
backgroundColor = theme.value.primaryColor
|
|
||||||
color = theme.value.baseColor
|
|
||||||
} else if (status.is_ac) {
|
|
||||||
const success = theme.value.successColor
|
|
||||||
backgroundColor = success + "50"
|
|
||||||
color = theme.value.textColorBase
|
|
||||||
} else {
|
|
||||||
const error = theme.value.errorColor
|
|
||||||
backgroundColor = error + "50"
|
|
||||||
color = theme.value.textColorBase
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { style: { backgroundColor, color } }
|
|
||||||
},
|
|
||||||
key: problem.id,
|
|
||||||
width: 150,
|
|
||||||
ellipsis: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
problems.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出弹窗
|
|
||||||
const showExportModal = ref(false)
|
|
||||||
const exportLoading = ref(false)
|
|
||||||
const exportForm = reactive({
|
|
||||||
first: 0,
|
|
||||||
second: 0,
|
|
||||||
third: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => total.value,
|
() => [contestStore.canEnter, running.value, autoRefresh.value] as const,
|
||||||
(val) => {
|
([enter, run, auto]) => {
|
||||||
if (val > 0) {
|
if (!enter) return
|
||||||
exportForm.first = Math.round(val * 0.1)
|
if (!contestStore.scoreboard) contestStore.loadScoreboard(props.contestID)
|
||||||
exportForm.second = Math.round(val * 0.2)
|
if (run && auto) resume()
|
||||||
exportForm.third = Math.round(val * 0.3)
|
else pause()
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
watch(autoRefresh, (on) => on && contestStore.loadScoreboard(props.contestID))
|
||||||
|
|
||||||
|
const board = computed(() => contestStore.scoreboard)
|
||||||
|
const allRows = computed(() => board.value?.rows ?? [])
|
||||||
|
const problems = computed(() => board.value?.problems ?? [])
|
||||||
|
const meId = computed(() => userStore.user?.id)
|
||||||
|
const me = computed(() => allRows.value.find((row) => row.userId === meId.value))
|
||||||
|
|
||||||
|
const classFilter = ref("")
|
||||||
|
const classes = computed(() => {
|
||||||
|
const count = new Map<string, number>()
|
||||||
|
for (const row of allRows.value) {
|
||||||
|
if (row.className) count.set(row.className, (count.get(row.className) ?? 0) + 1)
|
||||||
|
}
|
||||||
|
return [...count.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
})
|
||||||
|
const rows = computed(() =>
|
||||||
|
classFilter.value
|
||||||
|
? allRows.value.filter((row) => row.className === classFilter.value)
|
||||||
|
: allRows.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
function openExportModal() {
|
const updatedAgo = computed(() => {
|
||||||
if (total.value > 0) {
|
if (!contestStore.scoreboardAt) return ""
|
||||||
exportForm.first = Math.round(total.value * 0.1)
|
const seconds = Math.max(0, Math.floor((contestStore.now - contestStore.scoreboardAt) / 1000))
|
||||||
exportForm.second = Math.round(total.value * 0.2)
|
return seconds < 5 ? "刚刚" : `${seconds} 秒前`
|
||||||
exportForm.third = Math.round(total.value * 0.3)
|
|
||||||
}
|
|
||||||
showExportModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
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: PAGE,
|
|
||||||
offset: allRanks.length,
|
|
||||||
})
|
|
||||||
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
|
|
||||||
let level = ""
|
|
||||||
if (rank1 <= exportForm.first) {
|
|
||||||
level = "一等奖"
|
|
||||||
} else if (rank1 <= exportForm.first + exportForm.second) {
|
|
||||||
level = "二等奖"
|
|
||||||
} else if (rank1 <= exportForm.first + exportForm.second + exportForm.third) {
|
|
||||||
level = "三等奖"
|
|
||||||
} else {
|
|
||||||
level = "参与奖"
|
|
||||||
}
|
|
||||||
return { 用户名: rank.user.username, 等级: level }
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const csv = "用户名,等级\n" + rows.map((r) => `${r.用户名},${r.等级}`).join("\n")
|
const podium = computed(() => (ended.value ? allRows.value.slice(0, 3) : []))
|
||||||
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8" })
|
/** 我在自己班里第几 */
|
||||||
const url = URL.createObjectURL(blob)
|
const classPlace = computed(() => {
|
||||||
const a = document.createElement("a")
|
const row = me.value
|
||||||
a.href = url
|
if (!row?.className) return null
|
||||||
a.download = `${contestStore.contest?.title ?? "contest"}获奖情况.csv`
|
return allRows.value.filter((r) => r.className === row.className && r.rank! <= row.rank!).length
|
||||||
a.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
showExportModal.value = false
|
|
||||||
} finally {
|
|
||||||
exportLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听分页参数变化
|
|
||||||
watch([() => query.page, () => query.limit], listRanks)
|
|
||||||
|
|
||||||
// 自动刷新只在比赛进行中有意义(开关本身也只在这一档渲染),所以由「开关 + 比赛状态」
|
|
||||||
// 一起驱动。原来只 watch(autoRefresh):开关初值就是 true、进页面不产生变化,而
|
|
||||||
// useIntervalFn 建的时候又传了 immediate: false,于是表从没启动过 —— 开关明明是开着的,
|
|
||||||
// 排名却一直不刷新,得手动关一次再开。
|
|
||||||
watchEffect(() => {
|
|
||||||
const running = contestStore.contestStatus === ContestStatus.underway
|
|
||||||
if (autoRefresh.value && running) resume()
|
|
||||||
else pause()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
function move(row: ContestScoreRow) {
|
||||||
listRanks()
|
return running.value && row.rank && row.prevRank ? row.prevRank - row.rank : 0
|
||||||
addColumns()
|
}
|
||||||
})
|
|
||||||
|
function cellOf(row: ContestScoreRow, problemId: number): ContestScoreCell | undefined {
|
||||||
|
return row.cells[String(problemId)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProblem(displayId: string) {
|
||||||
|
router.push(`/contest/${props.contestID}/problem/${displayId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = computed(() => tone("success"))
|
||||||
|
const meBackground = computed(
|
||||||
|
() =>
|
||||||
|
`linear-gradient(${success.value.background}, ${success.value.background}), ${theme.value.cardColor}`,
|
||||||
|
)
|
||||||
|
const danger = computed(() => tone("error"))
|
||||||
|
const columns = computed(
|
||||||
|
() =>
|
||||||
|
`34px ${running.value ? "34px " : ""}150px 40px 70px repeat(${problems.value.length}, 58px)`,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- 排名变化图表 -->
|
<div v-if="contestStore.rankHidden" class="locked">
|
||||||
<LineChart :ranks="chart" :problems="problems" v-if="chart.length > 0" />
|
<b>{{ contestStore.contest?.tag }}考试,排名考完公布</b>
|
||||||
|
<span class="muted">考完回来就能看到全部名次和每道题的做对情况</span>
|
||||||
<!-- 排名表格 -->
|
</div>
|
||||||
<n-data-table striped :single-line="false" :scroll-x="1200" :columns="columns" :data="data" />
|
<div v-else-if="board" class="rank-page">
|
||||||
<n-space justify="end" align="center">
|
<div v-if="podium.length" class="podium-row">
|
||||||
<n-form
|
<div class="podium">
|
||||||
label-placement="left"
|
<div
|
||||||
inline
|
v-for="place in [2, 1, 3]"
|
||||||
:show-feedback="false"
|
v-show="podium[place - 1]"
|
||||||
v-if="contestStore.contestStatus === ContestStatus.underway"
|
:key="place"
|
||||||
|
class="step"
|
||||||
|
:class="`p${place}`"
|
||||||
>
|
>
|
||||||
<n-form-item label="开启自动刷新">
|
<svg
|
||||||
<n-switch v-model:value="autoRefresh" />
|
:width="place === 1 ? 26 : 22"
|
||||||
</n-form-item>
|
:height="place === 1 ? 26 : 22"
|
||||||
</n-form>
|
viewBox="0 0 24 24"
|
||||||
<n-button
|
fill="none"
|
||||||
v-if="contestStore.contestStatus === ContestStatus.finished"
|
stroke="currentColor"
|
||||||
type="primary"
|
stroke-width="2"
|
||||||
@click="openExportModal"
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="cup"
|
||||||
>
|
>
|
||||||
导出数据
|
<path d="M8 4h8v5a4 4 0 0 1-8 0z" />
|
||||||
</n-button>
|
<path d="M16 6h3v1a3 3 0 0 1-3 3" />
|
||||||
<Pagination
|
<path d="M8 6H5v1a3 3 0 0 0 3 3" />
|
||||||
:total="total"
|
<path d="M12 13v4" />
|
||||||
:limit="query.limit"
|
<path d="M8 20h8" />
|
||||||
:page="query.page"
|
</svg>
|
||||||
@update:limit="(limit: number) => (query.limit = limit)"
|
<template v-if="podium[place - 1]">
|
||||||
@update:page="(page: number) => (query.page = page)"
|
<UserName
|
||||||
|
class="podium-name"
|
||||||
|
:username="podium[place - 1]!.username"
|
||||||
|
:class-name="podium[place - 1]!.className"
|
||||||
|
strong
|
||||||
/>
|
/>
|
||||||
</n-space>
|
<span class="muted small">
|
||||||
|
{{ classLabel(podium[place - 1]!.className) }} · {{ podium[place - 1]!.solved }} 道 ·
|
||||||
<n-modal v-model:show="showExportModal" preset="dialog" title="导出获奖数据">
|
{{ secondsToDuration(podium[place - 1]!.totalTime) }}
|
||||||
<n-form
|
</span>
|
||||||
label-placement="left"
|
</template>
|
||||||
label-width="auto"
|
<div class="block">{{ place }}</div>
|
||||||
:show-feedback="false"
|
</div>
|
||||||
style="margin-top: 16px"
|
</div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div v-if="me" class="mine">
|
||||||
|
<span class="muted small">你的成绩</span>
|
||||||
|
<div class="mine-line">
|
||||||
|
<span class="mine-big">第 {{ me.rank }} 名</span
|
||||||
|
><span class="muted">/ {{ allRows.length }} 人</span>
|
||||||
|
</div>
|
||||||
|
<span class="small sec">
|
||||||
|
做对 {{ me.solved }} 道 · 用时 {{ secondsToDuration(me.totalTime) }}
|
||||||
|
<template v-if="classPlace && classes.length > 1">
|
||||||
|
· {{ classLabel(me.className) }}里第 {{ classPlace }}</template
|
||||||
>
|
>
|
||||||
<n-form-item label="一等奖人数" style="margin-bottom: 12px">
|
</span>
|
||||||
<n-input-number v-model:value="exportForm.first" :min="0" />
|
</div>
|
||||||
</n-form-item>
|
</div>
|
||||||
<n-form-item label="二等奖人数" style="margin-bottom: 12px">
|
|
||||||
<n-input-number v-model:value="exportForm.second" :min="0" />
|
<div class="tools">
|
||||||
</n-form-item>
|
<template v-if="classes.length > 1">
|
||||||
<n-form-item label="三等奖人数">
|
<button class="chip" :class="{ on: !classFilter }" @click="classFilter = ''">
|
||||||
<n-input-number v-model:value="exportForm.third" :min="0" />
|
全部 {{ allRows.length }}
|
||||||
</n-form-item>
|
</button>
|
||||||
</n-form>
|
<button
|
||||||
<template #action>
|
v-for="[name, n] in classes"
|
||||||
<n-button @click="showExportModal = false">取消</n-button>
|
:key="name"
|
||||||
<n-button type="primary" :loading="exportLoading" @click="downloadExcel"> 下载 CSV </n-button>
|
class="chip"
|
||||||
|
:class="{ on: classFilter === name }"
|
||||||
|
@click="classFilter = name"
|
||||||
|
>
|
||||||
|
{{ classLabel(name) }} {{ n }}
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
<span v-else class="muted small">{{ allRows.length }} 人</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<template v-if="running">
|
||||||
|
<n-switch v-model:value="autoRefresh" size="small" />
|
||||||
|
<span class="small">自动刷新</span>
|
||||||
|
<span v-if="autoRefresh" class="muted small">{{ updatedAgo }}</span>
|
||||||
</template>
|
</template>
|
||||||
<style>
|
</div>
|
||||||
.oj-time-with-modal {
|
|
||||||
|
<div class="table">
|
||||||
|
<div class="tr th" :style="{ gridTemplateColumns: columns }">
|
||||||
|
<span class="num">名次</span>
|
||||||
|
<span v-if="running"></span>
|
||||||
|
<span class="pad">学生</span>
|
||||||
|
<span class="num">做对</span>
|
||||||
|
<span class="num">用时</span>
|
||||||
|
<button
|
||||||
|
v-for="p in problems"
|
||||||
|
:key="p.id"
|
||||||
|
class="ph"
|
||||||
|
:title="p.title"
|
||||||
|
@click="openProblem(p._id)"
|
||||||
|
>
|
||||||
|
<b>{{ p._id }}</b>
|
||||||
|
<span>{{ p.solvedUsers }} 人</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.userId"
|
||||||
|
class="tr"
|
||||||
|
:class="{ me: row.userId === meId }"
|
||||||
|
:style="{ gridTemplateColumns: columns }"
|
||||||
|
>
|
||||||
|
<span class="num rank">{{ row.rank }}</span>
|
||||||
|
<span v-if="running" class="move">
|
||||||
|
<span v-if="move(row) > 0" class="up">↑{{ move(row) }}</span>
|
||||||
|
<span v-else-if="move(row) < 0" class="down">↓{{ -move(row) }}</span>
|
||||||
|
</span>
|
||||||
|
<UserName
|
||||||
|
class="pad"
|
||||||
|
:username="row.username"
|
||||||
|
:class-name="row.className"
|
||||||
|
:strong="row.userId === meId"
|
||||||
|
:title="row.realName ?? row.username"
|
||||||
|
/>
|
||||||
|
<span class="num solved">{{ row.solved }}</span>
|
||||||
|
<span class="num time">{{ secondsToDuration(row.totalTime) }}</span>
|
||||||
|
<span v-for="p in problems" :key="p.id" class="cell-wrap">
|
||||||
|
<template v-if="cellOf(row, p.id)">
|
||||||
|
<span
|
||||||
|
v-if="cellOf(row, p.id)!.isAc"
|
||||||
|
class="cell ac"
|
||||||
|
:class="{ first: cellOf(row, p.id)!.firstAc }"
|
||||||
|
:title="cellOf(row, p.id)!.firstAc ? '这题最先做对' : undefined"
|
||||||
|
>
|
||||||
|
{{ Math.floor(cellOf(row, p.id)!.acTime / 60)
|
||||||
|
}}<small v-if="cellOf(row, p.id)!.errors">-{{ cellOf(row, p.id)!.errors }}</small>
|
||||||
|
</span>
|
||||||
|
<span v-else class="cell no">{{
|
||||||
|
cellOf(row, p.id)!.errors ? `-${cellOf(row, p.id)!.errors}` : "交过"
|
||||||
|
}}</span>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!rows.length" class="empty muted">还没有人交题</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<span><i class="sw first"></i>这题最先做对</span>
|
||||||
|
<span><i class="sw ac"></i>做对,格子里是第几分钟(-1 是错过 1 次)</span>
|
||||||
|
<span><i class="sw no"></i>还没对,错了几次</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span>用时 = 每道做对题的分钟数相加,错一次多算 20 分钟</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.locked {
|
||||||
|
padding: 80px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec {
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-row {
|
||||||
|
padding: 12px 20px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 20px;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
width: 210px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.podium-name {
|
||||||
|
font-size: 15px;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p1 .podium-name {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p1 .cup,
|
||||||
|
.p1 .block {
|
||||||
|
color: #9a6700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p1 .block {
|
||||||
|
height: 66px;
|
||||||
|
background: rgba(240, 180, 40, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p2 .cup,
|
||||||
|
.p2 .block {
|
||||||
|
color: #5f6b7a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p2 .block {
|
||||||
|
height: 46px;
|
||||||
|
background: rgba(120, 135, 155, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p3 .cup,
|
||||||
|
.p3 .block {
|
||||||
|
color: #8c5a3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p3 .block {
|
||||||
|
height: 32px;
|
||||||
|
background: rgba(170, 110, 70, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine {
|
||||||
|
width: 330px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: v-bind("success.background");
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-big {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
color: v-bind("success.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
min-height: 48px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 13px;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.on {
|
||||||
|
border-color: transparent;
|
||||||
|
background: v-bind("success.background");
|
||||||
|
color: v-bind("success.color");
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
max-height: calc(100vh - 230px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tr {
|
||||||
|
display: grid;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 6px;
|
||||||
|
min-width: max-content;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.tr.me {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
top: 44px;
|
||||||
|
z-index: 1;
|
||||||
|
/* 浅绿是半透明的,钉住时底下的行会透上来,垫一层卡片底色 */
|
||||||
|
background: v-bind("meBackground");
|
||||||
|
box-shadow: inset 3px 0 0 v-bind("theme.successColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pad {
|
||||||
|
padding-left: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.solved {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.move {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.up {
|
||||||
|
color: v-bind("success.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.down {
|
||||||
|
color: v-bind("danger.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.ph {
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 16px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ph b {
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.ph span {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
width: 100%;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell small {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.ac {
|
||||||
|
font-weight: 600;
|
||||||
|
background: v-bind("success.background");
|
||||||
|
color: v-bind("success.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.ac.first {
|
||||||
|
background: v-bind("theme.successColor");
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.no {
|
||||||
|
background: v-bind("danger.background");
|
||||||
|
color: v-bind("danger.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
border-top: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw {
|
||||||
|
width: 28px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw.first {
|
||||||
|
background: v-bind("theme.successColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw.ac {
|
||||||
|
background: v-bind("success.background");
|
||||||
|
}
|
||||||
|
|
||||||
|
.sw.no {
|
||||||
|
background: v-bind("danger.background");
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -217,7 +217,10 @@ const contestBar = computed(() => {
|
|||||||
// 不在进行中的两种:管理员赛前看题是「还没开始」,不能一律写「已结束」
|
// 不在进行中的两种:管理员赛前看题是「还没开始」,不能一律写「已结束」
|
||||||
notStarted: contestStore.contestStatus === ContestStatus.not_started,
|
notStarted: contestStore.contestStatus === ContestStatus.not_started,
|
||||||
home: { name: "contest problems", params: { contestID: ctx.value.contestId } },
|
home: { name: "contest problems", params: { contestID: ctx.value.contestId } },
|
||||||
rank: { name: "contest rank", params: { contestID: ctx.value.contestId } },
|
// 期中期末进行中排名不公布,链接也不给
|
||||||
|
rank: contestStore.rankHidden
|
||||||
|
? null
|
||||||
|
: { name: "contest rank", params: { contestID: ctx.value.contestId } },
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -359,7 +362,9 @@ onSolvedHere(() => {
|
|||||||
<span class="countdown">{{ contestBar.remaining }}</span>
|
<span class="countdown">{{ contestBar.remaining }}</span>
|
||||||
</template>
|
</template>
|
||||||
<span v-else class="meta">{{ contestBar.notStarted ? "还没开始" : "已结束" }}</span>
|
<span v-else class="meta">{{ contestBar.notStarted ? "还没开始" : "已结束" }}</span>
|
||||||
<router-link :to="contestBar.rank" class="contest-rank">排名</router-link>
|
<router-link v-if="contestBar.rank" :to="contestBar.rank" class="contest-rank"
|
||||||
|
>排名</router-link
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ function segmentWidth(value: number, total: number) {
|
|||||||
现在只看得到你自己的提交,别人几次做对、错在哪,结束后一起公布。
|
现在只看得到你自己的提交,别人几次做对、错在哪,结束后一起公布。
|
||||||
</n-text>
|
</n-text>
|
||||||
<router-link
|
<router-link
|
||||||
|
v-if="!contestStore.rankHidden"
|
||||||
:to="{ name: 'contest rank', params: { contestID: route.params.contestID } }"
|
:to="{ name: 'contest rank', params: { contestID: route.params.contestID } }"
|
||||||
class="link"
|
class="link"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,8 +3,15 @@ import { formatISO, getTime, parseISO } from "date-fns"
|
|||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { ContestStatus, ContestType } from "utils/constants"
|
import { ContestStatus, ContestType } from "utils/constants"
|
||||||
import { duration, secondsToDuration } from "utils/functions"
|
import { duration, secondsToDuration } from "utils/functions"
|
||||||
|
import { isExamTag, type ContestScoreboard } from "@oj2/contract"
|
||||||
import type { Contest, ProblemRow } from "utils/types"
|
import type { Contest, ProblemRow } from "utils/types"
|
||||||
import { checkContestPassword, getContest, getContestAccess, getContestProblems } from "../api"
|
import {
|
||||||
|
checkContestPassword,
|
||||||
|
getContest,
|
||||||
|
getContestAccess,
|
||||||
|
getContestProblems,
|
||||||
|
getContestScoreboard,
|
||||||
|
} from "../api"
|
||||||
|
|
||||||
export const useContestStore = defineStore("contest", () => {
|
export const useContestStore = defineStore("contest", () => {
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -12,6 +19,10 @@ export const useContestStore = defineStore("contest", () => {
|
|||||||
const contest = ref<Contest | null>(null)
|
const contest = ref<Contest | null>(null)
|
||||||
const problems = ref<ProblemRow[]>([])
|
const problems = ref<ProblemRow[]>([])
|
||||||
const now = ref(0)
|
const now = ref(0)
|
||||||
|
/** 整张榜(学生比赛页右边的名次、排名页共用),进行中由页面每 10 秒刷一次 */
|
||||||
|
const scoreboard = ref<ContestScoreboard | null>(null)
|
||||||
|
/** 上次拉到榜的时刻(本机时间),「8 秒前更新」用 */
|
||||||
|
const scoreboardAt = ref(0)
|
||||||
|
|
||||||
let timer = 0
|
let timer = 0
|
||||||
|
|
||||||
@@ -54,8 +65,37 @@ export const useContestStore = defineStore("contest", () => {
|
|||||||
(userStore.isAuthed && contest.value?.createdBy.id === userStore.user!.id),
|
(userStore.isAuthed && contest.value?.createdBy.id === userStore.user!.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** 老师(出题人、超管、其他老师):看得到全班情况、真名,期中期末进行中也看得到排名 */
|
||||||
|
const isTeacher = computed(() => isContestAdmin.value || userStore.isTeacherOrAbove)
|
||||||
|
|
||||||
const isPrivate = computed(() => contest.value!.contestType === ContestType.private)
|
const isPrivate = computed(() => contest.value!.contestType === ContestType.private)
|
||||||
|
|
||||||
|
/** 期中、期末进行中,学生看不到排名和每题做对人数(后端同样拦着,这里只管界面) */
|
||||||
|
const rankHidden = computed(
|
||||||
|
() =>
|
||||||
|
!!contest.value &&
|
||||||
|
isExamTag(contest.value.tag) &&
|
||||||
|
contestStatus.value === ContestStatus.underway &&
|
||||||
|
!isTeacher.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 能不能看题目(也就能看榜):开始了、密码对了;老师 / 出题人随时 */
|
||||||
|
const canEnter = computed(() => {
|
||||||
|
if (!contest.value) return false
|
||||||
|
if (isContestAdmin.value) return true
|
||||||
|
if (contestStatus.value === ContestStatus.not_started) return false
|
||||||
|
return !isPrivate.value || access.value
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadScoreboard(contestID: string) {
|
||||||
|
try {
|
||||||
|
scoreboard.value = await getContestScoreboard(contestID)
|
||||||
|
scoreboardAt.value = Date.now()
|
||||||
|
} catch {
|
||||||
|
// 没开始 / 没密码时拉不到,界面按 canEnter 自己会挡住
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function init(contestID: string) {
|
async function init(contestID: string) {
|
||||||
problems.value = []
|
problems.value = []
|
||||||
const res = await getContest(contestID)
|
const res = await getContest(contestID)
|
||||||
@@ -81,6 +121,7 @@ export const useContestStore = defineStore("contest", () => {
|
|||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
contest.value = null
|
contest.value = null
|
||||||
|
scoreboard.value = null
|
||||||
problems.value = []
|
problems.value = []
|
||||||
toggleAccess(false)
|
toggleAccess(false)
|
||||||
now.value = 0
|
now.value = 0
|
||||||
@@ -116,6 +157,13 @@ export const useContestStore = defineStore("contest", () => {
|
|||||||
contest,
|
contest,
|
||||||
contestStatus,
|
contestStatus,
|
||||||
isContestAdmin,
|
isContestAdmin,
|
||||||
|
isTeacher,
|
||||||
|
rankHidden,
|
||||||
|
canEnter,
|
||||||
|
scoreboard,
|
||||||
|
scoreboardAt,
|
||||||
|
loadScoreboard,
|
||||||
|
now,
|
||||||
access,
|
access,
|
||||||
problems,
|
problems,
|
||||||
isPrivate,
|
isPrivate,
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ export const ojs: RouteRecordRaw = {
|
|||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
name: "contest rank",
|
name: "contest rank",
|
||||||
},
|
},
|
||||||
|
// 老师的「全班情况」,考完是「成绩」(代替原来后台的「审核」页)
|
||||||
|
{
|
||||||
|
path: "class",
|
||||||
|
component: () => import("oj/contest/pages/class.vue"),
|
||||||
|
props: true,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
name: "contest class",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -244,13 +252,6 @@ export const admins: RouteRecordRaw = {
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { requiresTeacherAdmin: true },
|
meta: { requiresTeacherAdmin: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "contest/:contestID/helper",
|
|
||||||
name: "admin contest helper",
|
|
||||||
component: () => import("admin/contest/helper.vue"),
|
|
||||||
props: true,
|
|
||||||
meta: { requiresTeacherAdmin: true },
|
|
||||||
},
|
|
||||||
// 只有super_admin可以访问的路由
|
// 只有super_admin可以访问的路由
|
||||||
{
|
{
|
||||||
path: "announcement/list",
|
path: "announcement/list",
|
||||||
|
|||||||
@@ -212,6 +212,24 @@ export function readableDuration(seconds: number): string {
|
|||||||
return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时`
|
return rest ? `${hours} 小时 ${rest} 分` : `${hours} 小时`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比赛有多长,给人看的:「40 分钟」「1 小时 30 分钟」「3 天」。
|
||||||
|
* `duration()` 会拆出「1月6天59分钟」这种,比赛列表里读不下去;一天以上只说几天
|
||||||
|
*/
|
||||||
|
export function contestLength(start: Date | string, end: Date | string): string {
|
||||||
|
const minutes = Math.round(
|
||||||
|
(normalizeDate(end).getTime() - normalizeDate(start).getTime()) / 60_000,
|
||||||
|
)
|
||||||
|
if (!Number.isFinite(minutes) || minutes <= 0) return ""
|
||||||
|
if (minutes < 60) return `${minutes} 分钟`
|
||||||
|
if (minutes < 24 * 60) {
|
||||||
|
const h = Math.floor(minutes / 60)
|
||||||
|
const m = minutes % 60
|
||||||
|
return m ? `${h} 小时 ${m} 分钟` : `${h} 小时`
|
||||||
|
}
|
||||||
|
return `${Math.round(minutes / (24 * 60))} 天`
|
||||||
|
}
|
||||||
|
|
||||||
export function durationToDays(start: Date | string, end: Date | string): string {
|
export function durationToDays(start: Date | string, end: Date | string): string {
|
||||||
const durationObj = getDurationObject(start, end)
|
const durationObj = getDurationObject(start, end)
|
||||||
const units = [
|
const units = [
|
||||||
|
|||||||
@@ -329,12 +329,6 @@ export type BlankContest = Omit<
|
|||||||
*/
|
*/
|
||||||
export type { ContestSubmissionInfo } from "@oj2/contract"
|
export type { ContestSubmissionInfo } from "@oj2/contract"
|
||||||
|
|
||||||
/**
|
|
||||||
* 榜单行。`submissionInfo` 的收窄(JSONB 原文的 snake_case 形状)已经搬进
|
|
||||||
* `contestRankItemSchema`,这里不再需要 Omit + 覆盖。
|
|
||||||
*/
|
|
||||||
export type ContestRank = import("@oj2/contract").ContestRankItem
|
|
||||||
|
|
||||||
export type { WebsiteConfig, OnlineCount } from "@oj2/contract"
|
export type { WebsiteConfig, OnlineCount } from "@oj2/contract"
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -344,7 +338,6 @@ export type {
|
|||||||
OrphanTestCase,
|
OrphanTestCase,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
AdminUserList,
|
AdminUserList,
|
||||||
AcmHelperItem,
|
|
||||||
AdminContestList,
|
AdminContestList,
|
||||||
AdminProblemSetProgress,
|
AdminProblemSetProgress,
|
||||||
AdminProblemSetProblem,
|
AdminProblemSetProblem,
|
||||||
|
|||||||
@@ -350,16 +350,6 @@ export const createContestRequestSchema = z.object({
|
|||||||
|
|
||||||
export const updateContestRequestSchema = createContestRequestSchema
|
export const updateContestRequestSchema = createContestRequestSchema
|
||||||
|
|
||||||
export const acmHelperItemSchema = z.object({
|
|
||||||
id: z.number().int(),
|
|
||||||
username: z.string(),
|
|
||||||
realName: z.string().nullable(),
|
|
||||||
problemId: z.string(),
|
|
||||||
problemDisplayId: z.string(),
|
|
||||||
acInfo: z.record(z.string(), z.unknown()),
|
|
||||||
checked: z.boolean(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const updateAcmHelperRequestSchema = z.object({
|
export const updateAcmHelperRequestSchema = z.object({
|
||||||
rankId: z.number().int().positive(),
|
rankId: z.number().int().positive(),
|
||||||
problemId: z.string().min(1),
|
problemId: z.string().min(1),
|
||||||
@@ -788,6 +778,11 @@ export const addContestProblemRequestSchema = z.object({
|
|||||||
displayId: z.string().trim().min(1).max(32),
|
displayId: z.string().trim().min(1).max(32),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 比赛题排顺序:给出这场比赛**全部**题目的 id,按这个顺序把编号改成 1、2、3… */
|
||||||
|
export const reorderContestProblemsRequestSchema = z.object({
|
||||||
|
problemIds: z.array(z.number().int().positive()).min(1).max(100),
|
||||||
|
})
|
||||||
|
|
||||||
export const testCaseEntrySchema = z.object({
|
export const testCaseEntrySchema = z.object({
|
||||||
stripped_output_md5: z.string(),
|
stripped_output_md5: z.string(),
|
||||||
input_size: z.number().int(),
|
input_size: z.number().int(),
|
||||||
@@ -901,7 +896,6 @@ export type CreateProblemRequest = z.infer<typeof createProblemRequestSchema>
|
|||||||
|
|
||||||
export type AdminContest = z.infer<typeof adminContestSchema>
|
export type AdminContest = z.infer<typeof adminContestSchema>
|
||||||
export type AdminContestList = z.infer<typeof adminContestListSchema>
|
export type AdminContestList = z.infer<typeof adminContestListSchema>
|
||||||
export type AcmHelperItem = z.infer<typeof acmHelperItemSchema>
|
|
||||||
export type AdminUser = z.infer<typeof adminUserSchema>
|
export type AdminUser = z.infer<typeof adminUserSchema>
|
||||||
export type AdminUserList = z.infer<typeof adminUserListSchema>
|
export type AdminUserList = z.infer<typeof adminUserListSchema>
|
||||||
export type JudgeServer = z.infer<typeof judgeServerSchema>
|
export type JudgeServer = z.infer<typeof judgeServerSchema>
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ export const contestSchema = z.object({
|
|||||||
status: contestStatusSchema,
|
status: contestStatusSchema,
|
||||||
contestType: z.enum(["Public", "Password Protected"]),
|
contestType: z.enum(["Public", "Password Protected"]),
|
||||||
now: z.string().optional(),
|
now: z.string().optional(),
|
||||||
|
/** 列表才带:几道题(可见的)、几个学生交过题 */
|
||||||
|
problemCount: z.number().int().optional(),
|
||||||
|
participantCount: z.number().int().optional(),
|
||||||
|
/**
|
||||||
|
* 列表才带,登录了才有:我在这场里的成绩,没参加是 null。
|
||||||
|
* `rank` 在期中 / 期末进行中是 null —— 那时排名不公布(见 {@link isExamTag})
|
||||||
|
*/
|
||||||
|
mine: z
|
||||||
|
.object({
|
||||||
|
rank: z.number().int().nullable(),
|
||||||
|
total: z.number().int(),
|
||||||
|
solved: z.number().int(),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const contestListSchema = paginatedSchema(contestSchema)
|
export const contestListSchema = paginatedSchema(contestSchema)
|
||||||
@@ -49,25 +64,93 @@ export const contestSubmissionInfoSchema = z.object({
|
|||||||
checked: z.boolean().optional(),
|
checked: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const contestRankItemSchema = z.object({
|
/**
|
||||||
id: z.number().int(),
|
* 期中、期末:进行中学生看不到排名、每题做对人数、谁最先做对,考完一起公布(2026-10 用户定的)。
|
||||||
user: sampleUserSchema,
|
* 练习照常全看得到 —— 「我需要竞争」。标签只有 练习 / 期中 / 期末 三个值
|
||||||
submissionNumber: z.number().int(),
|
*/
|
||||||
acceptedNumber: z.number().int(),
|
export const EXAM_TAGS = ["期中", "期末"] as const
|
||||||
totalTime: z.number().int(),
|
export function isExamTag(tag: string) {
|
||||||
submissionInfo: z.record(z.string(), contestSubmissionInfoSchema),
|
return (EXAM_TAGS as readonly string[]).includes(tag)
|
||||||
contestId: z.number().int(),
|
}
|
||||||
|
|
||||||
|
/** 榜单上一个人一道题的格子。时间都是秒,从比赛开始算 */
|
||||||
|
export const contestScoreCellSchema = z.object({
|
||||||
|
isAc: z.boolean(),
|
||||||
|
acTime: z.number(),
|
||||||
|
/** 做对的题:做对前错了几次(编译失败不算);没做对的题:到现在错了几次 */
|
||||||
|
errors: z.number().int(),
|
||||||
|
/** 学生里最先做对这题的(同一秒并列都算)。不用库里的 is_first_ac:老师赛中试做也会占掉它 */
|
||||||
|
firstAc: z.boolean(),
|
||||||
|
/** 老师在成绩页标的「看过了」 */
|
||||||
|
checked: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const contestRankSchema = paginatedSchema(contestRankItemSchema)
|
export const contestScoreRowSchema = z.object({
|
||||||
|
/** acm_contest_rank.id,标「看过了」要用 */
|
||||||
|
rankId: z.number().int(),
|
||||||
|
userId: z.number().int(),
|
||||||
|
username: z.string(),
|
||||||
|
/** 只给老师 */
|
||||||
|
realName: z.string().nullable(),
|
||||||
|
className: z.string().nullable(),
|
||||||
|
/** 名次(1 起)。排名不公布时自己那一行是 null */
|
||||||
|
rank: z.number().int().nullable(),
|
||||||
|
/** 5 分钟前的名次,比赛结束后是 null(名次不再变) */
|
||||||
|
prevRank: z.number().int().nullable(),
|
||||||
|
solved: z.number().int(),
|
||||||
|
/** 罚时,秒:每道做对的题做对时是第几秒,加起来,错一次多 20 分钟 */
|
||||||
|
totalTime: z.number().int(),
|
||||||
|
/** 题目 id → 格子,只有交过的题有 */
|
||||||
|
cells: z.record(z.string(), contestScoreCellSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const contestScoreProblemSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
_id: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
solvedUsers: z.number().int(),
|
||||||
|
triedUsers: z.number().int(),
|
||||||
|
firstSolver: z.object({ username: z.string(), acTime: z.number() }).nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比赛的整张榜,一次全给(一场最多百来人):学生比赛页右边的名次、排名页、老师的全班情况都用它。
|
||||||
|
* `hidden` = 期中期末进行中、看的人是学生:problems 的人数清零,rows 只有自己那一行
|
||||||
|
*/
|
||||||
|
export const contestScoreboardSchema = z.object({
|
||||||
|
hidden: z.boolean(),
|
||||||
|
problems: z.array(contestScoreProblemSchema),
|
||||||
|
rows: z.array(contestScoreRowSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 老师看的「全班情况 / 成绩」:在榜单之外,多每个人最后一次交的时间,和没进来的人。
|
||||||
|
* 比赛不绑班:参赛的人里,一个班来了一半以上就算这场是这个班的,没进来的从这些班的名单里找
|
||||||
|
*/
|
||||||
|
export const contestClassViewSchema = contestScoreboardSchema.extend({
|
||||||
|
lastSubmit: z.record(z.string(), z.object({ time: z.string(), problemId: z.number().int() })),
|
||||||
|
classes: z.array(z.string()),
|
||||||
|
absent: z.array(
|
||||||
|
z.object({
|
||||||
|
userId: z.number().int(),
|
||||||
|
username: z.string(),
|
||||||
|
realName: z.string().nullable(),
|
||||||
|
className: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
export type Contest = z.infer<typeof contestSchema>
|
export type Contest = z.infer<typeof contestSchema>
|
||||||
export type ContestList = z.infer<typeof contestListSchema>
|
export type ContestList = z.infer<typeof contestListSchema>
|
||||||
export type ContestRankItem = z.infer<typeof contestRankItemSchema>
|
|
||||||
export type ContestRank = z.infer<typeof contestRankSchema>
|
|
||||||
export type ContestAccess = z.infer<typeof contestAccessSchema>
|
export type ContestAccess = z.infer<typeof contestAccessSchema>
|
||||||
export type ContestSubmissionInfo = z.infer<typeof contestSubmissionInfoSchema>
|
export type ContestSubmissionInfo = z.infer<typeof contestSubmissionInfoSchema>
|
||||||
|
|
||||||
|
export type ContestScoreCell = z.infer<typeof contestScoreCellSchema>
|
||||||
|
export type ContestScoreRow = z.infer<typeof contestScoreRowSchema>
|
||||||
|
export type ContestScoreProblem = z.infer<typeof contestScoreProblemSchema>
|
||||||
|
export type ContestScoreboard = z.infer<typeof contestScoreboardSchema>
|
||||||
|
export type ContestClassView = z.infer<typeof contestClassViewSchema>
|
||||||
|
|
||||||
export type ContestStatus = z.infer<typeof contestStatusSchema>
|
export type ContestStatus = z.infer<typeof contestStatusSchema>
|
||||||
export type ContestPasswordRequest = z.infer<typeof contestPasswordRequestSchema>
|
export type ContestPasswordRequest = z.infer<typeof contestPasswordRequestSchema>
|
||||||
export type ContestProblems = z.infer<typeof contestProblemsSchema>
|
export type ContestProblems = z.infer<typeof contestProblemsSchema>
|
||||||
Reference in new issue
Block a user