Compare commits

..
2 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 1b730da401 docs(格式化): 补上 learn.css 也会被 vp fmt 和 bun fmt 来回改,以及怎么列出受影响的文件
Deploy / deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:07:14 +08:00
xuyueandClaude Opus 5.5 8c5399755e feat(相似题): 做对中等、困难题后不再只推一串简单题,每道推荐写出和原题共同的标签
- 共同标签一样多时先给难度最接近的,再并列才先简单的(原来一律简单的在前,
  做对困难题时五条里三条多是简单题)
- 每行第二行写共同的标签(知识点在前、主题在后)和做对人数;带类型标签、
  做过没对的红「−」,写法照题目列表
- 题面里的标题和「提示」「来源」一样,结果页签里的和「还能挑战」对齐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:06:49 +08:00
7 changed files with 196 additions and 44 deletions

No files matched your search

+2 -1
View File
@@ -62,7 +62,8 @@ cd apps/web && bun run build # 前端构建(不在 verify 里
配置都只在根目录的 `vite.config.ts` 一份:`fmt` 块(Oxfmt 的写法,`bun fmt` 直接读它;
`semi: false`、`singleQuote: true`、`printWidth: 160`)、`lint` 块(Oxlint,`denyWarnings`)、`run.tasks`
(`verify` 和它依赖的四个检查)。**别用 `vp fmt` / `vp check`**:Oxfmt 和 `bun fmt` 只在
长 CSS 值列表的折行上不一样,混着跑会把 `apps/web/public/style.css` 来回改。前端自己的构建
长 CSS 值列表的折行上不一样,混着跑会把 `apps/web/public/style.css`、`apps/web/src/oj/learn/learn.css` 来回改
(`vp fmt --list-different` 能列出当前受影响的文件)。前端自己的构建
配置在 `apps/web/vite.config.ts`,和根目录那份无关。
- 格式化只管代码:`.md` / `.yml` / `.json` / `.toml` 在 `fmt.ignorePatterns` 里排掉了
+26 -5
View File
@@ -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[],
)
})
+4 -1
View File
@@ -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<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`).then((response) => response.map(toProblemRow))
return api
.get<SimilarProblem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => response.map((item) => ({ ...toProblemRow(item), sharedTags: item.sharedTags })))
}
// ==================== 挑战 ====================
@@ -333,7 +333,7 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
<Markdown html :content="problem.source" />
</section>
<SimilarProblems v-if="showSimilar" title="相似题目推荐" />
<SimilarProblems v-if="showSimilar" title="相似题目推荐" place="content" />
</div>
</template>
@@ -1,11 +1,13 @@
<script lang="ts">
import type { ProblemRow } from 'utils/types'
type SimilarRow = ProblemRow & { sharedTags: string[] }
/**
* 同一道题两处(题面末尾、结果页签)都挂着时只拉一次。放在模块级:写在 setup 里就成了
* 每个实例各一份
*/
const cache = new Map<string, Promise<ProblemRow[]>>()
const cache = new Map<string, Promise<SimilarRow[]>>()
</script>
<script setup lang="ts">
@@ -13,17 +15,26 @@ import { storeToRefs } from 'pinia'
import { useThemeVars } from 'naive-ui'
import { getSimilarProblems } from 'oj/api'
import { useProblemStore } from 'oj/store/problem'
import { useTone } from 'shared/composables/tone'
import { problemTypes } from '../utils/problemType'
import ProblemStatus from './ProblemStatus.vue'
import ProblemTypeTag from './ProblemTypeTag.vue'
/**
* 相似题推荐。题面末尾(做对了、或者错了 3 次以上)和结果页签(不在课堂里的题做对之后)
* 两处用:原来只在题面最底下,学生交完不会再滚回去看。什么时候出现由调用方的 v-if 决定。
* `place` 只管标题的样子:题面里和「提示」「来源」一样带绿竖条,结果页签里和「还能挑战」对齐。
*
* 每行第二行写和这道题共同的标签:不点开就知道练的是什么。行的写法照题目列表
* (灰题号、淡底难度、做对人数、类型标签、做过没对的红「−」)
*/
const props = defineProps<{ title: string }>()
const props = defineProps<{ title: string; place: 'content' | 'result' }>()
const theme = useThemeVars()
const tone = useTone()
const { problem } = storeToRefs(useProblemStore())
const list = ref<ProblemRow[]>([])
const list = ref<SimilarRow[]>([])
watch(
() => problem.value?._id,
@@ -41,30 +52,36 @@ watch(
{ immediate: true },
)
// getSimilarProblems 已经过 toProblemRow,难度是中文,不是 Low/Mid/High
function difficultyType(difficulty: string) {
if (difficulty === '简单') return 'success'
if (difficulty === '困难') return 'error'
return 'warning'
// 做对了的接口已经筛掉,剩下的只有「没做过」和「做过没对」两种;一道都没做过就不留这一列
const showStatus = computed(() => list.value.some((row) => row.status === 'failed'))
const DIFFICULTY_TONE = { 简单: 'success', 中等: 'warning', 困难: 'error' } as const
function difficultyStyle(row: SimilarRow) {
if (!row.difficulty) return {}
const t = tone(DIFFICULTY_TONE[row.difficulty])
return { color: t.color, background: t.background }
}
</script>
<template>
<section v-if="list.length" class="similar">
<h3 class="title">{{ props.title }}</h3>
<n-list bordered>
<n-list-item v-for="sp in list" :key="sp._id">
<n-flex align="center" justify="space-between" :wrap="false">
<router-link :to="{ name: 'problem', params: { problemID: sp._id } }" class="link">
<n-tag size="small" :bordered="false">{{ sp._id }}</n-tag>
<span class="name">{{ sp.title }}</span>
</router-link>
<n-tag v-if="sp.difficulty" size="small" :type="difficultyType(sp.difficulty)">
{{ sp.difficulty }}
</n-tag>
</n-flex>
</n-list-item>
</n-list>
<section v-if="list.length" class="similar" :class="props.place">
<h3 class="heading">{{ props.title }}</h3>
<div class="rows">
<router-link v-for="sp in list" :key="sp._id" :to="{ name: 'problem', params: { problemID: sp._id } }" class="row" :class="{ 'with-status': showStatus }">
<span v-if="showStatus" class="status"><ProblemStatus :status="sp.status" /></span>
<span class="id">{{ sp._id }}</span>
<span class="name">
<span class="text">{{ sp.title }}</span>
<ProblemTypeTag v-for="kind in problemTypes(sp)" :key="kind" :kind="kind" />
</span>
<span v-if="sp.difficulty" class="difficulty" :style="difficultyStyle(sp)">{{ sp.difficulty }}</span>
<span class="shared">{{ sp.sharedTags.join(' · ') }}</span>
<span class="people"
><b>{{ sp.solvedUsers }}</b> 人做对</span
>
</router-link>
</div>
</section>
</template>
@@ -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');
}
</style>
@@ -357,7 +357,7 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
:language="submission.language"
:refresh-key="submission.id"
/>
<SimilarProblems v-if="showSimilar" title="再练几道相似的" />
<SimilarProblems v-if="showSimilar" title="再练几道相似的" place="result" />
</div>
</template>
<div v-show="!waitingReveal">
+9
View File
@@ -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<typeof challengeListSchema>
export type ProblemDifficulty = z.infer<typeof problemDifficultySchema>
export type ProblemListItem = z.infer<typeof problemListItemSchema>
export type ProblemList = z.infer<typeof problemListSchema>
export type SimilarProblem = z.infer<typeof similarProblemSchema>
export type ProblemTypeFilter = z.infer<typeof problemTypeFilterSchema>
export type ProblemListSort = z.infer<typeof problemListSortSchema>
export type ProblemProgress = z.infer<typeof problemProgressSchema>