Compare commits

...

14 Commits

Author SHA1 Message Date
44b9e6d8dc fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-03-17 19:09:39 +08:00
1f47eff479 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-03-17 08:13:04 +08:00
8be95b4c85 add end_time
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-03-16 23:44:41 +08:00
19b2e2a507 add icon url
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-03-10 14:15:45 +08:00
3e0dd76a1e fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-03-09 21:13:54 +08:00
315729d7e8 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-03-09 21:07:59 +08:00
cbce188028 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-03-09 20:43:59 +08:00
52d25f8b41 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-03-09 19:55:46 +08:00
b82840c692 fix
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-03-09 19:50:09 +08:00
5e07d3311b update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-03-08 21:40:30 +08:00
50c9ce3555 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-03-08 21:30:55 +08:00
8c02ad46e7 update
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled
2026-03-08 21:16:00 +08:00
9029e29148 feat: add teaching enhancement features
1. AI personalized hints after 3 failures (streaming SSE)
2. Submission error distribution panel in "my submissions" tab
3. Similar problem recommendations on AC or 3+ failures
4. Admin stuck problems analysis page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 21:12:47 +08:00
c1977d7152 Add .worktrees to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:58:50 +08:00
20 changed files with 594 additions and 53 deletions

View File

@@ -4,4 +4,5 @@ PUBLIC_OJ_URL=https://oj.xuyue.cc
PUBLIC_CODE_URL=https://code.xuyue.cc
PUBLIC_JUDGE0_URL=https://judge0api.xuyue.cc
PUBLIC_SIGNALING_URL=wss://signaling.xuyue.cc
PUBLIC_WS_URL=wss://oj.xuyue.cc/ws
PUBLIC_WS_URL=wss://oj.xuyue.cc/ws
PUBLIC_ICONIFY_URL=https://icon.xuyue.cc

3
.gitignore vendored
View File

@@ -26,4 +26,5 @@ dist-ssr
src/components.d.ts
src/auto-imports.d.ts
.claude
.claude
.worktrees

114
CLAUDE.md Normal file
View File

