feat(题目列表): 学生自己挑题时只看得到一堆标签和提交数,不知道自己练到哪了——改成左栏知识点进度、顶上先说做到了什么
Deploy / deploy (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
- 左栏是知识点(可切到主题)和自己的进度:做了一部分写 37/96 加进度条,全做对写「通关」, 一道没做对过的只写题数,不再是一整列 0/108 - 顶上一行:做对几道 · 这周 +n(或连续几周都有做对)· 离哪个成就最近 ·「有 n 道差一点」 (点开才列出来);新生引去「输出入门」;一言照旧在右边 - 每题最后一列是做对 / 做过的人数,代替提交数和按提交算的通过率(和题目页统计同一个口径) - 「画流程图 / 语法要求 / 有参考图」挂在题目名后面,「全部类型」下拉单独筛这三类, 不再混在排序里;题目页的「代码要求」统一叫「语法要求」 - 「只看没做完」开关(默认关)、「随便来一道」(当前知识点里先挑没做对的简单 / 中等题) - 画流程图的题,流程图评到 A / S 在状态列也打勾,和题单、课堂条一个口径 - 老师看不到状态列和个人进度;出题者筛选保留 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ba8928d41
commit
ed7752afbb
14 files changed
+2083
-339
No files matched your search
@@ -245,6 +245,9 @@ contestRoutes.get(
|
||||
showFlowchart: problem.showFlowchart,
|
||||
hasAstRules: problem.astRules !== null,
|
||||
myStatus: myStatusOf(statuses, problem.id),
|
||||
// 按人数是公开题库列表的列;比赛题表看的是榜单,不下发
|
||||
solvedUsers: 0,
|
||||
triedUsers: 0,
|
||||
}) satisfies ProblemListItem,
|
||||
),
|
||||
)
|
||||
|
||||
+116
-35
@@ -1,4 +1,13 @@
|
||||
import type { ProblemAuthor, ProblemDetail, ProblemList, ProblemListItem, Tag } from "@oj2/contract"
|
||||
import {
|
||||
problemListSortSchema,
|
||||
problemTypeFilterSchema,
|
||||
type ProblemAuthor,
|
||||
type ProblemDetail,
|
||||
type ProblemList,
|
||||
type ProblemListItem,
|
||||
type ProblemProgress,
|
||||
type Tag,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
@@ -21,6 +30,11 @@ import { db, schema } from "../db"
|
||||
import { astRequirements } from "../judge/ast"
|
||||
import { failure, success } from "../http"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import {
|
||||
buildProblemProgress,
|
||||
problemStates,
|
||||
problemUserCounts,
|
||||
} from "../services/problem-progress"
|
||||
import {
|
||||
asFilterValue,
|
||||
countFailedSubmissions,
|
||||
@@ -67,6 +81,9 @@ async function getProblemTags(problemIds: number[]) {
|
||||
return result
|
||||
}
|
||||
|
||||
type ProblemStates = Awaited<ReturnType<typeof problemStates>> | null
|
||||
type UserCounts = Awaited<ReturnType<typeof problemUserCounts>>
|
||||
|
||||
function listItem(
|
||||
row: {
|
||||
problem: typeof schema.problem.$inferSelect
|
||||
@@ -75,26 +92,43 @@ function listItem(
|
||||
},
|
||||
tags: Map<number, string[]>,
|
||||
statuses: Record<string, unknown>,
|
||||
states: ProblemStates,
|
||||
counts: UserCounts,
|
||||
) {
|
||||
const status = asRecord(statuses[String(row.problem.id)]).status
|
||||
const id = row.problem.id
|
||||
return {
|
||||
id: row.problem.id,
|
||||
id,
|
||||
_id: row.problem.displayId,
|
||||
title: row.problem.title,
|
||||
submissionNumber: row.problem.submissionNumber,
|
||||
acceptedNumber: row.problem.acceptedNumber,
|
||||
difficulty: row.problem.difficulty,
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
tags: tags.get(row.problem.id) ?? [],
|
||||
tags: tags.get(id) ?? [],
|
||||
contestId: row.problem.contestId,
|
||||
allowFlowchart: row.problem.allowFlowchart,
|
||||
showFlowchart: row.problem.showFlowchart,
|
||||
hasAstRules: row.problem.astRules !== null,
|
||||
myStatus: typeof status === "number" ? status : null,
|
||||
// 流程图评到 A / S 也打勾:problemStates 已经把它算进 solved
|
||||
myStatus: states?.solved.has(id)
|
||||
? JudgeStatus.ACCEPTED
|
||||
: typeof status === "number"
|
||||
? status
|
||||
: null,
|
||||
solvedUsers: counts.get(id)?.solved ?? 0,
|
||||
triedUsers: counts.get(id)?.tried ?? 0,
|
||||
} satisfies ProblemListItem
|
||||
}
|
||||
|
||||
/** 题号排序:先按长度再按字面,1001 < 1100 < P026 < SQL09,不会排成 1001、1100、11、2 */
|
||||
const displayIdOrder = [
|
||||
asc(sql`length(${schema.problem.displayId})`),
|
||||
asc(schema.problem.displayId),
|
||||
]
|
||||
|
||||
problemRoutes.get("/problems", optionalAuth, async (c) => {
|
||||
const user = c.get("user")
|
||||
const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const filters = [eq(schema.problem.visible, true), isNull(schema.problem.contestId)]
|
||||
@@ -102,6 +136,9 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
const difficulty = c.req.query("difficulty")?.trim()
|
||||
const tag = c.req.query("tag")?.trim()
|
||||
const type = problemTypeFilterSchema.safeParse(c.req.query("type")).data
|
||||
// 没登录时「只看没做完」在前端是灰的;直接拼地址来的就当没开
|
||||
const undone = c.req.query("undone") === "1" && !!user
|
||||
if (author) filters.push(eq(schema.user.username, author))
|
||||
if (keyword)
|
||||
filters.push(
|
||||
@@ -123,31 +160,32 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
|
||||
),
|
||||
)
|
||||
}
|
||||
if (type === "reference") filters.push(eq(schema.problem.showFlowchart, true))
|
||||
if (type === "flowchart") filters.push(eq(schema.problem.allowFlowchart, true))
|
||||
if (type === "ast") filters.push(sql`${schema.problem.astRules} is not null`)
|
||||
|
||||
const states = user ? await problemStates(user.id) : null
|
||||
if (undone && states && states.solved.size > 0)
|
||||
filters.push(notInArray(schema.problem.id, [...states.solved]))
|
||||
|
||||
const where = and(...filters)
|
||||
const sort = c.req.query("sort")
|
||||
// 默认:选了知识点按题号(同一系列的题挨在一起),全部题目按最新创建。
|
||||
// 旧的排序值(最多提交、画流程图……)落到默认 —— 类型已经拆成了单独的筛选
|
||||
const sort = problemListSortSchema.safeParse(c.req.query("sort") ?? "").data ?? ""
|
||||
const effective = sort || (tag ? "id" : "new")
|
||||
const allCounts = effective === "popular" ? await problemUserCounts() : null
|
||||
const popularity = allCounts
|
||||
? sql`case ${schema.problem.id} ${sql.join(
|
||||
[...allCounts].map(([id, n]) => sql`when ${id} then ${n.solved}`),
|
||||
sql` `,
|
||||
)} else 0 end`
|
||||
: null
|
||||
const order =
|
||||
sort === "flowchart"
|
||||
? [
|
||||
desc(schema.problem.allowFlowchart),
|
||||
desc(schema.problem.showFlowchart),
|
||||
desc(schema.problem.createTime),
|
||||
]
|
||||
: sort === "ast"
|
||||
? [desc(sql`(${schema.problem.astRules} is not null)`), desc(schema.problem.createTime)]
|
||||
: sort === "-accepted_number"
|
||||
? [desc(schema.problem.acceptedNumber)]
|
||||
: sort === "accepted_number"
|
||||
? [asc(schema.problem.acceptedNumber)]
|
||||
: sort === "-submission_number"
|
||||
? [desc(schema.problem.submissionNumber)]
|
||||
: sort === "submission_number"
|
||||
? [asc(schema.problem.submissionNumber)]
|
||||
: sort === "difficulty"
|
||||
? [asc(schema.problem.difficulty)]
|
||||
: sort === "create_time"
|
||||
? [asc(schema.problem.createTime)]
|
||||
: [desc(schema.problem.createTime)]
|
||||
effective === "id"
|
||||
? displayIdOrder
|
||||
: popularity
|
||||
? [desc(popularity), ...displayIdOrder]
|
||||
: [desc(schema.problem.createTime)]
|
||||
const [totalRow] = await db
|
||||
.select({ value: countDistinct(schema.problem.id) })
|
||||
.from(schema.problem)
|
||||
@@ -166,16 +204,28 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
|
||||
.orderBy(...order)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
const [tags, statuses] = await Promise.all([
|
||||
getProblemTags(rows.map((row) => row.problem.id)),
|
||||
getProblemStatuses(c.get("user")?.id),
|
||||
const ids = rows.map((row) => row.problem.id)
|
||||
const [tags, statuses, counts] = await Promise.all([
|
||||
getProblemTags(ids),
|
||||
getProblemStatuses(user?.id),
|
||||
allCounts ?? problemUserCounts(ids),
|
||||
])
|
||||
return success(c, {
|
||||
results: rows.map((row) => listItem(row, tags, statuses)),
|
||||
results: rows.map((row) => listItem(row, tags, statuses, states, counts)),
|
||||
total: totalRow?.value ?? 0,
|
||||
} satisfies ProblemList)
|
||||
})
|
||||
|
||||
/**
|
||||
* 题目列表顶上那行和左栏的进度。没登录给 null(前端那行换成「登录以后……」)。
|
||||
* 注册在 /problems/:displayId 前面,不然被它吃掉。
|
||||
*/
|
||||
problemRoutes.get("/problems/progress", optionalAuth, async (c) => {
|
||||
const user = c.get("user")
|
||||
if (!user) return success(c, null)
|
||||
return success(c, (await buildProblemProgress(user.id)) satisfies ProblemProgress)
|
||||
})
|
||||
|
||||
problemRoutes.get("/problem-tags", async (c) => {
|
||||
const keyword = c.req.query("keyword")?.trim()
|
||||
// 只数公开题库里可见的题:隐藏的题和比赛题都不算,否则标签会出现在
|
||||
@@ -204,12 +254,42 @@ problemRoutes.get("/problem-tags", async (c) => {
|
||||
return success(c, rows satisfies Tag[])
|
||||
})
|
||||
|
||||
problemRoutes.get("/problems/random", async (c) => {
|
||||
/**
|
||||
* 「随便来一道」:范围是当前知识点(不传就是全部),先挑自己还没做对的简单 / 中等题,
|
||||
* 没有就放宽到没做对的困难题,再没有就随便一道(全做对了也给一道,不让按钮落空)。
|
||||
*/
|
||||
problemRoutes.get("/problems/random", optionalAuth, async (c) => {
|
||||
const user = c.get("user")
|
||||
const tag = c.req.query("tag")?.trim()
|
||||
const states = user ? await problemStates(user.id) : null
|
||||
const solved = states ? [...states.solved] : []
|
||||
const [row] = await db
|
||||
.select({ displayId: schema.problem.displayId })
|
||||
.from(schema.problem)
|
||||
.where(and(eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
|
||||
.orderBy(sql`random()`)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.problem.visible, true),
|
||||
isNull(schema.problem.contestId),
|
||||
tag
|
||||
? inArray(
|
||||
schema.problem.id,
|
||||
db
|
||||
.select({ id: schema.problemTags.problemId })
|
||||
.from(schema.problemTags)
|
||||
.innerJoin(
|
||||
schema.problemTag,
|
||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||
)
|
||||
.where(eq(schema.problemTag.name, tag)),
|
||||
)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
solved.length ? asc(inArray(schema.problem.id, solved)) : sql`1`,
|
||||
asc(sql`${schema.problem.difficulty} = 'High'`),
|
||||
sql`random()`,
|
||||
)
|
||||
.limit(1)
|
||||
if (!row) return failure(c, 404, "no-problems", "No problem to pick")
|
||||
return success(c, row.displayId)
|
||||
@@ -303,10 +383,11 @@ problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
|
||||
asc(schema.problem.id),
|
||||
)
|
||||
.limit(5)
|
||||
const tags = await getProblemTags(rows.map((row) => row.problem.id))
|
||||
const ids = rows.map((row) => row.problem.id)
|
||||
const [tags, counts] = await Promise.all([getProblemTags(ids), problemUserCounts(ids)])
|
||||
return success(
|
||||
c,
|
||||
rows.map((row) => listItem(row, tags, statuses)),
|
||||
rows.map((row) => listItem(row, tags, statuses, null, counts)),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { FLOWCHART_PASS_GRADES, type ProblemProgress } from "@oj2/contract"
|
||||
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
import { isAccepted, UNJUDGED_RESULTS } from "../judge/status"
|
||||
import { asRecord } from "../routes/helpers"
|
||||
import { calendarDay, dayNumber, dayText, weekStart } from "../time"
|
||||
import { accepted } from "./learning-stats"
|
||||
|
||||
/**
|
||||
* 一个人在公开题库里的做题状态:做对了哪些、交过哪些。
|
||||
*
|
||||
* 代码看 `acm_problems_status`(列表的状态列一直就是它);画流程图的题,流程图评到
|
||||
* A / S 也算做对、交过流程图就算交过 —— 和题单、课堂条一个口径(题目页设计文档
|
||||
* 第 3 节决定 6)。有的题一节课全班都在画流程图,只看代码的话状态列是空的。
|
||||
*/
|
||||
export async function problemStates(userId: number) {
|
||||
const [[profile], flowRows] = await Promise.all([
|
||||
db
|
||||
.select({ value: schema.userProfile.acmProblemsStatus })
|
||||
.from(schema.userProfile)
|
||||
.where(eq(schema.userProfile.userId, userId))
|
||||
.limit(1),
|
||||
db
|
||||
.select({
|
||||
problemId: schema.flowchartSubmission.problemId,
|
||||
passed: sql<boolean>`bool_or(${schema.flowchartSubmission.status} = 2 and ${inArray(
|
||||
schema.flowchartSubmission.aiGrade,
|
||||
[...FLOWCHART_PASS_GRADES],
|
||||
)})`,
|
||||
})
|
||||
.from(schema.flowchartSubmission)
|
||||
.where(eq(schema.flowchartSubmission.userId, userId))
|
||||
.groupBy(schema.flowchartSubmission.problemId),
|
||||
])
|
||||
const solved = new Set<number>()
|
||||
const tried = new Set<number>()
|
||||
for (const [key, raw] of Object.entries(asRecord(asRecord(profile?.value).problems))) {
|
||||
const id = Number(key)
|
||||
const status = asRecord(raw).status
|
||||
if (!Number.isInteger(id) || typeof status !== "number") continue
|
||||
tried.add(id)
|
||||
if (isAccepted(status)) solved.add(id)
|
||||
}
|
||||
for (const row of flowRows) {
|
||||
tried.add(row.problemId)
|
||||
if (row.passed) solved.add(row.problemId)
|
||||
}
|
||||
return { solved, tried }
|
||||
}
|
||||
|
||||
/**
|
||||
* 每道题做对 / 交过的人数,和题目页「统计」页签(`problem-stats.ts`)同一个口径:
|
||||
* 代码提交,不算还在判的,AST_CHECK_FAILED 也是做对。不传 ids 就是全部题(排序用,全表约 85ms)。
|
||||
*/
|
||||
export async function problemUserCounts(problemIds?: number[]) {
|
||||
const result = new Map<number, { solved: number; tried: number }>()
|
||||
if (problemIds && problemIds.length === 0) return result
|
||||
const rows = await db
|
||||
.select({
|
||||
problemId: schema.submission.problemId,
|
||||
solved: sql<number>`count(distinct ${schema.submission.userId}) filter (where ${inArray(
|
||||
schema.submission.result,
|
||||
accepted,
|
||||
)})`.mapWith(Number),
|
||||
tried: sql<number>`count(distinct ${schema.submission.userId})`.mapWith(Number),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(
|
||||
and(
|
||||
isNull(schema.submission.contestId),
|
||||
sql`${schema.submission.result} not in (${sql.join(
|
||||
UNJUDGED_RESULTS.map((value) => sql`${value}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
problemIds ? inArray(schema.submission.problemId, problemIds) : undefined,
|
||||
),
|
||||
)
|
||||
.groupBy(schema.submission.problemId)
|
||||
for (const row of rows) result.set(row.problemId, { solved: row.solved, tried: row.tried })
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 顶行提示哪个成就。只挑做题能推动的几项(提交次数、编译错误这类不该鼓励),
|
||||
* 隐藏成就不提示(会泄露门槛),完成比例最高的那个;一样高按下面的先后挑 ——
|
||||
* 新生所有成就都是 0,不按先后的话会挑中「做对 1 道困难题」,把刚来的人往最难的题上引。
|
||||
*/
|
||||
const HINT_METRICS = [
|
||||
"accepted_count",
|
||||
"mid_ac_count",
|
||||
"hard_ac_count",
|
||||
"active_days",
|
||||
"max_ac_week_streak",
|
||||
]
|
||||
|
||||
function numberOf(metrics: Record<string, unknown>, key: string) {
|
||||
const value = metrics[key]
|
||||
return typeof value === "number" ? value : 0
|
||||
}
|
||||
|
||||
export async function buildProblemProgress(userId: number): Promise<ProblemProgress> {
|
||||
const [states, statRows, achievements, unlockedRows] = await Promise.all([
|
||||
problemStates(userId),
|
||||
db
|
||||
.select({ metrics: schema.userStat.metrics })
|
||||
.from(schema.userStat)
|
||||
.where(eq(schema.userStat.userId, userId))
|
||||
.limit(1),
|
||||
db
|
||||
.select()
|
||||
.from(schema.achievement)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.achievement.visible, true),
|
||||
eq(schema.achievement.hidden, false),
|
||||
eq(schema.achievement.operator, "gte"),
|
||||
inArray(schema.achievement.metric, HINT_METRICS),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ id: schema.userAchievement.achievementId })
|
||||
.from(schema.userAchievement)
|
||||
.where(eq(schema.userAchievement.userId, userId)),
|
||||
])
|
||||
const metrics = asRecord(statRows[0]?.metrics)
|
||||
|
||||
// 只数公开可见的题:隐藏题、比赛题在左栏里本来就不出现
|
||||
const visibleRows = await db
|
||||
.select({
|
||||
id: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
})
|
||||
.from(schema.problem)
|
||||
.where(and(eq(schema.problem.visible, true), isNull(schema.problem.contestId)))
|
||||
.orderBy(asc(sql`length(${schema.problem.displayId})`), asc(schema.problem.displayId))
|
||||
const visible = new Set(visibleRows.map((row) => row.id))
|
||||
const solvedVisible = [...states.solved].filter((id) => visible.has(id))
|
||||
const byTag: Record<string, number> = {}
|
||||
if (solvedVisible.length > 0) {
|
||||
const rows = await db
|
||||
.select({
|
||||
tagId: schema.problemTags.problemtagId,
|
||||
n: sql<number>`count(*)`.mapWith(Number),
|
||||
})
|
||||
.from(schema.problemTags)
|
||||
.where(inArray(schema.problemTags.problemId, solvedVisible))
|
||||
.groupBy(schema.problemTags.problemtagId)
|
||||
for (const row of rows) byTag[String(row.tagId)] = row.n
|
||||
}
|
||||
const almost = visibleRows
|
||||
.filter((row) => states.tried.has(row.id) && !states.solved.has(row.id))
|
||||
.map((row) => ({ _id: row.displayId, title: row.title }))
|
||||
|
||||
// 这周:_ac_per_day 的键是东八区日历日,周一起算(和周榜、连续周数同一个口径)
|
||||
const monday = calendarDay(weekStart())
|
||||
let weekSolved = 0
|
||||
for (const [day, n] of Object.entries(asRecord(metrics._ac_per_day))) {
|
||||
if (day >= monday && typeof n === "number") weekSolved += n
|
||||
}
|
||||
// 当前连续周数只在最近一次新通过落在本周或上周时还作数,再早就已经断了
|
||||
const lastWeek = typeof metrics._last_ac_week === "string" ? metrics._last_ac_week : ""
|
||||
const previousMonday = dayText(dayNumber(monday) - 7)
|
||||
const weekStreak =
|
||||
lastWeek === monday || lastWeek === previousMonday
|
||||
? numberOf(metrics, "_current_ac_week_streak")
|
||||
: 0
|
||||
|
||||
const unlocked = new Set(unlockedRows.map((row) => row.id))
|
||||
let next: ProblemProgress["next"] = null
|
||||
let best = -1
|
||||
for (const achievement of achievements) {
|
||||
if (unlocked.has(achievement.id) || achievement.threshold <= 0) continue
|
||||
// 连续周数看的是「现在连着几周」,不是历史最长:断过的人从头数
|
||||
const progress =
|
||||
achievement.metric === "max_ac_week_streak"
|
||||
? weekStreak
|
||||
: numberOf(metrics, achievement.metric)
|
||||
if (progress >= achievement.threshold) continue
|
||||
const ratio = progress / achievement.threshold
|
||||
// 一样高:先按指标先后,同一个指标挑门槛低的(「初出茅庐」而不是「渐入佳境」)
|
||||
const order = HINT_METRICS.indexOf(achievement.metric)
|
||||
const tie =
|
||||
next !== null &&
|
||||
ratio === best &&
|
||||
(order < HINT_METRICS.indexOf(next.metric) ||
|
||||
(achievement.metric === next.metric && achievement.threshold < next.threshold))
|
||||
if (ratio > best || tie) {
|
||||
best = ratio
|
||||
next = {
|
||||
name: achievement.name,
|
||||
icon: achievement.icon,
|
||||
rarity: achievement.rarity,
|
||||
metric: achievement.metric,
|
||||
threshold: achievement.threshold,
|
||||
progress,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
solved: numberOf(metrics, "accepted_count"),
|
||||
weekSolved,
|
||||
weekStreak,
|
||||
almost,
|
||||
byTag,
|
||||
next,
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type ProblemListItem,
|
||||
type ProblemStats,
|
||||
type ProblemList,
|
||||
type ProblemProgress,
|
||||
type CreateFlowchartResponse,
|
||||
type FlowchartScores,
|
||||
type FlowchartList,
|
||||
@@ -94,6 +95,16 @@ export async function getProblemList(
|
||||
}
|
||||
}
|
||||
|
||||
/** 题目列表顶上那行和左栏的进度;没登录是 null */
|
||||
export function getProblemProgress() {
|
||||
return api.get<ProblemProgress | null>("problems/progress")
|
||||
}
|
||||
|
||||
/** 「随便来一道」:当前知识点里先挑自己没做对的简单 / 中等题,返回题号 */
|
||||
export function getRandomProblem(tag: string) {
|
||||
return api.get<string>("problems/random", { params: tag ? { tag } : {} })
|
||||
}
|
||||
|
||||
export function getAuthors(all = false) {
|
||||
return api.get<ProblemAuthor[]>("problem-authors", {
|
||||
params: { all: all ? "1" : "0" },
|
||||
|
||||
@@ -164,9 +164,9 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 代码要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了 -->
|
||||
<!-- 语法要求(AST 规则)放在最前面:它是硬性的判定条件,写到一半才看见就晚了 -->
|
||||
<div v-if="astRequirements.length > 0" class="requirements">
|
||||
<span class="requirements-head">代码要求</span>
|
||||
<span class="requirements-head">语法要求</span>
|
||||
<template v-for="[lang, rules] in astRequirements" :key="lang">
|
||||
<span v-if="astRequirements.length > 1" class="lang-label">{{ lang }}</span>
|
||||
<n-tag
|
||||
@@ -467,7 +467,7 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 代码要求:一行的浅绿框,硬性的判定条件,放在最前面 */
|
||||
/* 语法要求:一行的浅绿框,硬性的判定条件,放在最前面 */
|
||||
.requirements {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ProblemRow } from "utils/types"
|
||||
import { Icon } from "@iconify/vue"
|
||||
|
||||
defineProps<{
|
||||
problem: ProblemRow
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<span>{{ problem.title }}</span>
|
||||
<Icon v-if="problem.allowFlowchart" width="18" icon="vscode-icons:file-type-drawio" />
|
||||
<Icon v-else-if="problem.showFlowchart" width="18" icon="vscode-icons:file-type-graphql" />
|
||||
<Icon v-if="problem.hasAstRules" width="18" icon="vscode-icons:file-type-light-todo" />
|
||||
</n-flex>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import type { ProblemTypeFilter } from "@oj2/contract"
|
||||
import { useDark } from "@vueuse/core"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { PROBLEM_TYPE_LABEL } from "../utils/problemType"
|
||||
|
||||
/**
|
||||
* 三类特殊题的小标签(设计稿「题目列表重设计 · 改版」)。轻重按对学生意味着什么分:
|
||||
* - 画流程图:最醒目(浅紫底)—— 只有这类题能交流程图,要做的事和普通题不一样
|
||||
* - 语法要求:蓝描边 —— 判题时会检查写法,要看得见
|
||||
* - 有参考图:灰描边,最轻 —— 是帮助,不用提醒
|
||||
* 都带图标和字,区分不只靠颜色。名字和题目页一致:「语法要求」。
|
||||
*/
|
||||
const props = defineProps<{ kind: ProblemTypeFilter }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const isDark = useDark()
|
||||
|
||||
const ICON: Record<ProblemTypeFilter, string> = {
|
||||
flowchart: "ph:pencil-simple-line-bold",
|
||||
ast: "ph:code-bold",
|
||||
reference: "ph:flow-arrow-bold",
|
||||
}
|
||||
|
||||
const style = computed(() => {
|
||||
const t = theme.value
|
||||
if (props.kind === "flowchart") {
|
||||
// 主题里没有紫色,两套手配的值,字都压到 4.5:1 以上
|
||||
return isDark.value
|
||||
? { color: "#c4adf5", background: "rgba(160, 120, 240, 0.18)", borderColor: "transparent" }
|
||||
: { color: "#5b2fa6", background: "#efe8fb", borderColor: "transparent" }
|
||||
}
|
||||
if (props.kind === "ast") {
|
||||
return {
|
||||
color: isDark.value ? t.infoColor : t.infoColorPressed,
|
||||
background: "transparent",
|
||||
borderColor: isDark.value ? "rgba(112, 192, 232, 0.45)" : "#b9cdee",
|
||||
}
|
||||
}
|
||||
return { color: t.textColor3, background: "transparent", borderColor: t.borderColor }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="type-tag" :class="kind" :style="style">
|
||||
<Icon :icon="ICON[props.kind]" :width="12" />{{ PROBLEM_TYPE_LABEL[props.kind] }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.type-tag {
|
||||
flex: none;
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 6px 0 5px;
|
||||
border: 1px solid;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.type-tag.flowchart {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import type { ProblemProgress } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||
import { useTone } from "oj/submission/composables/tone"
|
||||
|
||||
/**
|
||||
* 题目列表顶上那行的左半边:先说做到了什么(设计稿「题目列表重设计 · 改版」)。
|
||||
* 做对几道 · 这周新做对几道(这周还没有就说连续几周)· 离哪个成就最近 · 有几道差一点。
|
||||
* 没做对的题不常驻在这里:大多数学生一道「差一点」都没有(快照里 64%),常驻的话
|
||||
* 这行就只剩一言;有的人看见一排红色也是压力。点「差一点」才弹出来看。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
progress: ProblemProgress
|
||||
/** 一道还没做对时引去的知识点(「输出入门」),没有这个标签就是 null */
|
||||
startTag: { name: string; problemCount: number } | null
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ selectTag: [name: string] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
|
||||
const remaining = computed(() =>
|
||||
props.progress.next ? props.progress.next.threshold - props.progress.next.progress : 0,
|
||||
)
|
||||
|
||||
/** 离成就还差什么,按指标换说法 */
|
||||
const nextText = computed(() => {
|
||||
const next = props.progress.next
|
||||
if (!next) return ""
|
||||
const r = remaining.value
|
||||
switch (next.metric) {
|
||||
case "accepted_count":
|
||||
return next.progress === 0 ? "做对第一道题就解锁" : `再做对 ${r} 道就解锁`
|
||||
case "mid_ac_count":
|
||||
return `再做对 ${r} 道中等题就解锁`
|
||||
case "hard_ac_count":
|
||||
return `再做对 ${r} 道困难题就解锁`
|
||||
case "active_days":
|
||||
return `再来 ${r} 天就解锁`
|
||||
case "max_ac_week_streak":
|
||||
return `连续 ${next.threshold} 周有做对就解锁`
|
||||
default:
|
||||
return `还差 ${r} 就解锁`
|
||||
}
|
||||
})
|
||||
|
||||
const weekChip = computed(() => {
|
||||
const p = props.progress
|
||||
if (p.weekSolved > 0) return `这周 +${p.weekSolved}`
|
||||
if (p.weekStreak >= 2) return `连续 ${p.weekStreak} 周都有做对`
|
||||
return ""
|
||||
})
|
||||
|
||||
// 弹层在 body 下面,样式里的 v-bind 变量够不着,主题色就地给
|
||||
const listVars = computed(() => ({
|
||||
"--list-text": theme.value.textColor1,
|
||||
"--list-id": theme.value.textColor3,
|
||||
"--list-hover": theme.value.hoverColor,
|
||||
}))
|
||||
|
||||
const fresh = computed(() => props.progress.solved === 0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="progress-row" :class="{ compact }">
|
||||
<template v-if="!fresh">
|
||||
<span class="total">
|
||||
你做对了<b>{{ progress.solved }}</b
|
||||
>道题
|
||||
</span>
|
||||
<span v-if="weekChip" class="chip">{{ weekChip }}</span>
|
||||
</template>
|
||||
<i v-if="!fresh && progress.next" class="sep"></i>
|
||||
<RouterLink v-if="progress.next" to="/achievement" class="next" title="去看我的成就">
|
||||
<AchievementIcon :icon="progress.next.icon" :size="16" />
|
||||
<span>
|
||||
{{ nextText }}<b>「{{ progress.next.name }}」</b>
|
||||
</span>
|
||||
</RouterLink>
|
||||
<button v-if="fresh && startTag" class="start" @click="emit('selectTag', startTag.name)">
|
||||
从「{{ startTag.name }}」开始,一共 {{ startTag.problemCount }} 道
|
||||
<Icon icon="ph:caret-right-bold" :width="12" />
|
||||
</button>
|
||||
<i v-if="!fresh && progress.almost.length" class="sep"></i>
|
||||
<n-popover v-if="!fresh && progress.almost.length" trigger="click" placement="bottom-start">
|
||||
<template #trigger>
|
||||
<button class="almost">
|
||||
有 {{ progress.almost.length }} 道差一点
|
||||
<Icon icon="ph:caret-right-bold" :width="12" />
|
||||
</button>
|
||||
</template>
|
||||
<div class="almost-list" :style="listVars">
|
||||
<RouterLink
|
||||
v-for="p in progress.almost"
|
||||
:key="p._id"
|
||||
:to="'/problem/' + p._id"
|
||||
class="almost-item"
|
||||
>
|
||||
<span class="id">{{ p._id }}</span>
|
||||
{{ p.title }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</n-popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.progress-row.compact {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 10px;
|
||||
}
|
||||
|
||||
.progress-row.compact .sep {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.total {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.total b {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: v-bind("theme.successColor");
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chip {
|
||||
height: 24px;
|
||||
padding: 0 9px;
|
||||
border-radius: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
background: v-bind("tone('success').background");
|
||||
color: v-bind("tone('success').color");
|
||||
}
|
||||
|
||||
.next {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: v-bind("theme.textColor2");
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.next b {
|
||||
font-weight: 600;
|
||||
color: v-bind("theme.textColor1");
|
||||
}
|
||||
|
||||
.next:hover b {
|
||||
color: v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.almost {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: v-bind("theme.textColor3");
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.almost:hover {
|
||||
color: v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.start {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
background: v-bind("theme.primaryColor");
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.almost-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.almost-item {
|
||||
padding: 6px 4px;
|
||||
border-radius: 4px;
|
||||
color: var(--list-text);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.almost-item:hover {
|
||||
background: var(--list-hover);
|
||||
}
|
||||
|
||||
.almost-item .id {
|
||||
margin-right: 6px;
|
||||
color: var(--list-id);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,253 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import type { Tag, TagCategory } from "@oj2/contract"
|
||||
import { useThemeVars } from "naive-ui"
|
||||
import { useTone } from "oj/submission/composables/tone"
|
||||
|
||||
/**
|
||||
* 题目列表左栏:知识点 / 主题,带自己的进度(设计稿「题目列表重设计 · 改版」)。
|
||||
* 只把做到了的标出来:一道没做对过的只写题数(不写 0/108),做了一部分的写 37/96 加进度条,
|
||||
* 全做对的写「通关」。老师、没登录的人 progress 是 null,只有题数。
|
||||
*/
|
||||
const props = defineProps<{
|
||||
tags: Tag[]
|
||||
/** 选中的标签名,空串是「全部题目」 */
|
||||
selected: string
|
||||
allCount: number
|
||||
progress: Record<string, number> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [name: string] }>()
|
||||
|
||||
const theme = useThemeVars()
|
||||
const tone = useTone()
|
||||
|
||||
const mode = ref<TagCategory>("knowledge")
|
||||
// 从别处带着一个主题标签进来(?tag=周杰伦),左栏得切到「主题」才看得见它
|
||||
watch(
|
||||
() => [props.selected, props.tags] as const,
|
||||
([name]) => {
|
||||
const tag = props.tags.find((t) => t.name === name)
|
||||
if (tag) mode.value = tag.category
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 选中的知识点在下面(「输出入门」排在第 17 个),进来时滚到看得见的地方
|
||||
const navRef = ref<HTMLElement | null>(null)
|
||||
watch(
|
||||
() => [props.selected, props.tags.length, mode.value],
|
||||
() =>
|
||||
nextTick(() => navRef.value?.querySelector(".item.on")?.scrollIntoView({ block: "nearest" })),
|
||||
)
|
||||
|
||||
function byCategory(category: TagCategory) {
|
||||
return props.tags
|
||||
.filter((t) => t.category === category)
|
||||
.sort((a, b) => b.problemCount - a.problemCount)
|
||||
}
|
||||
|
||||
const knowledge = computed(() => byCategory("knowledge"))
|
||||
const themes = computed(() => byCategory("theme"))
|
||||
const shown = computed(() => (mode.value === "knowledge" ? knowledge.value : themes.value))
|
||||
|
||||
function solvedOf(tag: Tag) {
|
||||
return props.progress?.[String(tag.id)] ?? 0
|
||||
}
|
||||
|
||||
function percent(tag: Tag) {
|
||||
// 做对 1 道也要看得见一截,不然和没做一样
|
||||
return Math.max(3, Math.round((solvedOf(tag) / Math.max(1, tag.problemCount)) * 100))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="tag-nav">
|
||||
<div class="switch">
|
||||
<button :class="{ on: mode === 'knowledge' }" @click="mode = 'knowledge'">
|
||||
知识点 {{ knowledge.length }}
|
||||
</button>
|
||||
<button :class="{ on: mode === 'theme' }" @click="mode = 'theme'">
|
||||
主题 {{ themes.length }}
|
||||
</button>
|
||||
</div>
|
||||
<nav ref="navRef" class="items">
|
||||
<button class="item flat" :class="{ on: selected === '' }" @click="emit('select', '')">
|
||||
<span class="name">全部题目</span>
|
||||
<span class="count">{{ allCount || "" }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="tag in shown"
|
||||
:key="tag.id"
|
||||
class="item"
|
||||
:class="{ on: selected === tag.name, flat: !progress || solvedOf(tag) === 0 }"
|
||||
@click="emit('select', tag.name)"
|
||||
>
|
||||
<span class="line">
|
||||
<span class="name">{{ tag.name }}</span>
|
||||
<span v-if="!progress || solvedOf(tag) === 0" class="count">{{ tag.problemCount }}</span>
|
||||
<span v-else-if="solvedOf(tag) >= tag.problemCount" class="done">
|
||||
<Icon icon="ph:check-bold" :width="12" />通关
|
||||
</span>
|
||||
<span v-else class="count">
|
||||
<b>{{ solvedOf(tag) }}</b
|
||||
>/{{ tag.problemCount }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="progress && solvedOf(tag) > 0" class="bar">
|
||||
<span :style="{ width: percent(tag) + '%' }"></span>
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tag-nav {
|
||||
width: 224px;
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
border-right: 1px solid v-bind("theme.dividerColor");
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.switch {
|
||||
flex: none;
|
||||
padding: 14px 14px 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.switch button {
|
||||
height: 28px;
|
||||
border: 1px solid v-bind("theme.borderColor");
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
color: v-bind("theme.textColor2");
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.switch button:first-child {
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
|
||||
.switch button:last-child {
|
||||
margin-left: -1px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
.switch button.on {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border-color: v-bind("theme.primaryColor");
|
||||
color: v-bind("theme.primaryColor");
|
||||
}
|
||||
|
||||
.items {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0 8px 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.item {
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 6px 10px 7px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
color: v-bind("theme.textColor2");
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item.flat {
|
||||
padding: 7px 10px;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: v-bind("theme.hoverColor");
|
||||
}
|
||||
|
||||
.item.on {
|
||||
background: v-bind("tone('success').background");
|
||||
}
|
||||
|
||||
.item.on .name {
|
||||
font-weight: 600;
|
||||
color: v-bind("tone('success').color");
|
||||
}
|
||||
|
||||
.line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item.flat .line {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.count {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: v-bind("theme.textColor3");
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.count b {
|
||||
font-weight: 600;
|
||||
color: v-bind("tone('success').color");
|
||||
}
|
||||
|
||||
.done {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: v-bind("tone('success').color");
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: block;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
background: v-bind("theme.dividerColor");
|
||||
}
|
||||
|
||||
.bar span {
|
||||
display: block;
|
||||
height: 3px;
|
||||
background: v-bind("theme.successColor");
|
||||
}
|
||||
</style>
|
||||
+1105
-285
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
import type { ProblemTypeFilter } from "@oj2/contract"
|
||||
|
||||
/** 名字和题目页一致:「语法要求」(题目页原来叫「代码要求」,一起改了) */
|
||||
export const PROBLEM_TYPE_LABEL: Record<ProblemTypeFilter, string> = {
|
||||
flowchart: "画流程图",
|
||||
ast: "语法要求",
|
||||
reference: "有参考图",
|
||||
}
|
||||
|
||||
/** 「全部类型」下拉里每项下面那句话 */
|
||||
export const PROBLEM_TYPE_HINT: Record<ProblemTypeFilter, string> = {
|
||||
reference: "题目里附了老师画好的流程图,照着写代码",
|
||||
flowchart: "除了写代码,也可以交流程图;评到 A 或 S 级算做对",
|
||||
ast: "要求用(或者不能用)某种写法,交了会检查",
|
||||
}
|
||||
|
||||
/** 一道题挂哪几个标签,从醒目到轻:画流程图 → 语法要求 → 有参考图 */
|
||||
export function problemTypes(problem: {
|
||||
allowFlowchart: boolean
|
||||
hasAstRules: boolean
|
||||
showFlowchart: boolean
|
||||
}): ProblemTypeFilter[] {
|
||||
const kinds: ProblemTypeFilter[] = []
|
||||
if (problem.allowFlowchart) kinds.push("flowchart")
|
||||
if (problem.hasAstRules) kinds.push("ast")
|
||||
if (problem.showFlowchart) kinds.push("reference")
|
||||
return kinds
|
||||
}
|
||||
@@ -23,5 +23,7 @@ export function toProblemRow(result: ProblemListItem): ProblemRow {
|
||||
allowFlowchart: result.allowFlowchart,
|
||||
showFlowchart: result.showFlowchart,
|
||||
hasAstRules: result.hasAstRules,
|
||||
solvedUsers: result.solvedUsers,
|
||||
triedUsers: result.triedUsers,
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,8 @@ export interface ProblemRow {
|
||||
allowFlowchart: boolean
|
||||
showFlowchart: boolean
|
||||
hasAstRules: boolean
|
||||
solvedUsers: number
|
||||
triedUsers: number
|
||||
}
|
||||
|
||||
export interface AdminProblemRow {
|
||||
|
||||
Reference in new issue
Block a user