Compare commits
2
Commits
da360e2f68
...
1b730da401
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b730da401 | ||
|
|
8c5399755e |
No files matched your search
@@ -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` 里排掉了
|
||||
|
||||
@@ -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[],
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user