diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index 18b8069..282c304 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -12,6 +12,7 @@ import { type ProblemListItem, type ProblemProgress, type ProblemTypeFilter, + type SimilarProblem, type Tag, } from '@oj2/contract' import { and, asc, count, countDistinct, desc, eq, ilike, inArray, isNull, ne, notInArray, or, sql } from 'drizzle-orm' @@ -327,13 +328,25 @@ problemRoutes.get('/challenges', requireAuth, async (c) => { problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => { const [target] = await db - .select({ id: schema.problem.id }) + .select({ id: schema.problem.id, difficulty: schema.problem.difficulty }) .from(schema.problem) .where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param('displayId')})`, isNull(schema.problem.contestId))) .limit(1) if (!target) return failure(c, 404, 'problem-not-found', 'Problem not found') - const targetTags = await db.select({ id: schema.problemTags.problemtagId }).from(schema.problemTags).where(eq(schema.problemTags.problemId, target.id)) - if (targetTags.length === 0) return success(c, []) + // 知识点和主题标签一样算(用户 2026-11-03 定:同一套题材也算相似)。知识点排在前面 + // 只是为了「共同的标签」那行先写练的是什么 + const targetTags = await db + .select({ id: schema.problemTags.problemtagId, name: schema.problemTag.name }) + .from(schema.problemTags) + .innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)) + .where(eq(schema.problemTags.problemId, target.id)) + // 同一类里大的在前,和题目列表「知识点」那列一样 + .orderBy( + sql`${schema.problemTag.category} = 'knowledge' desc`, + sql`(select count(*) from ${schema.problemTags} pt where pt.problemtag_id = ${schema.problemTag.id}) desc`, + asc(schema.problemTag.name), + ) + if (targetTags.length === 0) return success(c, [] satisfies SimilarProblem[]) // 「已 AC 的不再推荐」必须下推到 SQL。早先是先 limit(5) 再在内存里筛,刷题多的 // 学生 5 条候选能被筛到只剩一两条、甚至清零(前端 v-if 一空整块就不渲染)—— // 而这个接口恰好只在**刚 AC** 或**连挂三次**时才被调用,正是候选最容易全中的时候。 @@ -343,7 +356,12 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => { const solvedIds = states ? [...states.solved] : [] // difficulty 是 text(Low / Mid / High),直接 order by 走的是字典序 —— // High 排在 Low 前面,「由易到难」会变成「最难的先上」。按语义显式排。 + // 共同标签一样多时先给和原题难度最接近的:原来一律简单的在前,做对一道困难题, + // 五条推荐里三条多是简单题(本地库 35 道困难题:119 / 175)。再并列才先简单的, + // 照顾连错三次的人 + const rankOf = (difficulty: string) => (difficulty === 'Low' ? 0 : difficulty === 'Mid' ? 1 : 2) const difficultyRank = sql`case ${schema.problem.difficulty} when 'Low' then 0 when 'Mid' then 1 else 2 end` + const difficultyGap = sql`abs(${difficultyRank} - ${sql.raw(String(rankOf(target.difficulty)))})` const sharedTags = count(schema.problemTags.problemtagId) const rows = await db .select({ @@ -376,13 +394,16 @@ problemRoutes.get('/problems/:displayId/similar', optionalAuth, async (c) => { ) .groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName) // 末尾的 id 是稳定排序用的:并列时没有它,同一道题两次请求能返回不同的 5 条 - .orderBy(desc(sharedTags), asc(difficultyRank), desc(schema.problem.acceptedNumber), asc(schema.problem.id)) + .orderBy(desc(sharedTags), asc(difficultyGap), asc(difficultyRank), desc(schema.problem.acceptedNumber), asc(schema.problem.id)) .limit(5) 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, states, counts)), + rows.map((row) => { + const own = new Set(tags.get(row.problem.id)) + return { ...listItem(row, tags, statuses, states, counts), sharedTags: targetTags.map((tag) => tag.name).filter((name) => own.has(name)) } + }) satisfies SimilarProblem[], ) }) diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index 7d896c7..da563dc 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -41,6 +41,7 @@ import { type ProblemRank, type CreateSubmissionResponse, type ProblemListItem, + type SimilarProblem, type ProblemStats, type ProblemProgress, type CreateFlowchartResponse, @@ -412,7 +413,9 @@ export function submitHintFeedback(hintId: number, helpful: boolean) { // ==================== 相似题目推荐 ==================== export function getSimilarProblems(problemId: string) { - return api.get(`problems/${encodeURIComponent(problemId)}/similar`).then((response) => response.map(toProblemRow)) + return api + .get(`problems/${encodeURIComponent(problemId)}/similar`) + .then((response) => response.map((item) => ({ ...toProblemRow(item), sharedTags: item.sharedTags }))) } // ==================== 挑战 ==================== diff --git a/apps/web/src/oj/problem/components/ProblemContent.vue b/apps/web/src/oj/problem/components/ProblemContent.vue index 2950c8e..e3d4c0b 100644 --- a/apps/web/src/oj/problem/components/ProblemContent.vue +++ b/apps/web/src/oj/problem/components/ProblemContent.vue @@ -333,7 +333,7 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem - + diff --git a/apps/web/src/oj/problem/components/SimilarProblems.vue b/apps/web/src/oj/problem/components/SimilarProblems.vue index 7d3936a..82ff409 100644 --- a/apps/web/src/oj/problem/components/SimilarProblems.vue +++ b/apps/web/src/oj/problem/components/SimilarProblems.vue @@ -1,11 +1,13 @@ @@ -73,34 +90,135 @@ function difficultyType(difficulty: string) { margin-top: 20px; } -.title { - font-size: 16px; - font-weight: 600; +.similar.result { + margin-top: 14px; +} + +.heading { margin: 0 0 8px; } -/* 列表项的内容区默认按内容撑宽,长标题会把难度挤出屏幕,省略号也不出来 */ -.similar :deep(.n-list-item__main) { - min-width: 0; +/* 和 ProblemContent 的小节标题(提示 / 来源)一样 */ +.content .heading { + font-size: 16px; + font-weight: 700; + line-height: 1.4; + padding-left: 9px; + border-left: 3px solid v-bind('theme.primaryColor'); } -.link { - display: inline-flex; +/* 和结果页签里的「还能挑战」一样 */ +.result .heading { + font-size: 15px; + font-weight: 700; +} + +.rows { + border: 1px solid v-bind('theme.dividerColor'); + border-radius: 6px; + overflow: hidden; +} + +/* + * 两行:题号 | 题目名 + 类型标签 | 难度 + * | 共同的标签 | 做对人数 + * 做过没对的题最前面多一列红「−」 + */ +.row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: + 'id name difficulty' + '. shared people'; align-items: center; - gap: 8px; - min-width: 0; - color: inherit; + gap: 4px 12px; + padding: 10px 16px; + color: v-bind('theme.textColor1'); text-decoration: none; } -.link:hover .name { +.row.with-status { + grid-template-columns: 16px auto minmax(0, 1fr) auto; + grid-template-areas: + 'status id name difficulty' + '. . shared people'; +} + +.row + .row { + border-top: 1px solid v-bind('theme.dividerColor'); +} + +.row:hover { + background: v-bind('theme.hoverColor'); +} + +.row:hover .text { text-decoration: underline; } +.status { + grid-area: status; + display: flex; + justify-content: center; +} + +.id { + grid-area: id; + min-width: 36px; + font-size: 14px; + color: v-bind('theme.textColor3'); + font-variant-numeric: tabular-nums; +} + .name { + grid-area: name; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.text { + min-width: 0; + font-size: 15px; color: v-bind('theme.primaryColor'); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.difficulty { + grid-area: difficulty; + justify-self: end; + height: 22px; + padding: 0 7px; + border-radius: 4px; + font-size: 13px; + display: inline-flex; + align-items: center; +} + +.shared { + grid-area: shared; + min-width: 0; + font-size: 13px; + color: v-bind('theme.textColor3'); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.people { + grid-area: people; + justify-self: end; + font-size: 13px; + color: v-bind('theme.textColor3'); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.people b { + font-weight: 600; + color: v-bind('theme.textColor2'); +} diff --git a/apps/web/src/oj/problem/components/SubmissionResult.vue b/apps/web/src/oj/problem/components/SubmissionResult.vue index 9ac1095..836851d 100644 --- a/apps/web/src/oj/problem/components/SubmissionResult.vue +++ b/apps/web/src/oj/problem/components/SubmissionResult.vue @@ -357,7 +357,7 @@ const columns: DataTableColumn[] = [ :language="submission.language" :refresh-key="submission.id" /> - +
diff --git a/packages/contract/src/problem.ts b/packages/contract/src/problem.ts index c3c5976..bf2e1fe 100644 --- a/packages/contract/src/problem.ts +++ b/packages/contract/src/problem.ts @@ -484,6 +484,14 @@ export const problemListItemSchema = z.object({ export const problemListSchema = paginatedSchema(problemListItemSchema) +/** + * 相似题推荐的一条。`sharedTags` 是它和原题共有的标签(知识点在前、主题在后), + * 学生不点开就知道练的是什么 + */ +export const similarProblemSchema = problemListItemSchema.extend({ + sharedTags: z.array(z.string()), +}) + /** 题目列表「全部类型」下拉:SQL / 有参考图 / 画流程图 / 语法要求 */ export const problemTypeFilterSchema = z.enum(['sql', 'reference', 'flowchart', 'ast']) @@ -601,6 +609,7 @@ export type ChallengeList = z.infer export type ProblemDifficulty = z.infer export type ProblemListItem = z.infer export type ProblemList = z.infer +export type SimilarProblem = z.infer export type ProblemTypeFilter = z.infer export type ProblemListSort = z.infer export type ProblemProgress = z.infer