Compare commits
2
Commits
2224478294
...
c17f88cdbb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c17f88cdbb | ||
|
|
8c53e28dcb |
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
-- 题单的「截止时间」改成「布置到」:它从来不截止任何东西,只管布置期内藏学生以前的代码(防抄),
|
||||||
|
-- 见 services/problemset.ts 的 problemSetLockCutoffs。列名 end_time 不改(机房和服务器共库,
|
||||||
|
-- 改名会让没升级的那边直接报错);旧值是按旧语义填的(3 个题单,都已过期),清掉。
|
||||||
|
ALTER TABLE "problemset" ADD COLUMN "assigned_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
UPDATE "problemset" SET "end_time" = NULL;--> statement-breakpoint
|
||||||
|
-- 「状态」和「可见」两道闸合成一个「公开」:草稿等于不公开,归档等于公开(一直都还能进)
|
||||||
|
UPDATE "problemset" SET "visible" = false WHERE "status" = 'draft';--> statement-breakpoint
|
||||||
|
UPDATE "problemset" SET "status" = 'active' WHERE "status" <> 'active';--> statement-breakpoint
|
||||||
|
-- 分数拿掉了,「总分达到 N」的奖章换成「做对 N 道」(线上那几套题每题 10 分,门槛不变)
|
||||||
|
UPDATE "problemset_badge" SET "condition_type" = 'problem_count', "condition_value" = GREATEST(1, CEIL("condition_value" / 10.0)) WHERE "condition_type" = 'score';
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -183,6 +183,13 @@
|
|||||||
"when": 1791521256834,
|
"when": 1791521256834,
|
||||||
"tag": "0025_submission_problem_user_idx",
|
"tag": "0025_submission_problem_user_idx",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 26,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1791553481647,
|
||||||
|
"tag": "0026_problemset_assignment",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -417,7 +417,17 @@ export const problemset = pgTable(
|
|||||||
difficulty: text().notNull().$type<ProblemSetDifficulty>(),
|
difficulty: text().notNull().$type<ProblemSetDifficulty>(),
|
||||||
status: text().notNull().$type<ProblemSetStatus>(),
|
status: text().notNull().$type<ProblemSetStatus>(),
|
||||||
createdById: integer("created_by_id").notNull(),
|
createdById: integer("created_by_id").notNull(),
|
||||||
endTime: timestamp("end_time", { withTimezone: true, mode: "string" }),
|
/**
|
||||||
|
* 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。
|
||||||
|
* 布置期内,题单里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见
|
||||||
|
* services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。
|
||||||
|
* 两个都为空 = 没布置。
|
||||||
|
*
|
||||||
|
* 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、
|
||||||
|
* 不一定同时升级,改列名会让没升级的那边一查题单就报错,所以只改代码里的名字
|
||||||
|
*/
|
||||||
|
assignedUntil: timestamp("end_time", { withTimezone: true, mode: "string" }),
|
||||||
|
assignedAt: timestamp("assigned_at", { withTimezone: true, mode: "string" }),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("problemset_created_by_id_01b5197f").using(
|
index("problemset_created_by_id_01b5197f").using(
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
addProblemToSetRequestSchema,
|
addProblemsToSetRequestSchema,
|
||||||
createProblemSetBadgeRequestSchema,
|
createProblemSetBadgeRequestSchema,
|
||||||
createProblemSetRequestSchema,
|
createProblemSetRequestSchema,
|
||||||
|
reorderProblemSetProblemsRequestSchema,
|
||||||
updateProblemInSetRequestSchema,
|
updateProblemInSetRequestSchema,
|
||||||
updateProblemSetBadgeRequestSchema,
|
updateProblemSetBadgeRequestSchema,
|
||||||
updateProblemSetRequestSchema,
|
updateProblemSetRequestSchema,
|
||||||
updateProblemSetStatusRequestSchema,
|
type AddProblemsToSetResult,
|
||||||
type AdminProblemSet,
|
type AdminProblemSet,
|
||||||
type AdminProblemSetBadge,
|
type AdminProblemSetBadge,
|
||||||
type AdminProblemSetList,
|
type AdminProblemSetList,
|
||||||
type AdminProblemSetProblem,
|
type AdminProblemSetProblem,
|
||||||
type AdminProblemSetProgress,
|
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"
|
import { and, asc, count, desc, eq, ilike, inArray, isNull, max, or, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||||
@@ -20,7 +20,7 @@ import type { AuthUser } from "../../auth/session"
|
|||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
import { failure, parseBody, success } from "../../http"
|
import { failure, parseBody, success } from "../../http"
|
||||||
import { recalculateBadge, resyncProgress } from "../../services/problemset"
|
import { recalculateBadge, resyncProgress } from "../../services/problemset"
|
||||||
import { asFilterValue, queryInteger, sampleUser } from "../helpers"
|
import { queryInteger, sampleUser } from "../helpers"
|
||||||
|
|
||||||
export const adminProblemSetRoutes = new Hono<AppEnv>()
|
export const adminProblemSetRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -49,11 +49,11 @@ async function serialize(row: typeof schema.problemset.$inferSelect) {
|
|||||||
return (await serializeMany([row]))[0]!
|
return (await serializeMany([row]))[0]!
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 批量版:列表接口走这个,固定 3 条查询,与行数无关(按行 serialize 就是 N+1) */
|
/** 批量版:列表接口走这个,固定 4 条查询,与行数无关(按行 serialize 就是 N+1) */
|
||||||
async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
||||||
if (rows.length === 0) return []
|
if (rows.length === 0) return []
|
||||||
const ids = rows.map((row) => row.id)
|
const ids = rows.map((row) => row.id)
|
||||||
const [problems, participants, creators] = await Promise.all([
|
const [problems, participants, badges, creators] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
problemsetId: schema.problemsetProblem.problemsetId,
|
problemsetId: schema.problemsetProblem.problemsetId,
|
||||||
@@ -66,10 +66,19 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
|||||||
.select({
|
.select({
|
||||||
problemsetId: schema.problemsetProgress.problemsetId,
|
problemsetId: schema.problemsetProgress.problemsetId,
|
||||||
value: count(),
|
value: count(),
|
||||||
|
completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`,
|
||||||
})
|
})
|
||||||
.from(schema.problemsetProgress)
|
.from(schema.problemsetProgress)
|
||||||
.where(inArray(schema.problemsetProgress.problemsetId, ids))
|
.where(inArray(schema.problemsetProgress.problemsetId, ids))
|
||||||
.groupBy(schema.problemsetProgress.problemsetId),
|
.groupBy(schema.problemsetProgress.problemsetId),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
problemsetId: schema.problemsetBadge.problemsetId,
|
||||||
|
value: count(),
|
||||||
|
})
|
||||||
|
.from(schema.problemsetBadge)
|
||||||
|
.where(inArray(schema.problemsetBadge.problemsetId, ids))
|
||||||
|
.groupBy(schema.problemsetBadge.problemsetId),
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: schema.user.id,
|
id: schema.user.id,
|
||||||
@@ -81,27 +90,57 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
|
|||||||
.where(inArray(schema.user.id, [...new Set(rows.map((row) => row.createdById))])),
|
.where(inArray(schema.user.id, [...new Set(rows.map((row) => row.createdById))])),
|
||||||
])
|
])
|
||||||
const problemsBySet = new Map(problems.map((item) => [item.problemsetId, item.value]))
|
const problemsBySet = new Map(problems.map((item) => [item.problemsetId, item.value]))
|
||||||
const participantsBySet = new Map(participants.map((item) => [item.problemsetId, item.value]))
|
const participantsBySet = new Map(participants.map((item) => [item.problemsetId, item]))
|
||||||
|
const badgesBySet = new Map(badges.map((item) => [item.problemsetId, item.value]))
|
||||||
const creatorById = new Map(creators.map((item) => [item.id, item]))
|
const creatorById = new Map(creators.map((item) => [item.id, item]))
|
||||||
|
const now = Date.now()
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const creator = creatorById.get(row.createdById)
|
const creator = creatorById.get(row.createdById)
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
difficulty: row.difficulty,
|
assignedUntil: row.assignedUntil,
|
||||||
status: row.status,
|
assignedAt: row.assignedAt,
|
||||||
endTime: row.endTime,
|
assigning:
|
||||||
|
row.assignedAt !== null &&
|
||||||
|
row.assignedUntil !== null &&
|
||||||
|
Date.parse(row.assignedUntil) > now,
|
||||||
visible: row.visible,
|
visible: row.visible,
|
||||||
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
|
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
|
||||||
createTime: row.createTime,
|
createTime: row.createTime,
|
||||||
lastUpdateTime: row.lastUpdateTime,
|
lastUpdateTime: row.lastUpdateTime,
|
||||||
problemsCount: problemsBySet.get(row.id) ?? 0,
|
problemsCount: problemsBySet.get(row.id) ?? 0,
|
||||||
participantCount: participantsBySet.get(row.id) ?? 0,
|
participantCount: participantsBySet.get(row.id)?.value ?? 0,
|
||||||
|
completedCount: participantsBySet.get(row.id)?.completed ?? 0,
|
||||||
|
badgeCount: badgesBySet.get(row.id) ?? 0,
|
||||||
} satisfies AdminProblemSet
|
} satisfies AdminProblemSet
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 布置期怎么落库。老师给的是「布置到哪天」(东八区当天结束的时刻),开始时刻由服务端定:
|
||||||
|
* - 不布置 → 两个都清掉;
|
||||||
|
* - 现在正在布置 → 只改结束,开始不动(延长、缩短都不该把「以前的代码」的分界挪到现在);
|
||||||
|
* - 没在布置(从没布置过,或者上一轮已经过期)→ 开始记成现在,这就是「再布置一次」。
|
||||||
|
*/
|
||||||
|
function assignment(
|
||||||
|
requested: string | null,
|
||||||
|
current: { assignedAt: string | null; assignedUntil: string | null } | null,
|
||||||
|
) {
|
||||||
|
if (requested === null) return { assignedAt: null, assignedUntil: null }
|
||||||
|
const until = Date.parse(requested)
|
||||||
|
if (!Number.isFinite(until) || until <= Date.now()) return "past" as const
|
||||||
|
const running =
|
||||||
|
current?.assignedAt != null &&
|
||||||
|
current.assignedUntil != null &&
|
||||||
|
Date.parse(current.assignedUntil) > Date.now()
|
||||||
|
return {
|
||||||
|
assignedAt: running ? current.assignedAt : new Date().toISOString(),
|
||||||
|
assignedUntil: new Date(until).toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 题单本体
|
// ---------------------------------------------------------------- 题单本体
|
||||||
|
|
||||||
adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
||||||
@@ -114,8 +153,6 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
|||||||
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。
|
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。
|
||||||
if (user.adminType !== "Super Admin") filters.push(eq(schema.problemset.createdById, user.id))
|
if (user.adminType !== "Super Admin") filters.push(eq(schema.problemset.createdById, user.id))
|
||||||
const keyword = c.req.query("keyword")?.trim()
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
const difficulty = c.req.query("difficulty")?.trim()
|
|
||||||
const status = c.req.query("status")?.trim()
|
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
filters.push(
|
filters.push(
|
||||||
or(
|
or(
|
||||||
@@ -124,8 +161,6 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
|||||||
)!,
|
)!,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
|
|
||||||
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
|
|
||||||
const where = filters.length ? and(...filters) : undefined
|
const where = filters.length ? and(...filters) : undefined
|
||||||
|
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
@@ -147,12 +182,19 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
|
|||||||
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
|
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
|
||||||
const parsed = await parseBody(c, createProblemSetRequestSchema)
|
const parsed = await parseBody(c, createProblemSetRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
|
const period = assignment(parsed.data.assignedUntil, null)
|
||||||
|
if (period === "past") return failure(c, 400, "assign-in-past", "布置到的日期已经过了")
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(schema.problemset)
|
.insert(schema.problemset)
|
||||||
.values({
|
.values({
|
||||||
...parsed.data,
|
title: parsed.data.title,
|
||||||
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
|
description: parsed.data.description,
|
||||||
|
visible: parsed.data.visible,
|
||||||
|
...period,
|
||||||
|
// 难度、状态两列还在表里、非空,但已经不用了(见契约 admin.ts 的说明)
|
||||||
|
difficulty: "Easy",
|
||||||
|
status: "active",
|
||||||
createdById: c.get("user")!.id,
|
createdById: c.get("user")!.id,
|
||||||
createTime: now,
|
createTime: now,
|
||||||
lastUpdateTime: now,
|
lastUpdateTime: now,
|
||||||
@@ -172,11 +214,15 @@ adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => {
|
|||||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
const parsed = await parseBody(c, updateProblemSetRequestSchema)
|
const parsed = await parseBody(c, updateProblemSetRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
|
const period = assignment(parsed.data.assignedUntil, row)
|
||||||
|
if (period === "past") return failure(c, 400, "assign-in-past", "布置到的日期已经过了")
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(schema.problemset)
|
.update(schema.problemset)
|
||||||
.set({
|
.set({
|
||||||
...parsed.data,
|
title: parsed.data.title,
|
||||||
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
|
description: parsed.data.description,
|
||||||
|
visible: parsed.data.visible,
|
||||||
|
...period,
|
||||||
lastUpdateTime: new Date().toISOString(),
|
lastUpdateTime: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.where(eq(schema.problemset.id, row.id))
|
.where(eq(schema.problemset.id, row.id))
|
||||||
@@ -196,22 +242,6 @@ adminProblemSetRoutes.put("/problem-sets/:id/visibility", requireTeacher, async
|
|||||||
return success(c, await serialize(updated!))
|
return success(c, await serialize(updated!))
|
||||||
})
|
})
|
||||||
|
|
||||||
adminProblemSetRoutes.put("/problem-sets/:id/status", requireTeacher, async (c) => {
|
|
||||||
const row = await loadOwned(c, c.get("user")!)
|
|
||||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
|
||||||
const parsed = await parseBody(c, updateProblemSetStatusRequestSchema, "status 不合法")
|
|
||||||
if (!parsed.success) return parsed.response
|
|
||||||
const [updated] = await db
|
|
||||||
.update(schema.problemset)
|
|
||||||
.set({
|
|
||||||
status: parsed.data.status,
|
|
||||||
lastUpdateTime: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.where(eq(schema.problemset.id, row.id))
|
|
||||||
.returning()
|
|
||||||
return success(c, await serialize(updated!))
|
|
||||||
})
|
|
||||||
|
|
||||||
adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
|
adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get("user")!)
|
const row = await loadOwned(c, c.get("user")!)
|
||||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
@@ -245,59 +275,97 @@ adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c
|
|||||||
difficulty: problem.difficulty,
|
difficulty: problem.difficulty,
|
||||||
order: item.order,
|
order: item.order,
|
||||||
isRequired: item.isRequired,
|
isRequired: item.isRequired,
|
||||||
score: item.score,
|
|
||||||
hint: item.hint,
|
|
||||||
}) satisfies AdminProblemSetProblem,
|
}) satisfies AdminProblemSetProblem,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加题:一次可以给好几个题号(后台搜索框里粘一串「3065 3066 3067」),按给的顺序接在最后。
|
||||||
|
* 找不到的、本来就在里面的不报错,分开报回去,前端告诉老师哪几个没加上。
|
||||||
|
*/
|
||||||
adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => {
|
adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => {
|
||||||
const row = await loadOwned(c, c.get("user")!)
|
const row = await loadOwned(c, c.get("user")!)
|
||||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
const parsed = await parseBody(c, addProblemToSetRequestSchema)
|
const parsed = await parseBody(c, addProblemsToSetRequestSchema)
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const [problem] = await db
|
const wanted = [...new Set(parsed.data.problemIds.map((id) => id.toLowerCase()))]
|
||||||
.select({ id: schema.problem.id })
|
const [problems, existing, last] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
|
||||||
.from(schema.problem)
|
.from(schema.problem)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
sql`lower(${schema.problem.displayId}) = lower(${parsed.data.problemId})`,
|
inArray(sql<string>`lower(${schema.problem.displayId})`, wanted),
|
||||||
eq(schema.problem.visible, true),
|
eq(schema.problem.visible, true),
|
||||||
isNull(schema.problem.contestId),
|
isNull(schema.problem.contestId),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在或不可见")
|
|
||||||
|
|
||||||
const [duplicate] = await db
|
|
||||||
.select({ id: schema.problemsetProblem.id })
|
|
||||||
.from(schema.problemsetProblem)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.problemsetProblem.problemsetId, row.id),
|
|
||||||
eq(schema.problemsetProblem.problemId, problem.id),
|
|
||||||
),
|
),
|
||||||
)
|
db
|
||||||
.limit(1)
|
.select({ problemId: schema.problemsetProblem.problemId })
|
||||||
if (duplicate) return failure(c, 409, "problem-already-in-set", "题目已在该题单中")
|
.from(schema.problemsetProblem)
|
||||||
|
.where(eq(schema.problemsetProblem.problemsetId, row.id)),
|
||||||
const [created] = await db
|
db
|
||||||
.insert(schema.problemsetProblem)
|
.select({ value: max(schema.problemsetProblem.order) })
|
||||||
.values({
|
.from(schema.problemsetProblem)
|
||||||
|
.where(eq(schema.problemsetProblem.problemsetId, row.id)),
|
||||||
|
])
|
||||||
|
const byDisplayId = new Map(problems.map((problem) => [problem.displayId.toLowerCase(), problem]))
|
||||||
|
const inSet = new Set(existing.map((item) => item.problemId))
|
||||||
|
const result: AddProblemsToSetResult = { added: [], missing: [], duplicate: [] }
|
||||||
|
const values: (typeof schema.problemsetProblem.$inferInsert)[] = []
|
||||||
|
let order = last[0]?.value ?? 0
|
||||||
|
for (const id of wanted) {
|
||||||
|
const problem = byDisplayId.get(id)
|
||||||
|
if (!problem) result.missing.push(id)
|
||||||
|
else if (inSet.has(problem.id)) result.duplicate.push(problem.displayId)
|
||||||
|
else {
|
||||||
|
inSet.add(problem.id)
|
||||||
|
order += 1
|
||||||
|
// 分数、提示两列还在表里但已经不用了:score 非空给 0
|
||||||
|
values.push({
|
||||||
problemsetId: row.id,
|
problemsetId: row.id,
|
||||||
problemId: problem.id,
|
problemId: problem.id,
|
||||||
order: parsed.data.order,
|
order,
|
||||||
isRequired: parsed.data.isRequired,
|
isRequired: true,
|
||||||
score: parsed.data.score,
|
score: 0,
|
||||||
hint: parsed.data.hint,
|
|
||||||
})
|
})
|
||||||
.returning({ id: schema.problemsetProblem.id })
|
result.added.push(problem.displayId)
|
||||||
// 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变,
|
}
|
||||||
// 否则学生看到的进度分母还是老的。旧栈是靠 ProblemSetProblem 的 post_save 信号做的,
|
}
|
||||||
// 不在 views 里,别因为翻不到显式调用就以为它没做(见 services/problemset.ts)。
|
if (values.length) {
|
||||||
|
await db.insert(schema.problemsetProblem).values(values)
|
||||||
|
// 题目集变了,已加入的人的分母、完成状态、奖章都得跟着变(旧栈靠 post_save 信号,
|
||||||
|
// 不在 views 里,别因为翻不到显式调用就以为它没做,见 services/problemset.ts)
|
||||||
await resyncProgress(row.id)
|
await resyncProgress(row.id)
|
||||||
return success(c, { id: created!.id }, 201)
|
}
|
||||||
|
return success(c, result, values.length ? 201 : 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 必须注册在 /problems/:itemId 前面:Hono 按注册顺序匹配,order 会被当成 :itemId 吃掉
|
||||||
|
adminProblemSetRoutes.put("/problem-sets/:id/problems/order", requireTeacher, async (c) => {
|
||||||
|
const row = await loadOwned(c, c.get("user")!)
|
||||||
|
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
|
const parsed = await parseBody(c, reorderProblemSetProblemsRequestSchema)
|
||||||
|
if (!parsed.success) return parsed.response
|
||||||
|
const links = await db
|
||||||
|
.select({ id: schema.problemsetProblem.id })
|
||||||
|
.from(schema.problemsetProblem)
|
||||||
|
.where(eq(schema.problemsetProblem.problemsetId, row.id))
|
||||||
|
const have = new Set(links.map((link) => link.id))
|
||||||
|
const given = new Set(parsed.data.ids)
|
||||||
|
if (given.size !== have.size || [...given].some((id) => !have.has(id))) {
|
||||||
|
return failure(c, 409, "problems-changed", "题目列表变了,刷新一下再排")
|
||||||
|
}
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (const [index, id] of parsed.data.ids.entries()) {
|
||||||
|
await tx
|
||||||
|
.update(schema.problemsetProblem)
|
||||||
|
.set({ order: index + 1 })
|
||||||
|
.where(eq(schema.problemsetProblem.id, id))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => {
|
adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => {
|
||||||
@@ -316,7 +384,8 @@ adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher,
|
|||||||
)
|
)
|
||||||
.returning({ id: schema.problemsetProblem.id })
|
.returning({ id: schema.problemsetProblem.id })
|
||||||
if (updated.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
|
if (updated.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
|
||||||
if (parsed.data.score !== undefined) await resyncProgress(row.id)
|
// 必做 / 选做变了,分母和「做完」跟着变
|
||||||
|
await resyncProgress(row.id)
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -453,84 +522,3 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher
|
|||||||
await db.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id))
|
await db.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id))
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 学生进度
|
|
||||||
|
|
||||||
adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c) => {
|
|
||||||
const row = await loadOwned(c, c.get("user")!)
|
|
||||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
progress: schema.problemsetProgress,
|
|
||||||
username: schema.user.username,
|
|
||||||
realName: schema.userProfile.realName,
|
|
||||||
})
|
|
||||||
.from(schema.problemsetProgress)
|
|
||||||
.innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
|
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
|
||||||
.where(eq(schema.problemsetProgress.problemsetId, row.id))
|
|
||||||
.orderBy(desc(schema.problemsetProgress.joinTime))
|
|
||||||
return success(
|
|
||||||
c,
|
|
||||||
rows.map(
|
|
||||||
({ progress, username, realName }) =>
|
|
||||||
({
|
|
||||||
id: progress.id,
|
|
||||||
userId: progress.userId,
|
|
||||||
username,
|
|
||||||
// 真名有意下发:这是老师看本班完成情况的页面,已由 requireTeacher + 归属校验把关
|
|
||||||
realName,
|
|
||||||
joinTime: progress.joinTime,
|
|
||||||
completeTime: progress.completeTime,
|
|
||||||
isCompleted: progress.isCompleted,
|
|
||||||
progressPercentage: progress.progressPercentage,
|
|
||||||
completedProblemsCount: progress.completedProblemsCount,
|
|
||||||
totalProblemsCount: progress.totalProblemsCount,
|
|
||||||
totalScore: progress.totalScore,
|
|
||||||
}) satisfies AdminProblemSetProgress,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
adminProblemSetRoutes.delete("/problem-sets/:id/progress/:userId", requireTeacher, async (c) => {
|
|
||||||
const row = await loadOwned(c, c.get("user")!)
|
|
||||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
|
||||||
const userId = queryInteger(c.req.param("userId"), 0, { min: 1 })
|
|
||||||
const deleted = await db.transaction(async (tx) => {
|
|
||||||
// 把人踢出题单,他基于这份题单拿到的奖章也该收回,否则奖章会悬空
|
|
||||||
const badges = await tx
|
|
||||||
.select({ id: schema.problemsetBadge.id })
|
|
||||||
.from(schema.problemsetBadge)
|
|
||||||
.where(eq(schema.problemsetBadge.problemsetId, row.id))
|
|
||||||
if (badges.length) {
|
|
||||||
await tx.delete(schema.userBadge).where(
|
|
||||||
and(
|
|
||||||
eq(schema.userBadge.userId, userId),
|
|
||||||
inArray(
|
|
||||||
schema.userBadge.badgeId,
|
|
||||||
badges.map((badge) => badge.id),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
await tx
|
|
||||||
.delete(schema.problemsetSubmission)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.problemsetSubmission.problemsetId, row.id),
|
|
||||||
eq(schema.problemsetSubmission.userId, userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return tx
|
|
||||||
.delete(schema.problemsetProgress)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.problemsetProgress.problemsetId, row.id),
|
|
||||||
eq(schema.problemsetProgress.userId, userId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.returning({ id: schema.problemsetProgress.id })
|
|
||||||
})
|
|
||||||
if (deleted.length === 0) return failure(c, 404, "progress-not-found", "用户未加入该题单")
|
|
||||||
return success(c, null)
|
|
||||||
})
|
|
||||||
@@ -23,7 +23,7 @@ import { db, schema } from "../db"
|
|||||||
import { failure, readJson, success } from "../http"
|
import { failure, readJson, success } from "../http"
|
||||||
import { flowchartQueue } from "../queue"
|
import { flowchartQueue } from "../queue"
|
||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
import { problemSetJoinTimes } from "../services/problemset"
|
import { problemSetLockCutoffs } from "../services/problemset"
|
||||||
import { consumeToken } from "../services/throttling"
|
import { consumeToken } from "../services/throttling"
|
||||||
import { buildWordFrequencies } from "../services/word-frequency"
|
import { buildWordFrequencies } from "../services/word-frequency"
|
||||||
import { dayStart } from "../time"
|
import { dayStart } from "../time"
|
||||||
@@ -49,36 +49,36 @@ function flowchartThrottleKey(userId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题单防抄:学生加入含这道题的题单之前画的图,先对他本人藏起来 —— 和代码提交同一道闸
|
* 题单防抄:题单布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸
|
||||||
* (routes/submission.ts 的 canViewSubmission,规则见 problemSetJoinTimes)。原来流程图
|
* (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图
|
||||||
* 这边没有这一条,加入题单之前画到 A 的那张图点一下就能载回画布,交上去就是 A。
|
* 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。
|
||||||
*/
|
*/
|
||||||
function hiddenByProblemSet(
|
function hiddenByProblemSet(
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
row: { userId: number; problemId: number; createTime: string },
|
row: { userId: number; problemId: number; createTime: string },
|
||||||
joinTimes: Map<number, string>,
|
cutoffs: Map<number, string>,
|
||||||
) {
|
) {
|
||||||
if (row.userId !== user.id || isTeacherOrAbove(user)) return false
|
if (row.userId !== user.id || isTeacherOrAbove(user)) return false
|
||||||
const joinTime = joinTimes.get(row.problemId)
|
const cutoff = cutoffs.get(row.problemId)
|
||||||
return joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)
|
return cutoff !== undefined && Date.parse(row.createTime) < Date.parse(cutoff)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canView(
|
function canView(
|
||||||
user: AuthUser,
|
user: AuthUser,
|
||||||
row: { userId: number; problemId: number; createTime: string },
|
row: { userId: number; problemId: number; createTime: string },
|
||||||
problem: { createdById: number },
|
problem: { createdById: number },
|
||||||
joinTimes: Map<number, string>,
|
cutoffs: Map<number, string>,
|
||||||
) {
|
) {
|
||||||
if (hiddenByProblemSet(user, row, joinTimes)) return false
|
if (hiddenByProblemSet(user, row, cutoffs)) return false
|
||||||
// 角色捷径只给老师:学生管理员算学生,不能看同学的流程图(同 submission.ts 的 canViewSubmission)
|
// 角色捷径只给老师:学生管理员算学生,不能看同学的流程图(同 submission.ts 的 canViewSubmission)
|
||||||
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
|
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
|
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
|
||||||
function joinTimesFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
|
function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
|
||||||
if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>())
|
if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>())
|
||||||
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
|
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
|
||||||
return problemSetJoinTimes(user.id, [...new Set(own)])
|
return problemSetLockCutoffs(user.id, [...new Set(own)])
|
||||||
}
|
}
|
||||||
|
|
||||||
function flowchartData(
|
function flowchartData(
|
||||||
@@ -321,7 +321,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
|||||||
.offset(offset),
|
.offset(offset),
|
||||||
displayId ? unknownDisplayIds(displayId, null) : [],
|
displayId ? unknownDisplayIds(displayId, null) : [],
|
||||||
])
|
])
|
||||||
const joinTimes = await joinTimesFor(
|
const cutoffs = await cutoffsFor(
|
||||||
user,
|
user,
|
||||||
rows.map((row) => row.flowchart),
|
rows.map((row) => row.flowchart),
|
||||||
)
|
)
|
||||||
@@ -341,7 +341,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
|||||||
aiModel: flowchart.aiModel,
|
aiModel: flowchart.aiModel,
|
||||||
processingTime: flowchart.processingTime,
|
processingTime: flowchart.processingTime,
|
||||||
evaluationTime: flowchart.evaluationTime,
|
evaluationTime: flowchart.evaluationTime,
|
||||||
showLink: canView(user, flowchart, problem, joinTimes),
|
showLink: canView(user, flowchart, problem, cutoffs),
|
||||||
}) satisfies FlowchartListItem,
|
}) satisfies FlowchartListItem,
|
||||||
),
|
),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
@@ -656,7 +656,7 @@ flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
|||||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart])))
|
if (!row || !canView(user, row.flowchart, row.problem, await cutoffsFor(user, [row.flowchart])))
|
||||||
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||||
return success(c, flowchartData(row.flowchart, row.username))
|
return success(c, flowchartData(row.flowchart, row.username))
|
||||||
})
|
})
|
||||||
@@ -669,7 +669,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
|||||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
||||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!row || !canView(user, row.flowchart, row.problem, await joinTimesFor(user, [row.flowchart])))
|
if (!row || !canView(user, row.flowchart, row.problem, await cutoffsFor(user, [row.flowchart])))
|
||||||
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
||||||
// 看得见不等于能重评:canView 放进来的出题人只能看。重评会清掉原来的
|
// 看得见不等于能重评:canView 放进来的出题人只能看。重评会清掉原来的
|
||||||
// 评分再抽一次,别人的提交只有老师能动
|
// 评分再抽一次,别人的提交只有老师能动
|
||||||
@@ -737,7 +737,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
|||||||
* userId / problemId / createTime 三列是 hiddenByProblemSet 要的,别删。
|
* userId / problemId / createTime 三列是 hiddenByProblemSet 要的,别删。
|
||||||
*/
|
*/
|
||||||
async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
||||||
const [rows, joinTimes] = await Promise.all([
|
const [rows, cutoffs] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
id: schema.flowchartSubmission.id,
|
id: schema.flowchartSubmission.id,
|
||||||
@@ -756,9 +756,9 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(asc(schema.flowchartSubmission.createTime)),
|
.orderBy(asc(schema.flowchartSubmission.createTime)),
|
||||||
joinTimesFor(user, [{ userId: user.id, problemId }]),
|
cutoffsFor(user, [{ userId: user.id, problemId }]),
|
||||||
])
|
])
|
||||||
const visible = rows.filter((row) => !hiddenByProblemSet(user, row, joinTimes))
|
const visible = rows.filter((row) => !hiddenByProblemSet(user, row, cutoffs))
|
||||||
return { visible, hidden: rows.length - visible.length }
|
return { visible, hidden: rows.length - visible.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+367
-258
@@ -1,44 +1,83 @@
|
|||||||
import {
|
import {
|
||||||
joinProblemSetRequestSchema,
|
joinProblemSetRequestSchema,
|
||||||
|
STUDENT_ROLES,
|
||||||
type ProblemSet,
|
type ProblemSet,
|
||||||
type ProblemSetBadge,
|
type ProblemSetBadge,
|
||||||
|
type ProblemSetClassView,
|
||||||
type ProblemSetList,
|
type ProblemSetList,
|
||||||
|
type ProblemSetLock,
|
||||||
type ProblemSetProblem,
|
type ProblemSetProblem,
|
||||||
type ProblemSetProgress,
|
|
||||||
type ProblemSetProgressList,
|
|
||||||
type UserBadge,
|
type UserBadge,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, avg, count, desc, eq, gt, ilike, inArray, ne, or, sql } from "drizzle-orm"
|
import { and, asc, count, desc, eq, ilike, inArray, isNull, notInArray, or, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||||
|
import type { AuthUser } from "../auth/session"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, parseBody, success } from "../http"
|
import { failure, parseBody, success } from "../http"
|
||||||
import { computeProgress } from "../services/problemset"
|
import { JudgeStatus, NON_FAILURE_RESULTS } from "../judge/status"
|
||||||
import { asFilterValue, asRecord, classPrefixCondition, queryInteger, sampleUser } from "./helpers"
|
import {
|
||||||
|
assigningSql,
|
||||||
|
computeProgress,
|
||||||
|
problemSetLockCutoffs,
|
||||||
|
problemSetLocksFor,
|
||||||
|
} from "../services/problemset"
|
||||||
|
import { asRecord, isTeacherOrAbove, queryInteger, sampleUser } from "./helpers"
|
||||||
|
|
||||||
export const problemsetRoutes = new Hono<AppEnv>()
|
export const problemsetRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
type ProblemSetRow = typeof schema.problemset.$inferSelect
|
type ProblemSetRow = typeof schema.problemset.$inferSelect
|
||||||
|
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
|
||||||
|
|
||||||
function progressSummary(progress: typeof schema.problemsetProgress.$inferSelect | undefined) {
|
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||||
|
|
||||||
|
/** 必做题的 problemId;一道必做都没标的题单退回全部(和 computeProgress 同一个口径) */
|
||||||
|
function gradedIds(links: { problemId: number; isRequired: boolean }[]) {
|
||||||
|
const required = links.filter((link) => link.isRequired)
|
||||||
|
return new Set((required.length ? required : links).map((link) => link.problemId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressSummary(progress: ProgressRow | undefined) {
|
||||||
return progress
|
return progress
|
||||||
? {
|
? {
|
||||||
isJoined: true,
|
isJoined: true,
|
||||||
progressPercentage: progress.progressPercentage,
|
joinTime: progress.joinTime,
|
||||||
completedCount: progress.completedProblemsCount,
|
completedCount: progress.completedProblemsCount,
|
||||||
totalCount: progress.totalProblemsCount,
|
totalCount: progress.totalProblemsCount,
|
||||||
|
solvedCount: Object.keys(asRecord(progress.progressDetail)).length,
|
||||||
isCompleted: progress.isCompleted,
|
isCompleted: progress.isCompleted,
|
||||||
|
completeTime: progress.completeTime,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
isJoined: false,
|
isJoined: false,
|
||||||
progressPercentage: 0,
|
joinTime: null,
|
||||||
completedCount: 0,
|
completedCount: 0,
|
||||||
totalCount: 0,
|
totalCount: 0,
|
||||||
|
solvedCount: 0,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
|
completeTime: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生能看到的题单:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生
|
||||||
|
* 看到的样子、看全班情况,不该被 404 挡在外面。
|
||||||
|
*/
|
||||||
|
async function loadVisible(id: number, user: AuthUser | null | undefined) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ set: schema.problemset, assigning: assigningSql })
|
||||||
|
.from(schema.problemset)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.problemset.id, id),
|
||||||
|
isTeacherOrAbove(user) ? undefined : eq(schema.problemset.visible, true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
return row ?? null
|
||||||
|
}
|
||||||
|
|
||||||
async function problemSetCreators(ids: number[]) {
|
async function problemSetCreators(ids: number[]) {
|
||||||
const map = new Map<number, ReturnType<typeof sampleUser>>()
|
const map = new Map<number, ReturnType<typeof sampleUser>>()
|
||||||
if (ids.length === 0) return map
|
if (ids.length === 0) return map
|
||||||
@@ -55,7 +94,7 @@ async function problemSetCreators(ids: number[]) {
|
|||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) {
|
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earnedTime?: string | null) {
|
||||||
return {
|
return {
|
||||||
id: badge.id,
|
id: badge.id,
|
||||||
problemSetId: badge.problemsetId,
|
problemSetId: badge.problemsetId,
|
||||||
@@ -64,28 +103,37 @@ function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: b
|
|||||||
icon: badge.icon,
|
icon: badge.icon,
|
||||||
conditionType: badge.conditionType,
|
conditionType: badge.conditionType,
|
||||||
conditionValue: badge.conditionValue,
|
conditionValue: badge.conditionValue,
|
||||||
isEarned: earned,
|
isEarned: earnedTime === undefined ? undefined : earnedTime !== null,
|
||||||
|
earnedTime,
|
||||||
} satisfies ProblemSetBadge
|
} satisfies ProblemSetBadge
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一次把整页题单的附属数据全查回来,再在内存里按 problemsetId 分组。
|
* 一次把整页题单的附属数据全查回来,再在内存里按 problemsetId 分组。
|
||||||
*
|
* 固定 6 条查询,与行数无关(按行查就是 N+1)。
|
||||||
* 以前是每行 5 条查询(题目数 / 我的进度 / 奖章 / 已获奖章 / 创建者),
|
|
||||||
* limit 最大 250 就是 1250 次往返。这里固定 5 条,与行数无关。
|
|
||||||
*/
|
*/
|
||||||
async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, includeBadges = false) {
|
async function serializeProblemSets(
|
||||||
|
rows: { set: ProblemSetRow; assigning: boolean }[],
|
||||||
|
userId?: number,
|
||||||
|
) {
|
||||||
if (rows.length === 0) return []
|
if (rows.length === 0) return []
|
||||||
const ids = rows.map((row) => row.id)
|
const ids = rows.map((row) => row.set.id)
|
||||||
const [problemCounts, progresses, badges, earnedRows, creators] = await Promise.all([
|
const [problemCounts, joinedCounts, progresses, badges, earnedRows, creators] = await Promise.all(
|
||||||
|
[
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
problemsetId: schema.problemsetProblem.problemsetId,
|
problemsetId: schema.problemsetProblem.problemsetId,
|
||||||
value: count(),
|
value: count(),
|
||||||
|
required: sql<number>`count(*) filter (where ${schema.problemsetProblem.isRequired})::int`,
|
||||||
})
|
})
|
||||||
.from(schema.problemsetProblem)
|
.from(schema.problemsetProblem)
|
||||||
.where(inArray(schema.problemsetProblem.problemsetId, ids))
|
.where(inArray(schema.problemsetProblem.problemsetId, ids))
|
||||||
.groupBy(schema.problemsetProblem.problemsetId),
|
.groupBy(schema.problemsetProblem.problemsetId),
|
||||||
|
db
|
||||||
|
.select({ problemsetId: schema.problemsetProgress.problemsetId, value: count() })
|
||||||
|
.from(schema.problemsetProgress)
|
||||||
|
.where(inArray(schema.problemsetProgress.problemsetId, ids))
|
||||||
|
.groupBy(schema.problemsetProgress.problemsetId),
|
||||||
userId
|
userId
|
||||||
? db
|
? db
|
||||||
.select()
|
.select()
|
||||||
@@ -96,17 +144,15 @@ async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, incl
|
|||||||
eq(schema.problemsetProgress.userId, userId),
|
eq(schema.problemsetProgress.userId, userId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Promise.resolve([] as (typeof schema.problemsetProgress.$inferSelect)[]),
|
: Promise.resolve([] as ProgressRow[]),
|
||||||
includeBadges
|
db
|
||||||
? db
|
|
||||||
.select()
|
.select()
|
||||||
.from(schema.problemsetBadge)
|
.from(schema.problemsetBadge)
|
||||||
.where(inArray(schema.problemsetBadge.problemsetId, ids))
|
.where(inArray(schema.problemsetBadge.problemsetId, ids))
|
||||||
.orderBy(asc(schema.problemsetBadge.id))
|
.orderBy(asc(schema.problemsetBadge.id)),
|
||||||
: Promise.resolve([] as (typeof schema.problemsetBadge.$inferSelect)[]),
|
userId
|
||||||
includeBadges && userId
|
|
||||||
? db
|
? db
|
||||||
.select({ id: schema.userBadge.badgeId })
|
.select({ id: schema.userBadge.badgeId, earnedTime: schema.userBadge.earnedTime })
|
||||||
.from(schema.userBadge)
|
.from(schema.userBadge)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
schema.problemsetBadge,
|
schema.problemsetBadge,
|
||||||
@@ -118,16 +164,18 @@ async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, incl
|
|||||||
inArray(schema.problemsetBadge.problemsetId, ids),
|
inArray(schema.problemsetBadge.problemsetId, ids),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: Promise.resolve([] as { id: number }[]),
|
: Promise.resolve([] as { id: number; earnedTime: string }[]),
|
||||||
problemSetCreators([...new Set(rows.map((row) => row.createdById))]),
|
problemSetCreators([...new Set(rows.map((row) => row.set.createdById))]),
|
||||||
])
|
],
|
||||||
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item.value]))
|
)
|
||||||
|
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item]))
|
||||||
|
const joinedBySet = new Map(joinedCounts.map((item) => [item.problemsetId, item.value]))
|
||||||
const progressBySet = new Map(progresses.map((item) => [item.problemsetId, item]))
|
const progressBySet = new Map(progresses.map((item) => [item.problemsetId, item]))
|
||||||
const badgesBySet = new Map<number, (typeof schema.problemsetBadge.$inferSelect)[]>()
|
const badgesBySet = new Map<number, (typeof schema.problemsetBadge.$inferSelect)[]>()
|
||||||
for (const badge of badges)
|
for (const badge of badges)
|
||||||
badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge])
|
badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge])
|
||||||
const earned = new Set(earnedRows.map((item) => item.id))
|
const earned = new Map(earnedRows.map((item) => [item.id, item.earnedTime]))
|
||||||
return rows.map((row) => {
|
return rows.map(({ set: row, assigning }) => {
|
||||||
const progress = progressBySet.get(row.id)
|
const progress = progressBySet.get(row.id)
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -136,17 +184,16 @@ async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, incl
|
|||||||
createdBy:
|
createdBy:
|
||||||
creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
|
creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
|
||||||
createTime: row.createTime,
|
createTime: row.createTime,
|
||||||
lastUpdateTime: row.lastUpdateTime,
|
assignedUntil: row.assignedUntil,
|
||||||
difficulty: row.difficulty,
|
assigning,
|
||||||
status: row.status,
|
problemsCount: countBySet.get(row.id)?.value ?? 0,
|
||||||
endTime: row.endTime,
|
// 一道必做都没标的题单退回「全部都算必做」,和 computeProgress 同一个口径
|
||||||
visible: row.visible,
|
requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0),
|
||||||
problemsCount: countBySet.get(row.id) ?? 0,
|
joinedCount: joinedBySet.get(row.id) ?? 0,
|
||||||
completedCount: progress?.completedProblemsCount ?? 0,
|
|
||||||
userProgress: progressSummary(progress),
|
userProgress: progressSummary(progress),
|
||||||
badges: includeBadges
|
badges: (badgesBySet.get(row.id) ?? []).map((badge) =>
|
||||||
? (badgesBySet.get(row.id) ?? []).map((badge) => badgeData(badge, earned.has(badge.id)))
|
badgeData(badge, userId ? (earned.get(badge.id) ?? null) : undefined),
|
||||||
: undefined,
|
),
|
||||||
} satisfies ProblemSet
|
} satisfies ProblemSet
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -154,10 +201,8 @@ async function serializeProblemSets(rows: ProblemSetRow[], userId?: number, incl
|
|||||||
problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
|
problemsetRoutes.get("/problem-sets", 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 filters = [eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft")]
|
const filters = [eq(schema.problemset.visible, true)]
|
||||||
const keyword = c.req.query("keyword")?.trim()
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
const difficulty = c.req.query("difficulty")?.trim()
|
|
||||||
const status = c.req.query("status")?.trim()
|
|
||||||
if (keyword)
|
if (keyword)
|
||||||
filters.push(
|
filters.push(
|
||||||
or(
|
or(
|
||||||
@@ -165,13 +210,11 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
|
|||||||
ilike(schema.problemset.description, `%${keyword}%`),
|
ilike(schema.problemset.description, `%${keyword}%`),
|
||||||
)!,
|
)!,
|
||||||
)
|
)
|
||||||
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
|
|
||||||
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
|
|
||||||
const where = and(...filters)
|
const where = and(...filters)
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.problemset).where(where),
|
db.select({ value: count() }).from(schema.problemset).where(where),
|
||||||
db
|
db
|
||||||
.select()
|
.select({ set: schema.problemset, assigning: assigningSql })
|
||||||
.from(schema.problemset)
|
.from(schema.problemset)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(desc(schema.problemset.createTime))
|
.orderBy(desc(schema.problemset.createTime))
|
||||||
@@ -179,49 +222,45 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
|
|||||||
.offset(offset),
|
.offset(offset),
|
||||||
])
|
])
|
||||||
return success(c, {
|
return success(c, {
|
||||||
results: await serializeProblemSets(rows, c.get("user")?.id, true),
|
results: await serializeProblemSets(rows, c.get("user")?.id),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
} satisfies ProblemSetList)
|
} satisfies ProblemSetList)
|
||||||
})
|
})
|
||||||
|
|
||||||
problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
|
// 字面量路由要排在 /problem-sets/:id 前面:Hono 按注册顺序匹配,不是静态优先
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
// 题号(problemDisplayId)和主键(problemId)都认:提交列表那边手里只有题号
|
||||||
const [row] = await db
|
problemsetRoutes.get("/problem-sets/locks", requireAuth, async (c) => {
|
||||||
.select()
|
const user = c.get("user")!
|
||||||
.from(schema.problemset)
|
if (isTeacherOrAbove(user)) return success(c, [] as ProblemSetLock[])
|
||||||
.where(
|
let problemId = queryInteger(c.req.query("problemId"), 0, { min: 1 })
|
||||||
and(
|
const displayId = c.req.query("problemDisplayId")?.trim()
|
||||||
eq(schema.problemset.id, id),
|
if (!problemId && displayId) {
|
||||||
eq(schema.problemset.visible, true),
|
const [problem] = await db
|
||||||
ne(schema.problemset.status, "draft"),
|
.select({ id: schema.problem.id })
|
||||||
),
|
.from(schema.problem)
|
||||||
)
|
.where(and(eq(schema.problem.displayId, displayId), isNull(schema.problem.contestId)))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
problemId = problem?.id ?? 0
|
||||||
|
}
|
||||||
|
if (!problemId) return success(c, [] as ProblemSetLock[])
|
||||||
|
const rows = await problemSetLocksFor(user.id, problemId)
|
||||||
|
return success(c, rows satisfies ProblemSetLock[])
|
||||||
|
})
|
||||||
|
|
||||||
|
problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
|
||||||
|
const row = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), c.get("user"))
|
||||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
const [data] = await serializeProblemSets([row], c.get("user")?.id)
|
const [data] = await serializeProblemSets([row], c.get("user")?.id)
|
||||||
return success(c, data)
|
return success(c, data)
|
||||||
})
|
})
|
||||||
|
|
||||||
problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const user = c.get("user")
|
||||||
const [problemSet] = await db
|
const found = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), user)
|
||||||
.select({ id: schema.problemset.id })
|
if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
.from(schema.problemset)
|
const id = found.set.id
|
||||||
.where(
|
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标
|
||||||
and(
|
// 编号的(1、2、3),老题单里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变
|
||||||
eq(schema.problemset.id, id),
|
|
||||||
eq(schema.problemset.visible, true),
|
|
||||||
ne(schema.problemset.status, "draft"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
|
||||||
// 只取卡片要渲染的四列。取 schema.problem 整行会把题面、样例、答案、ast_rules、
|
|
||||||
// flowchart_data、sql_display 一起拉回来,题单页一个都不用。
|
|
||||||
//
|
|
||||||
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而卡片是按数组
|
|
||||||
// 下标编号的(#1 #2 #3),题单 8 / 11 / 14 实际就存在 order 重复,不定死的话
|
|
||||||
// 「第 3 题」指哪道题每次刷新都可能不一样。后台那条列表一直是这么排的。
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
link: schema.problemsetProblem,
|
link: schema.problemsetProblem,
|
||||||
@@ -234,76 +273,86 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
|||||||
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||||
.where(eq(schema.problemsetProblem.problemsetId, id))
|
.where(eq(schema.problemsetProblem.problemsetId, id))
|
||||||
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
|
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
|
||||||
const progressRows = c.get("user")
|
const problemIds = rows.map((row) => row.problemId)
|
||||||
? await db
|
const [progressRows, wrongRows, cutoffs] = user
|
||||||
|
? await Promise.all([
|
||||||
|
db
|
||||||
.select({ detail: schema.problemsetProgress.progressDetail })
|
.select({ detail: schema.problemsetProgress.progressDetail })
|
||||||
.from(schema.problemsetProgress)
|
.from(schema.problemsetProgress)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.problemsetProgress.problemsetId, id),
|
eq(schema.problemsetProgress.problemsetId, id),
|
||||||
eq(schema.problemsetProgress.userId, c.get("user")!.id),
|
eq(schema.problemsetProgress.userId, user.id),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1),
|
||||||
|
db
|
||||||
|
.select({ problemId: schema.submission.problemId, value: count() })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.submission.userId, user.id),
|
||||||
|
eq(schema.submission.problemsetId, id),
|
||||||
|
notInArray(schema.submission.result, NON_FAILURE_RESULTS),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.submission.problemId),
|
||||||
|
isTeacherOrAbove(user)
|
||||||
|
? new Map<number, string>()
|
||||||
|
: problemSetLockCutoffs(user.id, problemIds),
|
||||||
|
])
|
||||||
|
: [[], [], new Map<number, string>()]
|
||||||
|
// 「以前做对过 · 旧代码先藏着」:这道题正被闸门挡着,而且分界之前确实做对过
|
||||||
|
const hiddenSolved =
|
||||||
|
user && cutoffs.size
|
||||||
|
? await db
|
||||||
|
.select({ problemId: schema.submission.problemId })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.submission.userId, user.id),
|
||||||
|
inArray(schema.submission.problemId, [...cutoffs.keys()]),
|
||||||
|
inArray(schema.submission.result, SOLVED_RESULTS),
|
||||||
|
or(
|
||||||
|
...[...cutoffs].map(
|
||||||
|
([problemId, cutoff]) =>
|
||||||
|
sql`(${schema.submission.problemId} = ${problemId} and ${schema.submission.createTime} < ${cutoff})`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.submission.problemId)
|
||||||
: []
|
: []
|
||||||
const completed = asRecord(progressRows[0]?.detail)
|
const hidden = new Set(hiddenSolved.map((row) => row.problemId))
|
||||||
|
const solved = asRecord(progressRows[0]?.detail)
|
||||||
|
const wrong = new Map(wrongRows.map((row) => [row.problemId, row.value]))
|
||||||
return success(
|
return success(
|
||||||
c,
|
c,
|
||||||
rows.map(
|
rows.map(({ link, problemId, displayId, title, difficulty }) => {
|
||||||
({ link, problemId, displayId, title, difficulty }) =>
|
const entry = solved[String(problemId)]
|
||||||
({
|
const solvedTime = entry === undefined ? null : asRecord(entry).submit_time
|
||||||
|
return {
|
||||||
id: link.id,
|
id: link.id,
|
||||||
problemSetId: link.problemsetId,
|
problemSetId: link.problemsetId,
|
||||||
problem: { id: problemId, _id: displayId, title, difficulty },
|
problem: { id: problemId, _id: displayId, title, difficulty },
|
||||||
order: link.order,
|
order: link.order,
|
||||||
isRequired: link.isRequired,
|
isRequired: link.isRequired,
|
||||||
score: link.score,
|
isCompleted: entry !== undefined,
|
||||||
hint: link.hint,
|
solvedTime: typeof solvedTime === "string" ? solvedTime : null,
|
||||||
isCompleted: String(problemId) in completed,
|
wrongCount: wrong.get(problemId) ?? 0,
|
||||||
}) satisfies ProblemSetProblem,
|
oldCodeHidden: hidden.has(problemId),
|
||||||
),
|
} satisfies ProblemSetProblem
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function recomputeProgress(
|
|
||||||
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
|
||||||
progress: typeof schema.problemsetProgress.$inferSelect,
|
|
||||||
detail: Record<string, unknown>,
|
|
||||||
) {
|
|
||||||
const links = await tx
|
|
||||||
.select({
|
|
||||||
problemId: schema.problemsetProblem.problemId,
|
|
||||||
score: schema.problemsetProblem.score,
|
|
||||||
isRequired: schema.problemsetProblem.isRequired,
|
|
||||||
})
|
|
||||||
.from(schema.problemsetProblem)
|
|
||||||
.where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
|
|
||||||
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份,
|
|
||||||
// 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed
|
|
||||||
const update = computeProgress(detail, links, progress.completeTime)
|
|
||||||
await tx
|
|
||||||
.update(schema.problemsetProgress)
|
|
||||||
.set(update)
|
|
||||||
.where(eq(schema.problemsetProgress.id, progress.id))
|
|
||||||
return { ...progress, ...update }
|
|
||||||
}
|
|
||||||
|
|
||||||
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
||||||
const parsed = await parseBody(c, joinProblemSetRequestSchema, "Invalid problem set")
|
const parsed = await parseBody(c, joinProblemSetRequestSchema, "Invalid problem set")
|
||||||
if (!parsed.success) return parsed.response
|
if (!parsed.success) return parsed.response
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const [problemSet] = await db
|
const found = await loadVisible(parsed.data.problemSetId, user)
|
||||||
.select({ id: schema.problemset.id })
|
if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
.from(schema.problemset)
|
const problemSet = found.set
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.problemset.id, parsed.data.problemSetId),
|
|
||||||
eq(schema.problemset.visible, true),
|
|
||||||
ne(schema.problemset.status, "draft"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ id: schema.problemsetProgress.id })
|
.select({ id: schema.problemsetProgress.id })
|
||||||
.from(schema.problemsetProgress)
|
.from(schema.problemsetProgress)
|
||||||
@@ -316,22 +365,21 @@ problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
if (existing) return failure(c, 409, "already-joined", "已经加入该题单")
|
if (existing) return failure(c, 409, "already-joined", "已经加入该题单")
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const [created] = await tx
|
const links = await tx
|
||||||
.insert(schema.problemsetProgress)
|
.select({
|
||||||
.values({
|
problemId: schema.problemsetProblem.problemId,
|
||||||
|
score: schema.problemsetProblem.score,
|
||||||
|
isRequired: schema.problemsetProblem.isRequired,
|
||||||
|
})
|
||||||
|
.from(schema.problemsetProblem)
|
||||||
|
.where(eq(schema.problemsetProblem.problemsetId, problemSet.id))
|
||||||
|
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份
|
||||||
|
await tx.insert(schema.problemsetProgress).values({
|
||||||
problemsetId: problemSet.id,
|
problemsetId: problemSet.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
joinTime: new Date().toISOString(),
|
joinTime: new Date().toISOString(),
|
||||||
completeTime: null,
|
...computeProgress({}, links, null),
|
||||||
isCompleted: false,
|
|
||||||
progressPercentage: 0,
|
|
||||||
completedProblemsCount: 0,
|
|
||||||
totalProblemsCount: 0,
|
|
||||||
totalScore: 0,
|
|
||||||
progressDetail: {},
|
|
||||||
})
|
})
|
||||||
.returning()
|
|
||||||
if (created) await recomputeProgress(tx, created, {})
|
|
||||||
})
|
})
|
||||||
return success(c, null, 201)
|
return success(c, null, 201)
|
||||||
})
|
})
|
||||||
@@ -372,137 +420,198 @@ problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
|
/**
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
* 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
|
||||||
const [problemSet] = await db
|
*
|
||||||
.select({ id: schema.problemset.id })
|
* 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计
|
||||||
.from(schema.problemset)
|
* 也是任何老师看任何班,这里和它们一致。
|
||||||
.where(
|
*
|
||||||
and(
|
* 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它,
|
||||||
eq(schema.problemset.id, id),
|
* 想看别的班再换(和比赛「全班情况」按参赛者推断班级是一个思路)。名单是那个班的全部学生,
|
||||||
eq(schema.problemset.visible, true),
|
* 没加入的也列出来,好点名。
|
||||||
ne(schema.problemset.status, "draft"),
|
*/
|
||||||
),
|
problemsetRoutes.get("/problem-sets/:id/class-view", requireTeacher, async (c) => {
|
||||||
)
|
const found = await loadVisible(queryInteger(c.req.param("id"), 0, { min: 1 }), c.get("user"))
|
||||||
.limit(1)
|
if (!found) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
const id = found.set.id
|
||||||
const badges = await db
|
const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES])
|
||||||
.select()
|
const [links, joinedClasses] = await Promise.all([
|
||||||
.from(schema.problemsetBadge)
|
|
||||||
.where(eq(schema.problemsetBadge.problemsetId, id))
|
|
||||||
return success(
|
|
||||||
c,
|
|
||||||
badges.map((badge) => badgeData(badge)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => {
|
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
|
||||||
const [problemSet] = await db
|
|
||||||
.select({ id: schema.problemset.id })
|
|
||||||
.from(schema.problemset)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(schema.problemset.id, id),
|
|
||||||
eq(schema.problemset.visible, true),
|
|
||||||
ne(schema.problemset.status, "draft"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
// 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂看板、数据统计
|
|
||||||
// 也是任何老师看任何班,这里和它们一致。后台那条(admin/problemset.ts 的 loadOwned)
|
|
||||||
// 能改能删,仍然只给创建者
|
|
||||||
if (!problemSet) {
|
|
||||||
return failure(c, 404, "problem-set-not-found", "题单不存在")
|
|
||||||
}
|
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
|
||||||
const className = c.req.query("className")?.trim()
|
|
||||||
const completion = c.req.query("completionStatus")?.trim()
|
|
||||||
const filters = [eq(schema.problemsetProgress.problemsetId, id)]
|
|
||||||
// 只填数字(251)当班级号补上 ks;班级按 classPrefixCondition 匹配,
|
|
||||||
// 否则 251 会把 2511 班也算进来。别的输入照旧按用户名包含
|
|
||||||
if (className) {
|
|
||||||
const classPrefix = /^\d+$/.test(className) ? `ks${className}` : className
|
|
||||||
filters.push(classPrefixCondition(classPrefix) ?? ilike(schema.user.username, `%${className}%`))
|
|
||||||
}
|
|
||||||
if (completion === "completed") filters.push(eq(schema.problemsetProgress.isCompleted, true))
|
|
||||||
else if (completion === "in_progress")
|
|
||||||
filters.push(
|
|
||||||
and(
|
|
||||||
eq(schema.problemsetProgress.isCompleted, false),
|
|
||||||
gt(schema.problemsetProgress.completedProblemsCount, 0),
|
|
||||||
)!,
|
|
||||||
)
|
|
||||||
else if (completion === "not_started")
|
|
||||||
filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0))
|
|
||||||
const where = and(...filters)
|
|
||||||
const [statsRows, rows, problemRows] = await Promise.all([
|
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
total: count(),
|
problemId: schema.problem.id,
|
||||||
completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`,
|
|
||||||
avgProgress: avg(schema.problemsetProgress.progressPercentage),
|
|
||||||
})
|
|
||||||
.from(schema.problemsetProgress)
|
|
||||||
.innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
|
|
||||||
.where(where),
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
progress: schema.problemsetProgress,
|
|
||||||
user: schema.user,
|
|
||||||
realName: schema.userProfile.realName,
|
|
||||||
})
|
|
||||||
.from(schema.problemsetProgress)
|
|
||||||
.innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
|
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
|
||||||
.where(where)
|
|
||||||
.orderBy(
|
|
||||||
desc(schema.problemsetProgress.isCompleted),
|
|
||||||
desc(schema.problemsetProgress.progressPercentage),
|
|
||||||
asc(schema.problemsetProgress.joinTime),
|
|
||||||
)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset),
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
id: schema.problem.id,
|
|
||||||
_id: schema.problem.displayId,
|
_id: schema.problem.displayId,
|
||||||
title: schema.problem.title,
|
title: schema.problem.title,
|
||||||
|
isRequired: schema.problemsetProblem.isRequired,
|
||||||
})
|
})
|
||||||
.from(schema.problemsetProblem)
|
.from(schema.problemsetProblem)
|
||||||
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||||
.where(eq(schema.problemsetProblem.problemsetId, id))
|
.where(eq(schema.problemsetProblem.problemsetId, id))
|
||||||
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)),
|
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)),
|
||||||
])
|
db
|
||||||
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem]))
|
.select({
|
||||||
const results = rows.map(
|
className: sql<string>`${schema.user.className}`,
|
||||||
({ progress, user: progressUser, realName }) =>
|
joined: count(),
|
||||||
({
|
last: sql<string>`max(${schema.problemsetProgress.joinTime})`,
|
||||||
id: progress.id,
|
})
|
||||||
problemSetId: progress.problemsetId,
|
.from(schema.problemsetProgress)
|
||||||
user: sampleUser(progressUser, realName),
|
.innerJoin(schema.user, eq(schema.user.id, schema.problemsetProgress.userId))
|
||||||
joinTime: progress.joinTime,
|
.where(
|
||||||
completeTime: progress.completeTime,
|
and(
|
||||||
isCompleted: progress.isCompleted,
|
eq(schema.problemsetProgress.problemsetId, id),
|
||||||
progressPercentage: progress.progressPercentage,
|
studentRole,
|
||||||
completedProblemsCount: progress.completedProblemsCount,
|
sql`coalesce(${schema.user.className}, '') <> ''`,
|
||||||
totalProblemsCount: progress.totalProblemsCount,
|
|
||||||
totalScore: progress.totalScore,
|
|
||||||
completedProblems: Object.keys(asRecord(progress.progressDetail)).flatMap(
|
|
||||||
(key) => problemMap.get(key) ?? [],
|
|
||||||
),
|
),
|
||||||
}) satisfies ProblemSetProgress,
|
|
||||||
)
|
)
|
||||||
const stats = statsRows[0]
|
.groupBy(schema.user.className)
|
||||||
|
.orderBy(sql`max(${schema.problemsetProgress.joinTime}) desc`),
|
||||||
|
])
|
||||||
|
const sizes = joinedClasses.length
|
||||||
|
? await db
|
||||||
|
.select({ className: sql<string>`${schema.user.className}`, value: count() })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(
|
||||||
|
schema.user.className,
|
||||||
|
joinedClasses.map((row) => row.className),
|
||||||
|
),
|
||||||
|
studentRole,
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.user.className)
|
||||||
|
: []
|
||||||
|
const sizeOf = new Map(sizes.map((row) => [row.className, row.value]))
|
||||||
|
// 只有零星几个人加入的班(别的班的学生自己点进来的)排到后面去:至少 5 个、三分之一的人加入了
|
||||||
|
// 才算「这个班在做」,这样的班按最近一次有人加入排,默认看第一个
|
||||||
|
const classes = joinedClasses
|
||||||
|
.map((row) => ({
|
||||||
|
className: row.className,
|
||||||
|
joined: row.joined,
|
||||||
|
size: sizeOf.get(row.className) ?? row.joined,
|
||||||
|
}))
|
||||||
|
.map((row, recency) => ({ row, recency, doing: row.joined >= 5 && row.joined * 3 >= row.size }))
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.doing !== b.doing
|
||||||
|
? Number(b.doing) - Number(a.doing)
|
||||||
|
: a.doing
|
||||||
|
? a.recency - b.recency
|
||||||
|
: b.row.joined - a.row.joined,
|
||||||
|
)
|
||||||
|
.map((item) => item.row)
|
||||||
|
const wanted = c.req.query("className")?.trim()
|
||||||
|
const className = wanted || classes[0]?.className || null
|
||||||
|
const graded = gradedIds(links)
|
||||||
|
const empty: ProblemSetClassView = {
|
||||||
|
classes,
|
||||||
|
className,
|
||||||
|
problems: links.map((link) => ({
|
||||||
|
id: link.problemId,
|
||||||
|
_id: link._id,
|
||||||
|
title: link.title,
|
||||||
|
isRequired: link.isRequired,
|
||||||
|
solved: 0,
|
||||||
|
})),
|
||||||
|
totalCount: graded.size,
|
||||||
|
students: [],
|
||||||
|
}
|
||||||
|
if (!className || links.length === 0) return success(c, empty)
|
||||||
|
|
||||||
|
const roster = await db
|
||||||
|
.select({
|
||||||
|
userId: schema.user.id,
|
||||||
|
username: schema.user.username,
|
||||||
|
realName: schema.userProfile.realName,
|
||||||
|
progress: schema.problemsetProgress,
|
||||||
|
})
|
||||||
|
.from(schema.user)
|
||||||
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
|
.leftJoin(
|
||||||
|
schema.problemsetProgress,
|
||||||
|
and(
|
||||||
|
eq(schema.problemsetProgress.userId, schema.user.id),
|
||||||
|
eq(schema.problemsetProgress.problemsetId, id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(eq(schema.user.className, className), studentRole, eq(schema.user.isDisabled, false)),
|
||||||
|
)
|
||||||
|
.orderBy(asc(schema.user.username))
|
||||||
|
if (roster.length === 0) return success(c, empty)
|
||||||
|
const userIds = roster.map((row) => row.userId)
|
||||||
|
const problemIds = links.map((link) => link.problemId)
|
||||||
|
const [wrongRows, beforeRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
userId: schema.submission.userId,
|
||||||
|
problemId: schema.submission.problemId,
|
||||||
|
value: count(),
|
||||||
|
})
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.submission.problemsetId, id),
|
||||||
|
inArray(schema.submission.userId, userIds),
|
||||||
|
notInArray(schema.submission.result, NON_FAILURE_RESULTS),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.submission.userId, schema.submission.problemId),
|
||||||
|
// 加入之前就做对过(哪个入口都算):老师据此知道谁可能是凭记忆默写的
|
||||||
|
db
|
||||||
|
.select({ userId: schema.submission.userId, problemId: schema.submission.problemId })
|
||||||
|
.from(schema.submission)
|
||||||
|
.innerJoin(
|
||||||
|
schema.problemsetProgress,
|
||||||
|
and(
|
||||||
|
eq(schema.problemsetProgress.userId, schema.submission.userId),
|
||||||
|
eq(schema.problemsetProgress.problemsetId, id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(schema.submission.userId, userIds),
|
||||||
|
inArray(schema.submission.problemId, problemIds),
|
||||||
|
inArray(schema.submission.result, SOLVED_RESULTS),
|
||||||
|
sql`${schema.submission.createTime} < ${schema.problemsetProgress.joinTime}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(schema.submission.userId, schema.submission.problemId),
|
||||||
|
])
|
||||||
|
const key = (userId: number, problemId: number) => `${userId}:${problemId}`
|
||||||
|
const wrong = new Map(wrongRows.map((row) => [key(row.userId, row.problemId), row.value]))
|
||||||
|
const before = new Set(beforeRows.map((row) => key(row.userId, row.problemId)))
|
||||||
|
const solvedCount = new Map<number, number>()
|
||||||
|
const students = roster.map((row) => {
|
||||||
|
const detail = asRecord(row.progress?.progressDetail)
|
||||||
|
let completed = 0
|
||||||
|
const cells = links.map((link) => {
|
||||||
|
const entry = detail[String(link.problemId)]
|
||||||
|
const time = entry === undefined ? null : asRecord(entry).submit_time
|
||||||
|
if (entry !== undefined) {
|
||||||
|
solvedCount.set(link.problemId, (solvedCount.get(link.problemId) ?? 0) + 1)
|
||||||
|
if (graded.has(link.problemId)) completed += 1
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
solvedTime: typeof time === "string" ? time : null,
|
||||||
|
wrongCount: wrong.get(key(row.userId, link.problemId)) ?? 0,
|
||||||
|
solvedBefore: before.has(key(row.userId, link.problemId)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
userId: row.userId,
|
||||||
|
username: row.username,
|
||||||
|
realName: row.realName,
|
||||||
|
joinTime: row.progress?.joinTime ?? null,
|
||||||
|
completedCount: completed,
|
||||||
|
cells,
|
||||||
|
}
|
||||||
|
})
|
||||||
return success(c, {
|
return success(c, {
|
||||||
results,
|
...empty,
|
||||||
total: stats?.total ?? 0,
|
problems: empty.problems.map((problem) => ({
|
||||||
statistics: {
|
...problem,
|
||||||
total: stats?.total ?? 0,
|
solved: solvedCount.get(problem.id) ?? 0,
|
||||||
completed: stats?.completed ?? 0,
|
})),
|
||||||
avgProgress: Number(stats?.avgProgress ?? 0),
|
students,
|
||||||
},
|
} satisfies ProblemSetClassView)
|
||||||
problems: problemRows,
|
|
||||||
} satisfies ProblemSetProgressList)
|
|
||||||
})
|
})
|
||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
} from "../services/contest"
|
} from "../services/contest"
|
||||||
import { CodeFormatError, formatCode } from "../services/format-code"
|
import { CodeFormatError, formatCode } from "../services/format-code"
|
||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
import { problemSetJoinTimes } from "../services/problemset"
|
import { problemSetLockCutoffs } from "../services/problemset"
|
||||||
import { consumeToken } from "../services/throttling"
|
import { consumeToken } from "../services/throttling"
|
||||||
import { dayStart } from "../time"
|
import { dayStart } from "../time"
|
||||||
import { asFilterValue, asRecord, isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
|
import { asFilterValue, asRecord, isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers"
|
||||||
@@ -289,14 +289,14 @@ function canViewSubmission(
|
|||||||
user: AuthUser | null,
|
user: AuthUser | null,
|
||||||
row: { userId: number; problemId: number; createTime: string },
|
row: { userId: number; problemId: number; createTime: string },
|
||||||
problem: { createdById: number },
|
problem: { createdById: number },
|
||||||
problemSetJoinTime?: Map<number, string>,
|
problemSetCutoffs?: Map<number, string>,
|
||||||
) {
|
) {
|
||||||
if (!user) return false
|
if (!user) return false
|
||||||
// 题单防作弊,见 problemSetJoinTimes。只对学生自己的提交生效,老师不受限。
|
// 题单防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。
|
||||||
// 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。
|
// 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。
|
||||||
if (row.userId === user.id && !isTeacherOrAbove(user)) {
|
if (row.userId === user.id && !isTeacherOrAbove(user)) {
|
||||||
const joinTime = problemSetJoinTime?.get(row.problemId)
|
const cutoff = problemSetCutoffs?.get(row.problemId)
|
||||||
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false
|
if (cutoff !== undefined && Date.parse(row.createTime) < Date.parse(cutoff)) return false
|
||||||
}
|
}
|
||||||
// 看别人代码的角色捷径只给老师和超管。学生管理员算学生(STUDENT_ROLES):他参赛、上榜、
|
// 看别人代码的角色捷径只给老师和超管。学生管理员算学生(STUDENT_ROLES):他参赛、上榜、
|
||||||
// 和同学做同一份作业,能读别人的提交就是开卷。旧后端这里是 `not user.is_regular_user()`,
|
// 和同学做同一份作业,能读别人的提交就是开卷。旧后端这里是 `not user.is_regular_user()`,
|
||||||
@@ -399,11 +399,11 @@ async function submissionDetail(id: string, user: AuthUser) {
|
|||||||
if (!row) return null
|
if (!row) return null
|
||||||
// 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103)
|
// 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103)
|
||||||
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
|
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
|
||||||
const joinTimes =
|
const cutoffs =
|
||||||
isTeacherOrAbove(user) || row.submission.userId !== user.id
|
isTeacherOrAbove(user) || row.submission.userId !== user.id
|
||||||
? undefined
|
? undefined
|
||||||
: await problemSetJoinTimes(user.id, [row.submission.problemId])
|
: await problemSetLockCutoffs(user.id, [row.submission.problemId])
|
||||||
if (!canViewSubmission(user, row.submission, row.problem, joinTimes)) return null
|
if (!canViewSubmission(user, row.submission, row.problem, cutoffs)) return null
|
||||||
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
|
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
|
||||||
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
|
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
|
||||||
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
|
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
|
||||||
@@ -569,9 +569,9 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
|||||||
displayId ? unknownDisplayIds(displayId, null) : [],
|
displayId ? unknownDisplayIds(displayId, null) : [],
|
||||||
])
|
])
|
||||||
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
|
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
|
||||||
const [joinTimes, problemsetTitles] = await Promise.all([
|
const [cutoffs, problemsetTitles] = await Promise.all([
|
||||||
user && !isTeacherOrAbove(user)
|
user && !isTeacherOrAbove(user)
|
||||||
? problemSetJoinTimes(user.id, [
|
? problemSetLockCutoffs(user.id, [
|
||||||
...new Set(
|
...new Set(
|
||||||
rows
|
rows
|
||||||
.filter((row) => row.submission.userId === user.id)
|
.filter((row) => row.submission.userId === user.id)
|
||||||
@@ -589,7 +589,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
|||||||
id: submission.id,
|
id: submission.id,
|
||||||
problemDisplayId: problem.displayId,
|
problemDisplayId: problem.displayId,
|
||||||
problemTitle: problem.title,
|
problemTitle: problem.title,
|
||||||
showLink: user ? canViewSubmission(user, submission, problem, joinTimes) : false,
|
showLink: user ? canViewSubmission(user, submission, problem, cutoffs) : false,
|
||||||
createTime: submission.createTime,
|
createTime: submission.createTime,
|
||||||
userId: submission.userId,
|
userId: submission.userId,
|
||||||
username: submission.username,
|
username: submission.username,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq, gt, inArray, isNull, notInArray, or, sql } from "drizzle-orm"
|
import { and, asc, eq, inArray, notInArray, sql } from "drizzle-orm"
|
||||||
|
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { publishAchievementNotification } from "../events"
|
import { publishAchievementNotification } from "../events"
|
||||||
@@ -10,7 +10,7 @@ type ProgressRow = typeof schema.problemsetProgress.$inferSelect
|
|||||||
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
|
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
|
||||||
type BadgeCheck = Pick<
|
type BadgeCheck = Pick<
|
||||||
ProgressRow,
|
ProgressRow,
|
||||||
"completedProblemsCount" | "totalProblemsCount" | "totalScore" | "progressDetail"
|
"completedProblemsCount" | "totalProblemsCount" | "progressDetail"
|
||||||
>
|
>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +132,7 @@ export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
|
|||||||
if (badge.conditionType === "problem_count") {
|
if (badge.conditionType === "problem_count") {
|
||||||
return Object.keys(asRecord(progress.progressDetail)).length >= badge.conditionValue
|
return Object.keys(asRecord(progress.progressDetail)).length >= badge.conditionValue
|
||||||
}
|
}
|
||||||
if (badge.conditionType === "score") return progress.totalScore >= badge.conditionValue
|
// 「总分达到 N」随分数一起拿掉了,0026 把它们换成了「做对 N 道」
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,8 +242,8 @@ export async function recordSolvedProblem(
|
|||||||
problemId: number,
|
problemId: number,
|
||||||
/**
|
/**
|
||||||
* 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id
|
* 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id
|
||||||
* 外键指向代码提交表,流程图提交的 id 进不去。不影响「解锁加入题单之前的提交」——
|
* 外键指向代码提交表,流程图提交的 id 进不去。不影响防抄闸门的解锁 ——
|
||||||
* 那道闸看的是 progress_detail(本文件下面的 problemSetJoinTimes)
|
* 那道闸看的是 progress_detail(本文件下面的 problemSetLockCutoffs)
|
||||||
*/
|
*/
|
||||||
submissionId: string | null,
|
submissionId: string | null,
|
||||||
solvedAt: string,
|
solvedAt: string,
|
||||||
@@ -390,50 +390,85 @@ export async function recordSolvedAndNotify(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 题单现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */
|
||||||
|
export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not null and ${schema.problemset.assignedUntil} > now())`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题单防作弊闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 加入时间。
|
* 题单防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻
|
||||||
* 代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)共用这一道闸。
|
* (早于它的提交藏起来)。代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)
|
||||||
|
* 共用这一道闸。
|
||||||
*
|
*
|
||||||
* 对齐旧后端 `submission/serializers.py:12` 的 `bulk_fetch_problemset_progress`。学生加入含
|
* 规则(2026-10 题单重设计,用户拍板):题单**布置期内**,题单里这几道题,学生在**布置开始之前**
|
||||||
* 某道题的题单后,他在加入之前留下的 AC 代码还摆在提交列表里,复制粘贴就能把题单刷完。
|
* 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习题单:快照里 16% 的
|
||||||
* 备份快照里 1734 人次、188 名学生进过这个窗口(占已解题次的 22.5%),不是边角情况。
|
* 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。
|
||||||
*
|
*
|
||||||
* 解锁的三条路全写在 where 里,任一成立就查不出来、也就不遮挡:
|
* 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了;
|
||||||
* - 已经在题单里做出这道题(progress_detail 里有这道题的 key)
|
* 现在分界是布置开始,没加入的人也藏,这个口子就没了。代价是别的班以前做过这几道题的人,
|
||||||
* - 题单过了截止时间(end_time;为空表示不设期限,只能靠做出来解锁)
|
* 在这一两周里也看不到自己的旧代码,用户认了。
|
||||||
* - 题单被归档(status 不是 active)
|
|
||||||
*
|
*
|
||||||
* 一道题可能同时落在多个已加入的题单里,取最晚的 join_time——「存在任一题单要求遮挡就遮挡」
|
* 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡:
|
||||||
* 等价于「提交时间早于最晚的那次加入」。旧后端这里用 `.first()` 取任意一条,一题多题单时
|
* - 题单不在布置期(没布置,或者布置期过了)
|
||||||
* 行为不确定,换成聚合顺手定死。
|
* - 题单没公开
|
||||||
|
* - 已经在这份题单里做对了这道题(progress_detail 里有这道题的 key)
|
||||||
|
*
|
||||||
|
* 一道题可能同时落在几个正在布置的题单里,取最晚的布置开始 ——「任一题单要求遮挡就遮挡」。
|
||||||
*/
|
*/
|
||||||
export async function problemSetJoinTimes(userId: number, problemIds: number[]) {
|
export async function problemSetLockCutoffs(userId: number, problemIds: number[]) {
|
||||||
const joinTimes = new Map<number, string>()
|
const cutoffs = new Map<number, string>()
|
||||||
if (problemIds.length === 0) return joinTimes
|
if (problemIds.length === 0) return cutoffs
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
problemId: schema.problemsetProblem.problemId,
|
problemId: schema.problemsetProblem.problemId,
|
||||||
// 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了
|
// 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了
|
||||||
// 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状。
|
// 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状
|
||||||
// 原来那个 `::text` 要撤掉:它的 OID 是 25、绕过那个 parser,反而会变成 PG 文本。
|
cutoff: sql<string>`max(${schema.problemset.assignedAt})`,
|
||||||
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})`,
|
|
||||||
})
|
})
|
||||||
.from(schema.problemsetProgress)
|
.from(schema.problemsetProblem)
|
||||||
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId))
|
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProblem.problemsetId))
|
||||||
.innerJoin(
|
.leftJoin(
|
||||||
schema.problemsetProblem,
|
schema.problemsetProgress,
|
||||||
eq(schema.problemsetProblem.problemsetId, schema.problemset.id),
|
and(
|
||||||
|
eq(schema.problemsetProgress.problemsetId, schema.problemset.id),
|
||||||
|
eq(schema.problemsetProgress.userId, userId),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.problemsetProgress.userId, userId),
|
|
||||||
inArray(schema.problemsetProblem.problemId, problemIds),
|
inArray(schema.problemsetProblem.problemId, problemIds),
|
||||||
eq(schema.problemset.status, "active"),
|
eq(schema.problemset.visible, true),
|
||||||
or(isNull(schema.problemset.endTime), gt(schema.problemset.endTime, sql`now()`)),
|
assigningSql,
|
||||||
sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`,
|
sql`not coalesce(jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text), false)`,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.groupBy(schema.problemsetProblem.problemId)
|
.groupBy(schema.problemsetProblem.problemId)
|
||||||
for (const row of rows) joinTimes.set(row.problemId, row.joinTime)
|
for (const row of rows) cutoffs.set(row.problemId, row.cutoff)
|
||||||
return joinTimes
|
return cutoffs
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的题单里 */
|
||||||
|
export async function problemSetLocksFor(userId: number, problemId: number) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
problemSetId: schema.problemset.id,
|
||||||
|
title: schema.problemset.title,
|
||||||
|
assignedUntil: sql<string>`${schema.problemset.assignedUntil}`,
|
||||||
|
})
|
||||||
|
.from(schema.problemsetProblem)
|
||||||
|
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProblem.problemsetId))
|
||||||
|
.leftJoin(
|
||||||
|
schema.problemsetProgress,
|
||||||
|
and(
|
||||||
|
eq(schema.problemsetProgress.problemsetId, schema.problemset.id),
|
||||||
|
eq(schema.problemsetProgress.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.problemsetProblem.problemId, problemId),
|
||||||
|
eq(schema.problemset.visible, true),
|
||||||
|
assigningSql,
|
||||||
|
sql`not coalesce(jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text), false)`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(asc(schema.problemset.assignedUntil))
|
||||||
}
|
}
|
||||||
+32
-98
@@ -2,7 +2,6 @@ import api from "utils/api"
|
|||||||
import { toAdminProblemRow } from "admin/transforms"
|
import { toAdminProblemRow } from "admin/transforms"
|
||||||
import type {
|
import type {
|
||||||
AcTrend,
|
AcTrend,
|
||||||
AdminProblemSetProgress,
|
|
||||||
BatchProblemTagResponse,
|
BatchProblemTagResponse,
|
||||||
GenerateSqlTestCaseResponse,
|
GenerateSqlTestCaseResponse,
|
||||||
RenameTagResponse,
|
RenameTagResponse,
|
||||||
@@ -40,6 +39,7 @@ import type {
|
|||||||
AdminProblemSetList,
|
AdminProblemSetList,
|
||||||
AdminProblemSet,
|
AdminProblemSet,
|
||||||
AdminProblemSetProblem,
|
AdminProblemSetProblem,
|
||||||
|
AddProblemsToSetResult,
|
||||||
TutorialListItem,
|
TutorialListItem,
|
||||||
TagCategory,
|
TagCategory,
|
||||||
} from "utils/types"
|
} from "utils/types"
|
||||||
@@ -495,15 +495,9 @@ export function updateACMHelperChecked(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 题单管理 API
|
// 题单管理 API
|
||||||
export function getProblemSetList(
|
export function getProblemSetList(offset = 0, limit = 10, keyword = "") {
|
||||||
offset = 0,
|
|
||||||
limit = 10,
|
|
||||||
keyword = "",
|
|
||||||
difficulty = "",
|
|
||||||
status = "",
|
|
||||||
) {
|
|
||||||
return api.get<AdminProblemSetList>("admin/problem-sets", {
|
return api.get<AdminProblemSetList>("admin/problem-sets", {
|
||||||
params: { offset, limit, keyword, difficulty, status },
|
params: { offset, limit, keyword },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,33 +505,20 @@ export function getProblemSetDetail(id: number) {
|
|||||||
return api.get<AdminProblemSet>(`admin/problem-sets/${id}`)
|
return api.get<AdminProblemSet>(`admin/problem-sets/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProblemSetBody {
|
export interface ProblemSetBody {
|
||||||
title?: string
|
title: string
|
||||||
description?: string
|
description: string
|
||||||
difficulty?: AdminProblemSet["difficulty"]
|
/** 布置到哪天(东八区当天结束的 ISO 时刻);null = 不布置 */
|
||||||
status?: AdminProblemSet["status"]
|
assignedUntil: string | null
|
||||||
// 表单里是 Date,出站要 ISO 串
|
visible: boolean
|
||||||
endTime?: Date | null
|
|
||||||
visible?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function toProblemSetBody(data: ProblemSetBody) {
|
|
||||||
return {
|
|
||||||
title: data.title,
|
|
||||||
description: data.description ?? "",
|
|
||||||
difficulty: data.difficulty ?? "Easy",
|
|
||||||
status: data.status ?? "active",
|
|
||||||
endTime: data.endTime ? new Date(data.endTime).toISOString() : null,
|
|
||||||
visible: data.visible ?? true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProblemSet(data: ProblemSetBody) {
|
export function createProblemSet(data: ProblemSetBody) {
|
||||||
return api.post<AdminProblemSet>("admin/problem-sets", toProblemSetBody(data))
|
return api.post<AdminProblemSet>("admin/problem-sets", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editProblemSet(data: ProblemSetBody & { id: number }) {
|
export function editProblemSet(id: number, data: ProblemSetBody) {
|
||||||
return api.put<AdminProblemSet>(`admin/problem-sets/${data.id}`, toProblemSetBody(data))
|
return api.put<AdminProblemSet>(`admin/problem-sets/${id}`, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteProblemSet(id: number) {
|
export function deleteProblemSet(id: number) {
|
||||||
@@ -548,49 +529,28 @@ export function toggleProblemSetVisible(id: number) {
|
|||||||
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`)
|
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/visibility`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateProblemSetStatus(id: number, status: string) {
|
|
||||||
return api.put<AdminProblemSet>(`admin/problem-sets/${id}/status`, { status })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 题单题目管理 API
|
// 题单题目管理 API
|
||||||
export function getProblemSetProblems(problemSetId: number) {
|
export function getProblemSetProblems(problemSetId: number) {
|
||||||
return api.get<AdminProblemSetProblem[]>(`admin/problem-sets/${problemSetId}/problems`)
|
return api.get<AdminProblemSetProblem[]>(`admin/problem-sets/${problemSetId}/problems`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addProblemToSet(
|
/** 一次加好几道(题号),按给的顺序接在最后;找不到的、已经在里面的分开报回来 */
|
||||||
problemSetId: number,
|
export function addProblemsToSet(problemSetId: number, problemIds: string[]) {
|
||||||
data: {
|
return api.post<AddProblemsToSetResult>(`admin/problem-sets/${problemSetId}/problems`, {
|
||||||
problemId: string
|
problemIds,
|
||||||
order?: number
|
|
||||||
isRequired?: boolean
|
|
||||||
score?: number
|
|
||||||
hint?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return api.post(`admin/problem-sets/${problemSetId}/problems`, {
|
|
||||||
problemId: data.problemId,
|
|
||||||
order: data.order ?? 0,
|
|
||||||
isRequired: data.isRequired ?? true,
|
|
||||||
score: data.score ?? 0,
|
|
||||||
hint: data.hint ?? "",
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editProblemInSet(
|
export function setProblemRequired(problemSetId: number, itemId: number, isRequired: boolean) {
|
||||||
problemSetId: number,
|
return api.put(`admin/problem-sets/${problemSetId}/problems/${itemId}`, { isRequired })
|
||||||
problemSetProblemId: number,
|
|
||||||
data: {
|
|
||||||
order?: number
|
|
||||||
isRequired?: boolean
|
|
||||||
score?: number
|
|
||||||
hint?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return api.put(`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`, data)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeProblemFromSet(problemSetId: number, problemSetProblemId: number) {
|
export function reorderProblemSetProblems(problemSetId: number, ids: number[]) {
|
||||||
return api.delete(`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`)
|
return api.put(`admin/problem-sets/${problemSetId}/problems/order`, { ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeProblemFromSet(problemSetId: number, itemId: number) {
|
||||||
|
return api.delete(`admin/problem-sets/${problemSetId}/problems/${itemId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 题单奖章管理 API
|
// 题单奖章管理 API
|
||||||
@@ -598,52 +558,26 @@ export function getProblemSetBadges(problemSetId: number) {
|
|||||||
return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`)
|
return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BadgeBody {
|
export interface BadgeBody {
|
||||||
name?: string
|
name: string
|
||||||
description?: string
|
description: string
|
||||||
icon?: string
|
icon: string
|
||||||
conditionType?: AdminProblemSetBadge["conditionType"]
|
conditionType: AdminProblemSetBadge["conditionType"]
|
||||||
conditionValue?: number
|
conditionValue: number
|
||||||
}
|
|
||||||
|
|
||||||
function toBadgeBody(data: BadgeBody) {
|
|
||||||
return {
|
|
||||||
name: data.name,
|
|
||||||
description: data.description ?? "",
|
|
||||||
icon: data.icon ?? "",
|
|
||||||
conditionType: data.conditionType,
|
|
||||||
conditionValue: data.conditionValue ?? 0,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProblemSetBadge(problemSetId: number, data: BadgeBody) {
|
export function createProblemSetBadge(problemSetId: number, data: BadgeBody) {
|
||||||
return api.post<AdminProblemSetBadge>(
|
return api.post<AdminProblemSetBadge>(`admin/problem-sets/${problemSetId}/badges`, data)
|
||||||
`admin/problem-sets/${problemSetId}/badges`,
|
|
||||||
toBadgeBody(data),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editProblemSetBadge(problemSetId: number, badgeId: number, data: BadgeBody) {
|
export function editProblemSetBadge(problemSetId: number, badgeId: number, data: BadgeBody) {
|
||||||
return api.put<AdminProblemSetBadge>(
|
return api.put<AdminProblemSetBadge>(`admin/problem-sets/${problemSetId}/badges/${badgeId}`, data)
|
||||||
`admin/problem-sets/${problemSetId}/badges/${badgeId}`,
|
|
||||||
toBadgeBody(data),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
|
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
|
||||||
return api.delete(`admin/problem-sets/${problemSetId}/badges/${badgeId}`)
|
return api.delete(`admin/problem-sets/${problemSetId}/badges/${badgeId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 题单进度管理 API
|
|
||||||
// 注意:返回的是裸数组,不是分页信封 —— 和 oj 侧的 /user-progress 不同
|
|
||||||
export function getProblemSetProgress(problemSetId: number) {
|
|
||||||
return api.get<AdminProblemSetProgress[]>(`admin/problem-sets/${problemSetId}/progress`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
|
|
||||||
return api.delete(`admin/problem-sets/${problemSetId}/progress/${userId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 学生卡点分析
|
// 学生卡点分析
|
||||||
export function getStuckProblems() {
|
export function getStuckProblems() {
|
||||||
return api.get<StuckProblem[]>("admin/problem-analytics/stuck")
|
return api.get<StuckProblem[]>("admin/problem-analytics/stuck")
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { errorMessage } from "utils/api"
|
|
||||||
import { deleteProblemSet, updateProblemSetStatus } from "admin/api"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
problemSetId: number
|
|
||||||
}
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const emit = defineEmits(["updated"])
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const showStatusModal = ref(false)
|
|
||||||
const newStatus = ref<"active" | "archived" | "draft">("active")
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ label: "活跃", value: "active" },
|
|
||||||
{ label: "已归档", value: "archived" },
|
|
||||||
{ label: "草稿", value: "draft" },
|
|
||||||
]
|
|
||||||
|
|
||||||
async function handleDeleteProblemSet() {
|
|
||||||
try {
|
|
||||||
await deleteProblemSet(props.problemSetId)
|
|
||||||
message.success("删除成功")
|
|
||||||
emit("updated")
|
|
||||||
} catch (err) {
|
|
||||||
message.error("删除失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goEdit() {
|
|
||||||
router.push({
|
|
||||||
name: "admin problemset edit",
|
|
||||||
params: { problemSetId: props.problemSetId },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function goDetail() {
|
|
||||||
router.push({
|
|
||||||
name: "admin problemset detail",
|
|
||||||
params: { problemSetId: props.problemSetId },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function openStatusModal() {
|
|
||||||
showStatusModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateStatus() {
|
|
||||||
try {
|
|
||||||
await updateProblemSetStatus(props.problemSetId, newStatus.value)
|
|
||||||
message.success("状态更新成功")
|
|
||||||
showStatusModal.value = false
|
|
||||||
emit("updated")
|
|
||||||
} catch (err) {
|
|
||||||
message.error("状态更新失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<n-flex>
|
|
||||||
<n-button size="small" secondary type="primary" @click="goEdit"> 编辑 </n-button>
|
|
||||||
<n-button size="small" secondary type="info" @click="goDetail"> 详情 </n-button>
|
|
||||||
<n-button size="small" secondary type="warning" @click="openStatusModal"> 状态 </n-button>
|
|
||||||
<n-popconfirm @positive-click="handleDeleteProblemSet">
|
|
||||||
<template #trigger>
|
|
||||||
<n-button secondary size="small" type="error">删除</n-button>
|
|
||||||
</template>
|
|
||||||
确定删除这个题单吗?删除后题单将不可见。
|
|
||||||
</n-popconfirm>
|
|
||||||
</n-flex>
|
|
||||||
|
|
||||||
<n-modal v-model:show="showStatusModal" preset="card" title="更新题单状态" style="width: 400px">
|
|
||||||
<n-space vertical>
|
|
||||||
<n-form>
|
|
||||||
<n-form-item label="状态" required>
|
|
||||||
<n-select v-model:value="newStatus" :options="statusOptions" placeholder="选择状态" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-space>
|
|
||||||
<template #footer>
|
|
||||||
<n-flex justify="end">
|
|
||||||
<n-button @click="showStatusModal = false">取消</n-button>
|
|
||||||
<n-button type="primary" @click="handleUpdateStatus">确认</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { h } from "vue"
|
|
||||||
import type { AdminProblemSetBadge } from "utils/types"
|
|
||||||
import { NButton, NImage } from "naive-ui"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
badges: AdminProblemSetBadge[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: "add-badge"): void
|
|
||||||
(e: "edit-badge", badge: AdminProblemSetBadge): void
|
|
||||||
(e: "delete-badge", badgeId: number): void
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
defineEmits<Emits>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
|
||||||
<h3>奖章列表</h3>
|
|
||||||
<n-button type="primary" @click="$emit('add-badge')"> 添加奖章 </n-button>
|
|
||||||
</n-flex>
|
|
||||||
<n-data-table
|
|
||||||
:columns="[
|
|
||||||
{
|
|
||||||
title: '图标',
|
|
||||||
key: 'icon',
|
|
||||||
render: (row) =>
|
|
||||||
h(NImage, {
|
|
||||||
src: row.icon,
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
objectFit: 'cover',
|
|
||||||
previewDisabled: true,
|
|
||||||
style: 'border-radius: 4px; border: 1px solid #d9d9d9',
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ title: '名称', key: 'name' },
|
|
||||||
{
|
|
||||||
title: '条件类型',
|
|
||||||
key: 'condition_type',
|
|
||||||
render: (row) => {
|
|
||||||
const typeMap: Record<string, string> = {
|
|
||||||
all_problems: '完成所有题目',
|
|
||||||
problem_count: '完成指定数量题目',
|
|
||||||
score: '达到指定分数',
|
|
||||||
}
|
|
||||||
return typeMap[row.conditionType] || row.conditionType
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '条件值',
|
|
||||||
key: 'condition_value',
|
|
||||||
render: (row) => {
|
|
||||||
return row.conditionType === 'all_problems' ? '-' : row.conditionValue
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ title: '描述', key: 'description' },
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 160,
|
|
||||||
render: (row) =>
|
|
||||||
h('div', { style: 'display: flex; gap: 8px;' }, [
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
size: 'small',
|
|
||||||
type: 'primary',
|
|
||||||
secondary: true,
|
|
||||||
onClick: () => $emit('edit-badge', row),
|
|
||||||
},
|
|
||||||
{ default: () => '编辑' },
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
size: 'small',
|
|
||||||
type: 'error',
|
|
||||||
secondary: true,
|
|
||||||
onClick: () => $emit('delete-badge', row.id),
|
|
||||||
},
|
|
||||||
{ default: () => '删除' },
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
:data="badges"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,155 +1,168 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import type { BadgeBody } from "admin/api"
|
||||||
import type { AdminProblemSetBadge } from "utils/types"
|
import type { AdminProblemSetBadge } from "utils/types"
|
||||||
|
|
||||||
// 添加和编辑共用一个弹窗:传了 badge 就是编辑,null 就是添加。
|
/**
|
||||||
// 原来是两份八成相同的组件,改一处表单项另一处总要漏
|
* 加奖章 / 改奖章共用一个弹窗:传了 badge 就是改,null 就是加。
|
||||||
interface Props {
|
* 条件只有两种:全部做完、做对 N 道(「总分达到 N」随分数一起拿掉了)。
|
||||||
show: boolean
|
* 「做对 N 道」数的是做对的全部题(含选做),和后端 eligibleForBadge 同一个口径。
|
||||||
badge: AdminProblemSetBadge | null
|
*/
|
||||||
}
|
const props = defineProps<{ badge: AdminProblemSetBadge | null; problemsCount: number }>()
|
||||||
|
const show = defineModel<boolean>("show", { required: true })
|
||||||
|
const emit = defineEmits<{ save: [data: BadgeBody] }>()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
type BadgeFormData = {
|
const ICONS = [1, 2, 3, 4, 5, 6].map((i) => `/badge-${i}.png`)
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
icon: string
|
|
||||||
conditionType: "all_problems" | "problem_count" | "score"
|
|
||||||
conditionValue?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
const form = reactive<BadgeBody>({
|
||||||
(e: "update:show", value: boolean): void
|
name: "",
|
||||||
(e: "create", data: BadgeFormData): void
|
description: "",
|
||||||
(e: "update", data: BadgeFormData): void
|
icon: ICONS[0]!,
|
||||||
}
|
conditionType: "problem_count",
|
||||||
|
conditionValue: 1,
|
||||||
|
})
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
// 每次打开都按当前模式重新填表:改就填这枚奖章的值,加就清空
|
||||||
const emit = defineEmits<Emits>()
|
watch(show, (open) => {
|
||||||
|
if (!open) return
|
||||||
|
Object.assign(form, {
|
||||||
|
name: props.badge?.name ?? "",
|
||||||
|
description: props.badge?.description ?? "",
|
||||||
|
icon: props.badge?.icon || ICONS[0]!,
|
||||||
|
conditionType: props.badge?.conditionType ?? "problem_count",
|
||||||
|
conditionValue: props.badge?.conditionValue || 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const badgeName = ref("")
|
const valid = computed(
|
||||||
const badgeDescription = ref("")
|
() =>
|
||||||
const badgeIcon = ref("")
|
form.name.trim().length > 0 &&
|
||||||
const badgeConditionType = ref<"all_problems" | "problem_count" | "score">("all_problems")
|
(form.conditionType === "all_problems" || form.conditionValue >= 1),
|
||||||
const badgeConditionValue = ref(1)
|
)
|
||||||
|
|
||||||
// 预设奖章图标选项
|
function save() {
|
||||||
const BADGE_LEN = 6
|
if (!valid.value) return
|
||||||
const badgeIconOptions = []
|
emit("save", {
|
||||||
for (let i = 1; i <= BADGE_LEN; i++) {
|
...form,
|
||||||
badgeIconOptions.push({
|
name: form.name.trim(),
|
||||||
label: `奖章${i}`,
|
conditionValue: form.conditionType === "all_problems" ? 0 : form.conditionValue,
|
||||||
value: `/badge-${i}.png`,
|
|
||||||
icon: `/badge-${i}.png`,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const conditionTypeOptions = [
|
|
||||||
{ label: "完成所有题目", value: "all_problems" },
|
|
||||||
{ label: "完成指定数量题目", value: "problem_count" },
|
|
||||||
{ label: "达到指定分数", value: "score" },
|
|
||||||
]
|
|
||||||
|
|
||||||
function handleConfirm() {
|
|
||||||
const data: BadgeFormData = {
|
|
||||||
name: badgeName.value,
|
|
||||||
description: badgeDescription.value,
|
|
||||||
icon: badgeIcon.value,
|
|
||||||
conditionType: badgeConditionType.value,
|
|
||||||
// 只有非"完成所有题目"时才带条件值
|
|
||||||
...(badgeConditionType.value === "all_problems"
|
|
||||||
? {}
|
|
||||||
: { conditionValue: badgeConditionValue.value }),
|
|
||||||
}
|
|
||||||
if (props.badge) emit("update", data)
|
|
||||||
else emit("create", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
emit("update:show", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每次打开都按当前模式重新填表:编辑就填这枚奖章的值,添加就清空。
|
|
||||||
// 编辑取消后再打开同一枚,看到的是库里的值,而不是上次没保存的改动
|
|
||||||
watch(
|
|
||||||
() => props.show,
|
|
||||||
(open) => {
|
|
||||||
if (!open) return
|
|
||||||
const badge = props.badge
|
|
||||||
badgeName.value = badge?.name ?? ""
|
|
||||||
badgeDescription.value = badge?.description ?? ""
|
|
||||||
badgeIcon.value = badge?.icon ?? ""
|
|
||||||
badgeConditionType.value = badge?.conditionType ?? "all_problems"
|
|
||||||
badgeConditionValue.value = badge?.conditionValue ?? 1
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-modal
|
<n-modal
|
||||||
:show="show"
|
v-model:show="show"
|
||||||
preset="card"
|
preset="card"
|
||||||
:title="badge ? '编辑奖章' : '添加奖章'"
|
:title="badge ? '改奖章' : '加奖章'"
|
||||||
style="width: 500px"
|
style="width: 460px"
|
||||||
@update:show="emit('update:show', $event)"
|
:mask-closable="false"
|
||||||
>
|
>
|
||||||
<n-form>
|
<div class="form">
|
||||||
<n-form-item label="奖章名称" required>
|
<div class="field">
|
||||||
<n-input v-model:value="badgeName" placeholder="请输入奖章名称" />
|
<span class="label">图标</span>
|
||||||
</n-form-item>
|
<div class="icons">
|
||||||
<n-form-item label="描述">
|
<button
|
||||||
<n-input v-model:value="badgeDescription" type="textarea" placeholder="奖章描述" required />
|
v-for="icon in ICONS"
|
||||||
</n-form-item>
|
:key="icon"
|
||||||
<n-form-item label="图标" required>
|
class="icon"
|
||||||
<n-flex align="center" gap="small">
|
:class="{ on: form.icon === icon }"
|
||||||
<div
|
:aria-label="`选这个图标`"
|
||||||
v-for="option in badgeIconOptions"
|
@click="form.icon = icon"
|
||||||
:key="option.value"
|
|
||||||
@click="badgeIcon = option.value"
|
|
||||||
:style="{
|
|
||||||
width: '60px',
|
|
||||||
height: '60px',
|
|
||||||
border: badgeIcon === option.value ? '2px solid #1890ff' : '1px solid #d9d9d9',
|
|
||||||
borderRadius: '4px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: badgeIcon === option.value ? '#f0f8ff' : 'transparent',
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<n-image
|
<img :src="icon" alt="" />
|
||||||
:src="option.icon"
|
</button>
|
||||||
width="50"
|
|
||||||
height="50"
|
|
||||||
object-fit="cover"
|
|
||||||
preview-disabled
|
|
||||||
style="border-radius: 2px"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</n-flex>
|
</div>
|
||||||
</n-form-item>
|
<div class="field">
|
||||||
<n-flex align="center">
|
<span class="label">名字</span>
|
||||||
<n-form-item label="获得条件">
|
<n-input v-model:value="form.name" placeholder="比如 人上人" maxlength="100" />
|
||||||
<n-select
|
</div>
|
||||||
style="width: 200px"
|
<div class="field">
|
||||||
v-model:value="badgeConditionType"
|
<span class="label">说明</span>
|
||||||
:options="conditionTypeOptions"
|
<n-input v-model:value="form.description" placeholder="选填,学生拿到时看得到" />
|
||||||
/>
|
</div>
|
||||||
</n-form-item>
|
<div class="field">
|
||||||
<n-form-item label="条件值" v-if="badgeConditionType !== 'all_problems'">
|
<span class="label">条件</span>
|
||||||
|
<n-radio-group v-model:value="form.conditionType">
|
||||||
|
<n-radio-button value="problem_count">做对几道</n-radio-button>
|
||||||
|
<n-radio-button value="all_problems">全部做完</n-radio-button>
|
||||||
|
</n-radio-group>
|
||||||
<n-input-number
|
<n-input-number
|
||||||
style="width: 120px"
|
v-if="form.conditionType === 'problem_count'"
|
||||||
v-model:value="badgeConditionValue"
|
v-model:value="form.conditionValue"
|
||||||
placeholder="条件值"
|
:min="1"
|
||||||
/>
|
:max="Math.max(1, problemsCount)"
|
||||||
</n-form-item>
|
style="width: 110px"
|
||||||
</n-flex>
|
>
|
||||||
</n-form>
|
<template #suffix>道</template>
|
||||||
|
</n-input-number>
|
||||||
|
</div>
|
||||||
|
<span class="hint">
|
||||||
|
{{
|
||||||
|
form.conditionType === "all_problems"
|
||||||
|
? "必做题全部做对就发。"
|
||||||
|
: "做对的题(选做也算)够这个数就发。"
|
||||||
|
}}改了条件会按大家现在的进度重新发、收。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<n-flex justify="end">
|
<n-flex justify="end">
|
||||||
<n-button @click="handleCancel">取消</n-button>
|
<n-button @click="show = false">取消</n-button>
|
||||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
<n-button type="primary" :disabled="!valid" @click="save">保存</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
width: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icons {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon.on {
|
||||||
|
border-color: v-bind("theme.primaryColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon img {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { h } from "vue"
|
|
||||||
import { NDataTable, NButton, NFlex } from "naive-ui"
|
|
||||||
import type { AdminProblemSetProblem } from "utils/types"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
problems: AdminProblemSetProblem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: "add-problem"): void
|
|
||||||
(e: "edit-problem", problem: AdminProblemSetProblem): void
|
|
||||||
(e: "remove-problem", problemSetProblemId: number): void
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
defineEmits<Emits>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
|
||||||
<h3>题目列表</h3>
|
|
||||||
<n-button type="primary" @click="$emit('add-problem')"> 添加题目 </n-button>
|
|
||||||
</n-flex>
|
|
||||||
<n-data-table
|
|
||||||
:columns="[
|
|
||||||
{ title: '题目ID', key: 'problemDisplayId', width: 80 },
|
|
||||||
{ title: '题目标题', key: 'title', minWidth: 200 },
|
|
||||||
{ title: '顺序', key: 'order', width: 80 },
|
|
||||||
{
|
|
||||||
title: '必做',
|
|
||||||
key: 'isRequired',
|
|
||||||
width: 80,
|
|
||||||
render: (row) => (row.isRequired ? '是' : '否'),
|
|
||||||
},
|
|
||||||
{ title: '分数', key: 'score', width: 80 },
|
|
||||||
{ title: '提示', key: 'hint', minWidth: 200 },
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 160,
|
|
||||||
render: (row) =>
|
|
||||||
h('div', { style: 'display: flex; gap: 8px;' }, [
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
size: 'small',
|
|
||||||
type: 'primary',
|
|
||||||
secondary: true,
|
|
||||||
onClick: () => $emit('edit-problem', row),
|
|
||||||
},
|
|
||||||
{ default: () => '编辑' },
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
size: 'small',
|
|
||||||
type: 'error',
|
|
||||||
secondary: true,
|
|
||||||
onClick: () => $emit('remove-problem', row.id),
|
|
||||||
},
|
|
||||||
{ default: () => '移除' },
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
:data="problems"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { AdminProblemSetProblem } from "utils/types"
|
|
||||||
|
|
||||||
// 添加和编辑共用一个弹窗:传了 problem 就是编辑,null 就是添加。
|
|
||||||
// 两种模式只差第一栏 —— 添加时填题目 ID,编辑时题目已定、只显示标题
|
|
||||||
interface Props {
|
|
||||||
show: boolean
|
|
||||||
problem: AdminProblemSetProblem | null
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProblemFormData = {
|
|
||||||
order: number
|
|
||||||
isRequired: boolean
|
|
||||||
score: number
|
|
||||||
hint: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: "update:show", value: boolean): void
|
|
||||||
(e: "create", data: ProblemFormData & { problemId: string }): void
|
|
||||||
(e: "update", data: ProblemFormData): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
const problemId = ref("")
|
|
||||||
const problemOrder = ref(0)
|
|
||||||
const problemRequired = ref(true)
|
|
||||||
const problemScore = ref(0)
|
|
||||||
const problemHint = ref("")
|
|
||||||
|
|
||||||
function handleConfirm() {
|
|
||||||
const data: ProblemFormData = {
|
|
||||||
order: problemOrder.value,
|
|
||||||
isRequired: problemRequired.value,
|
|
||||||
score: problemScore.value,
|
|
||||||
hint: problemHint.value,
|
|
||||||
}
|
|
||||||
if (props.problem) emit("update", data)
|
|
||||||
else emit("create", { problemId: problemId.value, ...data })
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCancel() {
|
|
||||||
emit("update:show", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 每次打开都按当前模式重新填表:编辑就填这道题的值,添加就清空。
|
|
||||||
// 编辑取消后再打开同一道,看到的是库里的值,而不是上次没保存的改动
|
|
||||||
watch(
|
|
||||||
() => props.show,
|
|
||||||
(open) => {
|
|
||||||
if (!open) return
|
|
||||||
const problem = props.problem
|
|
||||||
problemId.value = ""
|
|
||||||
problemOrder.value = problem?.order ?? 0
|
|
||||||
problemRequired.value = problem?.isRequired ?? true
|
|
||||||
problemScore.value = problem?.score ?? 0
|
|
||||||
problemHint.value = problem?.hint || ""
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<n-modal
|
|
||||||
:show="show"
|
|
||||||
preset="card"
|
|
||||||
:title="problem ? '编辑题目' : '添加题目'"
|
|
||||||
style="width: 500px"
|
|
||||||
@update:show="emit('update:show', $event)"
|
|
||||||
>
|
|
||||||
<n-form>
|
|
||||||
<n-form-item v-if="problem" label="题目标题">
|
|
||||||
<n-input :value="problem.title" disabled />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-else label="题目ID" required>
|
|
||||||
<n-input v-model:value="problemId" placeholder="请输入题目的显示ID(如:1001)" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="顺序">
|
|
||||||
<n-input-number v-model:value="problemOrder" placeholder="题目在题单中的顺序" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="是否必做">
|
|
||||||
<n-switch v-model:value="problemRequired" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="分数">
|
|
||||||
<n-input-number v-model:value="problemScore" placeholder="题目分数" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="提示">
|
|
||||||
<n-input v-model:value="problemHint" type="textarea" placeholder="题目提示" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<template #footer>
|
|
||||||
<n-flex justify="end">
|
|
||||||
<n-button @click="handleCancel">取消</n-button>
|
|
||||||
<n-button type="primary" @click="handleConfirm">确认</n-button>
|
|
||||||
</n-flex>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { parseTime } from "utils/functions"
|
|
||||||
import type { AdminProblemSet } from "utils/types"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
problemSet: AdminProblemSet
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<n-card title="题单信息" style="margin-bottom: 16px">
|
|
||||||
<n-descriptions :column="4" bordered>
|
|
||||||
<n-descriptions-item label="描述">
|
|
||||||
{{ problemSet.description }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="创建者">
|
|
||||||
{{ problemSet.createdBy.username }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="难度">
|
|
||||||
<n-tag
|
|
||||||
:type="
|
|
||||||
problemSet.difficulty === 'Easy'
|
|
||||||
? 'success'
|
|
||||||
: problemSet.difficulty === 'Medium'
|
|
||||||
? 'warning'
|
|
||||||
: 'error'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
problemSet.difficulty === "Easy"
|
|
||||||
? "简单"
|
|
||||||
: problemSet.difficulty === "Medium"
|
|
||||||
? "中等"
|
|
||||||
: "困难"
|
|
||||||
}}
|
|
||||||
</n-tag>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="状态">
|
|
||||||
<n-tag
|
|
||||||
:type="
|
|
||||||
problemSet.status === 'active'
|
|
||||||
? 'success'
|
|
||||||
: problemSet.status === 'archived'
|
|
||||||
? 'default'
|
|
||||||
: 'info'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
problemSet.status === "active"
|
|
||||||
? "活跃"
|
|
||||||
: problemSet.status === "archived"
|
|
||||||
? "已归档"
|
|
||||||
: "草稿"
|
|
||||||
}}
|
|
||||||
</n-tag>
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="可见">
|
|
||||||
{{ problemSet.visible ? "是" : "否" }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="题目数量">
|
|
||||||
{{ problemSet.problemsCount }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
<n-descriptions-item label="创建时间">
|
|
||||||
{{ parseTime(problemSet.createTime, "YYYY-MM-DD HH:mm:ss") }}
|
|
||||||
</n-descriptions-item>
|
|
||||||
</n-descriptions>
|
|
||||||
</n-card>
|
|
||||||
</template>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { h } from "vue"
|
|
||||||
import { NDataTable, NButton, NFlex } from "naive-ui"
|
|
||||||
import { parseTime } from "utils/functions"
|
|
||||||
import type { AdminProblemSetProgress } from "utils/types"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
progress: AdminProblemSetProgress[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: "remove-user", userId: number): void
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
// 定义表格列
|
|
||||||
const progressColumns = [
|
|
||||||
{ title: "用户", key: "username", width: 120 },
|
|
||||||
{
|
|
||||||
title: "加入时间",
|
|
||||||
key: "joinTime",
|
|
||||||
width: 180,
|
|
||||||
render: (row: AdminProblemSetProgress) => parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
|
|
||||||
},
|
|
||||||
{ title: "已完成", key: "completedProblemsCount", width: 100 },
|
|
||||||
{ title: "总题目", key: "totalProblemsCount", width: 100 },
|
|
||||||
{
|
|
||||||
title: "进度",
|
|
||||||
key: "progressPercentage",
|
|
||||||
width: 100,
|
|
||||||
render: (row: AdminProblemSetProgress) => `${row.progressPercentage.toFixed(0)}%`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "是否完成",
|
|
||||||
key: "isCompleted",
|
|
||||||
width: 100,
|
|
||||||
render: (row: AdminProblemSetProgress) => (row.isCompleted ? "是" : "否"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "操作",
|
|
||||||
key: "actions",
|
|
||||||
width: 120,
|
|
||||||
render: (row: AdminProblemSetProgress) =>
|
|
||||||
h(
|
|
||||||
NButton,
|
|
||||||
{
|
|
||||||
size: "small",
|
|
||||||
type: "error",
|
|
||||||
secondary: true,
|
|
||||||
onClick: () => emit("remove-user", row.userId),
|
|
||||||
},
|
|
||||||
{ default: () => "移除" },
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
|
|
||||||
<h3>用户进度</h3>
|
|
||||||
</n-flex>
|
|
||||||
<n-data-table :columns="progressColumns" :data="progress" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
|
||||||
import { errorMessage } from "utils/api"
|
|
||||||
import { NTabPane, NTabs, NButton } from "naive-ui"
|
|
||||||
import type {
|
|
||||||
AdminProblemSet,
|
|
||||||
AdminProblemSetProblem,
|
|
||||||
AddProblemToSetRequest,
|
|
||||||
UpdateProblemInSetRequest,
|
|
||||||
CreateProblemSetBadgeRequest,
|
|
||||||
AdminProblemSetBadge,
|
|
||||||
AdminProblemSetProgress,
|
|
||||||
} from "utils/types"
|
|
||||||
import {
|
|
||||||
getProblemSetDetail,
|
|
||||||
getProblemSetProblems,
|
|
||||||
getProblemSetBadges,
|
|
||||||
getProblemSetProgress,
|
|
||||||
addProblemToSet,
|
|
||||||
editProblemInSet,
|
|
||||||
removeProblemFromSet,
|
|
||||||
createProblemSetBadge,
|
|
||||||
editProblemSetBadge,
|
|
||||||
deleteProblemSetBadge,
|
|
||||||
removeUserFromProblemSet,
|
|
||||||
} from "../api"
|
|
||||||
import ProblemSetInfo from "./components/ProblemSetInfo.vue"
|
|
||||||
import ProblemManagement from "./components/ProblemManagement.vue"
|
|
||||||
import BadgeManagement from "./components/BadgeManagement.vue"
|
|
||||||
import ProgressManagement from "./components/ProgressManagement.vue"
|
|
||||||
import ProblemModal from "./components/ProblemModal.vue"
|
|
||||||
import BadgeModal from "./components/BadgeModal.vue"
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
/** 奖章弹窗的表单值:条件类型是「完成所有题目」时不带 conditionValue,后端补 0 */
|
|
||||||
type BadgeFormData = Omit<CreateProblemSetBadgeRequest, "conditionValue"> & {
|
|
||||||
conditionValue?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const problemSetId = computed(() => Number(route.params.problemSetId))
|
|
||||||
|
|
||||||
const problemSet = ref<AdminProblemSet | null>(null)
|
|
||||||
const problems = ref<AdminProblemSetProblem[]>([])
|
|
||||||
const badges = ref<AdminProblemSetBadge[]>([])
|
|
||||||
const progress = ref<AdminProblemSetProgress[]>([])
|
|
||||||
|
|
||||||
// 模态框状态。添加和编辑共用一个弹窗,editing* 为 null 就是添加
|
|
||||||
const showProblemModal = ref(false)
|
|
||||||
const showBadgeModal = ref(false)
|
|
||||||
|
|
||||||
// 编辑数据
|
|
||||||
const editingProblem = ref<AdminProblemSetProblem | null>(null)
|
|
||||||
const editingBadge = ref<AdminProblemSetBadge | null>(null)
|
|
||||||
|
|
||||||
async function loadProblemSetDetail() {
|
|
||||||
try {
|
|
||||||
const res = await getProblemSetDetail(problemSetId.value)
|
|
||||||
problemSet.value = res
|
|
||||||
} catch (err) {
|
|
||||||
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProblems() {
|
|
||||||
try {
|
|
||||||
const res = await getProblemSetProblems(problemSetId.value)
|
|
||||||
problems.value = res
|
|
||||||
} catch (err) {
|
|
||||||
message.error("加载题目列表失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadBadges() {
|
|
||||||
try {
|
|
||||||
const res = await getProblemSetBadges(problemSetId.value)
|
|
||||||
badges.value = res
|
|
||||||
} catch (err) {
|
|
||||||
message.error("加载奖章列表失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProgress() {
|
|
||||||
try {
|
|
||||||
const res = await getProblemSetProgress(problemSetId.value)
|
|
||||||
progress.value = res
|
|
||||||
} catch (err) {
|
|
||||||
message.error("加载进度列表失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddProblem(data: AddProblemToSetRequest) {
|
|
||||||
try {
|
|
||||||
await addProblemToSet(problemSetId.value, data)
|
|
||||||
message.success("题目添加成功")
|
|
||||||
showProblemModal.value = false
|
|
||||||
loadProblems()
|
|
||||||
loadProblemSetDetail() // 刷新题目数量
|
|
||||||
} catch (err) {
|
|
||||||
message.error("添加题目失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoveProblem(problemSetProblemId: number) {
|
|
||||||
try {
|
|
||||||
await removeProblemFromSet(problemSetId.value, problemSetProblemId)
|
|
||||||
message.success("题目移除成功")
|
|
||||||
loadProblems()
|
|
||||||
loadProblemSetDetail() // 刷新题目数量
|
|
||||||
} catch (err) {
|
|
||||||
message.error("移除题目失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEditProblem(data: UpdateProblemInSetRequest) {
|
|
||||||
if (!editingProblem.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
|
|
||||||
message.success("题目编辑成功")
|
|
||||||
showProblemModal.value = false
|
|
||||||
loadProblems()
|
|
||||||
} catch (err) {
|
|
||||||
message.error("编辑题目失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddBadge(data: BadgeFormData) {
|
|
||||||
try {
|
|
||||||
await createProblemSetBadge(problemSetId.value, data)
|
|
||||||
message.success("奖章创建成功")
|
|
||||||
showBadgeModal.value = false
|
|
||||||
loadBadges()
|
|
||||||
} catch (err) {
|
|
||||||
message.error("创建奖章失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteBadge(badgeId: number) {
|
|
||||||
try {
|
|
||||||
await deleteProblemSetBadge(problemSetId.value, badgeId)
|
|
||||||
message.success("奖章删除成功")
|
|
||||||
loadBadges()
|
|
||||||
} catch (err) {
|
|
||||||
message.error("删除奖章失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEditBadge(data: BadgeFormData) {
|
|
||||||
if (!editingBadge.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await editProblemSetBadge(problemSetId.value, editingBadge.value.id, data)
|
|
||||||
message.success("奖章编辑成功")
|
|
||||||
showBadgeModal.value = false
|
|
||||||
loadBadges()
|
|
||||||
} catch (err) {
|
|
||||||
message.error("编辑奖章失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoveUser(userId: number) {
|
|
||||||
try {
|
|
||||||
await removeUserFromProblemSet(problemSetId.value, userId)
|
|
||||||
message.success("用户移除成功")
|
|
||||||
loadProgress()
|
|
||||||
} catch (err) {
|
|
||||||
message.error("移除用户失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAddProblemModal() {
|
|
||||||
editingProblem.value = null
|
|
||||||
showProblemModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAddBadgeModal() {
|
|
||||||
editingBadge.value = null
|
|
||||||
showBadgeModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditProblemModal(problem: AdminProblemSetProblem) {
|
|
||||||
editingProblem.value = problem
|
|
||||||
showProblemModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditBadgeModal(badge: AdminProblemSetBadge) {
|
|
||||||
editingBadge.value = badge
|
|
||||||
showBadgeModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadProblemSetDetail()
|
|
||||||
loadProblems()
|
|
||||||
loadBadges()
|
|
||||||
loadProgress()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="problemSet">
|
|
||||||
<PageHeader :title="problemSet.title" :back="{ name: 'admin problemset list' }">
|
|
||||||
<template #actions>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
@click="
|
|
||||||
router.push({
|
|
||||||
name: 'admin problemset edit',
|
|
||||||
params: { problemSetId },
|
|
||||||
})
|
|
||||||
"
|
|
||||||
>
|
|
||||||
编辑题单
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<ProblemSetInfo :problem-set="problemSet" />
|
|
||||||
|
|
||||||
<n-tabs type="line">
|
|
||||||
<n-tab-pane name="problems" tab="题目管理">
|
|
||||||
<ProblemManagement
|
|
||||||
:problems="problems"
|
|
||||||
@add-problem="openAddProblemModal"
|
|
||||||
@edit-problem="openEditProblemModal"
|
|
||||||
@remove-problem="handleRemoveProblem"
|
|
||||||
/>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<n-tab-pane name="badges" tab="奖章管理">
|
|
||||||
<BadgeManagement
|
|
||||||
:badges="badges"
|
|
||||||
@add-badge="openAddBadgeModal"
|
|
||||||
@edit-badge="openEditBadgeModal"
|
|
||||||
@delete-badge="handleDeleteBadge"
|
|
||||||
/>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<n-tab-pane name="progress" tab="进度管理">
|
|
||||||
<ProgressManagement :progress="progress" @remove-user="handleRemoveUser" />
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
|
|
||||||
<!-- 模态框组件 -->
|
|
||||||
<ProblemModal
|
|
||||||
v-model:show="showProblemModal"
|
|
||||||
:problem="editingProblem"
|
|
||||||
@create="handleAddProblem"
|
|
||||||
@update="handleEditProblem"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<BadgeModal
|
|
||||||
v-model:show="showBadgeModal"
|
|
||||||
:badge="editingBadge"
|
|
||||||
@create="handleAddBadge"
|
|
||||||
@update="handleEditBadge"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,168 +1,820 @@
|
|||||||
<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 { errorMessage } from "utils/api"
|
||||||
import type { CreateProblemSetData, EditProblemSetData } from "utils/types"
|
import { fromPickerValue, parseTime, toPickerValue, zonedParts } from "utils/functions"
|
||||||
import { fromPickerValue, toPickerValue } from "utils/functions"
|
import type { AdminProblemRow, AdminProblemSet, AdminProblemSetBadge } from "utils/types"
|
||||||
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
|
import {
|
||||||
|
addProblemsToSet,
|
||||||
|
createProblemSet,
|
||||||
|
createProblemSetBadge,
|
||||||
|
deleteProblemSet,
|
||||||
|
deleteProblemSetBadge,
|
||||||
|
editProblemSet,
|
||||||
|
editProblemSetBadge,
|
||||||
|
getLibraryProblemList,
|
||||||
|
getProblemSetBadges,
|
||||||
|
getProblemSetDetail,
|
||||||
|
getProblemSetProblems,
|
||||||
|
removeProblemFromSet,
|
||||||
|
reorderProblemSetProblems,
|
||||||
|
setProblemRequired,
|
||||||
|
type BadgeBody,
|
||||||
|
} from "../api"
|
||||||
|
import BadgeModal from "./components/BadgeModal.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建 / 编辑题单,一屏做完(设计稿「题单重设计」后台那块):左边名字、说明、布置期、公开;
|
||||||
|
* 右边题目(搜题号或一次粘贴好几个、拖动排序、点一下切选做)和奖章。
|
||||||
|
*
|
||||||
|
* 布置期:选「布置到哪天」,布置期内这几道题学生以前交的代码先藏起来(防抄),不拦做题。
|
||||||
|
* 新建时题目先攒在页面上,保存时建好题单再一次加进去;编辑时加、删、排、切选做当场生效。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ problemSetId?: string }>()
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
const problemSetId = computed(() => Number(route.params.problemSetId))
|
const editing = computed(() => !!props.problemSetId)
|
||||||
const isEdit = computed(() => !!problemSetId.value)
|
const id = computed(() => Number(props.problemSetId))
|
||||||
|
const ready = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const current = ref<AdminProblemSet | null>(null)
|
||||||
|
|
||||||
const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
|
const form = reactive({ title: "", description: "", visible: true })
|
||||||
title: "",
|
|
||||||
description: "",
|
|
||||||
difficulty: "Easy",
|
|
||||||
status: "draft",
|
|
||||||
visible: false,
|
|
||||||
endTime: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
// n-date-picker 按浏览器本地渲染,所以要平移一次再绑定(见 utils/functions.ts
|
// ---------------------------------------------------------------- 布置期
|
||||||
// 的 toPickerValue)。`formData.endTime` 里始终存**真实时刻**,只有喂给选择器那一步换。
|
|
||||||
const endTimeTimestamp = computed({
|
|
||||||
get: () => (formData.value.endTime ? toPickerValue(formData.value.endTime.getTime()) : null),
|
|
||||||
set: (val: number | null) => {
|
|
||||||
formData.value.endTime = val ? new Date(fromPickerValue(val)) : null
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const difficultyOptions = [
|
const DAY = 86_400_000
|
||||||
{ label: "简单", value: "Easy" },
|
const OFFSET = 8 * 3_600_000
|
||||||
{ label: "中等", value: "Medium" },
|
const CHOICES = [
|
||||||
{ label: "困难", value: "Hard" },
|
{ days: 7, label: "一周" },
|
||||||
|
{ days: 14, label: "两周" },
|
||||||
|
{ days: 28, label: "四周" },
|
||||||
]
|
]
|
||||||
|
const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"]
|
||||||
|
|
||||||
const statusOptions = [
|
/** 东八区某一天结束的时刻(23:59:59.999) */
|
||||||
{ label: "活跃", value: "active" },
|
function endOfDay(ms: number) {
|
||||||
{ label: "已归档", value: "archived" },
|
const parts = zonedParts(new Date(ms))!
|
||||||
{ label: "草稿", value: "draft" },
|
return Date.UTC(parts.year, parts.month - 1, parts.day + 1) - OFFSET - 1
|
||||||
]
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
async function loadProblemSetDetail() {
|
|
||||||
if (!isEdit.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await getProblemSetDetail(problemSetId.value)
|
|
||||||
const data = res
|
|
||||||
formData.value = {
|
|
||||||
id: data.id,
|
|
||||||
title: data.title,
|
|
||||||
description: data.description,
|
|
||||||
difficulty: data.difficulty,
|
|
||||||
status: data.status,
|
|
||||||
visible: data.visible,
|
|
||||||
endTime: data.endTime ? new Date(data.endTime) : null,
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
message.error("加载题单详情失败:" + errorMessage(err, "未知错误"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
/** "none" 不布置 / 天数 / "pick" 自己选日期 */
|
||||||
if (!formData.value.title?.trim()) {
|
const choice = ref<"none" | "pick" | number>(14)
|
||||||
message.error("请输入题单标题")
|
const picked = ref(toPickerValue(Date.now() + 14 * DAY))
|
||||||
|
|
||||||
|
const untilMs = computed(() => {
|
||||||
|
if (choice.value === "none") return null
|
||||||
|
if (choice.value === "pick") return endOfDay(fromPickerValue(picked.value))
|
||||||
|
return endOfDay(Date.now() + choice.value * DAY)
|
||||||
|
})
|
||||||
|
|
||||||
|
function dayText(ms: number) {
|
||||||
|
const iso = new Date(ms).toISOString()
|
||||||
|
return `${parseTime(iso, "M月D日")} 周${WEEKDAYS[new Date(ms + OFFSET).getUTCDay()]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const running = computed(() => !!current.value?.assigning)
|
||||||
|
const expired = computed(
|
||||||
|
() => !!current.value && !current.value.assigning && !!current.value.assignedUntil,
|
||||||
|
)
|
||||||
|
|
||||||
|
const assignHint = computed(() => {
|
||||||
|
if (untilMs.value === null) {
|
||||||
|
return running.value
|
||||||
|
? "保存后马上结束布置,以前的代码都放出来。"
|
||||||
|
: "不布置:学生照样能加入、做题,只是不藏以前的代码。"
|
||||||
|
}
|
||||||
|
if (untilMs.value <= Date.now()) return "这一天已经过了,选个以后的日子。"
|
||||||
|
const head = `布置到 ${dayText(untilMs.value)}。`
|
||||||
|
return running.value
|
||||||
|
? `${head}这一轮从 ${parseTime(current.value!.assignedAt!, "M月D日")} 开始,改日期只是延长或提前结束。`
|
||||||
|
: `${head}${expired.value ? "上一轮已经结束,保存就是再布置一次。" : ""}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const disabledDate = (ts: number) => fromPickerValue(ts) + DAY <= Date.now()
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 题目
|
||||||
|
|
||||||
|
type Item = { id: number; displayId: string; title: string; isRequired: boolean }
|
||||||
|
const items = ref<Item[]>([])
|
||||||
|
|
||||||
|
async function loadProblems() {
|
||||||
|
const rows = await getProblemSetProblems(id.value)
|
||||||
|
items.value = rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
displayId: row.problemDisplayId,
|
||||||
|
title: row.title,
|
||||||
|
isRequired: row.isRequired,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = ref("")
|
||||||
|
const results = ref<AdminProblemRow[]>([])
|
||||||
|
const searching = ref(false)
|
||||||
|
|
||||||
|
/** 输入框里粘了好几个题号(空格、逗号、顿号隔开) */
|
||||||
|
const pasted = computed(() => {
|
||||||
|
const parts = keyword.value
|
||||||
|
.split(/[\s,,、;;]+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
return parts.length > 1 ? parts : []
|
||||||
|
})
|
||||||
|
|
||||||
|
watchDebounced(
|
||||||
|
keyword,
|
||||||
|
async (value) => {
|
||||||
|
if (!value.trim() || pasted.value.length) {
|
||||||
|
results.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
searching.value = true
|
||||||
if (!formData.value.description?.trim()) {
|
|
||||||
message.error("请输入题单描述")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEdit.value) {
|
results.value = (await getLibraryProblemList(0, 8, value.trim())).results
|
||||||
await editProblemSet(formData.value as EditProblemSetData)
|
|
||||||
message.success("题单更新成功")
|
|
||||||
} else {
|
|
||||||
await createProblemSet(formData.value as CreateProblemSetData)
|
|
||||||
message.success("题单创建成功")
|
|
||||||
}
|
|
||||||
router.push({ name: "admin problemset list" })
|
|
||||||
} catch (err) {
|
|
||||||
message.error((isEdit.value ? "更新" : "创建") + "题单失败:" + errorMessage(err, "未知错误"))
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
searching.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ debounce: 300 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const added = (displayId: string) =>
|
||||||
|
items.value.some((item) => item.displayId.toLowerCase() === displayId.toLowerCase())
|
||||||
|
|
||||||
|
async function addMany(displayIds: string[], titles = new Map<string, string>()) {
|
||||||
|
if (!editing.value) {
|
||||||
|
// 新建时先攒着;题目名要查一次,粘贴进来的题号只有号
|
||||||
|
for (const typed of displayIds) {
|
||||||
|
if (added(typed)) continue
|
||||||
|
let displayId = typed
|
||||||
|
let title = titles.get(typed)
|
||||||
|
if (!title) {
|
||||||
|
const found = (await getLibraryProblemList(0, 5, typed)).results.find(
|
||||||
|
(row) => row._id.toLowerCase() === typed.toLowerCase(),
|
||||||
|
)
|
||||||
|
if (!found) {
|
||||||
|
message.warning(`题号 ${typed} 找不到(或者没公开)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title = found.title
|
||||||
|
displayId = found._id
|
||||||
|
}
|
||||||
|
items.value.push({ id: 0, displayId, title, isRequired: true })
|
||||||
|
}
|
||||||
|
keyword.value = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await addProblemsToSet(id.value, displayIds)
|
||||||
|
const notes = []
|
||||||
|
if (res.missing.length) notes.push(`找不到:${res.missing.join("、")}`)
|
||||||
|
if (res.duplicate.length) notes.push(`已经在里面:${res.duplicate.join("、")}`)
|
||||||
|
if (notes.length) message.warning(notes.join(";"))
|
||||||
|
else message.success(`加了 ${res.added.length} 道`)
|
||||||
|
keyword.value = ""
|
||||||
|
await Promise.all([loadProblems(), reloadSet()])
|
||||||
|
} catch (err) {
|
||||||
|
message.error(errorMessage(err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
function addRow(row: AdminProblemRow) {
|
||||||
if (isEdit.value) {
|
addMany([row._id], new Map([[row._id, row.title]]))
|
||||||
loadProblemSetDetail()
|
}
|
||||||
|
|
||||||
|
async function toggleRequired(item: Item) {
|
||||||
|
item.isRequired = !item.isRequired
|
||||||
|
if (!editing.value) return
|
||||||
|
try {
|
||||||
|
await setProblemRequired(id.value, item.id, item.isRequired)
|
||||||
|
} catch (err) {
|
||||||
|
item.isRequired = !item.isRequired
|
||||||
|
message.error(errorMessage(err))
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
function remove(item: Item) {
|
||||||
|
if (!editing.value) {
|
||||||
|
items.value = items.value.filter((it) => it !== item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialog.warning({
|
||||||
|
title: "从题单里拿掉这道题?",
|
||||||
|
content: `「${item.title}」。已经加入的 ${current.value?.participantCount ?? 0} 个人的进度会重算,题目本身不受影响。`,
|
||||||
|
positiveText: "拿掉",
|
||||||
|
negativeText: "不拿",
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try {
|
||||||
|
await removeProblemFromSet(id.value, item.id)
|
||||||
|
await Promise.all([loadProblems(), reloadSet()])
|
||||||
|
} catch (err) {
|
||||||
|
message.error(errorMessage(err))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖动排序:原生 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
|
||||||
|
}
|
||||||
|
async function onDragEnd() {
|
||||||
|
if (dragging.value === null) return
|
||||||
|
dragging.value = null
|
||||||
|
if (!editing.value) return
|
||||||
|
try {
|
||||||
|
await reorderProblemSetProblems(
|
||||||
|
id.value,
|
||||||
|
items.value.map((item) => item.id),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
message.error(errorMessage(err))
|
||||||
|
await loadProblems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionalCount = computed(() => items.value.filter((item) => !item.isRequired).length)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 奖章
|
||||||
|
|
||||||
|
const badges = ref<AdminProblemSetBadge[]>([])
|
||||||
|
const badgeModal = ref(false)
|
||||||
|
const editingBadge = ref<AdminProblemSetBadge | null>(null)
|
||||||
|
|
||||||
|
function badgeCondition(badge: AdminProblemSetBadge) {
|
||||||
|
return badge.conditionType === "all_problems" ? "全部做完" : `做对 ${badge.conditionValue} 道`
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBadge(badge: AdminProblemSetBadge | null) {
|
||||||
|
editingBadge.value = badge
|
||||||
|
badgeModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBadge(data: BadgeBody) {
|
||||||
|
try {
|
||||||
|
if (editingBadge.value) await editProblemSetBadge(id.value, editingBadge.value.id, data)
|
||||||
|
else await createProblemSetBadge(id.value, data)
|
||||||
|
badgeModal.value = false
|
||||||
|
badges.value = await getProblemSetBadges(id.value)
|
||||||
|
} catch (err) {
|
||||||
|
message.error(errorMessage(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeBadge(badge: AdminProblemSetBadge) {
|
||||||
|
dialog.warning({
|
||||||
|
title: `删掉奖章「${badge.name}」?`,
|
||||||
|
content: badge.earnedCount
|
||||||
|
? `已经拿到它的 ${badge.earnedCount} 个人也会一起被收回。`
|
||||||
|
: "还没有人拿到它。",
|
||||||
|
positiveText: "删掉",
|
||||||
|
negativeText: "不删",
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try {
|
||||||
|
await deleteProblemSetBadge(id.value, badge.id)
|
||||||
|
badges.value = await getProblemSetBadges(id.value)
|
||||||
|
} catch (err) {
|
||||||
|
message.error(errorMessage(err))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 载入 / 保存
|
||||||
|
|
||||||
|
async function reloadSet() {
|
||||||
|
current.value = await getProblemSetDetail(id.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
keyword.value = ""
|
||||||
|
if (!editing.value) {
|
||||||
|
ready.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await reloadSet()
|
||||||
|
const set = current.value!
|
||||||
|
Object.assign(form, { title: set.title, description: set.description, visible: set.visible })
|
||||||
|
if (set.assigning) {
|
||||||
|
choice.value = "pick"
|
||||||
|
picked.value = toPickerValue(Date.parse(set.assignedUntil!))
|
||||||
|
} else {
|
||||||
|
choice.value = "none"
|
||||||
|
}
|
||||||
|
await Promise.all([loadProblems(), getProblemSetBadges(id.value).then((b) => (badges.value = b))])
|
||||||
|
ready.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
message.error("题单还没起名字")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (untilMs.value !== null && untilMs.value <= Date.now()) {
|
||||||
|
message.error("布置到的日期已经过了")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
visible: form.visible,
|
||||||
|
assignedUntil: untilMs.value === null ? null : new Date(untilMs.value).toISOString(),
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editing.value) {
|
||||||
|
current.value = await editProblemSet(id.value, body)
|
||||||
|
message.success("改好了")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const created = await createProblemSet(body)
|
||||||
|
if (items.value.length) {
|
||||||
|
await addProblemsToSet(
|
||||||
|
created.id,
|
||||||
|
items.value.map((item) => item.displayId),
|
||||||
|
)
|
||||||
|
const optional = items.value.filter((item) => !item.isRequired).map((item) => item.displayId)
|
||||||
|
if (optional.length) {
|
||||||
|
const rows = await getProblemSetProblems(created.id)
|
||||||
|
for (const row of rows.filter((r) => optional.includes(r.problemDisplayId))) {
|
||||||
|
await setProblemRequired(created.id, row.id, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.success(
|
||||||
|
items.value.length ? `题单建好了,${items.value.length} 道题都加进去了` : "题单建好了",
|
||||||
|
)
|
||||||
|
router.replace({ name: "admin problemset edit", params: { problemSetId: created.id } })
|
||||||
|
} catch (err) {
|
||||||
|
message.error(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSet() {
|
||||||
|
const set = current.value!
|
||||||
|
dialog.error({
|
||||||
|
title: `删除题单「${set.title}」?`,
|
||||||
|
content: set.participantCount
|
||||||
|
? `${set.participantCount} 个人的进度和他们拿到的奖章会一起删掉,找不回来。只是不想让学生看到的话,关掉「公开」就行。`
|
||||||
|
: "还没有人加入。删掉找不回来。",
|
||||||
|
positiveText: "删除",
|
||||||
|
negativeText: "不删",
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
try {
|
||||||
|
await deleteProblemSet(set.id)
|
||||||
|
router.replace({ name: "admin problemset list" })
|
||||||
|
} catch (err) {
|
||||||
|
message.error(errorMessage(err))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.problemSetId, load)
|
||||||
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<PageHeader :title="editing ? '编辑题单' : '新建题单'" :back="{ name: 'admin problemset list' }">
|
||||||
<PageHeader
|
<template v-if="editing" #actions>
|
||||||
:title="isEdit ? '编辑题单' : '新建题单'"
|
<n-button tag="a" :href="`/problemset/${problemSetId}`" target="_blank">
|
||||||
:back="
|
到前台看这个题单
|
||||||
isEdit
|
|
||||||
? { name: 'admin problemset detail', params: { problemSetId } }
|
|
||||||
: { name: 'admin problemset list' }
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-form :model="formData" label-placement="top">
|
|
||||||
<n-flex>
|
|
||||||
<n-form-item label="题单标题" required>
|
|
||||||
<n-input
|
|
||||||
v-model:value="formData.title"
|
|
||||||
placeholder="请输入题单标题"
|
|
||||||
maxlength="200"
|
|
||||||
show-count
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="难度">
|
|
||||||
<n-select
|
|
||||||
style="width: 100px"
|
|
||||||
v-model:value="formData.difficulty"
|
|
||||||
:options="difficultyOptions"
|
|
||||||
placeholder="选择难度"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="状态">
|
|
||||||
<n-select
|
|
||||||
style="width: 100px"
|
|
||||||
v-model:value="formData.status"
|
|
||||||
:options="statusOptions"
|
|
||||||
placeholder="选择状态"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="截止时间">
|
|
||||||
<n-date-picker
|
|
||||||
v-model:value="endTimeTimestamp"
|
|
||||||
type="datetime"
|
|
||||||
clearable
|
|
||||||
placeholder="不设置则无截止时间"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="isEdit" label="是否可见">
|
|
||||||
<n-switch v-model:value="formData.visible" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-flex>
|
|
||||||
|
|
||||||
<n-form-item label="题单描述" required>
|
|
||||||
<n-input
|
|
||||||
v-model:value="formData.description"
|
|
||||||
type="textarea"
|
|
||||||
placeholder="请输入题单描述"
|
|
||||||
:rows="4"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item>
|
|
||||||
<n-button type="primary" :loading="loading" @click="handleSubmit">
|
|
||||||
{{ isEdit ? "更新" : "创建" }}
|
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-form-item>
|
</template>
|
||||||
</n-form>
|
</PageHeader>
|
||||||
|
|
||||||
|
<div v-if="ready" class="cols">
|
||||||
|
<section class="card form">
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">名字</span>
|
||||||
|
<n-input v-model:value="form.title" placeholder="比如 字符串方法" maxlength="200" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">说明</span>
|
||||||
|
<n-input v-model:value="form.description" placeholder="选填,比如:第 5 章 字符串方法" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field top">
|
||||||
|
<span class="label">布置</span>
|
||||||
|
<div class="stack">
|
||||||
|
<n-flex :size="6" align="center">
|
||||||
|
<button class="chip" :class="{ on: choice === 'none' }" @click="choice = 'none'">
|
||||||
|
不布置
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="c in CHOICES"
|
||||||
|
:key="c.days"
|
||||||
|
class="chip"
|
||||||
|
:class="{ on: choice === c.days }"
|
||||||
|
@click="choice = c.days"
|
||||||
|
>
|
||||||
|
{{ c.label }}
|
||||||
|
</button>
|
||||||
|
<button class="chip" :class="{ on: choice === 'pick' }" @click="choice = 'pick'">
|
||||||
|
选日期…
|
||||||
|
</button>
|
||||||
|
<n-date-picker
|
||||||
|
v-if="choice === 'pick'"
|
||||||
|
v-model:value="picked"
|
||||||
|
type="date"
|
||||||
|
:is-date-disabled="disabledDate"
|
||||||
|
style="width: 150px"
|
||||||
|
/>
|
||||||
|
</n-flex>
|
||||||
|
<span class="hint">{{ assignHint }}</span>
|
||||||
|
<span class="hint">
|
||||||
|
布置期内,题单里这几道题学生以前交的代码先藏起来(不管加没加入),在题单里做对或者到期后再放出来,防止照着旧答案抄。不拦做题,到期后照样能做、照样算进度。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">公开</span>
|
||||||
|
<n-switch v-model:value="form.visible" />
|
||||||
|
<span class="hint">{{
|
||||||
|
form.visible ? "学生能在题单列表里看到" : "藏起来,学生看不到也进不去"
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="current" class="stats">
|
||||||
|
<span>{{ current.participantCount }} 人加入 · {{ current.completedCount }} 人做完</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<a :href="`/problemset/${current.id}?tab=class`" target="_blank">看全班情况 ›</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="save">
|
||||||
|
<n-button type="primary" :loading="saving" @click="submit">保存</n-button>
|
||||||
|
<span class="hint"
|
||||||
|
>{{ items.length }} 道题<template v-if="optionalCount">
|
||||||
|
· {{ optionalCount }} 道选做</template
|
||||||
|
><template v-if="editing"> · {{ badges.length }} 个奖章</template></span
|
||||||
|
>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<n-button v-if="editing" text type="error" @click="removeSet">删除题单</n-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="right">
|
||||||
|
<section class="card picker">
|
||||||
|
<div class="picker-head">
|
||||||
|
<b>题目</b>
|
||||||
|
<span class="hint">{{ items.length }} 道 · 拖动排顺序 · 点「必做」切成选做</span>
|
||||||
|
</div>
|
||||||
|
<n-input
|
||||||
|
v-model:value="keyword"
|
||||||
|
clearable
|
||||||
|
:loading="searching"
|
||||||
|
placeholder="搜题号或题目名;也能一次粘贴多个题号,如 3065 3066 3067"
|
||||||
|
@keyup.enter="pasted.length && addMany(pasted)"
|
||||||
|
/>
|
||||||
|
<div v-if="pasted.length" class="results">
|
||||||
|
<div class="result">
|
||||||
|
<span class="grow">粘贴了 {{ pasted.length }} 个题号:{{ pasted.join("、") }}</span>
|
||||||
|
<n-button size="small" type="primary" @click="addMany(pasted)">全部加进来</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-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 v-if="added(row._id)" class="muted tiny">已加入</span>
|
||||||
|
<n-button v-else size="small" text type="primary" @click="addRow(row)">+ 加入</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="items.length" class="list">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in items"
|
||||||
|
:key="item.displayId"
|
||||||
|
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 idx">{{ index + 1 }}</b>
|
||||||
|
<span class="muted num tiny pid">{{ item.displayId }}</span>
|
||||||
|
<span class="ell grow">{{ item.title }}</span>
|
||||||
|
<button
|
||||||
|
class="chip small"
|
||||||
|
:class="{ dashed: item.isRequired }"
|
||||||
|
:title="item.isRequired ? '点一下改成选做' : '点一下改回必做'"
|
||||||
|
@click="toggleRequired(item)"
|
||||||
|
>
|
||||||
|
{{ item.isRequired ? "必做" : "选做" }}
|
||||||
|
</button>
|
||||||
|
<n-button size="small" text :aria-label="`拿掉 ${item.title}`" @click="remove(item)"
|
||||||
|
>✕</n-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty hint">还没加题。在上面输入题号,比如 3065</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card picker">
|
||||||
|
<div class="picker-head">
|
||||||
|
<b>奖章</b>
|
||||||
|
<span class="hint">学生做对几道就发;改了条件会按现有进度重发</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<n-button v-if="editing" size="small" @click="openBadge(null)">+ 加奖章</n-button>
|
||||||
|
</div>
|
||||||
|
<div v-if="!editing" class="empty hint">先保存题单,再加奖章</div>
|
||||||
|
<div v-else-if="badges.length" class="list">
|
||||||
|
<div v-for="badge in badges" :key="badge.id" class="item badge">
|
||||||
|
<img :src="badge.icon" alt="" class="badge-icon" />
|
||||||
|
<b class="ell badge-name">{{ badge.name }}</b>
|
||||||
|
<span class="chip small static">{{ badgeCondition(badge) }}</span>
|
||||||
|
<span class="muted ell grow tiny">{{ badge.description }}</span>
|
||||||
|
<span class="muted num tiny">{{ badge.earnedCount }} 人拿到</span>
|
||||||
|
<n-button size="small" text type="primary" @click="openBadge(badge)">改</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
:aria-label="`删掉 ${badge.name}`"
|
||||||
|
@click="removeBadge(badge)"
|
||||||
|
>✕</n-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty hint">还没有奖章。比如「做对 3 道」发一个、「全部做完」发一个</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BadgeModal
|
||||||
|
v-model:show="badgeModal"
|
||||||
|
:badge="editingBadge"
|
||||||
|
:problems-count="items.length"
|
||||||
|
@save="saveBadge"
|
||||||
|
/>
|
||||||
</template>
|
</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 420px;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
flex: 999 1 480px;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-head b {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.top {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.top .label {
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
width: 40px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
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;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.on {
|
||||||
|
border-color: v-bind("theme.primaryColor");
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.small {
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.dashed {
|
||||||
|
border-style: dashed;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.static {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats a {
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
margin-top: -4px;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
height: 36px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result + .result {
|
||||||
|
border-top: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
height: 36px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.item + .item {
|
||||||
|
border-top: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.badge {
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grip {
|
||||||
|
cursor: grab;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
letter-spacing: -3px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.idx {
|
||||||
|
width: 20px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pid {
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-icon {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-name {
|
||||||
|
width: 80px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 16px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,87 +5,68 @@ import { usePagination } from "shared/composables/pagination"
|
|||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import type { AdminProblemSet } from "utils/types"
|
import type { AdminProblemSet } from "utils/types"
|
||||||
import { getProblemSetList, toggleProblemSetVisible } from "../api"
|
import { getProblemSetList, toggleProblemSetVisible } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import { NButton, NSwitch, NTag } from "naive-ui"
|
||||||
import { NTag, NSwitch } from "naive-ui"
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台题单列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」),
|
||||||
|
* 换成老师真正关心的:几道题、多少人加入 / 做完、布置到哪天。点一行进一屏编辑
|
||||||
|
*/
|
||||||
|
const router = useRouter()
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const problemSets = ref<AdminProblemSet[]>([])
|
const problemSets = ref<AdminProblemSet[]>([])
|
||||||
|
|
||||||
interface ProblemSetQuery {
|
const { query, clearQuery } = usePagination<{ keyword: string }>({ keyword: "" })
|
||||||
keyword: string
|
|
||||||
difficulty: string
|
function edit(row: AdminProblemSet) {
|
||||||
status: string
|
router.push({ name: "admin problemset edit", params: { problemSetId: row.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用分页 composable
|
|
||||||
const { query, clearQuery } = usePagination<ProblemSetQuery>({
|
|
||||||
keyword: "",
|
|
||||||
difficulty: "",
|
|
||||||
status: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
const difficultyOptions = [
|
|
||||||
{ label: "全部", value: "" },
|
|
||||||
{ label: "简单", value: "Easy" },
|
|
||||||
{ label: "中等", value: "Medium" },
|
|
||||||
{ label: "困难", value: "Hard" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ label: "全部", value: "" },
|
|
||||||
{ label: "活跃", value: "active" },
|
|
||||||
{ label: "已归档", value: "archived" },
|
|
||||||
{ label: "草稿", value: "draft" },
|
|
||||||
]
|
|
||||||
|
|
||||||
const columns: DataTableColumn<AdminProblemSet>[] = [
|
const columns: DataTableColumn<AdminProblemSet>[] = [
|
||||||
{ title: "ID", key: "id", width: 80 },
|
{ title: "ID", key: "id", width: 70 },
|
||||||
{ title: "标题", key: "title", minWidth: 200 },
|
{
|
||||||
{ title: "描述", key: "description", minWidth: 300, ellipsis: true },
|
title: "题单",
|
||||||
|
key: "title",
|
||||||
|
minWidth: 200,
|
||||||
|
render: (row) =>
|
||||||
|
h(NButton, { text: true, onClick: () => edit(row) }, { default: () => row.title }),
|
||||||
|
},
|
||||||
|
{ title: "题数", key: "problemsCount", width: 70 },
|
||||||
|
{
|
||||||
|
title: "加入 / 做完",
|
||||||
|
key: "participantCount",
|
||||||
|
width: 110,
|
||||||
|
render: (row) => `${row.participantCount} / ${row.completedCount}`,
|
||||||
|
},
|
||||||
|
{ title: "奖章", key: "badgeCount", width: 70 },
|
||||||
|
{
|
||||||
|
title: "布置",
|
||||||
|
key: "assignedUntil",
|
||||||
|
width: 130,
|
||||||
|
render: (row) =>
|
||||||
|
row.assigning
|
||||||
|
? h(
|
||||||
|
NTag,
|
||||||
|
{ type: "info", size: "small", bordered: false },
|
||||||
|
{ default: () => `到 ${parseTime(row.assignedUntil!, "M月D日")}` },
|
||||||
|
)
|
||||||
|
: "—",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "创建者",
|
title: "创建者",
|
||||||
key: "created_by",
|
key: "createdBy",
|
||||||
width: 120,
|
width: 110,
|
||||||
render: (row) => row.createdBy.username,
|
render: (row) => row.createdBy.username,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "难度",
|
|
||||||
key: "difficulty",
|
|
||||||
width: 100,
|
|
||||||
render: (row) => {
|
|
||||||
const difficultyMap = {
|
|
||||||
Easy: { type: "success" as const, text: "简单" },
|
|
||||||
Medium: { type: "warning" as const, text: "中等" },
|
|
||||||
Hard: { type: "error" as const, text: "困难" },
|
|
||||||
}
|
|
||||||
const config = difficultyMap[row.difficulty]
|
|
||||||
return h(NTag, { type: config.type, size: "small" }, { default: () => config.text })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "状态",
|
|
||||||
key: "status",
|
|
||||||
width: 100,
|
|
||||||
render: (row) => {
|
|
||||||
const statusMap = {
|
|
||||||
active: { type: "success" as const, text: "活跃" },
|
|
||||||
archived: { type: "default" as const, text: "已归档" },
|
|
||||||
draft: { type: "info" as const, text: "草稿" },
|
|
||||||
}
|
|
||||||
const config = statusMap[row.status]
|
|
||||||
return h(NTag, { type: config.type, size: "small" }, { default: () => config.text })
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "创建时间",
|
title: "创建时间",
|
||||||
key: "create_time",
|
key: "createTime",
|
||||||
width: 180,
|
width: 120,
|
||||||
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
|
render: (row) => parseTime(row.createTime, "YYYY-MM-DD"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "可见",
|
title: "公开",
|
||||||
key: "visible",
|
key: "visible",
|
||||||
width: 100,
|
width: 80,
|
||||||
render: (row) =>
|
render: (row) =>
|
||||||
h(NSwitch, {
|
h(NSwitch, {
|
||||||
value: row.visible,
|
value: row.visible,
|
||||||
@@ -94,84 +75,41 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
|
|||||||
onUpdateValue: () => toggleVisible(row.id),
|
onUpdateValue: () => toggleVisible(row.id),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "选项",
|
|
||||||
key: "actions",
|
|
||||||
fixed: "right",
|
|
||||||
width: 300,
|
|
||||||
render: (row) =>
|
|
||||||
h(Actions, {
|
|
||||||
problemSetId: row.id,
|
|
||||||
onUpdated: listProblemSets,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
async function listProblemSets() {
|
async function listProblemSets() {
|
||||||
if (query.page < 1) query.page = 1
|
if (query.page < 1) query.page = 1
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
const res = await getProblemSetList(
|
const res = await getProblemSetList(offset, query.limit, query.keyword)
|
||||||
offset,
|
|
||||||
query.limit,
|
|
||||||
query.keyword,
|
|
||||||
query.difficulty,
|
|
||||||
query.status,
|
|
||||||
)
|
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
problemSets.value = res.results
|
problemSets.value = res.results
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleVisible(problemSetId: number) {
|
async function toggleVisible(problemSetId: number) {
|
||||||
await toggleProblemSetVisible(problemSetId)
|
const updated = await toggleProblemSetVisible(problemSetId)
|
||||||
problemSets.value = problemSets.value.map((it) => {
|
problemSets.value = problemSets.value.map((it) => (it.id === problemSetId ? updated : it))
|
||||||
if (it.id === problemSetId) {
|
|
||||||
it.visible = !it.visible
|
|
||||||
}
|
|
||||||
return it
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(listProblemSets)
|
onMounted(listProblemSets)
|
||||||
|
watchDebounced(() => query.keyword, listProblemSets, { debounce: 500, maxWait: 1000 })
|
||||||
// 监听搜索关键词变化(防抖)
|
watch(() => [query.page, query.limit], listProblemSets)
|
||||||
watchDebounced(() => query.keyword, listProblemSets, {
|
|
||||||
debounce: 500,
|
|
||||||
maxWait: 1000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听其他查询条件变化
|
|
||||||
watch(() => [query.page, query.limit, query.difficulty, query.status], listProblemSets)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHeader title="题单列表">
|
<PageHeader title="题单列表">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<n-button type="primary" @click="$router.push({ name: 'admin problemset create' })">
|
<n-button type="primary" @click="router.push({ name: 'admin problemset create' })">
|
||||||
新建题单
|
新建题单
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="query.keyword"
|
v-model:value="query.keyword"
|
||||||
placeholder="输入标题关键字"
|
placeholder="搜题单名字"
|
||||||
clearable
|
clearable
|
||||||
@clear="clearQuery"
|
@clear="clearQuery"
|
||||||
style="width: 220px"
|
style="width: 220px"
|
||||||
/>
|
/>
|
||||||
<n-select
|
|
||||||
v-model:value="query.difficulty"
|
|
||||||
:options="difficultyOptions"
|
|
||||||
placeholder="全部难度"
|
|
||||||
style="width: 120px"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
<n-select
|
|
||||||
v-model:value="query.status"
|
|
||||||
:options="statusOptions"
|
|
||||||
placeholder="全部状态"
|
|
||||||
style="width: 120px"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<n-data-table striped :columns="columns" :data="problemSets" />
|
<n-data-table striped :columns="columns" :data="problemSets" />
|
||||||
|
|||||||
+15
-25
@@ -36,10 +36,10 @@ import {
|
|||||||
type HeatmapItem,
|
type HeatmapItem,
|
||||||
type SolvedList,
|
type SolvedList,
|
||||||
type ProblemSet,
|
type ProblemSet,
|
||||||
type ProblemSetBadge,
|
type ProblemSetClassView,
|
||||||
type ProblemSetList,
|
type ProblemSetList,
|
||||||
|
type ProblemSetLock,
|
||||||
type ProblemSetProblem,
|
type ProblemSetProblem,
|
||||||
type ProblemSetProgressList,
|
|
||||||
type UserBadge,
|
type UserBadge,
|
||||||
type FlowchartStatistics,
|
type FlowchartStatistics,
|
||||||
type SubmissionStatistics,
|
type SubmissionStatistics,
|
||||||
@@ -482,23 +482,15 @@ export function retryFlowchartSubmission(submissionId: string) {
|
|||||||
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
|
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 自己在这道题上评完的每一次(早的在前),加入题单之前的已经滤掉 */
|
/** 自己在这道题上评完的每一次(早的在前),题单布置期内藏着的已经滤掉 */
|
||||||
export function getFlowchartScores(problemId: number) {
|
export function getFlowchartScores(problemId: number) {
|
||||||
return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`)
|
return api.get<FlowchartScores>(`problems/${problemId}/flowchart/scores`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 题单相关API ====================
|
// ==================== 题单相关API ====================
|
||||||
|
|
||||||
export function getProblemSetList(
|
export function getProblemSetList(offset = 0, limit = 10, keyword = "") {
|
||||||
offset = 0,
|
return api.get<ProblemSetList>("problem-sets", { params: { offset, limit, keyword } })
|
||||||
limit = 10,
|
|
||||||
keyword = "",
|
|
||||||
difficulty = "",
|
|
||||||
status = "",
|
|
||||||
) {
|
|
||||||
return api.get<ProblemSetList>("problem-sets", {
|
|
||||||
params: { offset, limit, keyword, difficulty, status },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProblemSetDetail(id: number) {
|
export function getProblemSetDetail(id: number) {
|
||||||
@@ -517,20 +509,18 @@ export function getUserBadges(username?: string) {
|
|||||||
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? "me")}/badges`)
|
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? "me")}/badges`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProblemSetBadges(problemSetId: number) {
|
/** 老师看某个班在题单里的情况;不传班级就看服务端推断的那个班 */
|
||||||
return api.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
|
export function getProblemSetClassView(problemSetId: number, className = "") {
|
||||||
|
return api.get<ProblemSetClassView>(`problem-sets/${problemSetId}/class-view`, {
|
||||||
|
params: className ? { className } : {},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProblemSetUserProgress(
|
/** 这道题以前的代码为什么藏着:落在哪些正在布置的题单里。传主键或题号都行 */
|
||||||
problemSetId: number,
|
export function getProblemSetLocks(problem: { id: number } | { displayId: string }) {
|
||||||
params?: {
|
return api.get<ProblemSetLock[]>("problem-sets/locks", {
|
||||||
limit?: number
|
params: "id" in problem ? { problemId: problem.id } : { problemDisplayId: problem.displayId },
|
||||||
offset?: number
|
})
|
||||||
className?: string
|
|
||||||
completionStatus?: "" | "completed" | "in_progress" | "not_started"
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return api.get<ProblemSetProgressList>(`problem-sets/${problemSetId}/user-progress`, { params })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExercises(tutorialId: number): Promise<Exercise[]> {
|
export function getExercises(tutorialId: number): Promise<Exercise[]> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import type { RouteLocationRaw } from "vue-router"
|
import type { RouteLocationRaw } from "vue-router"
|
||||||
import { getProblemSetDetail, getProblemSetProblems } from "oj/api"
|
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from "oj/api"
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
|
import { isFlowchartPass, useFlowchartStore } from "oj/store/flowchart"
|
||||||
import { useLessonStore } from "oj/store/lesson"
|
import { useLessonStore } from "oj/store/lesson"
|
||||||
@@ -161,6 +161,7 @@ const setBar = computed(() => {
|
|||||||
const done = required.filter((item) => item.isCompleted).length
|
const done = required.filter((item) => item.isCompleted).length
|
||||||
return {
|
return {
|
||||||
title: problemSet.value.title,
|
title: problemSet.value.title,
|
||||||
|
joined: problemSet.value.userProgress.isJoined,
|
||||||
home: { name: "problemset", params: { problemSetId: ctx.value.problemSetId } },
|
home: { name: "problemset", params: { problemSetId: ctx.value.problemSetId } },
|
||||||
done,
|
done,
|
||||||
total: required.length,
|
total: required.length,
|
||||||
@@ -171,6 +172,22 @@ const setBar = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 没加入就点进了题(直接打开的链接):在条上给个「加入」,不然做对了也不记进度
|
||||||
|
const joiningSet = ref(false)
|
||||||
|
async function joinHere() {
|
||||||
|
const id = ctx.value.problemSetId
|
||||||
|
if (!id || joiningSet.value) return
|
||||||
|
joiningSet.value = true
|
||||||
|
try {
|
||||||
|
await joinProblemSet(Number(id))
|
||||||
|
problemSet.value = await getProblemSetDetail(Number(id))
|
||||||
|
} catch {
|
||||||
|
// 加入失败就还是那条提示,回题单页再点一次
|
||||||
|
} finally {
|
||||||
|
joiningSet.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 判题那一路已经记了账(judge/run.ts),这里只是把这一条就地改掉,不用重拉
|
// 判题那一路已经记了账(judge/run.ts),这里只是把这一条就地改掉,不用重拉
|
||||||
onSolvedHere(() => {
|
onSolvedHere(() => {
|
||||||
const item = setProblems.value.find(
|
const item = setProblems.value.find(
|
||||||
@@ -294,13 +311,19 @@ onSolvedHere(() => {
|
|||||||
<router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`">
|
<router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`">
|
||||||
‹ {{ setBar.title }}
|
‹ {{ setBar.title }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<div class="set-progress" aria-hidden="true">
|
<div v-if="setBar.joined" class="set-progress" aria-hidden="true">
|
||||||
<div class="set-progress-fill" :style="{ width: `${setBar.percentage}%` }" />
|
<div class="set-progress-fill" :style="{ width: `${setBar.percentage}%` }" />
|
||||||
</div>
|
</div>
|
||||||
<span class="meta">
|
<span v-if="setBar.joined" class="meta">
|
||||||
做完 {{ setBar.done }}/{{ setBar.total }}
|
做完 {{ setBar.done }}/{{ setBar.total }}
|
||||||
<template v-if="setBar.position"> · 第 {{ setBar.position }} 题</template>
|
<template v-if="setBar.position"> · 第 {{ setBar.position }} 题</template>
|
||||||
</span>
|
</span>
|
||||||
|
<template v-else>
|
||||||
|
<span class="meta">还没加入,在这里做对了不算进度</span>
|
||||||
|
<n-button size="tiny" type="primary" :loading="joiningSet" @click="joinHere"
|
||||||
|
>加入题单</n-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<component
|
<component
|
||||||
:is="setBar.previous ? 'router-link' : 'span'"
|
:is="setBar.previous ? 'router-link' : 'span'"
|
||||||
|
|||||||
@@ -236,7 +236,8 @@ function goCode() {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="hiddenCount" class="note">
|
<p v-if="hiddenCount" class="note">
|
||||||
另外 {{ hiddenCount }} 次是加入题单之前画的,先藏起来了:在题单里画到 A 或 S 就解锁
|
另外 {{ hiddenCount }} 次是题单布置之前画的,先藏起来了:在题单里画到 A 或
|
||||||
|
S,或者布置期结束就解锁
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- 正在评:评的是最新这一次,所以只在看最新时显示 -->
|
<!-- 正在评:评的是最新这一次,所以只在看最新时显示 -->
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useThemeVars } from "naive-ui"
|
|||||||
import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api"
|
import { getRankOfProblem, getSubmission, getSubmissions } from "oj/api"
|
||||||
import { useCodeStore } from "oj/store/code"
|
import { useCodeStore } from "oj/store/code"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
|
import LockNote from "oj/problemset/components/LockNote.vue"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import { useCollabStore } from "shared/store/collab"
|
import { useCollabStore } from "shared/store/collab"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
@@ -139,7 +140,7 @@ type Loaded = { code: string; language: LANGUAGE }
|
|||||||
const details = reactive<Record<string, Loaded | "loading" | "failed">>({})
|
const details = reactive<Record<string, Loaded | "loading" | "failed">>({})
|
||||||
|
|
||||||
async function toggle(row: SubmissionListItem) {
|
async function toggle(row: SubmissionListItem) {
|
||||||
// 加入题单之前的提交被锁住,点不开(后端也不会给代码)
|
// 题单布置期内藏着的旧提交,点不开(后端也不会给代码)
|
||||||
if (!row.showLink) return
|
if (!row.showLink) return
|
||||||
if (expanded.value === row.id) {
|
if (expanded.value === row.id) {
|
||||||
expanded.value = null
|
expanded.value = null
|
||||||
@@ -332,7 +333,7 @@ watch(query, () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- 加入题单之前交的那几次:合成一行,点不开(后端也不给代码) -->
|
<!-- 题单布置期内藏着的那几次:合成一行,点不开(后端也不给代码) -->
|
||||||
<div v-if="lockedCount" class="locked">
|
<div v-if="lockedCount" class="locked">
|
||||||
<svg
|
<svg
|
||||||
width="13"
|
width="13"
|
||||||
@@ -349,8 +350,9 @@ watch(query, () => {
|
|||||||
<path d="M8 11V8a4 4 0 018 0v3" />
|
<path d="M8 11V8a4 4 0 018 0v3" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>
|
<span>
|
||||||
加入题单之前的
|
有 {{ lockedCount }} 次提交先藏起来了:<LockNote
|
||||||
{{ lockedCount }} 次提交先藏起来了,在题单里做出这道题就能看(题单过了截止时间也会解锁)
|
:problem="{ displayId: problemDisplayId }"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { ProblemSet, ProblemSetBadge } from "utils/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 奖章的两种条件和后端 eligibleForBadge 同一个口径:
|
||||||
|
* - 全部做完:必做题全对(completedCount === totalCount)
|
||||||
|
* - 做对 N 道:做对的全部题数(含选做,solvedCount)≥ N
|
||||||
|
*/
|
||||||
|
export function badgeCondition(badge: ProblemSetBadge) {
|
||||||
|
return badge.conditionType === "all_problems" ? "全部做完" : `做对 ${badge.conditionValue} 道`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拿到这枚奖章还差几道;已经拿到为 0 */
|
||||||
|
export function badgeShortfall(badge: ProblemSetBadge, set: ProblemSet) {
|
||||||
|
if (badge.isEarned) return 0
|
||||||
|
const progress = set.userProgress
|
||||||
|
const remaining =
|
||||||
|
badge.conditionType === "all_problems"
|
||||||
|
? set.requiredCount - progress.completedCount
|
||||||
|
: badge.conditionValue - progress.solvedCount
|
||||||
|
return Math.max(1, remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阶梯顺序:按要做对的题数从少到多,「全部做完」排最后 */
|
||||||
|
export function ladder(set: ProblemSet) {
|
||||||
|
const need = (badge: ProblemSetBadge) =>
|
||||||
|
badge.conditionType === "all_problems" ? Number.MAX_SAFE_INTEGER : badge.conditionValue
|
||||||
|
return [...set.badges].sort((a, b) => need(a) - need(b) || a.id - b.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下一枚最近的奖章和还差几道;全拿到了(或者没有奖章)为 null */
|
||||||
|
export function nextBadge(set: ProblemSet) {
|
||||||
|
const pending = ladder(set).filter((badge) => !badge.isEarned)
|
||||||
|
if (pending.length === 0) return null
|
||||||
|
const scored = pending.map((badge) => ({ badge, left: badgeShortfall(badge, set) }))
|
||||||
|
scored.sort((a, b) => a.left - b.left)
|
||||||
|
return scored[0]!
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
|
import type { ProblemSet } from "utils/types"
|
||||||
|
import { badgeCondition, badgeShortfall, ladder, nextBadge } from "../badges"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 奖章阶梯(设计稿「题单重设计」详情 B):一排奖章连成一条线,拿到的亮、写哪天拿的;
|
||||||
|
* 下一个要拿的套金圈、写「再做对 n 道」;更远的灰着、写条件。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ set: ProblemSet }>()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const tone = useTone()
|
||||||
|
|
||||||
|
const steps = computed(() => {
|
||||||
|
const next = nextBadge(props.set)?.badge.id
|
||||||
|
return ladder(props.set).map((badge) => ({
|
||||||
|
badge,
|
||||||
|
next: badge.id === next,
|
||||||
|
sub: badge.isEarned
|
||||||
|
? badge.earnedTime
|
||||||
|
? parseTime(badge.earnedTime, "M月D日")
|
||||||
|
: "拿到了"
|
||||||
|
: badge.id === next && props.set.userProgress.isJoined
|
||||||
|
? `再做对 ${badgeShortfall(badge, props.set)} 道`
|
||||||
|
: badgeCondition(badge),
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 连线亮到最后一枚拿到的奖章那里 */
|
||||||
|
const filled = computed(() => {
|
||||||
|
const last = steps.value.map((step) => step.badge.isEarned).lastIndexOf(true)
|
||||||
|
return steps.value.length > 1 && last > 0 ? last / (steps.value.length - 1) : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const warning = computed(() => tone("warning"))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ladder" :style="{ width: `${steps.length * 92}px` }">
|
||||||
|
<div v-if="steps.length > 1" class="track">
|
||||||
|
<div class="track-fill" :style="{ width: `${filled * 100}%` }"></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="step in steps"
|
||||||
|
:key="step.badge.id"
|
||||||
|
class="step"
|
||||||
|
:title="`${step.badge.name} · ${badgeCondition(step.badge)}${step.badge.description ? `\n${step.badge.description}` : ''}`"
|
||||||
|
>
|
||||||
|
<span class="medal" :class="{ next: step.next }">
|
||||||
|
<img
|
||||||
|
:src="step.badge.icon"
|
||||||
|
:alt="step.badge.name"
|
||||||
|
:class="{ locked: !step.badge.isEarned }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<b class="name" :class="{ dim: !step.badge.isEarned }">{{ step.badge.name }}</b>
|
||||||
|
<span class="sub" :class="{ urge: step.next && !step.badge.isEarned }">{{ step.sub }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ladder {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track {
|
||||||
|
position: absolute;
|
||||||
|
left: 46px;
|
||||||
|
right: 46px;
|
||||||
|
top: 21px;
|
||||||
|
height: 3px;
|
||||||
|
background: v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: v-bind("theme.successColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
position: relative;
|
||||||
|
width: 92px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medal {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medal.next {
|
||||||
|
box-shadow: 0 0 0 2px #f0b44c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.medal img {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked {
|
||||||
|
filter: grayscale(1);
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
max-width: 88px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name.dim {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub {
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub.urge {
|
||||||
|
color: v-bind("warning.color");
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { getProblemSetClassView } from "oj/api"
|
||||||
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
|
import type { ProblemSetClassView } from "utils/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 老师看一个班在题单里的情况(设计稿「题单重设计」老师 · 全班情况):真名 × 每道题,
|
||||||
|
* 格子里写几点做对 / 错了几次;紫色角 = 加入之前就做对过(可能是凭记忆默写的)。
|
||||||
|
* 没加入的人在上面点名。班级默认是服务端推断的(最近在做的那个班),可以换。
|
||||||
|
* 点格子到提交列表看这个学生这道题的代码。
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ problemSetId: number }>()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const tone = useTone()
|
||||||
|
|
||||||
|
const data = ref<ProblemSetClassView | null>(null)
|
||||||
|
const className = ref("")
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
data.value = await getProblemSetClassView(props.problemSetId, className.value)
|
||||||
|
className.value = data.value.className ?? ""
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
watch(className, (value, old) => {
|
||||||
|
if (old && value !== data.value?.className) load()
|
||||||
|
})
|
||||||
|
|
||||||
|
function classLabel(name: string) {
|
||||||
|
return /^\d{3,}$/.test(name) ? `${name.slice(0, 2)}计算机${name.slice(2)}班` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
const classOptions = computed(() =>
|
||||||
|
(data.value?.classes ?? []).map((item) => ({
|
||||||
|
value: item.className,
|
||||||
|
label: `${classLabel(item.className)}(${item.joined}/${item.size} 人加入)`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const joined = computed(() => data.value?.students.filter((s) => s.joinTime) ?? [])
|
||||||
|
const absent = computed(() => data.value?.students.filter((s) => !s.joinTime) ?? [])
|
||||||
|
const average = computed(() =>
|
||||||
|
joined.value.length
|
||||||
|
? joined.value.reduce((sum, s) => sum + s.completedCount, 0) / joined.value.length
|
||||||
|
: 0,
|
||||||
|
)
|
||||||
|
const finished = computed(
|
||||||
|
() => joined.value.filter((s) => data.value && s.completedCount >= data.value.totalCount).length,
|
||||||
|
)
|
||||||
|
const beforeCount = computed(() =>
|
||||||
|
joined.value.reduce((sum, s) => sum + s.cells.filter((cell) => cell.solvedBefore).length, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
const columns = computed(
|
||||||
|
() => `22px 72px 44px repeat(${data.value?.problems.length ?? 1}, minmax(72px, 1fr))`,
|
||||||
|
)
|
||||||
|
|
||||||
|
function name(student: { realName: string | null; username: string }) {
|
||||||
|
return student.realName || student.username
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellLink(username: string, problemId: string) {
|
||||||
|
return `/submission?username=${encodeURIComponent(username)}&problem=${encodeURIComponent(problemId)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellTime(time: string) {
|
||||||
|
const today = parseTime(new Date(), "YYYY-MM-DD") === parseTime(time, "YYYY-MM-DD")
|
||||||
|
return parseTime(time, today ? "HH:mm" : "M/D")
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = computed(() => tone("success"))
|
||||||
|
const warning = computed(() => tone("warning"))
|
||||||
|
const error = computed(() => tone("error"))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="card">
|
||||||
|
<div v-if="data && !data.className" class="empty muted">还没有学生加入这个题单</div>
|
||||||
|
<template v-else-if="data">
|
||||||
|
<div class="summary">
|
||||||
|
<n-select
|
||||||
|
v-model:value="className"
|
||||||
|
:options="classOptions"
|
||||||
|
size="small"
|
||||||
|
class="pick"
|
||||||
|
:consistent-menu-width="false"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
><b class="num big">{{ joined.length }}</b
|
||||||
|
><span class="muted"> / {{ data.students.length }} 人加入</span></span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
><span class="muted">平均做对</span> <b class="num big">{{ average.toFixed(1) }}</b
|
||||||
|
><span class="muted"> / {{ data.totalCount }} 道</span></span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
><span class="muted">做完</span> <b class="num big">{{ finished }}</b
|
||||||
|
><span class="muted"> 人</span></span
|
||||||
|
>
|
||||||
|
<span v-if="absent.length" class="absent ell" :title="absent.map(name).join('、')"
|
||||||
|
>没加入:{{ absent.map(name).join("、") }}</span
|
||||||
|
>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span v-if="beforeCount" class="legend-before"
|
||||||
|
><span class="corner"></span>加入前就做对过({{ beforeCount }} 格)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scroll">
|
||||||
|
<div class="grid" :style="{ gridTemplateColumns: columns }">
|
||||||
|
<div class="head"></div>
|
||||||
|
<div class="head">姓名</div>
|
||||||
|
<div class="head right">做对</div>
|
||||||
|
<div v-for="(p, i) in data.problems" :key="p.id" class="head col" :title="p.title">
|
||||||
|
<b class="ell">{{ i + 1 }} {{ p.title }}</b>
|
||||||
|
<span class="num"
|
||||||
|
>{{ p.solved }} 人对<template v-if="!p.isRequired"> · 选做</template></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-for="(s, idx) in joined" :key="s.userId">
|
||||||
|
<div class="cell muted num tiny">{{ idx + 1 }}</div>
|
||||||
|
<div class="cell ell" :title="s.username">
|
||||||
|
<b>{{ name(s) }}</b>
|
||||||
|
</div>
|
||||||
|
<div class="cell right num">
|
||||||
|
<b>{{ s.completedCount }}</b>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-for="(c, i) in s.cells"
|
||||||
|
:key="i"
|
||||||
|
:href="cellLink(s.username, data.problems[i]!._id)"
|
||||||
|
target="_blank"
|
||||||
|
class="box"
|
||||||
|
:class="{ ok: c.solvedTime, bad: !c.solvedTime && c.wrongCount }"
|
||||||
|
:title="`${name(s)} · ${data.problems[i]!.title}${c.solvedBefore ? ' · 加入前就做对过' : ''}`"
|
||||||
|
>
|
||||||
|
<template v-if="c.solvedTime"
|
||||||
|
>✓ <span class="num">{{ cellTime(c.solvedTime) }}</span></template
|
||||||
|
>
|
||||||
|
<template v-else-if="c.wrongCount">错 {{ c.wrongCount }} 次</template>
|
||||||
|
<span v-if="c.solvedBefore" class="corner"></span>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="foot muted">
|
||||||
|
<span>绿 = 在题单里做对(几点做对的)</span><span>黄 = 交过还没对</span
|
||||||
|
><span>紫色角 = 加入前就做对过</span><span>点格子看这个学生这道题的提交</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span>按学号排</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="empty"><n-spin size="small" /></div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 6px;
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
min-height: 52px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.pick {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.absent {
|
||||||
|
max-width: 420px;
|
||||||
|
color: v-bind("error.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-before {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #5b3fa8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-before .corner {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
column-gap: 6px;
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head.col {
|
||||||
|
align-items: center;
|
||||||
|
line-height: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head.col b {
|
||||||
|
max-width: 100%;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
text-align: right;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.right {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box {
|
||||||
|
position: relative;
|
||||||
|
height: 26px;
|
||||||
|
margin: 3px 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.box.ok {
|
||||||
|
background: v-bind("success.background");
|
||||||
|
color: v-bind("success.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.box.bad {
|
||||||
|
background: v-bind("warning.background");
|
||||||
|
color: v-bind("warning.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.box:hover {
|
||||||
|
outline: 1px solid v-bind("theme.primaryColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.corner {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 3px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 8px solid #7a5fd0;
|
||||||
|
border-left: 8px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
min-height: 30px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 6px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-top: 1px solid v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiny {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ell {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { getProblemSetLocks } from "oj/api"
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
|
import type { ProblemSetLock } from "utils/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「旧代码为什么藏着」那一句(设计稿「题单重设计」旧代码藏着时学生看到的):
|
||||||
|
* 题单《X》布置中,这道题以前的代码先藏着 · 在题单里做对,或 M月D日 之后就能看。
|
||||||
|
* 规则在后端 problemSetLockCutoffs;查不到是哪个题单(比如刚过期)就说通用的一句
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ problem: { id: number } | { displayId: string } }>()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
|
const locks = ref<ProblemSetLock[]>([])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => ("id" in props.problem ? props.problem.id : props.problem.displayId),
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
locks.value = await getProblemSetLocks(props.problem)
|
||||||
|
} catch {
|
||||||
|
locks.value = []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const first = computed(() => locks.value[0])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span v-if="first">
|
||||||
|
题单<router-link :to="{ name: 'problemset', params: { problemSetId: first.problemSetId } }"
|
||||||
|
>《{{ first.title }}》</router-link
|
||||||
|
>布置中,这道题以前的代码先藏着 · 在题单里做对,或
|
||||||
|
{{ parseTime(first.assignedUntil, "M月D日") }} 之后就能看
|
||||||
|
</span>
|
||||||
|
<span v-else>这道题正在题单里布置,以前的代码先藏着 · 在题单里做对,或者布置期结束就能看</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
a {
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import type { ProblemSet, UserBadge as UserBadgeType } from "utils/types"
|
|
||||||
import { parseTime } from "utils/functions"
|
|
||||||
import UserBadge from "shared/components/UserBadge.vue"
|
|
||||||
import { useUserStore } from "shared/store/user"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
problemSet: ProblemSet
|
|
||||||
isJoined: boolean
|
|
||||||
isJoining: boolean
|
|
||||||
userBadges: UserBadgeType[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: "join"): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
|
|
||||||
function getDifficultyTag(difficulty: string) {
|
|
||||||
const difficultyMap: Record<
|
|
||||||
string,
|
|
||||||
{ type: "success" | "warning" | "error" | "default"; text: string }
|
|
||||||
> = {
|
|
||||||
Easy: { type: "success", text: "简单" },
|
|
||||||
Medium: { type: "warning", text: "中等" },
|
|
||||||
Hard: { type: "error", text: "困难" },
|
|
||||||
}
|
|
||||||
return difficultyMap[difficulty] || { type: "default", text: "未知" }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 进度一律读 userProgress:它的分母是**必做题数**,而 problemsCount 是总题数。
|
|
||||||
// 拿总题数当分母的话,做完全部必做题的人会看到「9 / 10、90%」,而同一张卡片上
|
|
||||||
// 又标着「已完成」—— 题单 6 那 10 个人正是这种。
|
|
||||||
function getProgressPercentage() {
|
|
||||||
return Math.round(props.problemSet?.userProgress?.progressPercentage ?? 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 有选做题时把「必做 N 题」标出来,否则「共 10 道题目」和「9 / 9」对不上
|
|
||||||
const optionalCount = computed(
|
|
||||||
() => props.problemSet.problemsCount - (props.problemSet.userProgress?.totalCount ?? 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
const endTimeText = computed(() =>
|
|
||||||
props.problemSet.endTime ? parseTime(props.problemSet.endTime, "YYYY-MM-DD HH:mm") : "",
|
|
||||||
)
|
|
||||||
|
|
||||||
function handleJoin() {
|
|
||||||
emit("join")
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<n-card style="margin-bottom: 24px">
|
|
||||||
<n-flex justify="space-between" align="center">
|
|
||||||
<n-flex align="center">
|
|
||||||
<n-tag type="warning" v-if="problemSet.status === 'archived'"> 已归档 </n-tag>
|
|
||||||
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
|
|
||||||
{{ getDifficultyTag(problemSet.difficulty).text }}
|
|
||||||
</n-tag>
|
|
||||||
<!-- 截止时间不是「到点不能做了」,是「到点之前看不到自己加入题单之前的旧代码」,
|
|
||||||
所以这里要连着解释一句,否则学生只看到一个日期,不知道它管什么 -->
|
|
||||||
<n-tooltip trigger="hover" v-if="endTimeText">
|
|
||||||
<template #trigger>
|
|
||||||
<n-tag type="info">截止 {{ endTimeText }}</n-tag>
|
|
||||||
</template>
|
|
||||||
这个时间之前,你在加入题单之前提交过的代码是看不到的;在题单里做出该题即可解锁。
|
|
||||||
</n-tooltip>
|
|
||||||
<n-h2 style="margin: 0">{{ problemSet.title }}</n-h2>
|
|
||||||
<n-tooltip trigger="hover" v-if="problemSet.description">
|
|
||||||
<template #trigger>
|
|
||||||
<Icon width="20" icon="fluent-emoji:information" />
|
|
||||||
</template>
|
|
||||||
{{ problemSet.description }}
|
|
||||||
</n-tooltip>
|
|
||||||
</n-flex>
|
|
||||||
|
|
||||||
<n-flex align="center" v-if="userStore.isAuthed">
|
|
||||||
<!-- 用户徽章显示区域 - 只在已加入且有徽章时显示 -->
|
|
||||||
<n-flex v-if="isJoined && userBadges.length > 0" align="center">
|
|
||||||
<n-text>已获徽章</n-text>
|
|
||||||
<UserBadge v-for="badge in userBadges" :key="badge.id" :badge="badge" />
|
|
||||||
</n-flex>
|
|
||||||
|
|
||||||
<!-- 完成进度 - 只在已加入时显示 -->
|
|
||||||
<n-flex align="center" v-if="isJoined">
|
|
||||||
<n-text strong>完成进度</n-text>
|
|
||||||
<n-text>
|
|
||||||
{{ problemSet.userProgress?.completedCount ?? 0 }} /
|
|
||||||
{{ problemSet.userProgress?.totalCount ?? 0 }}
|
|
||||||
</n-text>
|
|
||||||
<n-text depth="3" v-if="optionalCount > 0"> (另有 {{ optionalCount }} 道选做) </n-text>
|
|
||||||
</n-flex>
|
|
||||||
<n-progress
|
|
||||||
v-if="isJoined"
|
|
||||||
:percentage="getProgressPercentage()"
|
|
||||||
:height="8"
|
|
||||||
:border-radius="4"
|
|
||||||
style="width: 200px"
|
|
||||||
/>
|
|
||||||
<n-button
|
|
||||||
v-if="!isJoined"
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
:loading="isJoining"
|
|
||||||
@click="handleJoin"
|
|
||||||
>
|
|
||||||
加入题单
|
|
||||||
</n-button>
|
|
||||||
<n-tag v-else type="success" size="large">
|
|
||||||
<template #icon>
|
|
||||||
<Icon icon="ph:check-circle-fill" />
|
|
||||||
</template>
|
|
||||||
已加入
|
|
||||||
</n-tag>
|
|
||||||
</n-flex>
|
|
||||||
</n-flex>
|
|
||||||
</n-card>
|
|
||||||
</template>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Icon } from "@iconify/vue"
|
|
||||||
import type { ProblemSetProblem } from "utils/types"
|
|
||||||
import { DIFFICULTY } from "utils/constants"
|
|
||||||
import { getTagColor } from "utils/functions"
|
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
problems: ProblemSetProblem[]
|
|
||||||
isJoined: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: "problem-click", problemId: string): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
const { isDesktop } = useBreakpoints()
|
|
||||||
|
|
||||||
function handleProblemClick(problemId: string) {
|
|
||||||
emit("problem-click", problemId)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<n-grid :cols="isDesktop ? 4 : 1" :x-gap="16" :y-gap="16">
|
|
||||||
<n-grid-item v-for="(problemSetProblem, index) in problems" :key="problemSetProblem.id">
|
|
||||||
<n-card
|
|
||||||
hoverable
|
|
||||||
@click="handleProblemClick(problemSetProblem.problem._id)"
|
|
||||||
style="cursor: pointer"
|
|
||||||
>
|
|
||||||
<n-flex align="center">
|
|
||||||
<Icon
|
|
||||||
style="margin-right: 10px"
|
|
||||||
width="48"
|
|
||||||
icon="fluent-emoji:check-mark-button"
|
|
||||||
v-if="problemSetProblem.isCompleted"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-flex vertical style="flex: 1">
|
|
||||||
<n-flex align="center">
|
|
||||||
<n-h4 style="margin: 0">#{{ index + 1 }}</n-h4>
|
|
||||||
<n-h4 style="margin: 0">
|
|
||||||
{{ problemSetProblem.problem.title }}
|
|
||||||
</n-h4>
|
|
||||||
</n-flex>
|
|
||||||
|
|
||||||
<n-flex align="center" size="small">
|
|
||||||
<n-tag
|
|
||||||
v-if="problemSetProblem.problem.difficulty"
|
|
||||||
:type="getTagColor(problemSetProblem.problem.difficulty)"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{{ DIFFICULTY[problemSetProblem.problem.difficulty] }}
|
|
||||||
</n-tag>
|
|
||||||
<n-text type="info">分数:{{ problemSetProblem.score }}</n-text>
|
|
||||||
<n-text v-if="!problemSetProblem.isRequired">(选做)</n-text>
|
|
||||||
</n-flex>
|
|
||||||
</n-flex>
|
|
||||||
</n-flex>
|
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<div class="tip">
|
|
||||||
<n-text depth="3">题目完成后会自动返回题单页面</n-text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tip {
|
|
||||||
padding-top: 24px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { errorMessage } from "utils/api"
|
|
||||||
import { h, computed, ref, onMounted, watch } from "vue"
|
|
||||||
import { watchDebounced } from "@vueuse/core"
|
|
||||||
import { parseTime } from "utils/functions"
|
|
||||||
import type { ProblemSetProgress } from "utils/types"
|
|
||||||
import { getProblemSetUserProgress } from "../../api"
|
|
||||||
import { NFlex, NTag } from "naive-ui"
|
|
||||||
import { usePagination } from "shared/composables/pagination"
|
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const message = useMessage()
|
|
||||||
const problemSetId = computed(() => Number(route.params.problemSetId))
|
|
||||||
const progress = ref<ProblemSetProgress[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const total = ref(0)
|
|
||||||
const statistics = ref<{
|
|
||||||
total: number
|
|
||||||
completed: number
|
|
||||||
avgProgress: number
|
|
||||||
} | null>(null)
|
|
||||||
const classFilter = ref<string>("")
|
|
||||||
const completionFilter = ref<"" | "completed" | "in_progress" | "not_started">("")
|
|
||||||
const allProblems = ref<Array<{ id: number; _id: string; title: string }>>([])
|
|
||||||
|
|
||||||
// 完成度筛选选项
|
|
||||||
const completionOptions = [
|
|
||||||
{ label: "全部", value: "" },
|
|
||||||
{ label: "未开始", value: "not_started" },
|
|
||||||
{ label: "进行中", value: "in_progress" },
|
|
||||||
{ label: "已完成", value: "completed" },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 使用分页 composable
|
|
||||||
const { query } = usePagination({}, { defaultLimit: 50 })
|
|
||||||
|
|
||||||
// 加载用户进度数据
|
|
||||||
async function loadUserProgress() {
|
|
||||||
loading.value = true
|
|
||||||
const offset = (query.page - 1) * query.limit
|
|
||||||
const params: {
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
className?: string
|
|
||||||
completionStatus?: "" | "completed" | "in_progress" | "not_started"
|
|
||||||
} = {
|
|
||||||
limit: query.limit,
|
|
||||||
offset,
|
|
||||||
}
|
|
||||||
if (classFilter.value.trim()) {
|
|
||||||
params.className = classFilter.value.trim()
|
|
||||||
}
|
|
||||||
if (completionFilter.value) {
|
|
||||||
params.completionStatus = completionFilter.value
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await getProblemSetUserProgress(problemSetId.value, params)
|
|
||||||
|
|
||||||
progress.value = res.results
|
|
||||||
total.value = res.total
|
|
||||||
// 使用后端返回的统计数据(基于所有数据)
|
|
||||||
if (res.statistics) {
|
|
||||||
statistics.value = res.statistics
|
|
||||||
}
|
|
||||||
// 保存所有题目信息
|
|
||||||
if (res.problems) {
|
|
||||||
allProblems.value = res.problems
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// finally 里收掉 loading:以前 loading.value = false 写在 await 之后,
|
|
||||||
// 请求一失败(403、断网)转圈就永远停不下来
|
|
||||||
message.error("加载用户进度失败:" + errorMessage(err, "未知错误"))
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听分页参数变化
|
|
||||||
watch([() => query.page, () => query.limit], loadUserProgress)
|
|
||||||
// 监听班级过滤变化(防抖)
|
|
||||||
watchDebounced(
|
|
||||||
classFilter,
|
|
||||||
() => {
|
|
||||||
query.page = 1 // 重置到第一页
|
|
||||||
loadUserProgress()
|
|
||||||
},
|
|
||||||
{ debounce: 500 },
|
|
||||||
)
|
|
||||||
// 监听完成度筛选变化
|
|
||||||
watch(completionFilter, () => {
|
|
||||||
query.page = 1 // 重置到第一页
|
|
||||||
loadUserProgress()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 使用后端返回的统计数据
|
|
||||||
const stats = computed(() => {
|
|
||||||
if (statistics.value) {
|
|
||||||
return {
|
|
||||||
total: statistics.value.total,
|
|
||||||
completed: statistics.value.completed,
|
|
||||||
avgProgress: Math.round(statistics.value.avgProgress),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 如果后端还没有返回统计数据,使用默认值
|
|
||||||
return {
|
|
||||||
total: total.value,
|
|
||||||
completed: 0,
|
|
||||||
avgProgress: 0,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(loadUserProgress)
|
|
||||||
|
|
||||||
// 定义表格列
|
|
||||||
const progressColumns = [
|
|
||||||
{
|
|
||||||
title: "排名",
|
|
||||||
key: "rank",
|
|
||||||
width: 80,
|
|
||||||
render: (_row: ProblemSetProgress, index: number) => {
|
|
||||||
// 计算全局排名:当前页偏移 + 当前行索引 + 1
|
|
||||||
const globalRank = (query.page - 1) * query.limit + index + 1
|
|
||||||
return globalRank
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "用户",
|
|
||||||
key: "user.username",
|
|
||||||
width: 120,
|
|
||||||
render: (row: ProblemSetProgress) => row.user.username,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "加入时间",
|
|
||||||
key: "join_time",
|
|
||||||
width: 180,
|
|
||||||
render: (row: ProblemSetProgress) => parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "已完成数量",
|
|
||||||
key: "completedProblemsCount",
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "已/未完成题目",
|
|
||||||
key: "completed_problems",
|
|
||||||
width: 300,
|
|
||||||
render: (row: ProblemSetProgress) => {
|
|
||||||
if (row.progressPercentage === 100) {
|
|
||||||
return "全部题目已完成"
|
|
||||||
}
|
|
||||||
if (row.progressPercentage > 50 && row.progressPercentage < 100) {
|
|
||||||
const completedProblemIds = new Set(row.completedProblems.map((p: any) => p.id))
|
|
||||||
const incompleteProblems = allProblems.value.filter((p) => !completedProblemIds.has(p.id))
|
|
||||||
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
|
|
||||||
h(NFlex, {}, () =>
|
|
||||||
incompleteProblems.map((problem) =>
|
|
||||||
h(
|
|
||||||
NTag,
|
|
||||||
{ type: "warning", size: "small", style: "margin: 2px" },
|
|
||||||
() => `${problem._id}: ${problem.title}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
|
|
||||||
h(NFlex, {}, () =>
|
|
||||||
row.completedProblems.map((problem: any) =>
|
|
||||||
h(
|
|
||||||
NTag,
|
|
||||||
{
|
|
||||||
type: "success",
|
|
||||||
size: "small",
|
|
||||||
style: "margin: 2px",
|
|
||||||
},
|
|
||||||
() => `${problem._id}: ${problem.title}`,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
])
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "进度",
|
|
||||||
key: "progress_percentage",
|
|
||||||
width: 120,
|
|
||||||
render: (row: ProblemSetProgress) => {
|
|
||||||
return `${row.progressPercentage.toFixed(0)}%`
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "状态",
|
|
||||||
key: "is_completed",
|
|
||||||
width: 100,
|
|
||||||
render: (row: ProblemSetProgress) => {
|
|
||||||
if (row.isCompleted) {
|
|
||||||
return h(NTag, { type: "success" }, () => "已完成")
|
|
||||||
} else {
|
|
||||||
return h(NTag, { type: "warning" }, () => "进行中")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<!-- 过滤条件 -->
|
|
||||||
<n-form label-placement="left" inline>
|
|
||||||
<n-form-item label="班级">
|
|
||||||
<n-input
|
|
||||||
v-model:value="classFilter"
|
|
||||||
placeholder="输入班级名称"
|
|
||||||
style="width: 200px"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="完成度:">
|
|
||||||
<n-select
|
|
||||||
v-model:value="completionFilter"
|
|
||||||
:options="completionOptions"
|
|
||||||
placeholder="完成度"
|
|
||||||
style="width: 160px"
|
|
||||||
clearable
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
|
|
||||||
<!-- 统计信息卡片 -->
|
|
||||||
<n-grid :cols="3" :x-gap="16" style="margin-bottom: 16px">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-card size="small">
|
|
||||||
<n-statistic label="总参与人数" :value="stats.total" />
|
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-card size="small">
|
|
||||||
<n-statistic label="已完成人数" :value="stats.completed" />
|
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-card size="small">
|
|
||||||
<n-statistic label="平均进度" :value="stats.avgProgress.toFixed(0) + '%'" />
|
|
||||||
</n-card>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
:loading="loading"
|
|
||||||
:columns="progressColumns"
|
|
||||||
:data="progress"
|
|
||||||
:pagination="false"
|
|
||||||
:bordered="false"
|
|
||||||
:single-line="false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Pagination
|
|
||||||
:total="total"
|
|
||||||
:limit="query.limit"
|
|
||||||
:page="query.page"
|
|
||||||
@update:limit="(limit: number) => (query.limit = limit)"
|
|
||||||
@update:page="(page: number) => (query.page = page)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,130 +1,637 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet } from "oj/api"
|
||||||
|
import { useTone } from "oj/submission/composables/tone"
|
||||||
import { errorMessage } from "utils/api"
|
import { errorMessage } from "utils/api"
|
||||||
import { getProblemSetDetail, getProblemSetProblems, joinProblemSet, getUserBadges } from "../api"
|
import { parseTime } from "utils/functions"
|
||||||
import type { ProblemSet, ProblemSetProblem, UserBadge as UserBadgeType } from "utils/types"
|
import type { ProblemSet, ProblemSetProblem } from "utils/types"
|
||||||
import { useFireworks } from "../problem/composables/useFireworks"
|
import { useAuthModalStore } from "shared/store/authModal"
|
||||||
import ProblemSetHeader from "./components/ProblemSetHeader.vue"
|
|
||||||
import ProblemSetProblemsList from "./components/ProblemSetProblemsList.vue"
|
|
||||||
import UserProgressView from "./components/UserProgressView.vue"
|
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
|
import { useFireworks } from "../problem/composables/useFireworks"
|
||||||
|
import BadgeLadder from "./components/BadgeLadder.vue"
|
||||||
|
import ClassView from "./components/ClassView.vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题单详情(设计稿「题单重设计」详情 B):顶上一条是进度和奖章阶梯,下面题目分两列。
|
||||||
|
*
|
||||||
|
* - 还没加入:题目看得见、点不进去,右边一个大按钮「加入题单,开始做」。加入是用户要留的
|
||||||
|
* 一步(「只是打开就算加入的话,会有疑惑的」),所以这里要让人一眼看懂为什么要点。
|
||||||
|
* - 布置期内以前做对过的题标「以前做对过 · 旧代码先藏着」,规则见后端 problemSetLockCutoffs。
|
||||||
|
* - 做完了:整条变绿,写几点做完、用了多久。烟花只在做完的那一刻放(做对最后一道、
|
||||||
|
* 1.5 秒后跳回来的那一下),以后再打开不放。
|
||||||
|
* - 老师多一个「全班情况」页签。
|
||||||
|
*/
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const theme = useThemeVars()
|
||||||
const { celebrate } = useFireworks()
|
const tone = useTone()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const authStore = useAuthModalStore()
|
||||||
|
const { celebrate } = useFireworks()
|
||||||
|
|
||||||
const problemSetId = computed(() => Number(route.params.problemSetId))
|
const id = computed(() => Number(route.params.problemSetId))
|
||||||
|
const set = ref<ProblemSet | null>(null)
|
||||||
const problemSet = ref<ProblemSet | null>(null)
|
|
||||||
const problems = ref<ProblemSetProblem[]>([])
|
const problems = ref<ProblemSetProblem[]>([])
|
||||||
const isJoined = ref(false)
|
const missing = ref(false)
|
||||||
const isJoining = ref(false)
|
const joining = ref(false)
|
||||||
const userBadges = ref<UserBadgeType[]>([])
|
const tab = useRouteQuery<string>("tab", "problems")
|
||||||
const activeTab = ref("problems")
|
|
||||||
|
|
||||||
async function loadProblemSetDetail() {
|
async function load() {
|
||||||
const res = await getProblemSetDetail(problemSetId.value)
|
missing.value = false
|
||||||
problemSet.value = res
|
try {
|
||||||
isJoined.value = res.userProgress?.isJoined || false
|
const [detail, list] = await Promise.all([
|
||||||
}
|
getProblemSetDetail(id.value),
|
||||||
|
getProblemSetProblems(id.value),
|
||||||
async function loadProblems() {
|
])
|
||||||
const res = await getProblemSetProblems(problemSetId.value)
|
set.value = detail
|
||||||
problems.value = res
|
problems.value = list
|
||||||
}
|
} catch {
|
||||||
|
missing.value = true
|
||||||
async function loadUserBadges() {
|
return
|
||||||
if (!isJoined.value) return
|
}
|
||||||
|
const done = set.value.userProgress.completeTime
|
||||||
const res = await getUserBadges()
|
if (set.value.userProgress.isCompleted && done && Date.now() - Date.parse(done) < 60_000) {
|
||||||
userBadges.value = res.filter(
|
|
||||||
(badge: UserBadgeType) => badge.badge.problemSetId === problemSetId.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
await Promise.all([loadProblemSetDetail(), loadProblems()])
|
|
||||||
if (isJoined.value) {
|
|
||||||
if (problemSet.value?.userProgress?.isCompleted) {
|
|
||||||
celebrate()
|
celebrate()
|
||||||
}
|
}
|
||||||
loadUserBadges()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleProblemClick(problemId: string) {
|
watch(id, load)
|
||||||
if (!userStore.isAuthed) {
|
onMounted(load)
|
||||||
message.warning("请先登录!")
|
|
||||||
return
|
const progress = computed(() => set.value?.userProgress)
|
||||||
}
|
const joined = computed(() => !!progress.value?.isJoined)
|
||||||
if (!isJoined.value) {
|
/** 老师不用加入也能点进题目去看(不记进度),也不给他摆「加入」按钮 */
|
||||||
message.warning("请先点击【加入题单】按钮!")
|
const canOpen = computed(() => joined.value || userStore.isTeacherOrAbove)
|
||||||
return
|
const optional = computed(() => (set.value ? set.value.problemsCount - set.value.requiredCount : 0))
|
||||||
}
|
const percent = computed(() =>
|
||||||
router.push({
|
set.value && progress.value?.isJoined && set.value.requiredCount
|
||||||
|
? Math.min(100, (progress.value.completedCount / set.value.requiredCount) * 100)
|
||||||
|
: 0,
|
||||||
|
)
|
||||||
|
const hiddenCount = computed(() => problems.value.filter((item) => item.oldCodeHidden).length)
|
||||||
|
|
||||||
|
/** 做完用了多久:从加入到做完 */
|
||||||
|
const spent = computed(() => {
|
||||||
|
const p = progress.value
|
||||||
|
if (!p?.joinTime || !p.completeTime) return ""
|
||||||
|
const minutes = Math.round((Date.parse(p.completeTime) - Date.parse(p.joinTime)) / 60_000)
|
||||||
|
if (minutes < 60) return `${Math.max(1, minutes)} 分钟`
|
||||||
|
if (minutes < 24 * 60) return `${Math.round(minutes / 60)} 小时`
|
||||||
|
return `${Math.round(minutes / 1440)} 天`
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = computed(() => {
|
||||||
|
const half = Math.ceil(problems.value.length / 2)
|
||||||
|
return [problems.value.slice(0, half), problems.value.slice(half)].filter((col) => col.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
function indexOf(item: ProblemSetProblem) {
|
||||||
|
return problems.value.indexOf(item) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function solvedAt(time: string) {
|
||||||
|
const today = parseTime(new Date(), "YYYY-MM-DD") === parseTime(time, "YYYY-MM-DD")
|
||||||
|
return parseTime(time, today ? "HH:mm" : "M月D日")
|
||||||
|
}
|
||||||
|
|
||||||
|
function problemLink(item: ProblemSetProblem) {
|
||||||
|
return {
|
||||||
name: "problemset problem",
|
name: "problemset problem",
|
||||||
params: {
|
params: { problemSetId: id.value, problemID: item.problem._id },
|
||||||
problemSetId: problemSetId.value,
|
|
||||||
problemID: problemId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleJoinProblemSet() {
|
|
||||||
if (isJoining.value) return
|
|
||||||
|
|
||||||
isJoining.value = true
|
|
||||||
try {
|
|
||||||
await joinProblemSet(problemSetId.value)
|
|
||||||
isJoined.value = true
|
|
||||||
message.success("成功加入题单!")
|
|
||||||
// 加入题单后加载用户徽章
|
|
||||||
await loadUserBadges()
|
|
||||||
} catch (err) {
|
|
||||||
message.error("加入题单失败:" + errorMessage(err, "未知错误"))
|
|
||||||
} finally {
|
|
||||||
isJoining.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 「用户进度」那一栏调的是 requireTeacher 的接口(Teacher Admin | Super Admin),
|
async function join() {
|
||||||
// 所以这里的条件必须跟它一致。以前写的是「超管 或 自己完成了题单」,两边正好错开:
|
if (!userStore.isAuthed) {
|
||||||
// 学生做完题单会看到这一栏、点进去 403;而真正该用它的 Teacher Admin 反倒看不到。
|
authStore.openLoginModal()
|
||||||
const showTabs = computed(() => userStore.isTeacherOrAbove)
|
return
|
||||||
|
}
|
||||||
|
if (joining.value) return
|
||||||
|
joining.value = true
|
||||||
|
try {
|
||||||
|
await joinProblemSet(id.value)
|
||||||
|
message.success("加入了,现在开始在这里做对的题都会记进度")
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
message.error(errorMessage(err, "加入失败"))
|
||||||
|
} finally {
|
||||||
|
joining.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(init)
|
/** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */
|
||||||
|
const canEdit = computed(
|
||||||
|
() => !!set.value && (userStore.isSuperAdmin || set.value.createdBy.id === userStore.user?.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
const info = computed(() => tone("info"))
|
||||||
|
const success = computed(() => tone("success"))
|
||||||
|
const warning = computed(() => tone("warning"))
|
||||||
|
const purple = { color: "#5b3fa8", background: "rgba(122, 95, 208, 0.12)" }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="problemSet">
|
<n-result
|
||||||
<ProblemSetHeader
|
v-if="missing"
|
||||||
:problem-set="problemSet"
|
status="404"
|
||||||
:is-joined="isJoined"
|
title="题单不存在"
|
||||||
:is-joining="isJoining"
|
description="可能还没公开,或者已经删掉了"
|
||||||
:user-badges="userBadges"
|
>
|
||||||
@join="handleJoinProblemSet"
|
<template #footer>
|
||||||
/>
|
<n-button @click="router.push({ name: 'problemsets' })">回到题单列表</n-button>
|
||||||
<n-tabs v-if="showTabs" v-model:value="activeTab" animated>
|
</template>
|
||||||
<n-tab-pane name="problems" tab="题目列表">
|
</n-result>
|
||||||
<ProblemSetProblemsList
|
|
||||||
:problems="problems"
|
<div v-else-if="set" class="page">
|
||||||
:is-joined="isJoined"
|
<section class="band" :class="{ done: progress?.isCompleted }">
|
||||||
@problem-click="handleProblemClick"
|
<span v-if="progress?.isCompleted" class="done-mark" aria-hidden="true">✓</span>
|
||||||
/>
|
<div class="head">
|
||||||
</n-tab-pane>
|
<div class="row">
|
||||||
<n-tab-pane name="progress" tab="用户进度">
|
<router-link :to="{ name: 'problemsets' }" class="muted back">‹ 题单</router-link>
|
||||||
<UserProgressView />
|
<h2 class="ell">{{ set.title }}</h2>
|
||||||
</n-tab-pane>
|
<span v-if="set.assigning" class="pill assign"
|
||||||
</n-tabs>
|
>布置到 {{ parseTime(set.assignedUntil!, "M月D日") }}</span
|
||||||
<ProblemSetProblemsList
|
>
|
||||||
v-else
|
</div>
|
||||||
:problems="problems"
|
<template v-if="progress?.isCompleted">
|
||||||
:is-joined="isJoined"
|
<div class="done-text">
|
||||||
@problem-click="handleProblemClick"
|
<b>全部做完了</b> · {{ parseTime(progress.completeTime!, "M月D日 HH:mm") }}
|
||||||
/>
|
<template v-if="spent">,加入后用了 {{ spent }}</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="joined">
|
||||||
|
<div class="row baseline">
|
||||||
|
<span class="muted small">做对</span>
|
||||||
|
<span class="num count">{{ progress!.completedCount }}</span>
|
||||||
|
<span class="muted num">/ {{ set.requiredCount }} 道</span>
|
||||||
|
<span class="muted tiny gap">
|
||||||
|
<template v-if="optional > 0">另有 {{ optional }} 道选做 · </template>
|
||||||
|
{{ parseTime(progress!.joinTime!, "M月D日 HH:mm") }} 加入
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar"><div class="fill" :style="{ width: `${percent}%` }"></div></div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="muted small">
|
||||||
|
{{ set.createdBy.username }} 出的 · {{ set.problemsCount }} 道<template
|
||||||
|
v-if="optional > 0"
|
||||||
|
>,{{ optional }} 道选做</template
|
||||||
|
><template v-else>,全部必做</template>
|
||||||
|
<template v-if="!set.badges.length"> · 这个题单没有奖章</template>
|
||||||
|
</div>
|
||||||
|
<div v-if="set.description && set.description !== set.title" class="sec small desc">
|
||||||
|
{{ set.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-if="set.badges.length">
|
||||||
|
<span class="divider"></span>
|
||||||
|
<div class="ladder-box"><BadgeLadder :set="set" /></div>
|
||||||
|
</template>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div v-if="!joined && !userStore.isTeacherOrAbove" class="join">
|
||||||
|
<n-button type="primary" size="large" :loading="joining" @click="join">
|
||||||
|
加入题单,开始做
|
||||||
|
</n-button>
|
||||||
|
<span class="muted tiny">加入以后,在题单里做对的题才算进度</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="!joined && hiddenCount" class="notice" :style="purple">
|
||||||
|
<svg
|
||||||
|
class="lock"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="5" y="11" width="14" height="9" rx="2" />
|
||||||
|
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
|
||||||
|
</svg>
|
||||||
|
其中 <b>{{ hiddenCount }}</b> 道你以前做对过。布置期内(到
|
||||||
|
{{
|
||||||
|
parseTime(set.assignedUntil!, "M月D日")
|
||||||
|
}})这几道的旧代码先藏起来,在题单里重新做对就能看。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="userStore.isTeacherOrAbove" class="tabs">
|
||||||
|
<button class="seg" :class="{ on: tab !== 'class' }" @click="tab = 'problems'">题目</button>
|
||||||
|
<button class="seg" :class="{ on: tab === 'class' }" @click="tab = 'class'">全班情况</button>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<n-button
|
||||||
|
v-if="canEdit"
|
||||||
|
size="small"
|
||||||
|
@click="router.push({ name: 'admin problemset edit', params: { problemSetId: set.id } })"
|
||||||
|
>
|
||||||
|
编辑题单
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ClassView v-if="userStore.isTeacherOrAbove && tab === 'class'" :problem-set-id="set.id" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="problems.length" class="cols">
|
||||||
|
<section v-for="(col, ci) in columns" :key="ci" class="card list">
|
||||||
|
<component
|
||||||
|
:is="canOpen ? 'router-link' : 'div'"
|
||||||
|
v-for="item in col"
|
||||||
|
:key="item.id"
|
||||||
|
:to="canOpen ? problemLink(item) : undefined"
|
||||||
|
class="prow"
|
||||||
|
:class="{ off: !canOpen }"
|
||||||
|
>
|
||||||
|
<span v-if="item.isCompleted" class="dot done-dot">✓</span>
|
||||||
|
<span v-else-if="item.wrongCount" class="dot tried-dot">!</span>
|
||||||
|
<span v-else class="dot empty-dot"></span>
|
||||||
|
<span class="muted num idx">{{ indexOf(item) }}</span>
|
||||||
|
<span class="ell ptitle">{{ item.problem.title }}</span>
|
||||||
|
<span v-if="!item.isRequired" class="opt">选做</span>
|
||||||
|
<span v-if="item.oldCodeHidden" class="old" :style="purple"
|
||||||
|
><svg
|
||||||
|
class="lock"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="5" y="11" width="14" height="9" rx="2" />
|
||||||
|
<path d="M8 11V8a4 4 0 0 1 8 0v3" /></svg
|
||||||
|
>以前做对过 · 旧代码先藏着</span
|
||||||
|
>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span v-if="item.isCompleted && item.solvedTime" class="muted num tiny"
|
||||||
|
>{{ solvedAt(item.solvedTime) }} 做对</span
|
||||||
|
>
|
||||||
|
<span v-else-if="item.wrongCount && joined" class="tiny tried"
|
||||||
|
>交过 {{ item.wrongCount }} 次</span
|
||||||
|
>
|
||||||
|
<span v-else-if="canOpen" class="go">去做 ›</span>
|
||||||
|
</component>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<n-empty v-else description="这个题单还没有题目" />
|
||||||
|
<div v-if="joined && problems.length" class="muted tiny foot">做对一道会自动回到这里</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 14px 20px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px 24px;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 6px;
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.band.done {
|
||||||
|
background: v-bind("success.background");
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.done-mark {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: v-bind("theme.successColor");
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.done-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: v-bind("success.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 380px;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.baseline {
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
background: v-bind("theme.successColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
align-self: stretch;
|
||||||
|
background: v-bind("theme.dividerColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.ladder-box {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock {
|
||||||
|
flex-shrink: 0;
|
||||||
|
vertical-align: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.old .lock {
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
background: transparent;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg:first-child {
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg + .seg {
|
||||||
|
border-left: none;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg.on {
|
||||||
|
color: v-bind("success.color");
|
||||||
|
background: v-bind("success.background");
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 6px;
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prow {
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
border-bottom: 1px solid v-bind("theme.dividerColor");
|
||||||
|
color: v-bind("theme.textColor1");
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prow:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.prow:hover {
|
||||||
|
background: v-bind("theme.hoverColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.prow.off {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.done-dot {
|
||||||
|
background: v-bind("theme.successColor");
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tried-dot {
|
||||||
|
background: v-bind("warning.background");
|
||||||
|
color: v-bind("warning.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-dot {
|
||||||
|
border: 1.5px dashed v-bind("theme.borderColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.idx {
|
||||||
|
width: 20px;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ptitle {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.opt {
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.old {
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tried {
|
||||||
|
color: v-bind("warning.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.go {
|
||||||
|
font-size: 13px;
|
||||||
|
color: v-bind("theme.primaryColor");
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign {
|
||||||
|
color: v-bind("info.color");
|
||||||
|
background: v-bind("info.background");
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec {
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiny {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ell {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.cols {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.join {
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+353
-212
@@ -1,263 +1,404 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouteQuery } from "@vueuse/router"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { getProblemSetList } from "../api"
|
import { getProblemSetList } from "oj/api"
|
||||||
|
import { rgba, useTone } from "oj/submission/composables/tone"
|
||||||
|
import { parseTime } from "utils/functions"
|
||||||
import type { ProblemSet } from "utils/types"
|
import type { ProblemSet } from "utils/types"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import { badgeCondition, ladder, nextBadge } from "./badges"
|
||||||
import { usePagination } from "shared/composables/pagination"
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题单列表(设计稿「题单重设计」列表 A):老师正在布置的题单单独一排大卡片 —— 做对几道、
|
||||||
|
* 布置到哪天、再做对几道拿哪个奖章;其余题单一排排小卡片,加入过的在前。
|
||||||
|
*
|
||||||
|
* 题单一共十几个,一次全取回来在前端分组,不分页
|
||||||
|
*/
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const tone = useTone()
|
||||||
|
|
||||||
const total = ref(0)
|
const sets = ref<ProblemSet[]>([])
|
||||||
const problemSets = ref<ProblemSet[]>([])
|
const loaded = ref(false)
|
||||||
|
const keyword = ref("")
|
||||||
|
|
||||||
interface ProblemSetQuery {
|
onMounted(async () => {
|
||||||
keyword: string
|
try {
|
||||||
}
|
sets.value = (await getProblemSetList(0, 250)).results
|
||||||
|
} finally {
|
||||||
// 使用分页 composable
|
loaded.value = true
|
||||||
const { query, clearQuery } = usePagination<ProblemSetQuery>(
|
|
||||||
{
|
|
||||||
keyword: useRouteQuery("keyword", "").value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
defaultLimit: 30,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async function listProblemSets() {
|
|
||||||
if (query.page < 1) query.page = 1
|
|
||||||
const offset = (query.page - 1) * query.limit
|
|
||||||
const res = await getProblemSetList(offset, query.limit, query.keyword)
|
|
||||||
total.value = res.total
|
|
||||||
problemSets.value = res.results
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDifficultyTag(difficulty: string) {
|
|
||||||
const difficultyMap: Record<
|
|
||||||
string,
|
|
||||||
{ type: "success" | "warning" | "error" | "default"; text: string }
|
|
||||||
> = {
|
|
||||||
Easy: { type: "success", text: "简单" },
|
|
||||||
Medium: { type: "warning", text: "中等" },
|
|
||||||
Hard: { type: "error", text: "困难" },
|
|
||||||
}
|
}
|
||||||
return difficultyMap[difficulty] || { type: "default", text: "未知" }
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToProblemSet(problemSetId: number) {
|
|
||||||
router.push(`/problemset/${problemSetId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConditionText(conditionType: string, conditionValue: number): string {
|
|
||||||
const conditionMap: Record<string, string> = {
|
|
||||||
all_problems: "完成所有题目",
|
|
||||||
problem_count: `完成 ${conditionValue} 道题目`,
|
|
||||||
score: `达到 ${conditionValue} 分`,
|
|
||||||
}
|
|
||||||
return conditionMap[conditionType] || "未知条件"
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(listProblemSets)
|
|
||||||
|
|
||||||
// 监听搜索关键词变化(防抖)
|
|
||||||
watchDebounced(() => query.keyword, listProblemSets, {
|
|
||||||
debounce: 500,
|
|
||||||
maxWait: 1000,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听其他查询条件变化
|
const shown = computed(() => {
|
||||||
watch(() => [query.page, query.limit], listProblemSets)
|
const word = keyword.value.trim().toLowerCase()
|
||||||
|
if (!word) return sets.value
|
||||||
|
return sets.value.filter(
|
||||||
|
(set) => set.title.toLowerCase().includes(word) || set.description.toLowerCase().includes(word),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 布置中的:快到期的在前 */
|
||||||
|
const assigning = computed(() =>
|
||||||
|
shown.value
|
||||||
|
.filter((set) => set.assigning)
|
||||||
|
.sort((a, b) => Date.parse(a.assignedUntil!) - Date.parse(b.assignedUntil!)),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 其他:加入过的在前(最近加入的最前),再按新建排 */
|
||||||
|
const others = computed(() =>
|
||||||
|
shown.value
|
||||||
|
.filter((set) => !set.assigning)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ja = a.userProgress.joinTime
|
||||||
|
const jb = b.userProgress.joinTime
|
||||||
|
if (ja && jb) return Date.parse(jb) - Date.parse(ja)
|
||||||
|
if (ja || jb) return ja ? -1 : 1
|
||||||
|
return Date.parse(b.createTime) - Date.parse(a.createTime)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const joinedOthers = computed(() => others.value.filter((set) => set.userProgress.isJoined).length)
|
||||||
|
|
||||||
|
function description(set: ProblemSet) {
|
||||||
|
// 简介大多就是把标题再抄一遍,一样的就不重复显示
|
||||||
|
return set.description && set.description !== set.title ? set.description : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function optional(set: ProblemSet) {
|
||||||
|
return set.problemsCount - set.requiredCount
|
||||||
|
}
|
||||||
|
|
||||||
|
function percent(set: ProblemSet) {
|
||||||
|
const progress = set.userProgress
|
||||||
|
return progress.isJoined && set.requiredCount
|
||||||
|
? Math.min(100, (progress.completedCount / set.requiredCount) * 100)
|
||||||
|
: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(set: ProblemSet) {
|
||||||
|
router.push({ name: "problemset", params: { problemSetId: set.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = computed(() => tone("info"))
|
||||||
|
const success = computed(() => tone("success"))
|
||||||
|
// 做完的卡片只淡淡地染一点绿(设计稿),用 success.background 那一档太抢眼
|
||||||
|
const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-flex vertical size="large">
|
<div class="page">
|
||||||
<!-- 难度和状态两个筛选器撤了:线上 16 个题单全是 Easy / active,选「中等」「困难」
|
<div class="top">
|
||||||
「已归档」永远是空列表。接口那两个 query 参数还在,哪天真的用起这两个字段,
|
<h2>题单</h2>
|
||||||
把 select 加回来即可。 -->
|
<div class="spacer"></div>
|
||||||
<n-space>
|
<n-input v-model:value="keyword" placeholder="搜题单" clearable class="search" />
|
||||||
<n-input
|
|
||||||
v-model:value="query.keyword"
|
|
||||||
placeholder="搜索题单..."
|
|
||||||
clearable
|
|
||||||
@clear="clearQuery"
|
|
||||||
style="width: 200px"
|
|
||||||
/>
|
|
||||||
</n-space>
|
|
||||||
|
|
||||||
<div v-if="problemSets.length > 0" class="set-grid">
|
|
||||||
<div
|
|
||||||
v-for="problemSet in problemSets"
|
|
||||||
:key="problemSet.id"
|
|
||||||
class="set-card"
|
|
||||||
:class="{ completed: problemSet.userProgress?.isCompleted }"
|
|
||||||
@click="goToProblemSet(problemSet.id)"
|
|
||||||
>
|
|
||||||
<n-flex justify="space-between" align="center" :wrap="false" :size="8">
|
|
||||||
<span class="set-title">{{ problemSet.title }}</span>
|
|
||||||
<n-tag v-if="problemSet.userProgress?.isCompleted" type="success" size="small" round>
|
|
||||||
已完成
|
|
||||||
</n-tag>
|
|
||||||
<n-tag v-else-if="problemSet.userProgress?.isJoined" type="info" size="small" round>
|
|
||||||
进行中
|
|
||||||
</n-tag>
|
|
||||||
</n-flex>
|
|
||||||
|
|
||||||
<!-- 简介大多就是把标题再抄一遍,一样的就不重复显示 -->
|
|
||||||
<n-text
|
|
||||||
v-if="problemSet.description && problemSet.description !== problemSet.title"
|
|
||||||
depth="3"
|
|
||||||
class="set-desc"
|
|
||||||
>
|
|
||||||
{{ problemSet.description }}
|
|
||||||
</n-text>
|
|
||||||
|
|
||||||
<div class="set-progress">
|
|
||||||
<n-flex justify="space-between" align="center" class="set-progress-text">
|
|
||||||
<n-text depth="3">
|
|
||||||
<!-- 进度是加入/完成时存下的快照,分母用快照自己的 totalCount:题单后来
|
|
||||||
加了题的话,拿现在的 problemsCount 去除,就会出现「已完成」却是 5 / 6 -->
|
|
||||||
<template v-if="problemSet.userProgress?.isJoined">
|
|
||||||
已完成 {{ problemSet.userProgress.completedCount }} /
|
|
||||||
{{ problemSet.userProgress.totalCount }} 题
|
|
||||||
<template v-if="problemSet.problemsCount > problemSet.userProgress.totalCount">
|
|
||||||
· 新加了
|
|
||||||
{{ problemSet.problemsCount - problemSet.userProgress.totalCount }} 题
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<template v-else>共 {{ problemSet.problemsCount }} 题 · 还没开始</template>
|
|
||||||
</n-text>
|
|
||||||
<!-- 线上题单全是简单难度,只有不是简单时才值得标出来 -->
|
|
||||||
<n-tag
|
|
||||||
v-if="problemSet.difficulty !== 'Easy'"
|
|
||||||
:type="getDifficultyTag(problemSet.difficulty).type"
|
|
||||||
size="small"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
{{ getDifficultyTag(problemSet.difficulty).text }}
|
|
||||||
</n-tag>
|
|
||||||
<n-tag v-if="problemSet.status === 'archived'" size="small" :bordered="false">
|
|
||||||
已归档
|
|
||||||
</n-tag>
|
|
||||||
</n-flex>
|
|
||||||
<n-progress
|
|
||||||
type="line"
|
|
||||||
:percentage="Math.round(problemSet.userProgress?.progressPercentage ?? 0)"
|
|
||||||
:show-indicator="false"
|
|
||||||
:height="6"
|
|
||||||
status="success"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-flex v-if="problemSet.badges?.length" align="center" :size="6" class="set-badges">
|
<template v-if="assigning.length">
|
||||||
<n-text depth="3" class="set-badges-label">
|
<div class="sect">
|
||||||
徽章 {{ problemSet.badges.filter((b) => b.isEarned).length }} /
|
布置中<span class="muted">老师现在布置的 · {{ assigning.length }} 个</span>
|
||||||
{{ problemSet.badges.length }}
|
</div>
|
||||||
</n-text>
|
<div class="big-grid">
|
||||||
<n-tooltip v-for="badge in problemSet.badges" :key="badge.id" trigger="hover">
|
<a
|
||||||
<template #trigger>
|
v-for="set in assigning"
|
||||||
<n-image
|
:key="set.id"
|
||||||
|
:href="`/problemset/${set.id}`"
|
||||||
|
class="card big"
|
||||||
|
:class="{ done: set.userProgress.isCompleted }"
|
||||||
|
@click.prevent="open(set)"
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<b class="ell title">{{ set.title }}</b>
|
||||||
|
<span v-if="set.userProgress.isCompleted" class="pill done-pill">✓ 做完了</span>
|
||||||
|
</div>
|
||||||
|
<div class="muted ell desc">{{ description(set) }}</div>
|
||||||
|
<div class="row baseline">
|
||||||
|
<template v-if="set.userProgress.isJoined">
|
||||||
|
<span class="num count">{{ set.userProgress.completedCount }}</span>
|
||||||
|
<span class="muted num">/ {{ set.requiredCount }}</span>
|
||||||
|
</template>
|
||||||
|
<span v-else class="muted small">还没加入 · {{ set.requiredCount }} 道</span>
|
||||||
|
<span v-if="optional(set) > 0" class="muted tiny">另有 {{ optional(set) }} 道选做</span>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span class="pill assign">布置到 {{ parseTime(set.assignedUntil!, "M月D日") }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bar"><div class="fill" :style="{ width: `${percent(set)}%` }"></div></div>
|
||||||
|
<div v-if="nextBadge(set)" class="row hint">
|
||||||
|
<img :src="nextBadge(set)!.badge.icon" alt="" class="icon locked" />
|
||||||
|
<span>
|
||||||
|
再做对 <b class="num">{{ nextBadge(set)!.left }}</b> 道拿「{{
|
||||||
|
nextBadge(set)!.badge.name
|
||||||
|
}}」
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="set.badges.length" class="row hint got">
|
||||||
|
<img
|
||||||
|
v-for="badge in ladder(set)"
|
||||||
|
:key="badge.id"
|
||||||
:src="badge.icon"
|
:src="badge.icon"
|
||||||
:alt="badge.name"
|
:alt="badge.name"
|
||||||
width="24"
|
:title="badge.name"
|
||||||
height="24"
|
class="icon"
|
||||||
object-fit="cover"
|
|
||||||
preview-disabled
|
|
||||||
:class="badge.isEarned ? 'earned-badge' : 'locked-badge'"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
<span>奖章全拿到了</span>
|
||||||
<n-flex vertical size="small">
|
|
||||||
<span style="font-weight: bold"> 徽章: {{ badge.name }} </span>
|
|
||||||
<span>
|
|
||||||
获取条件:
|
|
||||||
{{ getConditionText(badge.conditionType, badge.conditionValue) }}
|
|
||||||
</span>
|
|
||||||
<n-text type="primary" v-if="badge.isEarned"> ✓ 已获得 </n-text>
|
|
||||||
</n-flex>
|
|
||||||
</n-tooltip>
|
|
||||||
</n-flex>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else class="muted hint">这个题单没有奖章</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="others.length" class="sect">
|
||||||
|
{{ assigning.length ? "其他题单" : "题单" }}
|
||||||
|
<span class="muted"
|
||||||
|
>{{ others.length }} 个<template v-if="joinedOthers"> · 加入过的在前</template></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="small-grid">
|
||||||
|
<a
|
||||||
|
v-for="set in others"
|
||||||
|
:key="set.id"
|
||||||
|
:href="`/problemset/${set.id}`"
|
||||||
|
class="card small-card"
|
||||||
|
:class="{ done: set.userProgress.isCompleted }"
|
||||||
|
@click.prevent="open(set)"
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<b class="ell">{{ set.title }}</b>
|
||||||
|
<span v-if="set.userProgress.isCompleted" class="green tiny">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="row tiny">
|
||||||
|
<span v-if="set.userProgress.isJoined" class="num">
|
||||||
|
做对 <b>{{ set.userProgress.completedCount }}</b> / {{ set.requiredCount }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="muted num"
|
||||||
|
>{{ set.problemsCount }} 道 · {{ set.joinedCount }} 人做过</span
|
||||||
|
>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<img
|
||||||
|
v-for="badge in ladder(set)"
|
||||||
|
:key="badge.id"
|
||||||
|
:src="badge.icon"
|
||||||
|
:alt="badge.name"
|
||||||
|
:title="`${badge.name} · ${badgeCondition(badge)}${badge.isEarned ? ' · 已拿到' : ''}`"
|
||||||
|
class="mini"
|
||||||
|
:class="{ locked: !badge.isEarned }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination
|
<n-empty
|
||||||
v-if="problemSets.length > 0"
|
v-if="loaded && !shown.length"
|
||||||
:total="total"
|
:description="keyword ? '没有找到这样的题单' : '还没有题单'"
|
||||||
v-model:limit="query.limit"
|
|
||||||
v-model:page="query.page"
|
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</div>
|
||||||
<n-empty v-if="problemSets.length === 0"></n-empty>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.set-grid {
|
.page {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.set-card {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sect {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.sect .muted {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: v-bind("theme.textColor3");
|
||||||
|
}
|
||||||
|
|
||||||
|
.green {
|
||||||
|
color: v-bind("success.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.small {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiny {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ell {
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row.baseline {
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 16px 18px;
|
}
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
.card {
|
||||||
cursor: pointer;
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid v-bind("theme.borderColor");
|
||||||
|
border-radius: 6px;
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
color: v-bind("theme.textColor1");
|
||||||
|
text-decoration: none;
|
||||||
transition:
|
transition:
|
||||||
border-color 0.2s,
|
border-color 0.2s,
|
||||||
box-shadow 0.2s;
|
box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.set-card:hover {
|
.card:hover {
|
||||||
border-color: #18a058;
|
border-color: v-bind("theme.primaryColor");
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.set-card.completed {
|
.card.done {
|
||||||
background-color: rgba(24, 160, 88, 0.05);
|
background: v-bind("doneBackground");
|
||||||
}
|
}
|
||||||
|
|
||||||
.set-title {
|
.big {
|
||||||
font-size: 16px;
|
padding: 14px 16px;
|
||||||
font-weight: 600;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.set-desc {
|
|
||||||
font-size: 13px;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.set-progress {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
margin-top: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.set-progress-text {
|
.title {
|
||||||
font-size: 13px;
|
flex-grow: 1;
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.set-badges-label {
|
.desc {
|
||||||
|
height: 17px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
margin-right: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.earned-badge {
|
.count {
|
||||||
border: 2px solid #ffd700;
|
font-size: 22px;
|
||||||
border-radius: 50%;
|
font-weight: 700;
|
||||||
box-shadow: 0 0 8px rgba(255, 215, 0, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 没拿到的徽章压成灰色:原来拿没拿到只差一圈金边,一排看下去分不清 */
|
.pill {
|
||||||
.locked-badge {
|
height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign {
|
||||||
|
color: v-bind("info.color");
|
||||||
|
background: v-bind("info.background");
|
||||||
|
}
|
||||||
|
|
||||||
|
.done-pill {
|
||||||
|
color: v-bind("success.color");
|
||||||
|
background: v-bind("success.background");
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: v-bind("theme.actionColor");
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
background: v-bind("theme.successColor");
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
height: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: v-bind("theme.textColor2");
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint.got {
|
||||||
|
gap: 4px;
|
||||||
|
color: v-bind("success.color");
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint.got span {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.locked {
|
||||||
filter: grayscale(1);
|
filter: grayscale(1);
|
||||||
opacity: 0.45;
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-card {
|
||||||
|
height: 74px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-card b {
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -62,7 +62,7 @@ export const useFlowchartStore = defineStore("flowchart", () => {
|
|||||||
* 1.5 秒后跳回题单,都只认这一下 —— 进页面读到以前的 A 不该再跳一次
|
* 1.5 秒后跳回题单,都只认这一下 —— 进页面读到以前的 A 不该再跳一次
|
||||||
*/
|
*/
|
||||||
const evaluatedSeq = ref(0)
|
const evaluatedSeq = ref(0)
|
||||||
/** 这道题评完的每一次,早的在前;hidden = 加入题单之前、被藏起来的次数 */
|
/** 这道题评完的每一次,早的在前;hidden = 题单布置期内被藏起来的次数 */
|
||||||
const scores = ref<FlowchartScores["scores"]>([])
|
const scores = ref<FlowchartScores["scores"]>([])
|
||||||
const hiddenCount = ref(0)
|
const hiddenCount = ref(0)
|
||||||
const submissionCount = computed(() => scores.value.length)
|
const submissionCount = computed(() => scores.value.length)
|
||||||
|
|||||||
@@ -202,7 +202,9 @@ const canRetry = computed(
|
|||||||
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
|
<Icon icon="ph:lock-simple" :width="30" class="lock-icon" />
|
||||||
<!-- 列表项里没有 userId,分不出是别人的还是自己被题单挡住的,文案两种都说得通 -->
|
<!-- 列表项里没有 userId,分不出是别人的还是自己被题单挡住的,文案两种都说得通 -->
|
||||||
<span class="locked-title">这张流程图看不到</span>
|
<span class="locked-title">这张流程图看不到</span>
|
||||||
<span class="muted">别人画的看不到;自己的在题单里做完这道题就能看到。</span>
|
<span class="muted"
|
||||||
|
>别人画的看不到;自己以前画的在题单布置期内先藏着,在题单里做对或者布置期结束就能看到。</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="body">
|
<div v-else class="body">
|
||||||
<div class="graph-box">
|
<div class="graph-box">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import LockNote from "oj/problemset/components/LockNote.vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { getSubmission } from "oj/api"
|
import { getSubmission } from "oj/api"
|
||||||
@@ -388,8 +389,8 @@ function openStandalone() {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="locked === 'problemset'">
|
<template v-else-if="locked === 'problemset'">
|
||||||
<span class="locked-title">这道题在你加入的题单里</span>
|
<span class="locked-title">这份代码在题单布置期内先藏着</span>
|
||||||
<span class="muted">加入题单之前交的代码先藏起来,在题单里做完这道题就能看到。</span>
|
<span class="muted"><LockNote :problem="{ displayId: row.problemDisplayId }" /></span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="locked-title">登录之后才能看代码</span>
|
<span class="locked-title">登录之后才能看代码</span>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useDark } from "@vueuse/core"
|
|||||||
|
|
||||||
export type Tone = "success" | "error" | "warning" | "info" | "default"
|
export type Tone = "success" | "error" | "warning" | "info" | "default"
|
||||||
|
|
||||||
function rgba(hex: string, alpha: number) {
|
export function rgba(hex: string, alpha: number) {
|
||||||
const value = hex.replace("#", "")
|
const value = hex.replace("#", "")
|
||||||
const full =
|
const full =
|
||||||
value.length === 3
|
value.length === 3
|
||||||
|
|||||||
@@ -968,7 +968,11 @@ function dayBreak(index: number) {
|
|||||||
<span
|
<span
|
||||||
v-if="!row.showLink"
|
v-if="!row.showLink"
|
||||||
class="lock-hint"
|
class="lock-hint"
|
||||||
:title="othersRow(row) ? '别人的代码看不到' : '在题单里做完才能看'"
|
:title="
|
||||||
|
othersRow(row)
|
||||||
|
? '别人的代码看不到'
|
||||||
|
: '题单布置期内先藏着:在题单里做对,或者布置期结束就能看'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<Icon icon="ph:lock-simple" :width="13" />
|
<Icon icon="ph:lock-simple" :width="13" />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -337,12 +337,14 @@ export const admins: RouteRecordRaw = {
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { requiresTeacherAdmin: true },
|
meta: { requiresTeacherAdmin: true },
|
||||||
},
|
},
|
||||||
|
// 原来的「详情」页(题目 / 奖章 / 进度三个页签)并进了一屏编辑,老链接转过去
|
||||||
{
|
{
|
||||||
path: "problemset/:problemSetId",
|
path: "problemset/:problemSetId",
|
||||||
name: "admin problemset detail",
|
name: "admin problemset detail",
|
||||||
component: () => import("admin/problemset/detail.vue"),
|
redirect: (to) => ({
|
||||||
props: true,
|
name: "admin problemset edit",
|
||||||
meta: { requiresTeacherAdmin: true },
|
params: { problemSetId: to.params.problemSetId },
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "ai/reports",
|
path: "ai/reports",
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-popover trigger="hover" placement="top">
|
|
||||||
<template #trigger>
|
|
||||||
<div class="badge-container">
|
|
||||||
<img
|
|
||||||
:src="badge.badge.icon"
|
|
||||||
:alt="badge.badge.name"
|
|
||||||
class="badge-icon"
|
|
||||||
@error="handleImageError"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<n-card size="small" class="badge-popover">
|
|
||||||
<n-flex vertical>
|
|
||||||
<n-text strong>{{ badge.badge.name }}</n-text>
|
|
||||||
<n-tag type="info"> 获得条件:{{ getConditionText() }} </n-tag>
|
|
||||||
<n-text depth="3">
|
|
||||||
获得时间:{{ parseTime(badge.earnedTime, "YYYY-MM-DD HH:mm:ss") }}
|
|
||||||
</n-text>
|
|
||||||
</n-flex>
|
|
||||||
</n-card>
|
|
||||||
</n-popover>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { UserBadge } from "utils/types"
|
|
||||||
import { parseTime } from "utils/functions"
|
|
||||||
interface Props {
|
|
||||||
badge: UserBadge
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
function handleImageError(event: Event) {
|
|
||||||
const img = event.target as HTMLImageElement
|
|
||||||
// 如果图片加载失败,显示默认图标
|
|
||||||
img.src = "/badge-1.png" // 使用默认徽章图标
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConditionText() {
|
|
||||||
const { conditionType, conditionValue } = props.badge.badge
|
|
||||||
|
|
||||||
switch (conditionType) {
|
|
||||||
case "all_problems":
|
|
||||||
return "完成所有题目"
|
|
||||||
case "problem_count":
|
|
||||||
return `完成 ${conditionValue} 道题目`
|
|
||||||
case "score":
|
|
||||||
return `获得 ${conditionValue} 分`
|
|
||||||
default:
|
|
||||||
return "未知条件"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.badge-container {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
border: 2px solid #e0e0e0;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-icon:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
border-color: #1890ff;
|
|
||||||
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-popover {
|
|
||||||
max-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-popover .n-space {
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -195,31 +195,12 @@ export type {
|
|||||||
ProblemSetList,
|
ProblemSetList,
|
||||||
ProblemSetBadge,
|
ProblemSetBadge,
|
||||||
ProblemSetProblem,
|
ProblemSetProblem,
|
||||||
ProblemSetProgress,
|
ProblemSetClassView,
|
||||||
ProblemSetProgressList,
|
ProblemSetClassStudent,
|
||||||
|
ProblemSetLock,
|
||||||
UserBadge,
|
UserBadge,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
|
|
||||||
export type { CompletedProblem } from "@oj2/contract"
|
|
||||||
|
|
||||||
export interface CreateProblemSetData {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
difficulty: "Easy" | "Medium" | "Hard"
|
|
||||||
status: "active" | "archived" | "draft"
|
|
||||||
endTime?: Date | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EditProblemSetData {
|
|
||||||
id: number
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
difficulty?: "Easy" | "Medium" | "Hard"
|
|
||||||
status?: "active" | "archived" | "draft"
|
|
||||||
endTime?: Date | null
|
|
||||||
visible?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Code {
|
export interface Code {
|
||||||
language: LANGUAGE
|
language: LANGUAGE
|
||||||
value: string
|
value: string
|
||||||
@@ -339,12 +320,11 @@ export type {
|
|||||||
AdminUser,
|
AdminUser,
|
||||||
AdminUserList,
|
AdminUserList,
|
||||||
AdminContestList,
|
AdminContestList,
|
||||||
AdminProblemSetProgress,
|
|
||||||
AdminProblemSetProblem,
|
AdminProblemSetProblem,
|
||||||
AdminProblemSet,
|
AdminProblemSet,
|
||||||
AdminProblemSetList,
|
AdminProblemSetList,
|
||||||
AdminProblemSetBadge,
|
AdminProblemSetBadge,
|
||||||
AddProblemToSetRequest,
|
AddProblemsToSetResult,
|
||||||
UpdateProblemInSetRequest,
|
UpdateProblemInSetRequest,
|
||||||
CreateProblemSetBadgeRequest,
|
CreateProblemSetBadgeRequest,
|
||||||
AdminAiReport,
|
AdminAiReport,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { achievementRaritySchema } from "./achievement"
|
|||||||
import { rankProfileSchema } from "./account"
|
import { rankProfileSchema } from "./account"
|
||||||
import { paginatedSchema, sampleUserSchema } from "./common"
|
import { paginatedSchema, sampleUserSchema } from "./common"
|
||||||
import { reactionKeySchema } from "./content"
|
import { reactionKeySchema } from "./content"
|
||||||
|
import { badgeConditionTypeSchema } from "./problemset"
|
||||||
import { problemLanguageSchema, runnableLanguageSchema } from "./language"
|
import { problemLanguageSchema, runnableLanguageSchema } from "./language"
|
||||||
import {
|
import {
|
||||||
astRuleSchema,
|
astRuleSchema,
|
||||||
@@ -358,17 +359,22 @@ export const updateAcmHelperRequestSchema = z.object({
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- 题单管理
|
// ---------------------------------------------------------------- 题单管理
|
||||||
|
|
||||||
|
// 难度、状态两列还留在表里(0026 没删列,删列要手工放行破坏性迁移),
|
||||||
|
// 但界面和接口都不再用:线上 16 个题单全是 Easy / active,「状态 + 可见」两道闸合成了「公开」
|
||||||
export const problemSetDifficultySchema = z.enum(["Easy", "Medium", "Hard"])
|
export const problemSetDifficultySchema = z.enum(["Easy", "Medium", "Hard"])
|
||||||
export const problemSetStatusSchema = z.enum(["draft", "active", "archived"])
|
export const problemSetStatusSchema = z.enum(["draft", "active", "archived"])
|
||||||
export const badgeConditionTypeSchema = z.enum(["all_problems", "problem_count", "score"])
|
|
||||||
|
|
||||||
export const adminProblemSetSchema = z.object({
|
export const adminProblemSetSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
difficulty: problemSetDifficultySchema,
|
/** 布置到哪天(东八区当天结束);null = 没布置 */
|
||||||
status: problemSetStatusSchema,
|
assignedUntil: z.string().nullable(),
|
||||||
endTime: z.string().nullable(),
|
/** 这一轮布置开始的时刻 */
|
||||||
|
assignedAt: z.string().nullable(),
|
||||||
|
/** 现在在不在布置期内(服务端的钟) */
|
||||||
|
assigning: z.boolean(),
|
||||||
|
/** 公开:学生能在题单列表里看到、能进 */
|
||||||
visible: z.boolean(),
|
visible: z.boolean(),
|
||||||
createdBy: sampleUserSchema,
|
createdBy: sampleUserSchema,
|
||||||
createTime: z.string(),
|
createTime: z.string(),
|
||||||
@@ -376,23 +382,22 @@ export const adminProblemSetSchema = z.object({
|
|||||||
problemsCount: z.number().int(),
|
problemsCount: z.number().int(),
|
||||||
/** 加入这份题单的人数,后台用来判断改动会影响多少人 */
|
/** 加入这份题单的人数,后台用来判断改动会影响多少人 */
|
||||||
participantCount: z.number().int(),
|
participantCount: z.number().int(),
|
||||||
|
/** 做完(必做题全对)的人数 */
|
||||||
|
completedCount: z.number().int(),
|
||||||
|
badgeCount: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const adminProblemSetListSchema = paginatedSchema(adminProblemSetSchema)
|
export const adminProblemSetListSchema = paginatedSchema(adminProblemSetSchema)
|
||||||
|
|
||||||
export const createProblemSetRequestSchema = z.object({
|
export const createProblemSetRequestSchema = z.object({
|
||||||
title: z.string().trim().min(1).max(200),
|
title: z.string().trim().min(1).max(200),
|
||||||
description: z.string(),
|
description: z.string().default(""),
|
||||||
difficulty: problemSetDifficultySchema.default("Easy"),
|
/** 布置到哪天;null = 不布置。从「没在布置」变成「布置到将来」时,服务端把布置开始记成现在 */
|
||||||
status: problemSetStatusSchema.default("active"),
|
assignedUntil: z.string().nullable().default(null),
|
||||||
endTime: z.string().nullable().default(null),
|
|
||||||
visible: z.boolean().default(true),
|
visible: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateProblemSetRequestSchema = createProblemSetRequestSchema
|
export const updateProblemSetRequestSchema = createProblemSetRequestSchema
|
||||||
export const updateProblemSetStatusRequestSchema = z.object({
|
|
||||||
status: problemSetStatusSchema,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const adminProblemSetProblemSchema = z.object({
|
export const adminProblemSetProblemSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
@@ -403,24 +408,28 @@ export const adminProblemSetProblemSchema = z.object({
|
|||||||
difficulty: z.string(),
|
difficulty: z.string(),
|
||||||
order: z.number().int(),
|
order: z.number().int(),
|
||||||
isRequired: z.boolean(),
|
isRequired: z.boolean(),
|
||||||
score: z.number().int(),
|
|
||||||
hint: z.string().nullable(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const addProblemToSetRequestSchema = z.object({
|
export const addProblemsToSetRequestSchema = z.object({
|
||||||
/** 展示用题号(_id),不是自增主键 —— 老师手里只有题号 */
|
/** 展示用题号(_id),不是自增主键 —— 老师手里只有题号。按给的顺序接在最后 */
|
||||||
problemId: z.string().trim().min(1),
|
problemIds: z.array(z.string().trim().min(1)).min(1).max(100),
|
||||||
order: z.number().int().default(0),
|
})
|
||||||
isRequired: z.boolean().default(true),
|
|
||||||
score: z.number().int().default(0),
|
export const addProblemsToSetResultSchema = z.object({
|
||||||
hint: z.string().default(""),
|
added: z.array(z.string()),
|
||||||
|
/** 题号不存在、不可见或是比赛题 */
|
||||||
|
missing: z.array(z.string()),
|
||||||
|
/** 本来就在题单里 */
|
||||||
|
duplicate: z.array(z.string()),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateProblemInSetRequestSchema = z.object({
|
export const updateProblemInSetRequestSchema = z.object({
|
||||||
order: z.number().int().optional(),
|
isRequired: z.boolean(),
|
||||||
isRequired: z.boolean().optional(),
|
})
|
||||||
score: z.number().int().optional(),
|
|
||||||
hint: z.string().optional(),
|
/** 拖动排序:题单里题目的链接 id,按新顺序给全 */
|
||||||
|
export const reorderProblemSetProblemsRequestSchema = z.object({
|
||||||
|
ids: z.array(z.number().int()).min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const adminProblemSetBadgeSchema = z.object({
|
export const adminProblemSetBadgeSchema = z.object({
|
||||||
@@ -438,27 +447,13 @@ export const adminProblemSetBadgeSchema = z.object({
|
|||||||
export const createProblemSetBadgeRequestSchema = z.object({
|
export const createProblemSetBadgeRequestSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(100),
|
name: z.string().trim().min(1).max(100),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
icon: z.string(),
|
icon: z.string().min(1),
|
||||||
conditionType: badgeConditionTypeSchema,
|
conditionType: badgeConditionTypeSchema,
|
||||||
conditionValue: z.number().int().default(0),
|
conditionValue: z.number().int().default(0),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateProblemSetBadgeRequestSchema = createProblemSetBadgeRequestSchema
|
export const updateProblemSetBadgeRequestSchema = createProblemSetBadgeRequestSchema
|
||||||
|
|
||||||
export const adminProblemSetProgressSchema = z.object({
|
|
||||||
id: z.number().int(),
|
|
||||||
userId: z.number().int(),
|
|
||||||
username: z.string(),
|
|
||||||
realName: z.string().nullable(),
|
|
||||||
joinTime: z.string(),
|
|
||||||
completeTime: z.string().nullable(),
|
|
||||||
isCompleted: z.boolean(),
|
|
||||||
progressPercentage: z.number(),
|
|
||||||
completedProblemsCount: z.number().int(),
|
|
||||||
totalProblemsCount: z.number().int(),
|
|
||||||
totalScore: z.number().int(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 标签与题目分析
|
// ---------------------------------------------------------------- 标签与题目分析
|
||||||
|
|
||||||
export const adminTagSchema = z.object({
|
export const adminTagSchema = z.object({
|
||||||
@@ -927,7 +922,6 @@ export type AstCheckRequest = z.infer<typeof astCheckRequestSchema>
|
|||||||
export type AstCheckResponse = z.infer<typeof astCheckResponseSchema>
|
export type AstCheckResponse = z.infer<typeof astCheckResponseSchema>
|
||||||
export type GenerateTestInputsRequest = z.infer<typeof generateTestInputsRequestSchema>
|
export type GenerateTestInputsRequest = z.infer<typeof generateTestInputsRequestSchema>
|
||||||
export type GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema>
|
export type GenerateTestInputsResponse = z.infer<typeof generateTestInputsResponseSchema>
|
||||||
export type AdminProblemSetProgress = z.infer<typeof adminProblemSetProgressSchema>
|
|
||||||
|
|
||||||
export type AdminAnnouncementList = z.infer<typeof adminAnnouncementListSchema>
|
export type AdminAnnouncementList = z.infer<typeof adminAnnouncementListSchema>
|
||||||
export type CreateAnnouncementRequest = z.infer<typeof createAnnouncementRequestSchema>
|
export type CreateAnnouncementRequest = z.infer<typeof createAnnouncementRequestSchema>
|
||||||
@@ -959,14 +953,16 @@ export type UpdateContestRequest = z.infer<typeof updateContestRequestSchema>
|
|||||||
export type UpdateAcmHelperRequest = z.infer<typeof updateAcmHelperRequestSchema>
|
export type UpdateAcmHelperRequest = z.infer<typeof updateAcmHelperRequestSchema>
|
||||||
export type ProblemSetDifficulty = z.infer<typeof problemSetDifficultySchema>
|
export type ProblemSetDifficulty = z.infer<typeof problemSetDifficultySchema>
|
||||||
export type ProblemSetStatus = z.infer<typeof problemSetStatusSchema>
|
export type ProblemSetStatus = z.infer<typeof problemSetStatusSchema>
|
||||||
export type BadgeConditionType = z.infer<typeof badgeConditionTypeSchema>
|
|
||||||
export type AdminProblemSet = z.infer<typeof adminProblemSetSchema>
|
export type AdminProblemSet = z.infer<typeof adminProblemSetSchema>
|
||||||
export type AdminProblemSetList = z.infer<typeof adminProblemSetListSchema>
|
export type AdminProblemSetList = z.infer<typeof adminProblemSetListSchema>
|
||||||
export type CreateProblemSetRequest = z.infer<typeof createProblemSetRequestSchema>
|
export type CreateProblemSetRequest = z.infer<typeof createProblemSetRequestSchema>
|
||||||
export type UpdateProblemSetRequest = z.infer<typeof updateProblemSetRequestSchema>
|
export type UpdateProblemSetRequest = z.infer<typeof updateProblemSetRequestSchema>
|
||||||
export type UpdateProblemSetStatusRequest = z.infer<typeof updateProblemSetStatusRequestSchema>
|
|
||||||
export type AdminProblemSetProblem = z.infer<typeof adminProblemSetProblemSchema>
|
export type AdminProblemSetProblem = z.infer<typeof adminProblemSetProblemSchema>
|
||||||
export type AddProblemToSetRequest = z.infer<typeof addProblemToSetRequestSchema>
|
export type AddProblemsToSetRequest = z.infer<typeof addProblemsToSetRequestSchema>
|
||||||
|
export type AddProblemsToSetResult = z.infer<typeof addProblemsToSetResultSchema>
|
||||||
|
export type ReorderProblemSetProblemsRequest = z.infer<
|
||||||
|
typeof reorderProblemSetProblemsRequestSchema
|
||||||
|
>
|
||||||
export type UpdateProblemInSetRequest = z.infer<typeof updateProblemInSetRequestSchema>
|
export type UpdateProblemInSetRequest = z.infer<typeof updateProblemInSetRequestSchema>
|
||||||
export type AdminProblemSetBadge = z.infer<typeof adminProblemSetBadgeSchema>
|
export type AdminProblemSetBadge = z.infer<typeof adminProblemSetBadgeSchema>
|
||||||
export type CreateProblemSetBadgeRequest = z.infer<typeof createProblemSetBadgeRequestSchema>
|
export type CreateProblemSetBadgeRequest = z.infer<typeof createProblemSetBadgeRequestSchema>
|
||||||
|
|||||||
@@ -3,23 +3,38 @@ import { z } from "zod"
|
|||||||
import { paginatedSchema, sampleUserSchema } from "./common"
|
import { paginatedSchema, sampleUserSchema } from "./common"
|
||||||
import { maskedProblemDifficultySchema } from "./problem"
|
import { maskedProblemDifficultySchema } from "./problem"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我在这份题单里的进度。
|
||||||
|
*
|
||||||
|
* 两个计数口径不同,别混用:
|
||||||
|
* - completedCount / totalCount 只数**必做题**,进度条和「做完了」看它;
|
||||||
|
* - solvedCount 数做对的**全部**题(含选做),「做对 N 道」奖章看它 ——
|
||||||
|
* 和后端 eligibleForBadge 的 problem_count 同一个口径。
|
||||||
|
*/
|
||||||
export const problemSetUserProgressSummarySchema = z.object({
|
export const problemSetUserProgressSummarySchema = z.object({
|
||||||
isJoined: z.boolean(),
|
isJoined: z.boolean(),
|
||||||
progressPercentage: z.number(),
|
joinTime: z.string().nullable(),
|
||||||
completedCount: z.number().int(),
|
completedCount: z.number().int(),
|
||||||
totalCount: z.number().int(),
|
totalCount: z.number().int(),
|
||||||
|
solvedCount: z.number().int(),
|
||||||
isCompleted: z.boolean(),
|
isCompleted: z.boolean(),
|
||||||
|
completeTime: z.string().nullable(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 奖章只有两种条件:全部做完、做对 N 道(「总分达到 N」随分数一起拿掉了,0026 换算过) */
|
||||||
|
export const badgeConditionTypeSchema = z.enum(["all_problems", "problem_count"])
|
||||||
|
|
||||||
export const problemSetBadgeSchema = z.object({
|
export const problemSetBadgeSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
problemSetId: z.number().int(),
|
problemSetId: z.number().int(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
icon: z.string(),
|
icon: z.string(),
|
||||||
conditionType: z.enum(["all_problems", "problem_count", "score"]),
|
conditionType: badgeConditionTypeSchema,
|
||||||
conditionValue: z.number().int(),
|
conditionValue: z.number().int(),
|
||||||
isEarned: z.boolean().optional(),
|
isEarned: z.boolean().optional(),
|
||||||
|
/** 我拿到它的时刻;没拿到、没登录为 null */
|
||||||
|
earnedTime: z.string().nullable().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const problemSetSchema = z.object({
|
export const problemSetSchema = z.object({
|
||||||
@@ -28,26 +43,26 @@ export const problemSetSchema = z.object({
|
|||||||
description: z.string(),
|
description: z.string(),
|
||||||
createdBy: sampleUserSchema,
|
createdBy: sampleUserSchema,
|
||||||
createTime: z.string(),
|
createTime: z.string(),
|
||||||
lastUpdateTime: z.string().optional(),
|
/** 布置到哪天(东八区当天结束的时刻);null = 没布置 */
|
||||||
difficulty: z.enum(["Easy", "Medium", "Hard"]),
|
assignedUntil: z.string().nullable(),
|
||||||
status: z.enum(["active", "archived", "draft"]),
|
/** 现在是不是在布置期内。服务端按自己的钟算好,前端不用拿本机时间去比 */
|
||||||
endTime: z.string().nullable(),
|
assigning: z.boolean(),
|
||||||
visible: z.boolean(),
|
/** 全部题数(含选做) */
|
||||||
problemsCount: z.number().int(),
|
problemsCount: z.number().int(),
|
||||||
completedCount: z.number().int().optional(),
|
/** 必做题数(一道必做都没标时等于全部题数)——「做完」的分母,没加入时也要用 */
|
||||||
|
requiredCount: z.number().int(),
|
||||||
|
/** 加入过的人数 */
|
||||||
|
joinedCount: z.number().int(),
|
||||||
userProgress: problemSetUserProgressSummarySchema,
|
userProgress: problemSetUserProgressSummarySchema,
|
||||||
badges: z.array(problemSetBadgeSchema).optional(),
|
badges: z.array(problemSetBadgeSchema),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const problemSetListSchema = paginatedSchema(problemSetSchema)
|
export const problemSetListSchema = paginatedSchema(problemSetSchema)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题单页的题目卡片只渲染题号、标题、难度,所以只下发这三样(外加题目主键)。
|
* 题单页的题目行只渲染题号、标题,所以只下发这几样(外加题目主键)。
|
||||||
*
|
* 以前复用 problemListItemSchema,服务端每次要多 join 一次 user / 标签表,
|
||||||
* 以前这里复用 problemListItemSchema,代价是服务端每次都要多 join 一次 user +
|
* 而那些字段题单页一个都不渲染。
|
||||||
* user_profile 凑 createdBy、再多查一次标签表凑 tags,而 tags / submissionNumber /
|
|
||||||
* acceptedNumber / createdBy / flowchart / hasAstRules / myStatus 在题单页一个都不渲染
|
|
||||||
* —— myStatus 甚至是写死的 null。
|
|
||||||
*/
|
*/
|
||||||
export const problemSetProblemItemSchema = z.object({
|
export const problemSetProblemItemSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
@@ -62,42 +77,72 @@ export const problemSetProblemSchema = z.object({
|
|||||||
problem: problemSetProblemItemSchema,
|
problem: problemSetProblemItemSchema,
|
||||||
order: z.number().int(),
|
order: z.number().int(),
|
||||||
isRequired: z.boolean(),
|
isRequired: z.boolean(),
|
||||||
score: z.number().int(),
|
/** 在这份题单里做对了没有(代码通过,或流程图拿到 A / S) */
|
||||||
hint: z.string().nullable(),
|
|
||||||
isCompleted: z.boolean(),
|
isCompleted: z.boolean(),
|
||||||
|
/** 在这份题单里做对的时刻 */
|
||||||
|
solvedTime: z.string().nullable(),
|
||||||
|
/** 在这份题单里交错了几次(还没做对时显示「交过 n 次」) */
|
||||||
|
wrongCount: z.number().int(),
|
||||||
|
/**
|
||||||
|
* 布置期内、以前做对过、旧代码正被藏着 —— 行上标「以前做对过 · 旧代码先藏着」。
|
||||||
|
* 规则见后端 services/problemset.ts 的 problemSetLockCutoffs
|
||||||
|
*/
|
||||||
|
oldCodeHidden: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const joinProblemSetRequestSchema = z.object({
|
export const joinProblemSetRequestSchema = z.object({
|
||||||
problemSetId: z.number().int().positive(),
|
problemSetId: z.number().int().positive(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const completedProblemSchema = z.object({
|
/** 老师看某个班在这份题单里的情况:真名 × 每道题 */
|
||||||
|
export const problemSetClassCellSchema = z.object({
|
||||||
|
/** 在题单里做对的时刻 */
|
||||||
|
solvedTime: z.string().nullable(),
|
||||||
|
/** 在题单里交错的次数 */
|
||||||
|
wrongCount: z.number().int(),
|
||||||
|
/** 加入之前就做对过这道题(任何入口) */
|
||||||
|
solvedBefore: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const problemSetClassStudentSchema = z.object({
|
||||||
|
userId: z.number().int(),
|
||||||
|
username: z.string(),
|
||||||
|
realName: z.string().nullable(),
|
||||||
|
/** 没加入为 null */
|
||||||
|
joinTime: z.string().nullable(),
|
||||||
|
/** 做对的必做题数 */
|
||||||
|
completedCount: z.number().int(),
|
||||||
|
cells: z.array(problemSetClassCellSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const problemSetClassViewSchema = z.object({
|
||||||
|
/** 加入过这份题单的学生分属哪些班:三分之一以上的人加入了的班在前(按最近一次加入),零星几个人的在后 */
|
||||||
|
classes: z.array(
|
||||||
|
z.object({ className: z.string(), joined: z.number().int(), size: z.number().int() }),
|
||||||
|
),
|
||||||
|
/** 这次看的是哪个班;谁都没加入时为 null */
|
||||||
|
className: z.string().nullable(),
|
||||||
|
problems: z.array(
|
||||||
|
z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
_id: z.string(),
|
_id: z.string(),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
})
|
isRequired: z.boolean(),
|
||||||
|
/** 这个班在题单里做对的人数 */
|
||||||
export const problemSetProgressSchema = z.object({
|
solved: z.number().int(),
|
||||||
id: z.number().int(),
|
|
||||||
problemSetId: z.number().int(),
|
|
||||||
user: sampleUserSchema,
|
|
||||||
joinTime: z.string(),
|
|
||||||
completeTime: z.string().nullable(),
|
|
||||||
isCompleted: z.boolean(),
|
|
||||||
progressPercentage: z.number(),
|
|
||||||
completedProblemsCount: z.number().int(),
|
|
||||||
totalProblemsCount: z.number().int(),
|
|
||||||
totalScore: z.number().int(),
|
|
||||||
completedProblems: z.array(completedProblemSchema),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const problemSetProgressListSchema = paginatedSchema(problemSetProgressSchema).extend({
|
|
||||||
statistics: z.object({
|
|
||||||
total: z.number().int(),
|
|
||||||
completed: z.number().int(),
|
|
||||||
avgProgress: z.number(),
|
|
||||||
}),
|
}),
|
||||||
problems: z.array(completedProblemSchema),
|
),
|
||||||
|
/** 必做题数(全是选做时等于全部题数),「做完」的分母 */
|
||||||
|
totalCount: z.number().int(),
|
||||||
|
/** 这个班的全部学生(按学号),没加入的 joinTime 为 null */
|
||||||
|
students: z.array(problemSetClassStudentSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 某道题为什么藏着旧代码:落在哪些正在布置的题单里 */
|
||||||
|
export const problemSetLockSchema = z.object({
|
||||||
|
problemSetId: z.number().int(),
|
||||||
|
title: z.string(),
|
||||||
|
assignedUntil: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const userBadgeSchema = z.object({
|
export const userBadgeSchema = z.object({
|
||||||
@@ -112,10 +157,11 @@ export type ProblemSet = z.infer<typeof problemSetSchema>
|
|||||||
export type ProblemSetList = z.infer<typeof problemSetListSchema>
|
export type ProblemSetList = z.infer<typeof problemSetListSchema>
|
||||||
export type ProblemSetBadge = z.infer<typeof problemSetBadgeSchema>
|
export type ProblemSetBadge = z.infer<typeof problemSetBadgeSchema>
|
||||||
export type ProblemSetProblem = z.infer<typeof problemSetProblemSchema>
|
export type ProblemSetProblem = z.infer<typeof problemSetProblemSchema>
|
||||||
export type ProblemSetProgress = z.infer<typeof problemSetProgressSchema>
|
export type ProblemSetClassView = z.infer<typeof problemSetClassViewSchema>
|
||||||
export type ProblemSetProgressList = z.infer<typeof problemSetProgressListSchema>
|
export type ProblemSetClassStudent = z.infer<typeof problemSetClassStudentSchema>
|
||||||
|
export type ProblemSetLock = z.infer<typeof problemSetLockSchema>
|
||||||
export type UserBadge = z.infer<typeof userBadgeSchema>
|
export type UserBadge = z.infer<typeof userBadgeSchema>
|
||||||
export type CompletedProblem = z.infer<typeof completedProblemSchema>
|
export type BadgeConditionType = z.infer<typeof badgeConditionTypeSchema>
|
||||||
|
|
||||||
export type ProblemSetUserProgressSummary = z.infer<typeof problemSetUserProgressSummarySchema>
|
export type ProblemSetUserProgressSummary = z.infer<typeof problemSetUserProgressSummarySchema>
|
||||||
export type JoinProblemSetRequest = z.infer<typeof joinProblemSetRequestSchema>
|
export type JoinProblemSetRequest = z.infer<typeof joinProblemSetRequestSchema>
|
||||||
Reference in new issue
Block a user