@@ -0,0 +1,114 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**ojnext** is the frontend for an Online Judge platform. Built with Vue 3 + TypeScript using Rsbuild (Rust-based bundler), Naive UI component library, Pinia for state management, and Vue Router.
## Commands
```bash
npm start # Start dev server on port 5173
npm run build # Production build
npm run build:staging # Staging build
npm run build:test # Test build
npm fmt # Format with Prettier
```
No test suite is configured. Linting is via Prettier only.
## Architecture
### Directory Structure
```
src/
├── shared/ # Cross-cutting concerns: layout, stores, composables, API
├── oj/ # User-facing features (problems, submissions, contests, etc.)
├── admin/ # Admin panel features
├── utils/ # Constants, types, HTTP client, helpers
├── routes.ts # Route definitions (two top-level: ojs, admins)
├── main.ts # App entry point
└── App.vue # Root component with Naive UI theme setup
```
### Module Pattern
Each feature module (under `oj/` or `admin/`) typically has:
- `views/` — page-level Vue components
- `components/` — feature-specific components
- `api.ts` — API calls specific to the feature
Shared logic lives in `shared/`:
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `screenMode` (problem split-screen layout), `loginSummary` (AI activity summary)
- `composables/``pagination` (URL-synced), `websocket` (reconnect + heartbeat), `sync` (Yjs/y-webrtc for collaborative editing), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `breakpoints`, `maxkb`
- `layout/``default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha)
### Auto-Imports
Configured via `unplugin-auto-import` and `unplugin-vue-components`. You do **not** need to manually import:
- Vue APIs (`ref`, `computed`, `watch`, etc.)
- Vue Router (`useRouter`, `useRoute`)
- Pinia (`defineStore`, `storeToRefs`)
- VueUse composables
- Naive UI composables (`useDialog`, `useMessage`, `useNotification`, `useLoadingBar`)
- Naive UI components (all `N*` components)
- Naive UI types (`DataTableColumn`, `FormRules`, `FormItemRule`, `SelectOption`, `UploadCustomRequestOptions`, `UploadFileInfo`, `MenuOption`, `DropdownOption`)
Generated type declaration files: `src/auto-imports.d.ts`, `src/components.d.ts`.
### Path Aliases
```
utils → ./src/utils
oj → ./src/oj
admin → ./src/admin
shared → ./src/shared
```
### HTTP Client
`utils/http.ts` — Axios instance with interceptors. All API calls proxy through the dev server:
- `/api` and `/public``PUBLIC_OJ_URL` (backend)
- `/ws``PUBLIC_WS_URL` (WebSocket backend)
### Key Utilities
- `utils/constants.ts` — Judge status codes, language IDs, difficulty levels, contest types
- `utils/types.ts` — TypeScript interfaces for all domain models
- `utils/permissions.ts` — Permission check helpers
- `utils/judge.ts` — Judge-related utilities
- `utils/renders.ts` — Table column render helpers for Naive UI DataTable
### Environment Variables
Variables prefixed with `PUBLIC_` are injected at build time. Env files: `.env`, `.env.staging`, `.env.test`.
| Variable | Purpose |
|---|---|
| `PUBLIC_OJ_URL` | Backend REST API base URL |
| `PUBLIC_WS_URL` | WebSocket server URL |
| `PUBLIC_ENV` | Environment name (dev/staging/production) |
| `PUBLIC_CODE_URL` | Code execution service |
| `PUBLIC_JUDGE0_URL` | Judge0 API |
| `PUBLIC_MAXKB_URL` | Knowledge base service |
| `PUBLIC_SIGNALING_URL` | WebRTC signaling server |
| `PUBLIC_ICONIFY_URL` | Iconify icon CDN |
### Routing
Routes are defined in `src/routes.ts` with two root routes: `ojs` (user-facing) and `admins` (admin panel). Route meta fields used:
- `requiresAuth` — redirect to login if not authenticated
- `requiresSuperAdmin` — super admin only
- `requiresProblemPermission` — problem management access
### Real-time Features
- WebSocket via composable in `shared/composables/` for submission status updates
- Yjs + y-webrtc for collaborative editing in the flowchart editor
## Related Repository
The backend is at `../OnlineJudge` — a Django 5 + DRF project. See its CLAUDE.md for backend details.

View File

@@ -7,6 +7,10 @@
z-index: 100 !important;
}
.md-editor-preview img {
height: auto !important;
}
.md-editor-preview h1 {
font-size: 1.6rem !important;
}

View File

@@ -318,6 +318,7 @@ export function createProblemSet(data: {
description: string
difficulty: string
status: string
end_time?: Date | null
}) {
return http.post("admin/problemset", data)
}
@@ -328,6 +329,7 @@ export function editProblemSet(data: {
description?: string
difficulty?: string
status?: string
end_time?: Date | null
visible?: boolean
}) {
return http.put("admin/problemset", data)
@@ -434,3 +436,8 @@ export function getProblemSetProgress(problemSetId: number) {
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
return http.delete(`admin/problemset/${problemSetId}/progress/${userId}`)
}
// 学生卡点分析
export function getStuckProblems() {
return http.get("admin/problem/stuck")
}

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { getStuckProblems } from "admin/api"
interface StuckProblem {
problem_id: string
problem_title: string
total: number
failed: number
failed_users: number
ac_rate: number
}
const loading = ref(true)
const data = ref<StuckProblem[]>([])
const columns: DataTableColumn<StuckProblem>[] = [
{ title: "题目 ID", key: "problem_id", width: 100 },
{ title: "题目名称", key: "problem_title", minWidth: 200 },
{ title: "总提交", key: "total", width: 100, sorter: "default" },
{ title: "失败次数", key: "failed", width: 100, sorter: "default" },
{
title: "卡住学生数",
key: "failed_users",
width: 120,
sorter: "default",
defaultSortOrder: "descend",
},
{
title: "AC 率",
key: "ac_rate",
width: 100,
sorter: "default",
render: (row) => `${row.ac_rate}%`,
},
]
onMounted(async () => {
try {
const res = await getStuckProblems()
data.value = res.data
} finally {
loading.value = false
}
})
</script>
<template>
<h2 style="margin-top: 0">学生卡点分析只分析前40道题目</h2>
<n-data-table
:loading="loading"
:columns="columns"
:data="data"
striped
:pagination="{ pageSize: 20 }"
/>
</template>

View File

@@ -140,6 +140,12 @@ watch(() => [query.page, query.limit, query.author], listProblems)
>
新建
</n-button>
<n-button
v-if="!isContestProblemList"
@click="$router.push({ name: 'admin stuck problems' })"
>
卡点分析
</n-button>
</n-flex>
<n-flex>
<n-button v-if="isContestProblemList" @click="createContestProblem">

View File

@@ -15,6 +15,17 @@ const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
difficulty: "Easy",
status: "draft",
visible: false,
end_time: null,
})
const endTimeTimestamp = computed({
get: () =>
formData.value.end_time
? new Date(formData.value.end_time).getTime()
: null,
set: (val: number | null) => {
formData.value.end_time = val ? new Date(val) : null
},
})
const difficultyOptions = [
@@ -44,6 +55,7 @@ async function loadProblemSetDetail() {
difficulty: data.difficulty,
status: data.status,
visible: data.visible,
end_time: data.end_time ? new Date(data.end_time) : null,
}
} catch (err: any) {
message.error("加载题单详情失败:" + (err.data || "未知错误"))
@@ -120,6 +132,14 @@ onMounted(() => {
placeholder="选择状态"
/>
</n-form-item>
<n-form-item label="截止时间">
<n-date-picker
v-model:value="endTimeTimestamp"
type="datetime"
clearable
placeholder="不设置则无截止时间"
/>
</n-form-item>
<n-form-item v-if="isEdit" label="是否可见">
<n-switch v-model:value="formData.visible" />
</n-form-item>

View File

@@ -301,6 +301,12 @@ export function getAILoginSummary() {
return http.get("ai/login_summary")
}
// ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) {
return http.get("problem/similar", { params: { problem_id: problemId } })
}
// ==================== 流程图相关API ====================
export function submitFlowchart(data: {

View File

@@ -5,11 +5,13 @@ import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { createTestSubmission } from "utils/judge"
import { DIFFICULTY } from "utils/constants"
import { Problem, ProblemStatus } from "utils/types"
import Copy from "shared/components/Copy.vue"
import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { getSimilarProblems } from "oj/api"
type Sample = Problem["samples"][number] & {
id: number
@@ -28,6 +30,43 @@ const { problem } = storeToRefs(problemStore)
const problemSetId = computed(() => route.params.problemSetId)
const router = useRouter()
// 相似题目推荐
const similarProblems = ref<any[]>([])
const similarLoaded = ref(false)
async function loadSimilarProblems() {
if (similarLoaded.value || !problem.value) return
try {
const res = await getSimilarProblems(problem.value._id)
similarProblems.value = res.data || []
} catch {
similarProblems.value = []
}
similarLoaded.value = true
}
// 切换题目时重置相似推荐状态
watch(
() => problem.value?._id,
() => {
similarProblems.value = []
similarLoaded.value = false
},
)
// AC 或失败次数 >= 3 时加载推荐
watch(
() => [problem.value?._id, problem.value?.my_status, problemStore.totalFailCount],
([, status, failCount]) => {
if (status === 0 || (failCount as number) >= 3) {
loadSimilarProblems()
}
},
{ immediate: true },
)
const hasTriedButNotPassed = computed(() => {
return (
problem.value?.my_status !== undefined &&
@@ -231,6 +270,52 @@ function type(status: ProblemStatus) {
:theme="isDark ? 'dark' : 'light'"
/>
</div>
<!-- 相似题目推荐 -->
<div v-if="similarProblems.length > 0">
<n-divider />
<p class="title" :style="style">
<n-flex align="center">
<Icon icon="fluent-emoji-flat:light-bulb"></Icon>
相似题目推荐
</n-flex>
</p>
<n-list bordered>
<n-list-item v-for="sp in similarProblems" :key="sp._id">
<n-flex align="center" justify="space-between">
<n-flex align="center">
<n-tag size="small">{{ sp._id }}</n-tag>
<n-button
text
type="info"
@click="
router.push({
name: 'problem',
params: { problemID: sp._id },
})
"
>
{{ sp.title }}
</n-button>
</n-flex>
<n-tag
size="small"
:type="
sp.difficulty === 'Low'
? 'success'
: sp.difficulty === 'High'
? 'error'
: 'warning'
"
>
{{
DIFFICULTY[sp.difficulty as keyof typeof DIFFICULTY] || "中等"
}}
</n-tag>
</n-flex>
</n-list-item>
</n-list>
</div>
</div>
</template>
@@ -247,7 +332,7 @@ function type(status: ProblemStatus) {
.testcase {
font-size: 14px;
white-space: pre;
font-family: "Monaco", monospace;
font-family: "Monaco";
}
.status-alert {

View File

@@ -38,14 +38,18 @@ const editorHeight = computed(() =>
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)",
)
onMounted(() => {
function loadCode() {
const savedCode = storage.get(storageKey.value)
codeStore.setCode(
savedCode ||
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
})
}
onMounted(loadCode)
watch(() => problem.value?._id, loadCode)
const changeCode = (v: string) => {
storage.set(storageKey.value, v)

View File

@@ -4,7 +4,11 @@ import { getSubmissions, getRankOfProblem } from "oj/api"
import Pagination from "shared/components/Pagination.vue"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useUserStore } from "shared/store/user"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import {
JUDGE_STATUS,
LANGUAGE_SHOW_VALUE,
SubmissionStatus,
} from "utils/constants"
import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders"
import { Submission } from "utils/types"
@@ -83,6 +87,23 @@ const query = reactive({
page: 1,
})
// 错误分布统计
const statusDistribution = computed(() => {
if (!submissions.value.length) return []
const counts = new Map<number, number>()
for (const s of submissions.value) {
counts.set(s.result, (counts.get(s.result) || 0) + 1)
}
return Array.from(counts.entries())
.sort((a, b) => a[0] - b[0])
.map(([result, count]) => ({
result,
name: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.name || "未知",
type: JUDGE_STATUS[result as keyof typeof JUDGE_STATUS]?.type || "info",
count,
}))
})
const errorMsg = computed(() => {
if (!userStore.isAuthed) return "请先登录"
else if (!userStore.showSubmissions) return "提交列表已被管理员关闭"
@@ -256,6 +277,25 @@ watch(query, listSubmissions)
</template>
<template v-if="userStore.showSubmissions && userStore.isAuthed">
<!-- 错误分布统计 -->
<n-flex
v-if="statusDistribution.length"
class="tip"
align="center"
:wrap="true"
>
<span style="font-weight: bold; font-size: 13px">我的提交统计</span>
<n-tag
v-for="item in statusDistribution"
:key="item.result"
:type="item.type as any"
size="small"
round
>
{{ item.name }} × {{ item.count }}
</n-tag>
</n-flex>
<n-data-table
v-if="submissions.length > 0"
striped

View File

@@ -3,11 +3,24 @@ import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
import { submissionMemoryFormat, submissionTimeFormat } from "utils/functions"
import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import { useProblemStore } from "oj/store/problem"
import { consumeJSONEventStream } from "utils/stream"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { useDark } from "@vueuse/core"
const props = defineProps<{
submission?: Submission
}>()
const isDark = useDark()
const problemStore = useProblemStore()
// AI 提示状态
const hintContent = ref("")
const hintLoading = ref(false)
const hintError = ref("")
// 错误信息格式化
const msg = computed(() => {
if (!props.submission) return ""
@@ -30,6 +43,50 @@ const msg = computed(() => {
return msg
})
// 是否显示AI提示区域
const showAIHint = computed(() => {
if (!props.submission) return false
return (
problemStore.totalFailCount >= 3 &&
props.submission.result !== SubmissionStatus.accepted &&
props.submission.result !== SubmissionStatus.pending &&
props.submission.result !== SubmissionStatus.judging &&
props.submission.result !== SubmissionStatus.submitting
)
})
async function fetchHint(submissionId: string) {
hintLoading.value = true
hintContent.value = ""
hintError.value = ""
try {
const response = await fetch("/api/ai/hint", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ submission_id: submissionId }),
})
await consumeJSONEventStream(response, {
onMessage: (data: {
type: string
content?: string
message?: string
}) => {
if (data.type === "delta" && data.content) {
hintContent.value += data.content
} else if (data.type === "error") {
hintError.value = data.message || "AI 提示生成失败"
}
},
})
} catch (e: any) {
hintError.value = e.message || "请求失败"
} finally {
hintLoading.value = false
}
}
// 测试用例表格数据(只在部分通过时显示)
const infoTable = computed(() => {
if (!props.submission?.info?.data?.length) return []
@@ -87,6 +144,36 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
:columns="columns"
/>
</n-flex>
<!-- AI 提示区域 -->
<template v-if="showAIHint">
<n-divider />
<n-card size="small">
<template #header>
<span class="gradient-text">AI 提示</span>
</template>
<n-alert
v-if="hintError"
type="error"
:title="hintError"
class="mb-3"
/>
<n-button
v-if="!hintContent && !hintLoading"
type="primary"
@click="fetchHint(submission.id)"
>
AI 分析我的代码
</n-button>
<n-spin v-else-if="hintLoading && !hintContent" size="small" />
<MdPreview
v-if="hintContent"
:model-value="hintContent"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
/>
</n-card>
</template>
</div>
</template>
@@ -96,4 +183,12 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
word-break: break-all;
line-height: 1.5;
}
.gradient-text {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
font-weight: bold;
}
</style>

View File

@@ -124,6 +124,23 @@ async function submit() {
startMonitoring(res.data.submission_id)
}
// ==================== 失败计数 ====================
watch(
() => submission.value?.result,
(result) => {
if (result === undefined || result === null) return
if (
result === SubmissionStatus.pending ||
result === SubmissionStatus.judging ||
result === SubmissionStatus.submitting
)
return
if (result !== SubmissionStatus.accepted) {
problemStore.incrementFailCount()
}
},
)
// ==================== AC庆祝效果 ====================
watch(
() => submission.value?.result,

View File

@@ -104,6 +104,7 @@ async function init() {
}
}
onMounted(init)
watch(() => props.problemID, init)
onBeforeUnmount(() => {
problem.value = null
errMsg.value = "无数据"
@@ -116,9 +117,66 @@ watch(isMobile, (value) => {
</script>
<template>
<n-grid v-if="problem" x-gap="16" :cols="screenModeStore.isBothMode ? 7 : 1">
<n-gi :span="isDesktop ? 3 : 7" v-if="shouldShowProblem">
<n-scrollbar v-if="isDesktop" style="max-height: calc(100vh - 92px)">
<template v-if="problem">
<n-split
v-if="isDesktop && screenModeStore.isBothMode"
direction="horizontal"
:default-size="0.43"
:min="0.2"
:max="0.8"
style="height: calc(100vh - 92px)"
>
<template #1>
<n-scrollbar style="height: 100%">
<n-tabs v-model:value="currentTab" type="segment">
<n-tab-pane name="content" tab="题目描述">
<ProblemContent />
</n-tab-pane>
<n-tab-pane
v-if="problem.show_flowchart && problem.mermaid_code"
name="flowchart"
tab="流程图表"
>
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane
name="info"
tab="题目统计"
:disabled="!!props.problemSetId"
>
<ProblemInfo />
</n-tab-pane>
<n-tab-pane
v-if="!props.contestID"
name="comment"
tab="题目点评"
:disabled="!!props.problemSetId"
>
<ProblemComment />
</n-tab-pane>
<n-tab-pane
name="submission"
tab="我的提交"
:disabled="!!props.problemSetId"
>
<ProblemSubmission />
</n-tab-pane>
</n-tabs>
</n-scrollbar>
</template>
<template #2>
<component :is="inProblem ? ProblemEditor : ContestEditor" />
</template>
</n-split>
<!-- Desktop: code only mode -->
<template v-else-if="isDesktop && screenModeStore.isCodeOnlyMode">
<EditorForTest />
</template>
<!-- Desktop: problem only mode -->
<template v-else-if="isDesktop && shouldShowProblem">
<n-scrollbar style="max-height: calc(100vh - 92px)">
<n-tabs v-model:value="currentTab" type="segment">
<n-tab-pane name="content" tab="题目描述">
<ProblemContent />
@@ -154,42 +212,38 @@ watch(isMobile, (value) => {
</n-tab-pane>
</n-tabs>
</n-scrollbar>
<n-tabs v-else v-model:value="currentTab" type="segment">
<n-tab-pane name="content" tab="描述">
<ProblemContent />
</n-tab-pane>
<n-tab-pane v-if="problem.show_flowchart" name="flowchart" tab="流程">
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane name="editor" tab="代码">
<component :is="inProblem ? ProblemEditor : ContestEditor" />
</n-tab-pane>
<n-tab-pane name="info" tab="统计" :disabled="!!props.problemSetId">
<ProblemInfo />
</n-tab-pane>
<n-tab-pane
v-if="!props.contestID"
name="comment"
tab="点评"
:disabled="!!props.problemSetId"
>
<ProblemComment />
</n-tab-pane>
<n-tab-pane
name="submission"
tab="提交"
:disabled="!!props.problemSetId"
>
<ProblemSubmission />
</n-tab-pane>
</n-tabs>
</n-gi>
<n-gi :span="4" v-if="isDesktop && screenModeStore.isBothMode">
<component :is="inProblem ? ProblemEditor : ContestEditor" />
</n-gi>
<n-gi v-if="isDesktop && screenModeStore.isCodeOnlyMode">
<EditorForTest />
</n-gi>
</n-grid>
</template>
<!-- Mobile -->
<n-tabs v-else v-model:value="currentTab" type="segment">
<n-tab-pane name="content" tab="描述">
<ProblemContent />
</n-tab-pane>
<n-tab-pane v-if="problem.show_flowchart" name="flowchart" tab="流程">
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane name="editor" tab="代码">
<component :is="inProblem ? ProblemEditor : ContestEditor" />
</n-tab-pane>
<n-tab-pane name="info" tab="统计" :disabled="!!props.problemSetId">
<ProblemInfo />
</n-tab-pane>
<n-tab-pane
v-if="!props.contestID"
name="comment"
tab="点评"
:disabled="!!props.problemSetId"
>
<ProblemComment />
</n-tab-pane>
<n-tab-pane
name="submission"
tab="提交"
:disabled="!!props.problemSetId"
>
<ProblemSubmission />
</n-tab-pane>
</n-tabs>
</template>
<n-empty v-else :description="errMsg"></n-empty>
</template>

View File

@@ -10,6 +10,9 @@ export const useProblemStore = defineStore("problem", () => {
const problem = ref<Problem | null>(null)
const route = useRoute()
// 本次会话内累计的失败次数(与服务端 my_failed_count 叠加)
const localFailCount = ref(0)
// ==================== 计算属性 ====================
const languages = computed<LANGUAGE[]>(() => {
if (route.name === "problem" && problem.value?.allow_flowchart) {
@@ -18,11 +21,27 @@ export const useProblemStore = defineStore("problem", () => {
return problem.value?.languages ?? []
})
return {
// 状态
problem,
const totalFailCount = computed(
() => (problem.value?.my_failed_count ?? 0) + localFailCount.value,
)
// 计算属性
function incrementFailCount() {
localFailCount.value++
}
// 切题时重置
watch(
() => problem.value?.id,
() => {
localFailCount.value = 0
},
)
return {
problem,
localFailCount,
languages,
totalFailCount,
incrementFailCount,
}
})

View File

@@ -276,6 +276,12 @@ export const admins: RouteRecordRaw = {
props: true,
meta: { requiresSuperAdmin: true },
},
{
path: "problem/stuck",
name: "admin stuck problems",
component: () => import("admin/problem/Stuck.vue"),
meta: { requiresSuperAdmin: true },
},
// 题单管理路由
{
path: "problemset/list",

View File

@@ -112,6 +112,7 @@ const active = computed(() => {
if (path === "/admin") return "admin home"
if (path.startsWith("/admin/config")) return "admin config"
if (path.startsWith("/admin/problemset")) return "admin problemset list"
if (path.startsWith("/admin/problem/stuck")) return "admin stuck problems"
if (path.startsWith("/admin/problem")) return "admin problem list"
if (path.startsWith("/admin/contest")) return "admin contest list"
if (path.startsWith("/admin/user")) return "admin user list"

View File

@@ -239,9 +239,9 @@ export const CODE_TEMPLATES = {
} as const
export enum ScreenMode {
both = "题目 | 自测",
code = "自测",
problem = "题目",
both = "双栏",
code = "自测",
problem = "题目",
}
export enum ChartType {

View File

@@ -128,6 +128,7 @@ export interface Problem {
share_submission: boolean
contest: number
my_status: number
my_failed_count?: number
visible: boolean
// 流程图相关字段
@@ -192,6 +193,7 @@ export interface ProblemSet {
create_time: Date
difficulty: "Easy" | "Medium" | "Hard"
status: "active" | "archived" | "draft"
end_time: Date | null
visible: boolean
problems_count: number
completed_count: number
@@ -212,6 +214,7 @@ export interface ProblemSetList {
create_time: Date
difficulty: "Easy" | "Medium" | "Hard"
status: "active" | "archived" | "draft"
end_time: Date | null
problems_count: number
visible: boolean
user_progress: {
@@ -276,6 +279,7 @@ export interface CreateProblemSetData {
description: string
difficulty: "Easy" | "Medium" | "Hard"
status: "active" | "archived" | "draft"
end_time?: Date | null
}
export interface EditProblemSetData {
@@ -284,6 +288,7 @@ export interface EditProblemSetData {
description?: string
difficulty?: "Easy" | "Medium" | "Hard"
status?: "active" | "archived" | "draft"
end_time?: Date | null
visible?: boolean
}