feat(题目列表): 学生自己挑题时只看得到一堆标签和提交数,不知道自己练到哪了——改成左栏知识点进度、顶上先说做到了什么
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:
xuyueandClaude Opus 5.5 committed 2026-10-09 12:31:49 +08:00
1 parent 1ba8928d41
commit ed7752afbb
14 files changed
+2083 -339

No files matched your search

+11
View File
@@ -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>
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
}
+2
View File
@@ -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,
}
}
+2
View File
@@ -167,6 +167,8 @@ export interface ProblemRow {
allowFlowchart: boolean
showFlowchart: boolean
hasAstRules: boolean
solvedUsers: number
triedUsers: number
}
export interface AdminProblemRow {