update
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2025-10-23 00:54:10 +08:00
parent 9789b86920
commit 9f07fcb0a0
15 changed files with 1223 additions and 63 deletions

View File

@@ -294,7 +294,7 @@ export function getProblemSetList(
difficulty = "", difficulty = "",
status = "", status = "",
) { ) {
return http.get("admin/problemset/", { return http.get("admin/problemset", {
params: { params: {
paging: true, paging: true,
offset, offset,
@@ -307,7 +307,7 @@ export function getProblemSetList(
} }
export function getProblemSetDetail(id: number) { export function getProblemSetDetail(id: number) {
return http.get(`admin/problemset/${id}/`) return http.get(`admin/problemset/${id}`)
} }
export function createProblemSet(data: { export function createProblemSet(data: {
@@ -316,7 +316,7 @@ export function createProblemSet(data: {
difficulty: string difficulty: string
status: string status: string
}) { }) {
return http.post("admin/problemset/", data) return http.post("admin/problemset", data)
} }
export function editProblemSet(data: { export function editProblemSet(data: {
@@ -327,85 +327,107 @@ export function editProblemSet(data: {
status?: string status?: string
visible?: boolean visible?: boolean
}) { }) {
return http.put("admin/problemset/", data) return http.put("admin/problemset", data)
} }
export function deleteProblemSet(id: number) { export function deleteProblemSet(id: number) {
return http.delete("admin/problemset/", { params: { id } }) return http.delete("admin/problemset", { params: { id } })
} }
export function toggleProblemSetVisible(id: number) { export function toggleProblemSetVisible(id: number) {
return http.put("admin/problemset/visible/", { id }) return http.put("admin/problemset/visible", { id })
} }
export function updateProblemSetStatus(id: number, status: string) { export function updateProblemSetStatus(id: number, status: string) {
return http.put("admin/problemset/status/", { id, status }) return http.put("admin/problemset/status", { id, status })
} }
// 题单题目管理 API // 题单题目管理 API
export function getProblemSetProblems(problemSetId: number) { export function getProblemSetProblems(problemSetId: number) {
return http.get(`admin/problemset/${problemSetId}/problems/`) return http.get(`admin/problemset/${problemSetId}/problems`)
} }
export function addProblemToSet(problemSetId: number, data: { export function addProblemToSet(
problem_id: string problemSetId: number,
order?: number data: {
is_required?: boolean problem_id: string
score?: number order?: number
hint?: string is_required?: boolean
}) { score?: number
return http.post(`admin/problemset/${problemSetId}/problems/`, data) hint?: string
},
) {
return http.post(`admin/problemset/${problemSetId}/problems`, data)
} }
export function editProblemInSet(problemSetId: number, problemSetProblemId: number, data: { export function editProblemInSet(
order?: number problemSetId: number,
is_required?: boolean problemSetProblemId: number,
score?: number data: {
hint?: string order?: number
}) { is_required?: boolean
return http.put(`admin/problemset/${problemSetId}/problems/${problemSetProblemId}/`, data) score?: number
hint?: string
},
) {
return http.put(
`admin/problemset/${problemSetId}/problems/${problemSetProblemId}`,
data,
)
} }
export function removeProblemFromSet(problemSetId: number, problemSetProblemId: number) { export function removeProblemFromSet(
return http.delete(`admin/problemset/${problemSetId}/problems/${problemSetProblemId}/`) problemSetId: number,
problemSetProblemId: number,
) {
return http.delete(
`admin/problemset/${problemSetId}/problems/${problemSetProblemId}`,
)
} }
// 题单奖章管理 API // 题单奖章管理 API
export function getProblemSetBadges(problemSetId: number) { export function getProblemSetBadges(problemSetId: number) {
return http.get(`admin/problemset/${problemSetId}/badges/`) return http.get(`admin/problemset/${problemSetId}/badges`)
} }
export function createProblemSetBadge(problemSetId: number, data: { export function createProblemSetBadge(
name: string problemSetId: number,
description: string data: {
icon: string name: string
condition_type: string description: string
condition_value: number icon: string
level?: number condition_type: string
}) { condition_value: number
return http.post(`admin/problemset/${problemSetId}/badges/`, data) level?: number
},
) {
return http.post(`admin/problemset/${problemSetId}/badges`, data)
} }
export function editProblemSetBadge(problemSetId: number, badgeId: number, data: { export function editProblemSetBadge(
name?: string problemSetId: number,
description?: string badgeId: number,
icon?: string data: {
condition_type?: string name?: string
condition_value?: number description?: string
level?: number icon?: string
}) { condition_type?: string
return http.put(`admin/problemset/${problemSetId}/badges/${badgeId}/`, data) condition_value?: number
level?: number
},
) {
return http.put(`admin/problemset/${problemSetId}/badges/${badgeId}`, data)
} }
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) { export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
return http.delete(`admin/problemset/${problemSetId}/badges/${badgeId}/`) return http.delete(`admin/problemset/${problemSetId}/badges/${badgeId}`)
} }
// 题单进度管理 API // 题单进度管理 API
export function getProblemSetProgress(problemSetId: number) { export function getProblemSetProgress(problemSetId: number) {
return http.get(`admin/problemset/${problemSetId}/progress/`) return http.get(`admin/problemset/${problemSetId}/progress`)
} }
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}`)
} }

View File

@@ -162,7 +162,7 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
label: (ctx: TooltipItem<"bar">) => { label: (ctx: TooltipItem<"bar" | "line">) => {
const dsLabel = ctx.dataset.label || "" const dsLabel = ctx.dataset.label || ""
if ((ctx.dataset as any).yAxisID === "y1") { if ((ctx.dataset as any).yAxisID === "y1") {
const idx = Number(ctx.parsed.y) const idx = Number(ctx.parsed.y)
@@ -170,7 +170,7 @@ const options = computed<ChartOptions<"bar" | "line">>(() => {
} }
return `${dsLabel}: ${ctx.formattedValue}` return `${dsLabel}: ${ctx.formattedValue}`
}, },
footer: (items: TooltipItem<"bar">[]) => { footer: (items: TooltipItem<"bar" | "line">[]) => {
const barItems = items.filter( const barItems = items.filter(
(item) => (item.dataset as any).yAxisID === "y", (item) => (item.dataset as any).yAxisID === "y",
) )

View File

@@ -125,12 +125,12 @@ const data = computed<ChartData<"line">>(() => {
}) })
// 图表配置 // 图表配置
const options = computed<ChartOptions<"line">>(() => { const options = computed(() => {
return { return {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { interaction: {
mode: "index", mode: "index" as const,
intersect: false, intersect: false,
}, },
scales: { scales: {
@@ -142,8 +142,8 @@ const options = computed<ChartOptions<"line">>(() => {
}, },
}, },
y: { y: {
type: "linear", type: "linear" as const,
position: "left", position: "left" as const,
title: { title: {
display: true, display: true,
text: "平均提交次数(次/题)", text: "平均提交次数(次/题)",
@@ -159,8 +159,8 @@ const options = computed<ChartOptions<"line">>(() => {
}, },
}, },
y1: { y1: {
type: "linear", type: "linear" as const,
position: "right", position: "right" as const,
min: 0, min: 0,
max: 100, max: 100,
title: { title: {

View File

@@ -303,3 +303,71 @@ export function getCurrentProblemFlowchartSubmission(problemId: number) {
params: { problem_id: problemId }, params: { problem_id: problemId },
}) })
} }
// ==================== 题单相关API ====================
export function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return http.get("problemset", {
params: {
offset,
limit,
keyword,
difficulty,
status,
},
})
}
export function getProblemSetDetail(id: number) {
return http.get(`problemset/${id}`)
}
export function getProblemSetProblems(problemSetId: number) {
return http.get(`problemset/${problemSetId}/problems`)
}
export function joinProblemSet(problemSetId: number) {
return http.post("problemset/progress", {
problemset_id: problemSetId,
})
}
export function getProblemSetSubmissions(
problemSetId: number,
params: {
problem_id?: string
result?: string
language?: string
offset?: number
limit?: number
} = {}
) {
return http.get(`problemset/${problemSetId}/submissions`, {
params,
})
}
export function getProblemSetStatistics(problemSetId: number) {
return http.get(`problemset/${problemSetId}/statistics`)
}
export function updateProblemSetProgress(
problemSetId: number,
data: {
problem_id: number
status: string
score?: number
submit_time?: string
}
) {
return http.put("problemset/progress", {
problemset_id: problemSetId,
...data,
})
}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { storeToRefs } from "pinia" import { storeToRefs } from "pinia"
import { getComment, submitCode } from "oj/api" import { getComment, submitCode, updateProblemSetProgress } from "oj/api"
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 { useFireworks } from "oj/problem/composables/useFireworks" import { useFireworks } from "oj/problem/composables/useFireworks"
@@ -24,6 +24,7 @@ const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore) const { problem } = storeToRefs(problemStore)
const route = useRoute() const route = useRoute()
const contestID = <string>route.params.contestID ?? "" const contestID = <string>route.params.contestID ?? ""
const problemsetID = computed(() => route.params.problemSetId as string || "")
const [commentPanel] = useToggle() const [commentPanel] = useToggle()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
@@ -112,16 +113,31 @@ async function submit() {
// ==================== AC庆祝效果 ==================== // ==================== AC庆祝效果 ====================
watch( watch(
() => submission.value?.result, () => submission.value?.result,
(result) => { async (result) => {
if (result !== SubmissionStatus.accepted) return if (result !== SubmissionStatus.accepted) return
// 1. 刷新题目状态 // 1. 刷新题目状态
problem.value!.my_status = 0 problem.value!.my_status = 0
// 2. 放烟花(随机效果 // 2. 更新题单进度(如果来自题单
if (problemsetID.value) {
try {
await updateProblemSetProgress(Number(problemsetID.value), {
problem_id: problem.value!.id,
status: "completed",
score: 100, // 通过得满分
submit_time: new Date().toISOString(),
})
console.log(`[ProblemSet] 题单进度已更新: problemset=${problemsetID.value}, problem=${problem.value!.id}`)
} catch (error) {
console.error("更新题单进度失败:", error)
}
}
// 3. 放烟花(随机效果)
celebrate() celebrate()
// 3. 显示评价框(非比赛模式) // 4. 显示评价框(非比赛模式)
if (!contestID) { if (!contestID) {
showCommentPanelDelayed() showCommentPanelDelayed()
} }

View File

@@ -14,7 +14,7 @@ import {
} from "shared/composables/websocket" } from "shared/composables/websocket"
// API 和状态管理 // API 和状态管理
import { getCurrentProblemFlowchartSubmission, submitFlowchart } from "oj/api" import { getCurrentProblemFlowchartSubmission, submitFlowchart, updateProblemSetProgress } from "oj/api"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
// ==================== 类型定义 ==================== // ==================== 类型定义 ====================
@@ -43,6 +43,10 @@ const { problem } = toRefs(problemStore)
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
const { convertToMermaid } = useMermaidConverter() const { convertToMermaid } = useMermaidConverter()
const { renderError, renderFlowchart } = useMermaid() const { renderError, renderFlowchart } = useMermaid()
const route = useRoute()
// 获取题单ID
const problemsetID = computed(() => route.params.problemSetId as string || "")
// 状态管理 // 状态管理
const rendering = ref(false) const rendering = ref(false)
@@ -62,7 +66,7 @@ const evaluation = ref<Evaluation>({
// ==================== WebSocket 相关函数 ==================== // ==================== WebSocket 相关函数 ====================
// 处理 WebSocket 消息 // 处理 WebSocket 消息
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => { const handleWebSocketMessage = async (data: FlowchartEvaluationUpdate) => {
console.log("收到流程图评分更新:", data) console.log("收到流程图评分更新:", data)
if (data.type === "flowchart_evaluation_completed") { if (data.type === "flowchart_evaluation_completed") {
@@ -72,6 +76,21 @@ const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
grade: data.grade || "", grade: data.grade || "",
} }
message.success(`流程图评分完成!得分: ${data.score}分 (${data.grade}级)`) message.success(`流程图评分完成!得分: ${data.score}分 (${data.grade}级)`)
// 更新题单进度(如果来自题单)
if (problemsetID.value) {
try {
await updateProblemSetProgress(Number(problemsetID.value), {
problem_id: problem.value!.id,
status: "completed",
score: data.score || 0,
submit_time: new Date().toISOString(),
})
console.log(`[ProblemSet] 题单进度已更新: problemset=${problemsetID.value}, problem=${problem.value!.id}, score=${data.score}`)
} catch (error) {
console.error("更新题单进度失败:", error)
}
}
} else if (data.type === "flowchart_evaluation_failed") { } else if (data.type === "flowchart_evaluation_failed") {
console.log("处理评分失败消息") console.log("处理评分失败消息")
loading.value = false loading.value = false

View File

@@ -33,16 +33,21 @@ const ProblemFlowchart = defineAsyncComponent(
interface Props { interface Props {
problemID: string problemID: string
contestID?: string contestID?: string
problemSetId?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
contestID: "", contestID: "",
problemSetId: "",
}) })
const errMsg = ref("无数据") const errMsg = ref("无数据")
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// 从路由参数中获取题单ID
const problemsetID = computed(() => props.problemSetId || route.params.problemSetId as string)
const problemStore = useProblemStore() const problemStore = useProblemStore()
const screenModeStore = useScreenModeStore() const screenModeStore = useScreenModeStore()
const { problem } = storeToRefs(problemStore) const { problem } = storeToRefs(problemStore)

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from "@iconify/vue" import { Icon } from "@iconify/vue"
import { NSpace, NTag } from "naive-ui" import { NFlex, NTag } from "naive-ui"
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import { getProblemList, getRandomProblemID } from "oj/api" import { getProblemList, getRandomProblemID } from "oj/api"
import { getTagColor } from "utils/functions" import { getTagColor } from "utils/functions"
@@ -180,7 +180,7 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
key: "tags", key: "tags",
width: 260, width: 260,
render: (row) => render: (row) =>
h(NSpace, () => row.tags.map((t) => h(NTag, { key: t }, () => t))), h(NFlex, () => row.tags.map((t) => h(NTag, { key: t }, () => t))),
}, },
{ {
title: renderTableTitle("出题者", "streamline-emojis:man-raising-hand-2"), title: renderTableTitle("出题者", "streamline-emojis:man-raising-hand-2"),

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { getProblemSetStatistics } from "oj/api"
import { ProblemSetStatistics } from "utils/types"
import { useBreakpoints } from "shared/composables/breakpoints"
import { NTag } from "naive-ui"
import { h } from "vue"
interface Props {
problemSetId: number
}
const props = defineProps<Props>()
const { isDesktop } = useBreakpoints()
const message = useMessage()
const statistics = ref<ProblemSetStatistics | null>(null)
const loading = ref(false)
// 结果状态映射
const resultStatusMap: Record<string, { text: string; type: string }> = {
"-2": { text: "编译错误", type: "error" },
"-1": { text: "答案错误", type: "error" },
"0": { text: "通过", type: "success" },
"1": { text: "时间超限", type: "warning" },
"2": { text: "时间超限", type: "warning" },
"3": { text: "内存超限", type: "warning" },
"4": { text: "运行时错误", type: "error" },
"5": { text: "系统错误", type: "error" },
"6": { text: "等待中", type: "info" },
"7": { text: "评测中", type: "info" },
"8": { text: "部分通过", type: "warning" },
}
async function loadStatistics() {
loading.value = true
try {
const res = await getProblemSetStatistics(props.problemSetId)
statistics.value = res.data
} catch (err: any) {
message.error("加载统计信息失败:" + (err.data || "未知错误"))
} finally {
loading.value = false
}
}
onMounted(() => {
loadStatistics()
})
</script>
<template>
<n-flex vertical size="large" v-if="statistics">
<!-- 总体统计 -->
<n-card title="总体统计">
<n-grid :cols="isDesktop ? 4 : 2" :x-gap="16" :y-gap="16">
<n-grid-item>
<n-statistic label="总提交数" :value="statistics.total_submissions">
<template #prefix>
<Icon icon="streamline-emojis:file" />
</template>
</n-statistic>
</n-grid-item>
<n-grid-item>
<n-statistic label="通过数" :value="statistics.accepted_submissions">
<template #prefix>
<Icon icon="streamline-emojis:check-mark" />
</template>
</n-statistic>
</n-grid-item>
<n-grid-item>
<n-statistic label="通过率" :value="`${statistics.acceptance_rate}%`">
<template #prefix>
<Icon icon="streamline-emojis:chart-increasing" />
</template>
</n-statistic>
</n-grid-item>
<n-grid-item>
<n-statistic label="完成进度" :value="`${statistics.progress.progress_percentage.toFixed(1)}%`">
<template #prefix>
<Icon icon="streamline-emojis:target" />
</template>
</n-statistic>
</n-grid-item>
</n-grid>
</n-card>
<!-- 进度信息 -->
<n-card title="题单进度">
<n-flex vertical size="medium">
<n-flex justify="space-between" align="center">
<n-text>完成题目数</n-text>
<n-text strong>
{{ statistics.progress.completed_problems_count }} / {{ statistics.progress.total_problems_count }}
</n-text>
</n-flex>
<n-progress
:percentage="statistics.progress.progress_percentage"
:height="8"
:border-radius="4"
/>
<n-flex justify="space-between" align="center">
<n-text>总得分</n-text>
<n-text strong>{{ statistics.progress.total_score }} </n-text>
</n-flex>
</n-flex>
</n-card>
<!-- 题目统计 -->
<n-card title="题目统计">
<n-data-table
:columns="[
{
title: '题目',
key: 'problem_title',
minWidth: 200,
},
{
title: '总提交',
key: 'total_submissions',
width: 100,
},
{
title: '通过',
key: 'accepted_submissions',
width: 100,
},
{
title: '状态',
key: 'is_completed',
width: 100,
render: (row) => {
return h(
NTag,
{
type: row.is_completed ? 'success' : 'default',
},
() => row.is_completed ? '已完成' : '未完成'
)
},
},
]"
:data="Object.values(statistics.problem_stats)"
:pagination="false"
/>
</n-card>
<!-- 语言统计 -->
<n-card title="语言统计" v-if="Object.keys(statistics.language_stats).length > 0">
<n-flex vertical size="small">
<n-flex
v-for="(count, language) in statistics.language_stats"
:key="language"
justify="space-between"
align="center"
>
<n-text>{{ language }}</n-text>
<n-text strong>{{ count }} </n-text>
</n-flex>
</n-flex>
</n-card>
<!-- 结果统计 -->
<n-card title="结果统计" v-if="Object.keys(statistics.result_stats).length > 0">
<n-flex vertical size="small">
<n-flex
v-for="(count, result) in statistics.result_stats"
:key="result"
justify="space-between"
align="center"
>
<n-tag
:type="(resultStatusMap[String(result)]?.type as any) || 'default'"
size="small"
>
{{ resultStatusMap[String(result)]?.text || '未知' }}
</n-tag>
<n-text strong>{{ count }} </n-text>
</n-flex>
</n-flex>
</n-card>
</n-flex>
<n-empty v-else-if="!loading" description="暂无统计信息" />
<n-spin v-else size="large" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { getProblemSetSubmissions } from "oj/api"
import { parseTime } from "utils/functions"
import { ProblemSetSubmission } from "utils/types"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
import Pagination from "shared/components/Pagination.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { usePagination } from "shared/composables/pagination"
import SubmissionDetail from "oj/submission/detail.vue"
import { NButton } from "naive-ui"
interface Props {
problemSetId: number
}
const props = defineProps<Props>()
const { isDesktop } = useBreakpoints()
const message = useMessage()
const router = useRouter()
// 弹框状态管理
const [codePanelVisible, toggleCodePanel] = useToggle(false)
const submissionID = ref("")
const problemID = ref("")
// 显示代码弹框
function showCodePanel(id: string, problem: string) {
submissionID.value = id
problemID.value = problem
toggleCodePanel(true)
}
// 使用分页 composable
const { query, clearQuery } = usePagination({
problem_id: "",
result: "",
language: "",
})
const submissions = ref<ProblemSetSubmission[]>([])
const total = ref(0)
const loading = ref(false)
const columns = computed(() => {
const baseColumns = [
{
title: renderTableTitle("提交时间", "noto:seven-oclock"),
key: "submit_time",
minWidth: 200,
render: (row: ProblemSetSubmission) => parseTime(row.submit_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
key: "id",
minWidth: 200,
render: (row: ProblemSetSubmission) => {
return h(
NButton,
{
text: true,
type: "info",
onClick: () => {
showCodePanel(row.submission, row.problem.toString())
},
},
() => row.submission.slice(0, 12),
)
},
},
{
title: renderTableTitle("状态", "streamline-emojis:panda-face"),
key: "result",
minWidth: 140,
render: (row: ProblemSetSubmission) => h(SubmissionResultTag, { result: row.result as any }),
},
{
title: renderTableTitle("题目", "streamline-emojis:blossom"),
key: "problem_title",
minWidth: 300,
render: (row: ProblemSetSubmission) => {
return h(
NButton,
{
text: true,
type: "primary",
onClick: () => {
router.push(`/problem/${row.problem_id}`)
},
},
() => `${row.problem_id} ${row.problem_title}`,
)
},
},
{
title: renderTableTitle("语言", "streamline-emojis:globe-showing-europe-africa"),
key: "language",
minWidth: 120,
render: (row: ProblemSetSubmission) => LANGUAGE_SHOW_VALUE[row.language as keyof typeof LANGUAGE_SHOW_VALUE] || row.language,
},
]
if (isDesktop.value) {
baseColumns.push(
{
title: renderTableTitle("代码长度", "streamline-emojis:file"),
key: "code_length",
minWidth: 100,
render: (row: ProblemSetSubmission) => `${row.code_length} 字符`,
},
{
title: renderTableTitle("执行时间", "streamline-emojis:stopwatch"),
key: "execution_time",
minWidth: 100,
render: (row: ProblemSetSubmission) => `${row.execution_time}ms`,
},
{
title: renderTableTitle("内存使用", "streamline-emojis:brain"),
key: "memory_usage",
minWidth: 100,
render: (row: ProblemSetSubmission) => `${row.memory_usage}KB`,
}
)
}
return baseColumns
})
async function loadSubmissions() {
loading.value = true
try {
const res = await getProblemSetSubmissions(props.problemSetId, {
...query,
offset: query.limit * (query.page - 1),
})
submissions.value = res.data.results
total.value = res.data.total
} catch (err: any) {
message.error("加载提交记录失败:" + (err.data || "未知错误"))
} finally {
loading.value = false
}
}
function search() {
query.page = 1
loadSubmissions()
}
function clear() {
clearQuery()
search()
}
onMounted(() => {
loadSubmissions()
})
watch(query, loadSubmissions)
</script>
<template>
<n-flex vertical size="large">
<!-- 搜索表单 -->
<n-form :show-feedback="false" inline label-placement="left">
<n-form-item label="题目ID">
<n-input
v-model:value="query.problem_id"
placeholder="输入题目ID"
clearable
@keyup.enter="search"
/>
</n-form-item>
<n-form-item label="结果">
<n-select
v-model:value="query.result"
placeholder="选择结果"
clearable
:options="[
{ label: '通过', value: '0' },
{ label: '答案错误', value: '-1' },
{ label: '编译错误', value: '-2' },
{ label: '时间超限', value: '1' },
{ label: '内存超限', value: '3' },
{ label: '运行时错误', value: '4' },
{ label: '系统错误', value: '5' },
{ label: '等待中', value: '6' },
{ label: '评测中', value: '7' },
{ label: '部分通过', value: '8' },
]"
/>
</n-form-item>
<n-form-item label="语言">
<n-select
v-model:value="query.language"
placeholder="选择语言"
clearable
:options="[
{ label: 'C', value: 'C' },
{ label: 'C++', value: 'C++' },
{ label: 'Java', value: 'Java' },
{ label: 'Python', value: 'Python' },
{ label: 'JavaScript', value: 'JavaScript' },
]"
/>
</n-form-item>
<n-form-item>
<n-button @click="search" type="primary">搜索</n-button>
</n-form-item>
<n-form-item>
<n-button @click="clear" quaternary>重置</n-button>
</n-form-item>
</n-form>
<!-- 提交记录表格 -->
<n-data-table
v-if="submissions.length > 0"
:bordered="false"
:columns="columns"
:data="submissions"
:loading="loading"
/>
<n-empty v-else-if="!loading" description="暂无提交记录" />
<!-- 分页 -->
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<!-- 代码详情弹框 -->
<n-modal
v-model:show="codePanelVisible"
preset="card"
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
title="代码详情"
>
<SubmissionDetail
:problemID="problemID"
:submissionID="submissionID"
hideList
/>
</n-modal>
</n-flex>
</template>
<style scoped></style>

View File

@@ -0,0 +1,274 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import {
getProblemSetDetail,
getProblemSetProblems,
joinProblemSet,
} from "../api"
import { parseTime, getTagColor, getACRate } from "utils/functions"
import { ProblemSet, ProblemSetProblem } from "utils/types"
import ProblemStatus from "../problem/components/ProblemStatus.vue"
import ProblemSetSubmissions from "./components/ProblemSetSubmissions.vue"
import ProblemSetStatistics from "./components/ProblemSetStatistics.vue"
import { DIFFICULTY } from "utils/constants"
import { useBreakpoints } from "shared/composables/breakpoints"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const { isDesktop } = useBreakpoints()
const problemSetId = computed(() => Number(route.params.problemSetId))
const problemSet = ref<ProblemSet | null>(null)
const problems = ref<ProblemSetProblem[]>([])
const isJoined = ref(false)
const isJoining = ref(false)
// 刷新题单详情的函数
async function refreshProblemSetDetail() {
try {
const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res.data
// 更新加入状态
isJoined.value = res.data.user_progress?.is_joined || false
console.log(`[ProblemSet] 题单详情已刷新: ${problemSet.value?.title}`)
} catch (err: any) {
console.error("刷新题单详情失败:", err)
}
}
// 标签页状态
const activeTab = ref("problems")
const tabOptions = computed(() => {
const options = [
{ label: "题目列表", value: "problems" }
]
if (isJoined.value) {
options.push(
{ label: "提交记录", value: "submissions" },
{ label: "统计信息", value: "statistics" }
)
}
return options
})
function getDifficultyTag(difficulty: string) {
const difficultyMap: Record<
string,
{ type: "success" | "warning" | "error" | "default"; text: string }
> = {
Easy: { type: "success", text: "简单" },
Medium: { type: "warning", text: "中等" },
Hard: { type: "error", text: "困难" },
}
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
function goToProblem(problemId: string) {
// 使用新的路由结构:/problemset/{id}/problem/{problem_id}
router.push(`/problemset/${problemSetId.value}/problem/${problemId}`)
}
function getProblemStatus(
myStatus: number | null | undefined,
): "not_test" | "passed" | "failed" {
if (myStatus === null || myStatus === undefined) {
return "not_test"
} else if (myStatus === 0) {
return "passed"
} else {
return "failed"
}
}
function getProgressPercentage() {
if (!problemSet.value) return 0
return Math.round(
(problemSet.value.completed_count / problemSet.value.problems_count) * 100,
)
}
async function loadProblemSetDetail() {
try {
const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res.data
// 更新加入状态
isJoined.value = res.data.user_progress?.is_joined || false
} catch (err: any) {
message.error("加载题单详情失败:" + (err.data || "未知错误"))
}
}
async function loadProblems() {
try {
const res = await getProblemSetProblems(problemSetId.value)
problems.value = res.data
} catch (err: any) {
message.error("加载题目列表失败:" + (err.data || "未知错误"))
}
}
async function handleJoinProblemSet() {
if (isJoining.value) return
isJoining.value = true
try {
await joinProblemSet(problemSetId.value)
isJoined.value = true
message.success("成功加入题单!")
// 不需要重新加载详情,因为我们已经更新了本地状态
// await loadProblemSetDetail()
} catch (err: any) {
message.error("加入题单失败:" + (err.data || "未知错误"))
} finally {
isJoining.value = false
}
}
onMounted(async () => {
await Promise.all([loadProblemSetDetail(), loadProblems()])
})
// 监听路由变化,当从题单题目页面返回时刷新题单详情
watch(
() => route.path,
(newPath, oldPath) => {
// 如果从题单题目页面返回到题单详情页面,刷新题单详情
if (oldPath?.includes('/problem/') && newPath === `/problemset/${problemSetId.value}` && isJoined.value) {
refreshProblemSetDetail()
}
}
)
</script>
<template>
<div v-if="problemSet">
<!-- 题单信息头部 -->
<n-card style="margin-bottom: 24px">
<n-flex justify="space-between" align="center">
<n-flex align="center">
<n-tag type="warning" v-if="problemSet.status === 'archived'">
已归档
</n-tag>
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
{{ getDifficultyTag(problemSet.difficulty).text }}
</n-tag>
<n-h2 style="margin: 0">{{ problemSet.title }}</n-h2>
<n-tooltip trigger="hover" v-if="problemSet.description">
<template #trigger>
<Icon width="20" icon="emojione:information" />
</template>
{{ problemSet.description }}
</n-tooltip>
</n-flex>
<n-flex align="center">
<n-flex align="center">
<n-text strong>完成进度</n-text>
<n-text>
{{ problemSet.completed_count }} / {{ problemSet.problems_count }}
</n-text>
</n-flex>
<n-progress
:percentage="getProgressPercentage()"
:height="8"
:border-radius="4"
style="width: 200px"
/>
<n-button
v-if="!isJoined"
type="primary"
size="large"
:loading="isJoining"
@click="handleJoinProblemSet"
>
加入题单
</n-button>
<n-tag v-else type="success" size="large">
<template #icon>
<Icon icon="material-symbols:check-circle" />
</template>
已加入
</n-tag>
</n-flex>
</n-flex>
</n-card>
<!-- 标签页 -->
<n-tabs v-model:value="activeTab" type="segment">
<n-tab-pane name="problems" tab="题目列表">
<n-grid :cols="isDesktop ? 4 : 1" :x-gap="16" :y-gap="16">
<n-grid-item
v-for="(problemSetProblem, index) in problems"
:key="problemSetProblem.id"
>
<n-card
hoverable
@click="goToProblem(problemSetProblem.problem._id)"
style="cursor: pointer"
>
<n-flex>
<n-flex align="center">
<n-h2 style="margin: 0 20px 0 0">#{{ index + 1 }}. </n-h2>
<n-flex vertical style="flex: 1">
<n-flex align="center">
<n-tag
:type="getTagColor(problemSetProblem.problem.difficulty)"
size="small"
>
{{ DIFFICULTY[problemSetProblem.problem.difficulty] }}
</n-tag>
<n-h4 style="margin: 0">
{{ problemSetProblem.problem.title }}
</n-h4>
</n-flex>
<n-flex align="center" size="small">
<n-text type="info">
分数{{ problemSetProblem.score }}
</n-text>
<n-text v-if="!problemSetProblem.is_required">选做</n-text>
<n-text depth="3" style="font-size: 12px">
通过率
{{
getACRate(
problemSetProblem.problem.accepted_number,
problemSetProblem.problem.submission_number,
)
}}
</n-text>
</n-flex>
</n-flex>
</n-flex>
<n-flex align="center">
<ProblemStatus
:status="getProblemStatus(problemSetProblem.problem.my_status)"
/>
<n-icon>
<Icon icon="streamline-emojis:right-arrow" />
</n-icon>
</n-flex>
</n-flex>
</n-card>
</n-grid-item>
</n-grid>
</n-tab-pane>
<n-tab-pane v-if="isJoined" name="submissions" tab="提交记录">
<ProblemSetSubmissions :problemSetId="problemSetId" />
</n-tab-pane>
<n-tab-pane v-if="isJoined" name="statistics" tab="统计信息">
<ProblemSetStatistics :problemSetId="problemSetId" />
</n-tab-pane>
</n-tabs>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useRouteQuery } from "@vueuse/router"
import { getProblemSetList } from "../api"
import { parseTime } from "utils/functions"
import { ProblemSetList } from "utils/types"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { useBreakpoints } from "shared/composables/breakpoints"
const router = useRouter()
const { isDesktop } = useBreakpoints()
const total = ref(0)
const problemSets = ref<ProblemSetList[]>([])
interface ProblemSetQuery {
keyword: string
difficulty: string
status: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemSetQuery>({
keyword: useRouteQuery("keyword", "").value,
difficulty: useRouteQuery("difficulty", "").value,
status: useRouteQuery("status", "").value,
})
const difficultyOptions = [
{ label: "全部", value: "" },
{ label: "简单", value: "Easy" },
{ label: "中等", value: "Medium" },
{ label: "困难", value: "Hard" },
]
const statusOptions = [
{ label: "全部", value: "" },
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
]
async function listProblemSets() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getProblemSetList(
offset,
query.limit,
query.keyword,
query.difficulty,
query.status,
)
total.value = res.data.total
problemSets.value = res.data.results
}
function getDifficultyTag(difficulty: string) {
const difficultyMap: Record<
string,
{ type: "success" | "warning" | "error" | "default"; text: string }
> = {
Easy: { type: "success", text: "简单" },
Medium: { type: "warning", text: "中等" },
Hard: { type: "error", text: "困难" },
}
return difficultyMap[difficulty] || { type: "default", text: "未知" }
}
function goToProblemSet(problemSetId: number) {
router.push(`/problemset/${problemSetId}`)
}
function getConditionText(
conditionType: string,
conditionValue: number,
): string {
const conditionMap: Record<string, string> = {
all_problems: "完成所有题目",
problem_count: `完成 ${conditionValue} 道题目`,
score: `达到 ${conditionValue}`,
}
return conditionMap[conditionType] || "未知条件"
}
function getProgressColor(percentage: number) {
if (percentage >= 80) return "#18a058" // 绿色
if (percentage >= 50) return "#f0a020" // 橙色
return "#d03050" // 红色
}
onMounted(listProblemSets)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listProblemSets, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(
() => [query.page, query.limit, query.difficulty, query.status],
listProblemSets,
)
</script>
<template>
<div>
<n-flex vertical size="large">
<n-space>
<n-space align="center">
<n-text>难度</n-text>
<n-select
v-model:value="query.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
style="width: 120px"
clearable
/>
</n-space>
<n-space align="center">
<n-text>状态</n-text>
<n-select
v-model:value="query.status"
:options="statusOptions"
placeholder="选择状态"
style="width: 120px"
clearable
/>
</n-space>
<n-input
v-model:value="query.keyword"
placeholder="搜索题单..."
clearable
@clear="clearQuery"
style="width: 200px"
/>
</n-space>
<n-grid :cols="isDesktop ? 4 : 1" :x-gap="16" :y-gap="16">
<n-grid-item v-for="problemSet in problemSets" :key="problemSet.id">
<n-card
hoverable
@click="goToProblemSet(problemSet.id)"
style="cursor: pointer"
>
<template #header>
<n-flex justify="space-between" align="center">
<n-text strong>{{ problemSet.title }}</n-text>
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
{{ getDifficultyTag(problemSet.difficulty).text }}
</n-tag>
</n-flex>
</template>
<n-flex vertical size="large">
<n-flex justify="space-between" align="center">
<n-flex>
<Icon width="20" icon="streamline-emojis:blossom" />
<n-text>{{ problemSet.problems_count }} 道题目</n-text>
</n-flex>
<n-flex align="center" size="small">
<n-tag type="warning" v-if="problemSet.status === 'archived'">
已归档
</n-tag>
<n-tag v-if="problemSet.user_progress?.is_joined" type="success" size="small">
<template #icon>
<Icon icon="material-symbols:check-circle" width="12" />
</template>
已加入
</n-tag>
</n-flex>
</n-flex>
<!-- 用户进度显示 -->
<div v-if="problemSet.user_progress?.is_joined">
<n-flex align="center" justify="space-between" style="margin-bottom: 8px">
<n-text depth="3" style="font-size: 12px">
我的进度: {{ problemSet.user_progress.completed_count }} / {{ problemSet.user_progress.total_count }}
</n-text>
<n-progress
type="line"
:percentage="Math.round(problemSet.user_progress.progress_percentage)"
:height="4"
:border-radius="2"
style="width: 100px"
:color="getProgressColor(problemSet.user_progress.progress_percentage)"
/>
</n-flex>
</div>
<!-- 奖章显示 -->
<div v-if="problemSet.badges && problemSet.badges.length > 0">
<n-flex align="center" justify="space-between">
<n-text depth="3">
创建于
{{ parseTime(problemSet.create_time, "YYYY-MM-DD") }}
</n-text>
<n-flex>
<n-tooltip
v-for="badge in problemSet.badges"
:key="badge.id"
trigger="hover"
>
<template #trigger>
<n-image
:src="badge.icon"
:alt="badge.name"
width="24"
height="24"
object-fit="cover"
/>
</template>
<n-flex vertical size="small">
<span style="font-weight: bold"
>徽章: {{ badge.name }}</span
>
<span>
获取条件:
{{
getConditionText(
badge.condition_type,
badge.condition_value,
)
}}
</span>
</n-flex>
</n-tooltip>
</n-flex>
</n-flex>
</div>
</n-flex>
</n-card>
</n-grid-item>
</n-grid>
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</n-flex>
</div>
</template>
<style scoped></style>

View File

@@ -100,6 +100,24 @@ export const ojs: RouteRecordRaw = {
path: "flowchart", path: "flowchart",
component: () => import("oj/flowchart/index.vue"), component: () => import("oj/flowchart/index.vue"),
}, },
{
path: "problemset",
component: () => import("oj/problemset/list.vue"),
name: "problemsets",
},
{
path: "problemset/:problemSetId",
component: () => import("oj/problemset/detail.vue"),
props: true,
name: "problemset",
},
{
path: "problemset/:problemSetId/problem/:problemID",
component: () => import("oj/problem/detail.vue"),
props: true,
name: "problemset problem",
meta: { requiresAuth: true },
},
], ],
} }

View File

@@ -94,6 +94,11 @@ const menus = computed<MenuOption[]>(() => [
key: "problem", key: "problem",
icon: renderIcon("streamline-emojis:blossom"), icon: renderIcon("streamline-emojis:blossom"),
}, },
{
label: () => h(RouterLink, { to: "/problemset" }, { default: () => "题单" }),
key: "problemset",
icon: renderIcon("streamline-emojis:green-book"),
},
{ {
label: () => label: () =>
h(RouterLink, { to: "/submission" }, { default: () => "提交" }), h(RouterLink, { to: "/submission" }, { default: () => "提交" }),

View File

@@ -194,6 +194,13 @@ export interface ProblemSet {
visible: boolean visible: boolean
problems_count: number problems_count: number
completed_count: number completed_count: number
user_progress: {
is_joined: boolean
progress_percentage: number
completed_count: number
total_count: number
is_completed: boolean
}
} }
export interface ProblemSetList { export interface ProblemSetList {
@@ -213,6 +220,7 @@ export interface ProblemSetList {
total_count: number total_count: number
is_completed: boolean is_completed: boolean
} }
badges: ProblemSetBadge[]
} }
export interface ProblemSetProblem { export interface ProblemSetProblem {
@@ -594,3 +602,42 @@ export interface DetailsData {
} }
export type Grade = "S" | "A" | "B" | "C" export type Grade = "S" | "A" | "B" | "C"
// 题单提交记录相关类型
export interface ProblemSetSubmission {
id: number
problem: number
problem_id: number
problem_title: string
submission: string
result: number
result_text: string
score: number
language: string
code_length: number
execution_time: number
memory_usage: number
submit_time: string
}
export interface ProblemSetStatistics {
total_submissions: number
accepted_submissions: number
acceptance_rate: number
problem_stats: {
[problemId: string]: {
problem_title: string
total_submissions: number
accepted_submissions: number
is_completed: boolean
}
}
language_stats: { [language: string]: number }
result_stats: { [result: number]: number }
progress: {
completed_problems_count: number
total_problems_count: number
progress_percentage: number
total_score: number
}
}