feat(相似题): 做对中等、困难题后不再只推一串简单题,每道推荐写出和原题共同的标签

- 共同标签一样多时先给难度最接近的,再并列才先简单的(原来一律简单的在前,
  做对困难题时五条里三条多是简单题)
- 每行第二行写共同的标签(知识点在前、主题在后)和做对人数;带类型标签、
  做过没对的红「−」,写法照题目列表
- 题面里的标题和「提示」「来源」一样,结果页签里的和「还能挑战」对齐

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-03 13:06:49 +08:00
1 parent da360e2f68
commit 8c5399755e
6 files changed
+194 -43

No files matched your search

+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">