Compare commits
3 Commits
fa511e39e0
...
8c02ad46e7
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c02ad46e7 | |||
| 9029e29148 | |||
| c1977d7152 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,4 +26,5 @@ dist-ssr
|
|||||||
src/components.d.ts
|
src/components.d.ts
|
||||||
src/auto-imports.d.ts
|
src/auto-imports.d.ts
|
||||||
|
|
||||||
.claude
|
.claude
|
||||||
|
.worktrees
|
||||||
113
CLAUDE.md
Normal file
113
CLAUDE.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# 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, config, authModal, screenMode, loginSummary)
|
||||||
|
- `composables/` — reusable composables (pagination, WebSocket, sync, etc.)
|
||||||
|
- `layout/` — `default.vue` and `admin.vue` layout wrappers
|
||||||
|
- `api.ts` — shared API calls
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
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 `D:\Projects\OnlineJudge` — a Django 5 + DRF project. See its CLAUDE.md for backend details.
|
||||||
@@ -434,3 +434,8 @@ export function getProblemSetProgress(problemSetId: number) {
|
|||||||
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
|
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
|
||||||
return http.delete(`admin/problemset/${problemSetId}/progress/${userId}`)
|
return http.delete(`admin/problemset/${problemSetId}/progress/${userId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 学生卡点分析
|
||||||
|
export function getStuckProblems() {
|
||||||
|
return http.get("admin/problem/stuck")
|
||||||
|
}
|
||||||
|
|||||||
56
src/admin/problem/Stuck.vue
Normal file
56
src/admin/problem/Stuck.vue
Normal 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">学生卡点分析</h2>
|
||||||
|
<n-data-table
|
||||||
|
:loading="loading"
|
||||||
|
:columns="columns"
|
||||||
|
:data="data"
|
||||||
|
striped
|
||||||
|
:pagination="{ pageSize: 20 }"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -301,6 +301,12 @@ export function getAILoginSummary() {
|
|||||||
return http.get("ai/login_summary")
|
return http.get("ai/login_summary")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 相似题目推荐 ====================
|
||||||
|
|
||||||
|
export function getSimilarProblems(problemId: string) {
|
||||||
|
return http.get("problem/similar", { params: { problem_id: problemId } })
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 流程图相关API ====================
|
// ==================== 流程图相关API ====================
|
||||||
|
|
||||||
export function submitFlowchart(data: {
|
export function submitFlowchart(data: {
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import { storeToRefs } from "pinia"
|
|||||||
import { useCodeStore } from "oj/store/code"
|
import { useCodeStore } from "oj/store/code"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { createTestSubmission } from "utils/judge"
|
import { createTestSubmission } from "utils/judge"
|
||||||
|
import { DIFFICULTY } from "utils/constants"
|
||||||
import { Problem, ProblemStatus } from "utils/types"
|
import { Problem, ProblemStatus } from "utils/types"
|
||||||
import Copy from "shared/components/Copy.vue"
|
import Copy from "shared/components/Copy.vue"
|
||||||
import { useDark } from "@vueuse/core"
|
import { useDark } from "@vueuse/core"
|
||||||
import { MdPreview } from "md-editor-v3"
|
import { MdPreview } from "md-editor-v3"
|
||||||
import "md-editor-v3/lib/preview.css"
|
import "md-editor-v3/lib/preview.css"
|
||||||
|
import { getSimilarProblems } from "oj/api"
|
||||||
|
|
||||||
type Sample = Problem["samples"][number] & {
|
type Sample = Problem["samples"][number] & {
|
||||||
id: number
|
id: number
|
||||||
@@ -28,6 +30,34 @@ const { problem } = storeToRefs(problemStore)
|
|||||||
|
|
||||||
const problemSetId = computed(() => route.params.problemSetId)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// AC 或失败次数 >= 3 时加载推荐
|
||||||
|
watch(
|
||||||
|
() => [problem.value?.my_status, problemStore.totalFailCount],
|
||||||
|
([status, failCount]) => {
|
||||||
|
if (status === 0 || (failCount as number) >= 3) {
|
||||||
|
loadSimilarProblems()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
const hasTriedButNotPassed = computed(() => {
|
const hasTriedButNotPassed = computed(() => {
|
||||||
return (
|
return (
|
||||||
problem.value?.my_status !== undefined &&
|
problem.value?.my_status !== undefined &&
|
||||||
@@ -231,6 +261,52 @@ function type(status: ProblemStatus) {
|
|||||||
:theme="isDark ? 'dark' : 'light'"
|
:theme="isDark ? 'dark' : 'light'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 相似题目推荐 -->
|
||||||
|
<div v-if="similarProblems.length > 0">
|
||||||
|
<n-divider />
|
||||||
|
<p class="title" :style="style">
|
||||||
|
<n-flex align="center">
|
||||||
|
<Icon icon="streamline-emojis: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { getSubmissions, getRankOfProblem } from "oj/api"
|
|||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
||||||
import { useUserStore } from "shared/store/user"
|
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 { parseTime } from "utils/functions"
|
||||||
import { renderTableTitle } from "utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import { Submission } from "utils/types"
|
import { Submission } from "utils/types"
|
||||||
@@ -83,6 +87,23 @@ const query = reactive({
|
|||||||
page: 1,
|
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(() => {
|
const errorMsg = computed(() => {
|
||||||
if (!userStore.isAuthed) return "请先登录"
|
if (!userStore.isAuthed) return "请先登录"
|
||||||
else if (!userStore.showSubmissions) return "提交列表已被管理员关闭"
|
else if (!userStore.showSubmissions) return "提交列表已被管理员关闭"
|
||||||
@@ -256,6 +277,25 @@ watch(query, listSubmissions)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="userStore.showSubmissions && userStore.isAuthed">
|
<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
|
<n-data-table
|
||||||
v-if="submissions.length > 0"
|
v-if="submissions.length > 0"
|
||||||
striped
|
striped
|
||||||
|
|||||||
@@ -3,11 +3,24 @@ import { JUDGE_STATUS, SubmissionStatus } from "utils/constants"
|
|||||||
import { submissionMemoryFormat, submissionTimeFormat } from "utils/functions"
|
import { submissionMemoryFormat, submissionTimeFormat } from "utils/functions"
|
||||||
import type { Submission } from "utils/types"
|
import type { Submission } from "utils/types"
|
||||||
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
|
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<{
|
const props = defineProps<{
|
||||||
submission?: Submission
|
submission?: Submission
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const isDark = useDark()
|
||||||
|
const problemStore = useProblemStore()
|
||||||
|
|
||||||
|
// AI 提示状态
|
||||||
|
const hintContent = ref("")
|
||||||
|
const hintLoading = ref(false)
|
||||||
|
const hintError = ref("")
|
||||||
|
|
||||||
// 错误信息格式化
|
// 错误信息格式化
|
||||||
const msg = computed(() => {
|
const msg = computed(() => {
|
||||||
if (!props.submission) return ""
|
if (!props.submission) return ""
|
||||||
@@ -30,6 +43,50 @@ const msg = computed(() => {
|
|||||||
return msg
|
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(() => {
|
const infoTable = computed(() => {
|
||||||
if (!props.submission?.info?.data?.length) return []
|
if (!props.submission?.info?.data?.length) return []
|
||||||
@@ -87,6 +144,36 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
|
|||||||
:columns="columns"
|
:columns="columns"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -96,4 +183,12 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
line-height: 1.5;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -124,6 +124,23 @@ async function submit() {
|
|||||||
startMonitoring(res.data.submission_id)
|
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庆祝效果 ====================
|
// ==================== AC庆祝效果 ====================
|
||||||
watch(
|
watch(
|
||||||
() => submission.value?.result,
|
() => submission.value?.result,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export const useProblemStore = defineStore("problem", () => {
|
|||||||
const problem = ref<Problem | null>(null)
|
const problem = ref<Problem | null>(null)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 本次会话内累计的失败次数(与服务端 my_failed_count 叠加)
|
||||||
|
const localFailCount = ref(0)
|
||||||
|
|
||||||
// ==================== 计算属性 ====================
|
// ==================== 计算属性 ====================
|
||||||
const languages = computed<LANGUAGE[]>(() => {
|
const languages = computed<LANGUAGE[]>(() => {
|
||||||
if (route.name === "problem" && problem.value?.allow_flowchart) {
|
if (route.name === "problem" && problem.value?.allow_flowchart) {
|
||||||
@@ -18,11 +21,27 @@ export const useProblemStore = defineStore("problem", () => {
|
|||||||
return problem.value?.languages ?? []
|
return problem.value?.languages ?? []
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
const totalFailCount = computed(
|
||||||
// 状态
|
() => (problem.value?.my_failed_count ?? 0) + localFailCount.value,
|
||||||
problem,
|
)
|
||||||
|
|
||||||
// 计算属性
|
function incrementFailCount() {
|
||||||
|
localFailCount.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切题时重置
|
||||||
|
watch(
|
||||||
|
() => problem.value?.id,
|
||||||
|
() => {
|
||||||
|
localFailCount.value = 0
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
problem,
|
||||||
|
localFailCount,
|
||||||
languages,
|
languages,
|
||||||
|
totalFailCount,
|
||||||
|
incrementFailCount,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -276,6 +276,12 @@ export const admins: RouteRecordRaw = {
|
|||||||
props: true,
|
props: true,
|
||||||
meta: { requiresSuperAdmin: true },
|
meta: { requiresSuperAdmin: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "problem/stuck",
|
||||||
|
name: "admin stuck problems",
|
||||||
|
component: () => import("admin/problem/Stuck.vue"),
|
||||||
|
meta: { requiresSuperAdmin: true },
|
||||||
|
},
|
||||||
// 题单管理路由
|
// 题单管理路由
|
||||||
{
|
{
|
||||||
path: "problemset/list",
|
path: "problemset/list",
|
||||||
|
|||||||
@@ -99,6 +99,15 @@ const options = computed<MenuOption[]>(() => {
|
|||||||
),
|
),
|
||||||
key: "admin tutorial list",
|
key: "admin tutorial list",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: () =>
|
||||||
|
h(
|
||||||
|
RouterLink,
|
||||||
|
{ to: "/admin/problem/stuck" },
|
||||||
|
{ default: () => "卡点" },
|
||||||
|
),
|
||||||
|
key: "admin stuck problems",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +121,7 @@ const active = computed(() => {
|
|||||||
if (path === "/admin") return "admin home"
|
if (path === "/admin") return "admin home"
|
||||||
if (path.startsWith("/admin/config")) return "admin config"
|
if (path.startsWith("/admin/config")) return "admin config"
|
||||||
if (path.startsWith("/admin/problemset")) return "admin problemset list"
|
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/problem")) return "admin problem list"
|
||||||
if (path.startsWith("/admin/contest")) return "admin contest list"
|
if (path.startsWith("/admin/contest")) return "admin contest list"
|
||||||
if (path.startsWith("/admin/user")) return "admin user list"
|
if (path.startsWith("/admin/user")) return "admin user list"
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export interface Problem {
|
|||||||
share_submission: boolean
|
share_submission: boolean
|
||||||
contest: number
|
contest: number
|
||||||
my_status: number
|
my_status: number
|
||||||
|
my_failed_count?: number
|
||||||
visible: boolean
|
visible: boolean
|
||||||
|
|
||||||
// 流程图相关字段
|
// 流程图相关字段
|
||||||
|
|||||||
Reference in New Issue
Block a user