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

This commit is contained in:
2025-10-22 20:29:17 +08:00
parent 6bc2140052
commit 9789b86920
19 changed files with 1015 additions and 338 deletions

BIN
public/badge-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/badge-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/badge-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/badge-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
public/badge-5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -314,7 +314,6 @@ export function createProblemSet(data: {
title: string title: string
description: string description: string
difficulty: string difficulty: string
is_public: boolean
status: string status: string
}) { }) {
return http.post("admin/problemset/", data) return http.post("admin/problemset/", data)
@@ -325,7 +324,6 @@ export function editProblemSet(data: {
title?: string title?: string
description?: string description?: string
difficulty?: string difficulty?: string
is_public?: boolean
status?: string status?: string
visible?: boolean visible?: boolean
}) { }) {
@@ -350,7 +348,7 @@ export function getProblemSetProblems(problemSetId: number) {
} }
export function addProblemToSet(problemSetId: number, data: { export function addProblemToSet(problemSetId: number, data: {
problem_id: number problem_id: string
order?: number order?: number
is_required?: boolean is_required?: boolean
score?: number score?: number
@@ -359,8 +357,17 @@ export function addProblemToSet(problemSetId: number, data: {
return http.post(`admin/problemset/${problemSetId}/problems/`, data) return http.post(`admin/problemset/${problemSetId}/problems/`, data)
} }
export function removeProblemFromSet(problemSetId: number, problemId: number) { export function editProblemInSet(problemSetId: number, problemSetProblemId: number, data: {
return http.delete(`admin/problemset/${problemSetId}/problems/${problemId}/`) order?: number
is_required?: boolean
score?: number
hint?: string
}) {
return http.put(`admin/problemset/${problemSetId}/problems/${problemSetProblemId}/`, data)
}
export function removeProblemFromSet(problemSetId: number, problemSetProblemId: number) {
return http.delete(`admin/problemset/${problemSetId}/problems/${problemSetProblemId}/`)
} }
// 题单奖章管理 API // 题单奖章管理 API
@@ -379,6 +386,17 @@ export function createProblemSetBadge(problemSetId: number, data: {
return http.post(`admin/problemset/${problemSetId}/badges/`, data) return http.post(`admin/problemset/${problemSetId}/badges/`, data)
} }
export function editProblemSetBadge(problemSetId: number, badgeId: number, data: {
name?: string
description?: string
icon?: string
condition_type?: string
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}/`)
} }

View File

@@ -0,0 +1,154 @@
<script setup lang="ts">
import { NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NButton, NFlex, NImage } from "naive-ui"
interface Props {
show: boolean
}
interface Emits {
(e: "update:show", value: boolean): void
(e: "confirm", data: {
name: string
description: string
icon: string
condition_type: "all_problems" | "problem_count" | "score"
condition_value?: number
}): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const newBadgeName = ref("")
const newBadgeDescription = ref("")
const newBadgeIcon = ref("")
const newBadgeConditionType = ref<"all_problems" | "problem_count" | "score">("all_problems")
const newBadgeConditionValue = ref(1)
// 预设奖章图标选项
const badgeIconOptions = [
{ label: "奖章1", value: "/badge-1.png", icon: "/badge-1.png" },
{ label: "奖章2", value: "/badge-2.png", icon: "/badge-2.png" },
{ label: "奖章3", value: "/badge-3.png", icon: "/badge-3.png" },
{ label: "奖章4", value: "/badge-4.png", icon: "/badge-4.png" },
{ label: "奖章5", value: "/badge-5.png", icon: "/badge-5.png" },
]
const conditionTypeOptions = [
{ label: "完成所有题目", value: "all_problems" },
{ label: "完成指定数量题目", value: "problem_count" },
{ label: "达到指定分数", value: "score" },
]
function handleConfirm() {
const data: any = {
name: newBadgeName.value,
description: newBadgeDescription.value,
icon: newBadgeIcon.value,
condition_type: newBadgeConditionType.value,
}
// 只有非"完成所有题目"时才添加条件值
if (newBadgeConditionType.value !== "all_problems") {
data.condition_value = newBadgeConditionValue.value
}
emit("confirm", data)
}
function handleCancel() {
emit("update:show", false)
}
// 重置表单
watch(() => props.show, (newVal) => {
if (newVal) {
newBadgeName.value = ""
newBadgeDescription.value = ""
newBadgeIcon.value = ""
newBadgeConditionType.value = "all_problems"
newBadgeConditionValue.value = 1
}
})
</script>
<template>
<n-modal
:show="show"
preset="card"
title="添加奖章"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form>
<n-form-item label="奖章名称" required>
<n-input v-model:value="newBadgeName" placeholder="请输入奖章名称" />
</n-form-item>
<n-form-item label="描述">
<n-input
v-model:value="newBadgeDescription"
type="textarea"
placeholder="奖章描述"
/>
</n-form-item>
<n-form-item label="图标" required>
<n-flex align="center" gap="small">
<div
v-for="option in badgeIconOptions"
:key="option.value"
@click="newBadgeIcon = option.value"
:style="{
width: '60px',
height: '60px',
border:
newBadgeIcon === option.value
? '2px solid #1890ff'
: '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor:
newBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
}"
>
<n-image
:src="option.icon"
width="50"
height="50"
object-fit="cover"
preview-disabled
style="border-radius: 2px"
/>
</div>
</n-flex>
</n-form-item>
<n-flex align="center">
<n-form-item label="获得条件">
<n-select
style="width: 200px"
v-model:value="newBadgeConditionType"
:options="conditionTypeOptions"
/>
</n-form-item>
<n-form-item
label="条件值"
v-if="newBadgeConditionType !== 'all_problems'"
>
<n-input-number
style="width: 120px"
v-model:value="newBadgeConditionValue"
placeholder="条件值"
/>
</n-form-item>
</n-flex>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import { NModal, NForm, NFormItem, NInput, NInputNumber, NSwitch, NButton, NFlex } from "naive-ui"
interface Props {
show: boolean
}
interface Emits {
(e: "update:show", value: boolean): void
(e: "confirm", data: {
problem_id: string
order: number
is_required: boolean
score: number
hint: string
}): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const newProblemId = ref("")
const newProblemOrder = ref(0)
const newProblemRequired = ref(true)
const newProblemScore = ref(0)
const newProblemHint = ref("")
function handleConfirm() {
emit("confirm", {
problem_id: newProblemId.value,
order: newProblemOrder.value,
is_required: newProblemRequired.value,
score: newProblemScore.value,
hint: newProblemHint.value,
})
}
function handleCancel() {
emit("update:show", false)
}
// 重置表单
watch(() => props.show, (newVal) => {
if (newVal) {
newProblemId.value = ""
newProblemOrder.value = 0
newProblemRequired.value = true
newProblemScore.value = 0
newProblemHint.value = ""
}
})
</script>
<template>
<n-modal
:show="show"
preset="card"
title="添加题目"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form>
<n-form-item label="题目ID" required>
<n-input
v-model:value="newProblemId"
placeholder="请输入题目的显示ID1001"
/>
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="newProblemOrder"
placeholder="题目在题单中的顺序"
/>
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="newProblemRequired" />
</n-form-item>
<n-form-item label="分数">
<n-input-number
v-model:value="newProblemScore"
placeholder="题目分数"
/>
</n-form-item>
<n-form-item label="提示">
<n-input
v-model:value="newProblemHint"
type="textarea"
placeholder="题目提示"
/>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { h } from "vue"
import { NDataTable, NButton, NFlex, NImage } from "naive-ui"
import { ProblemSetBadge } from "utils/types"
interface Props {
badges: ProblemSetBadge[]
}
interface Emits {
(e: "add-badge"): void
(e: "edit-badge", badge: ProblemSetBadge): void
(e: "delete-badge", badgeId: number): void
}
defineProps<Props>()
defineEmits<Emits>()
</script>
<template>
<div>
<n-flex
justify="space-between"
align="center"
style="margin-bottom: 16px"
>
<h3>奖章列表</h3>
<n-button type="primary" @click="$emit('add-badge')">
添加奖章
</n-button>
</n-flex>
<n-data-table
:columns="[
{
title: '图标',
key: 'icon',
render: (row) =>
h(NImage, {
src: row.icon,
width: 40,
height: 40,
objectFit: 'cover',
previewDisabled: true,
style: 'border-radius: 4px; border: 1px solid #d9d9d9',
}),
},
{ title: '名称', key: 'name' },
{
title: '条件类型',
key: 'condition_type',
render: (row) => {
const typeMap: Record<string, string> = {
all_problems: '完成所有题目',
problem_count: '完成指定数量题目',
score: '达到指定分数',
}
return typeMap[row.condition_type] || row.condition_type
},
},
{
title: '条件值',
key: 'condition_value',
render: (row) => {
return row.condition_type === 'all_problems'
? '-'
: row.condition_value
},
},
{ title: '描述', key: 'description' },
{
title: '操作',
key: 'actions',
width: 160,
render: (row) =>
h('div', { style: 'display: flex; gap: 8px;' }, [
h(
NButton,
{
size: 'small',
type: 'primary',
secondary: true,
onClick: () => $emit('edit-badge', row),
},
{ default: () => '编辑' },
),
h(
NButton,
{
size: 'small',
type: 'error',
secondary: true,
onClick: () => $emit('delete-badge', row.id),
},
{ default: () => '删除' },
),
]),
},
]"
:data="badges"
/>
</div>
</template>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NButton, NFlex, NImage } from "naive-ui"
import { ProblemSetBadge } from "utils/types"
interface Props {
show: boolean
badge: ProblemSetBadge | null
}
interface Emits {
(e: "update:show", value: boolean): void
(e: "confirm", data: {
name: string
description: string
icon: string
condition_type: "all_problems" | "problem_count" | "score"
condition_value?: number
}): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const editBadgeName = ref("")
const editBadgeDescription = ref("")
const editBadgeIcon = ref("")
const editBadgeConditionType = ref<"all_problems" | "problem_count" | "score">("all_problems")
const editBadgeConditionValue = ref(1)
// 预设奖章图标选项
const badgeIconOptions = [
{ label: "奖章1", value: "/badge-1.png", icon: "/badge-1.png" },
{ label: "奖章2", value: "/badge-2.png", icon: "/badge-2.png" },
{ label: "奖章3", value: "/badge-3.png", icon: "/badge-3.png" },
{ label: "奖章4", value: "/badge-4.png", icon: "/badge-4.png" },
{ label: "奖章5", value: "/badge-5.png", icon: "/badge-5.png" },
]
const conditionTypeOptions = [
{ label: "完成所有题目", value: "all_problems" },
{ label: "完成指定数量题目", value: "problem_count" },
{ label: "达到指定分数", value: "score" },
]
function handleConfirm() {
const data: any = {
name: editBadgeName.value,
description: editBadgeDescription.value,
icon: editBadgeIcon.value,
condition_type: editBadgeConditionType.value,
}
// 只有非"完成所有题目"时才添加条件值
if (editBadgeConditionType.value !== "all_problems") {
data.condition_value = editBadgeConditionValue.value
}
emit("confirm", data)
}
function handleCancel() {
emit("update:show", false)
}
// 当奖章数据变化时,更新表单数据
watch(() => props.badge, (newBadge) => {
if (newBadge) {
editBadgeName.value = newBadge.name
editBadgeDescription.value = newBadge.description
editBadgeIcon.value = newBadge.icon
editBadgeConditionType.value = newBadge.condition_type
editBadgeConditionValue.value = newBadge.condition_value
}
}, { immediate: true })
</script>
<template>
<n-modal
:show="show"
preset="card"
title="编辑奖章"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form v-if="badge">
<n-form-item label="奖章名称" required>
<n-input v-model:value="editBadgeName" placeholder="请输入奖章名称" />
</n-form-item>
<n-form-item label="描述">
<n-input
v-model:value="editBadgeDescription"
type="textarea"
placeholder="奖章描述"
/>
</n-form-item>
<n-form-item label="图标" required>
<n-flex align="center" gap="small">
<div
v-for="option in badgeIconOptions"
:key="option.value"
@click="editBadgeIcon = option.value"
:style="{
width: '60px',
height: '60px',
border:
editBadgeIcon === option.value
? '2px solid #1890ff'
: '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor:
editBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
}"
>
<n-image
:src="option.icon"
width="50"
height="50"
object-fit="cover"
style="border-radius: 2px"
preview-disabled
/>
</div>
</n-flex>
</n-form-item>
<n-flex align="center">
<n-form-item label="获得条件">
<n-select
style="width: 200px"
v-model:value="editBadgeConditionType"
:options="conditionTypeOptions"
/>
</n-form-item>
<n-form-item
label="条件值"
v-if="editBadgeConditionType !== 'all_problems'"
>
<n-input-number
style="width: 120px"
v-model:value="editBadgeConditionValue"
placeholder="条件值"
/>
</n-form-item>
</n-flex>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { NModal, NForm, NFormItem, NInput, NInputNumber, NSwitch, NButton, NFlex } from "naive-ui"
import { ProblemSetProblem } from "utils/types"
interface Props {
show: boolean
problem: ProblemSetProblem | null
}
interface Emits {
(e: "update:show", value: boolean): void
(e: "confirm", data: {
order: number
is_required: boolean
score: number
hint: string
}): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const editProblemOrder = ref(0)
const editProblemRequired = ref(true)
const editProblemScore = ref(0)
const editProblemHint = ref("")
function handleConfirm() {
emit("confirm", {
order: editProblemOrder.value,
is_required: editProblemRequired.value,
score: editProblemScore.value,
hint: editProblemHint.value,
})
}
function handleCancel() {
emit("update:show", false)
}
// 当问题数据变化时,更新表单数据
watch(() => props.problem, (newProblem) => {
if (newProblem) {
editProblemOrder.value = newProblem.order
editProblemRequired.value = newProblem.is_required
editProblemScore.value = newProblem.score
editProblemHint.value = newProblem.hint
}
}, { immediate: true })
</script>
<template>
<n-modal
:show="show"
preset="card"
title="编辑题目"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form v-if="problem">
<n-form-item label="题目标题">
<n-input :value="problem.problem.title" disabled />
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="editProblemOrder"
placeholder="题目在题单中的顺序"
/>
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="editProblemRequired" />
</n-form-item>
<n-form-item label="分数">
<n-input-number
v-model:value="editProblemScore"
placeholder="题目分数"
/>
</n-form-item>
<n-form-item label="提示">
<n-input
v-model:value="editProblemHint"
type="textarea"
placeholder="题目提示"
/>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { h } from "vue"
import { NDataTable, NButton, NFlex } from "naive-ui"
import { ProblemSetProblem } from "utils/types"
interface Props {
problems: ProblemSetProblem[]
}
interface Emits {
(e: "add-problem"): void
(e: "edit-problem", problem: ProblemSetProblem): void
(e: "remove-problem", problemSetProblemId: number): void
}
defineProps<Props>()
defineEmits<Emits>()
</script>
<template>
<div>
<n-flex
justify="space-between"
align="center"
style="margin-bottom: 16px"
>
<h3>题目列表</h3>
<n-button type="primary" @click="$emit('add-problem')">
添加题目
</n-button>
</n-flex>
<n-data-table
:columns="[
{ title: '题目ID', key: 'problem._id', width: 80 },
{ title: '题目标题', key: 'problem.title', minWidth: 200 },
{ title: '顺序', key: 'order', width: 80 },
{
title: '必做',
key: 'is_required',
width: 80,
render: (row) => (row.is_required ? '是' : '否'),
},
{ title: '分数', key: 'score', width: 80 },
{ title: '提示', key: 'hint', minWidth: 200 },
{
title: '操作',
key: 'actions',
width: 160,
render: (row) =>
h('div', { style: 'display: flex; gap: 8px;' }, [
h(
NButton,
{
size: 'small',
type: 'primary',
secondary: true,
onClick: () => $emit('edit-problem', row),
},
{ default: () => '编辑' },
),
h(
NButton,
{
size: 'small',
type: 'error',
secondary: true,
onClick: () => $emit('remove-problem', row.id),
},
{ default: () => '移除' },
),
]),
},
]"
:data="problems"
/>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { NCard, NTag, NButton, NFlex } from "naive-ui"
import { parseTime } from "utils/functions"
import { ProblemSet } from "utils/types"
interface Props {
problemSet: ProblemSet
}
defineProps<Props>()
</script>
<template>
<n-card title="题单信息" style="margin-bottom: 16px">
<n-flex vertical gap="medium">
<n-flex>
<span style="width: 100px; font-weight: bold">描述</span>
<span>{{ problemSet.description }}</span>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">创建者</span>
<span>{{ problemSet.created_by.username }}</span>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">难度</span>
<n-tag
:type="
problemSet.difficulty === 'Easy'
? 'success'
: problemSet.difficulty === 'Medium'
? 'warning'
: 'error'
"
>
{{
problemSet.difficulty === "Easy"
? "简单"
: problemSet.difficulty === "Medium"
? "中等"
: "困难"
}}
</n-tag>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">状态</span>
<n-tag
:type="
problemSet.status === 'active'
? 'success'
: problemSet.status === 'archived'
? 'default'
: 'info'
"
>
{{
problemSet.status === "active"
? "活跃"
: problemSet.status === "archived"
? "已归档"
: "草稿"
}}
</n-tag>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">可见</span>
<span>{{ problemSet.visible ? "是" : "否" }}</span>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">题目数量</span>
<span>{{ problemSet.problems_count }}</span>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">创建时间</span>
<span>{{
parseTime(problemSet.create_time, "YYYY-MM-DD HH:mm:ss")
}}</span>
</n-flex>
</n-flex>
</n-card>
</template>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { h } from "vue"
import { NDataTable, NButton, NFlex } from "naive-ui"
import { parseTime } from "utils/functions"
import { ProblemSetProgress } from "utils/types"
interface Props {
progress: ProblemSetProgress[]
}
interface Emits {
(e: "remove-user", userId: number): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
// 定义表格列
const progressColumns = [
{ title: "用户", key: "user.username", width: 120 },
{
title: "加入时间",
key: "join_time",
width: 180,
render: (row: ProblemSetProgress) =>
parseTime(row.join_time, "YYYY-MM-DD HH:mm:ss"),
},
{ title: "已完成", key: "completed_problems_count", width: 100 },
{ title: "总题目", key: "total_problems_count", width: 100 },
{
title: "进度",
key: "progress_percentage",
width: 100,
render: (row: ProblemSetProgress) => `${row.progress_percentage}%`,
},
{
title: "是否完成",
key: "is_completed",
width: 100,
render: (row: ProblemSetProgress) => (row.is_completed ? "是" : "否"),
},
{
title: "操作",
key: "actions",
width: 120,
render: (row: ProblemSetProgress) =>
h(
NButton,
{
size: "small",
type: "error",
secondary: true,
onClick: () => emit("remove-user", row.user.id),
},
{ default: () => "移除" },
),
},
]
</script>
<template>
<div>
<n-flex
justify="space-between"
align="center"
style="margin-bottom: 16px"
>
<h3>用户进度</h3>
</n-flex>
<n-data-table :columns="progressColumns" :data="progress" />
</div>
</template>

View File

@@ -1,19 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import { h } from "vue" import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
import { NTabPane, NTabs, NCard, NTag, NButton, NModal, NForm, NFormItem, NInput, NSelect, NSwitch, NInputNumber } from "naive-ui" import {
import { parseTime } from "utils/functions" ProblemSet,
import { ProblemSet, ProblemSetProblem, ProblemSetBadge, ProblemSetProgress } from "utils/types" ProblemSetProblem,
ProblemSetBadge,
ProblemSetProgress,
} from "utils/types"
import { import {
getProblemSetDetail, getProblemSetDetail,
getProblemSetProblems, getProblemSetProblems,
getProblemSetBadges, getProblemSetBadges,
getProblemSetProgress, getProblemSetProgress,
addProblemToSet, addProblemToSet,
editProblemInSet,
removeProblemFromSet, removeProblemFromSet,
createProblemSetBadge, createProblemSetBadge,
editProblemSetBadge,
deleteProblemSetBadge, deleteProblemSetBadge,
removeUserFromProblemSet, removeUserFromProblemSet,
} from "../api" } from "../api"
import ProblemSetInfo from "./components/ProblemSetInfo.vue"
import ProblemManagement from "./components/ProblemManagement.vue"
import BadgeManagement from "./components/BadgeManagement.vue"
import ProgressManagement from "./components/ProgressManagement.vue"
import AddProblemModal from "./components/AddProblemModal.vue"
import EditProblemModal from "./components/EditProblemModal.vue"
import AddBadgeModal from "./components/AddBadgeModal.vue"
import EditBadgeModal from "./components/EditBadgeModal.vue"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -26,64 +39,15 @@ const problems = ref<ProblemSetProblem[]>([])
const badges = ref<ProblemSetBadge[]>([]) const badges = ref<ProblemSetBadge[]>([])
const progress = ref<ProblemSetProgress[]>([]) const progress = ref<ProblemSetProgress[]>([])
// 添加题目相关 // 模态框状态
const showAddProblemModal = ref(false) const showAddProblemModal = ref(false)
const newProblemId = ref<number | null>(null) const showEditProblemModal = ref(false)
const newProblemOrder = ref(0)
const newProblemRequired = ref(true)
const newProblemScore = ref(0)
const newProblemHint = ref("")
// 添加奖章相关
const showAddBadgeModal = ref(false) const showAddBadgeModal = ref(false)
const newBadgeName = ref("") const showEditBadgeModal = ref(false)
const newBadgeDescription = ref("")
const newBadgeIcon = ref("")
const newBadgeConditionType = ref<"all_problems" | "problem_count" | "score">("all_problems")
const newBadgeConditionValue = ref(1)
const newBadgeLevel = ref(1)
const conditionTypeOptions = [ // 编辑数据
{ label: "完成所有题目", value: "all_problems" }, const editingProblem = ref<ProblemSetProblem | null>(null)
{ label: "完成指定数量题目", value: "problem_count" }, const editingBadge = ref<ProblemSetBadge | null>(null)
{ label: "达到指定分数", value: "score" },
]
// 定义表格列
const progressColumns = [
{ title: '用户', key: 'user.username', width: 120 },
{
title: '加入时间',
key: 'join_time',
width: 180,
render: (row: ProblemSetProgress) => parseTime(row.join_time, "YYYY-MM-DD HH:mm:ss")
},
{ title: '已完成', key: 'completed_problems_count', width: 100 },
{ title: '总题目', key: 'total_problems_count', width: 100 },
{
title: '进度',
key: 'progress_percentage',
width: 100,
render: (row: ProblemSetProgress) => `${row.progress_percentage}%`
},
{
title: '是否完成',
key: 'is_completed',
width: 100,
render: (row: ProblemSetProgress) => row.is_completed ? '是' : '否'
},
{
title: '操作',
key: 'actions',
width: 120,
render: (row: ProblemSetProgress) => h(NButton, {
size: 'small',
type: 'error',
secondary: true,
onClick: () => handleRemoveUser(row.user.id)
}, { default: () => '移除' })
}
]
async function loadProblemSetDetail() { async function loadProblemSetDetail() {
try { try {
@@ -121,20 +85,9 @@ async function loadProgress() {
} }
} }
async function handleAddProblem() { async function handleAddProblem(data: any) {
if (!newProblemId.value) {
message.error("请输入题目ID")
return
}
try { try {
await addProblemToSet(problemSetId.value, { await addProblemToSet(problemSetId.value, data)
problem_id: newProblemId.value,
order: newProblemOrder.value,
is_required: newProblemRequired.value,
score: newProblemScore.value,
hint: newProblemHint.value,
})
message.success("题目添加成功") message.success("题目添加成功")
showAddProblemModal.value = false showAddProblemModal.value = false
loadProblems() loadProblems()
@@ -144,9 +97,9 @@ async function handleAddProblem() {
} }
} }
async function handleRemoveProblem(problemId: number) { async function handleRemoveProblem(problemSetProblemId: number) {
try { try {
await removeProblemFromSet(problemSetId.value, problemId) await removeProblemFromSet(problemSetId.value, problemSetProblemId)
message.success("题目移除成功") message.success("题目移除成功")
loadProblems() loadProblems()
loadProblemSetDetail() // 刷新题目数量 loadProblemSetDetail() // 刷新题目数量
@@ -155,21 +108,22 @@ async function handleRemoveProblem(problemId: number) {
} }
} }
async function handleAddBadge() { async function handleEditProblem(data: any) {
if (!newBadgeName.value.trim()) { if (!editingProblem.value) return
message.error("请输入奖章名称")
return
}
try { try {
await createProblemSetBadge(problemSetId.value, { await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
name: newBadgeName.value, message.success("题目编辑成功")
description: newBadgeDescription.value, showEditProblemModal.value = false
icon: newBadgeIcon.value, loadProblems()
condition_type: newBadgeConditionType.value, } catch (err: any) {
condition_value: newBadgeConditionValue.value, message.error("编辑题目失败:" + (err.data || "未知错误"))
level: newBadgeLevel.value, }
}) }
async function handleAddBadge(data: any) {
try {
await createProblemSetBadge(problemSetId.value, data)
message.success("奖章创建成功") message.success("奖章创建成功")
showAddBadgeModal.value = false showAddBadgeModal.value = false
loadBadges() loadBadges()
@@ -188,6 +142,19 @@ async function handleDeleteBadge(badgeId: number) {
} }
} }
async function handleEditBadge(data: any) {
if (!editingBadge.value) return
try {
await editProblemSetBadge(problemSetId.value, editingBadge.value.id, data)
message.success("奖章编辑成功")
showEditBadgeModal.value = false
loadBadges()
} catch (err: any) {
message.error("编辑奖章失败:" + (err.data || "未知错误"))
}
}
async function handleRemoveUser(userId: number) { async function handleRemoveUser(userId: number) {
try { try {
await removeUserFromProblemSet(problemSetId.value, userId) await removeUserFromProblemSet(problemSetId.value, userId)
@@ -199,24 +166,23 @@ async function handleRemoveUser(userId: number) {
} }
function openAddProblemModal() { function openAddProblemModal() {
newProblemId.value = null
newProblemOrder.value = 0
newProblemRequired.value = true
newProblemScore.value = 0
newProblemHint.value = ""
showAddProblemModal.value = true showAddProblemModal.value = true
} }
function openAddBadgeModal() { function openAddBadgeModal() {
newBadgeName.value = ""
newBadgeDescription.value = ""
newBadgeIcon.value = ""
newBadgeConditionType.value = "all_problems"
newBadgeConditionValue.value = 1
newBadgeLevel.value = 1
showAddBadgeModal.value = true showAddBadgeModal.value = true
} }
function openEditProblemModal(problem: ProblemSetProblem) {
editingProblem.value = problem
showEditProblemModal.value = true
}
function openEditBadgeModal(badge: ProblemSetBadge) {
editingBadge.value = badge
showEditBadgeModal.value = true
}
onMounted(() => { onMounted(() => {
loadProblemSetDetail() loadProblemSetDetail()
loadProblems() loadProblems()
@@ -228,191 +194,71 @@ onMounted(() => {
<template> <template>
<div v-if="problemSet"> <div v-if="problemSet">
<n-flex class="titleWrapper" justify="space-between" align="center"> <n-flex class="titleWrapper" justify="space-between" align="center">
<n-flex align="center"> <h2 class="title">{{ problemSet.title }}</h2>
<n-button @click="router.back()" secondary>
返回
</n-button>
<h2 class="title">{{ problemSet.title }}</h2>
</n-flex>
<n-button <n-button
type="primary" type="primary"
@click="router.push({ name: 'admin problemset edit', params: { problemSetId } })" @click="
router.push({
name: 'admin problemset edit',
params: { problemSetId },
})
"
> >
编辑题单 编辑题单
</n-button> </n-button>
</n-flex> </n-flex>
<n-card title="题单信息" style="margin-bottom: 16px"> <ProblemSetInfo :problem-set="problemSet" />
<n-flex vertical gap="medium">
<n-flex>
<span style="width: 100px; font-weight: bold">描述</span>
<span>{{ problemSet.description }}</span>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">创建者</span>
<span>{{ problemSet.created_by.username }}</span>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">难度</span>
<n-tag :type="problemSet.difficulty === 'Easy' ? 'success' : problemSet.difficulty === 'Medium' ? 'warning' : 'error'">
{{ problemSet.difficulty === 'Easy' ? '简单' : problemSet.difficulty === 'Medium' ? '中等' : '困难' }}
</n-tag>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">状态</span>
<n-tag :type="problemSet.status === 'active' ? 'success' : problemSet.status === 'archived' ? 'default' : 'info'">
{{ problemSet.status === 'active' ? '活跃' : problemSet.status === 'archived' ? '已归档' : '草稿' }}
</n-tag>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">公开</span>
<span>{{ problemSet.is_public ? '是' : '否' }}</span>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">可见</span>
<span>{{ problemSet.visible ? '是' : '否' }}</span>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">题目数量</span>
<span>{{ problemSet.problems_count }}</span>
</n-flex>
<n-flex>
<span style="width: 100px; font-weight: bold">创建时间</span>
<span>{{ parseTime(problemSet.create_time, "YYYY-MM-DD HH:mm:ss") }}</span>
</n-flex>
</n-flex>
</n-card>
<n-tabs type="line"> <n-tabs type="line">
<n-tab-pane name="problems" tab="题目管理"> <n-tab-pane name="problems" tab="题目管理">
<n-flex justify="space-between" align="center" style="margin-bottom: 16px"> <ProblemManagement
<h3>题目列表</h3> :problems="problems"
<n-button type="primary" @click="openAddProblemModal"> @add-problem="openAddProblemModal"
添加题目 @edit-problem="openEditProblemModal"
</n-button> @remove-problem="handleRemoveProblem"
</n-flex>
<n-data-table
:columns="[
{ title: '题目ID', key: 'problem.id', width: 80 },
{ title: '题目标题', key: 'problem.title', minWidth: 200 },
{ title: '顺序', key: 'order', width: 80 },
{ title: '必做', key: 'is_required', width: 80, render: (row) => row.is_required ? '是' : '否' },
{ title: '分数', key: 'score', width: 80 },
{ title: '提示', key: 'hint', minWidth: 200 },
{
title: '操作',
key: 'actions',
width: 120,
render: (row) => h(NButton, {
size: 'small',
type: 'error',
secondary: true,
onClick: () => handleRemoveProblem(row.problem.id)
}, { default: () => '移除' })
}
]"
:data="problems"
/> />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="badges" tab="奖章管理"> <n-tab-pane name="badges" tab="奖章管理">
<n-flex justify="space-between" align="center" style="margin-bottom: 16px"> <BadgeManagement
<h3>奖章列表</h3> :badges="badges"
<n-button type="primary" @click="openAddBadgeModal"> @add-badge="openAddBadgeModal"
添加奖章 @edit-badge="openEditBadgeModal"
</n-button> @delete-badge="handleDeleteBadge"
</n-flex>
<n-data-table
:columns="[
{ title: '名称', key: 'name', minWidth: 150 },
{ title: '描述', key: 'description', minWidth: 200 },
{ title: '图标', key: 'icon', width: 100 },
{ title: '条件类型', key: 'condition_type', width: 120 },
{ title: '条件值', key: 'condition_value', width: 100 },
{ title: '等级', key: 'level', width: 80 },
{
title: '操作',
key: 'actions',
width: 120,
render: (row) => h(NButton, {
size: 'small',
type: 'error',
secondary: true,
onClick: () => handleDeleteBadge(row.id)
}, { default: () => '删除' })
}
]"
:data="badges"
/> />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="progress" tab="进度管理"> <n-tab-pane name="progress" tab="进度管理">
<n-flex justify="space-between" align="center" style="margin-bottom: 16px"> <ProgressManagement
<h3>用户进度</h3> :progress="progress"
</n-flex> @remove-user="handleRemoveUser"
<n-data-table
:columns="progressColumns"
:data="progress"
/> />
</n-tab-pane> </n-tab-pane>
</n-tabs> </n-tabs>
<!-- 添加题目模态框 --> <!-- 模态框组件 -->
<n-modal v-model:show="showAddProblemModal" preset="card" title="添加题目" style="width: 500px"> <AddProblemModal
<n-form> v-model:show="showAddProblemModal"
<n-form-item label="题目ID" required> @confirm="handleAddProblem"
<n-input-number v-model:value="newProblemId" placeholder="请输入题目ID" /> />
</n-form-item>
<n-form-item label="顺序">
<n-input-number v-model:value="newProblemOrder" placeholder="题目在题单中的顺序" />
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="newProblemRequired" />
</n-form-item>
<n-form-item label="分数">
<n-input-number v-model:value="newProblemScore" placeholder="题目分数" />
</n-form-item>
<n-form-item label="提示">
<n-input v-model:value="newProblemHint" type="textarea" placeholder="题目提示" />
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="showAddProblemModal = false">取消</n-button>
<n-button type="primary" @click="handleAddProblem">确认</n-button>
</n-flex>
</template>
</n-modal>
<!-- 添加奖章模态框 --> <EditProblemModal
<n-modal v-model:show="showAddBadgeModal" preset="card" title="添加奖章" style="width: 500px"> v-model:show="showEditProblemModal"
<n-form> :problem="editingProblem"
<n-form-item label="奖章名称" required> @confirm="handleEditProblem"
<n-input v-model:value="newBadgeName" placeholder="请输入奖章名称" /> />
</n-form-item>
<n-form-item label="描述"> <AddBadgeModal
<n-input v-model:value="newBadgeDescription" type="textarea" placeholder="奖章描述" /> v-model:show="showAddBadgeModal"
</n-form-item> @confirm="handleAddBadge"
<n-form-item label="图标"> />
<n-input v-model:value="newBadgeIcon" placeholder="奖章图标" />
</n-form-item> <EditBadgeModal
<n-form-item label="条件类型"> v-model:show="showEditBadgeModal"
<n-select v-model:value="newBadgeConditionType" :options="conditionTypeOptions" /> :badge="editingBadge"
</n-form-item> @confirm="handleEditBadge"
<n-form-item label="条件值"> />
<n-input-number v-model:value="newBadgeConditionValue" placeholder="条件值" />
</n-form-item>
<n-form-item label="等级">
<n-input-number v-model:value="newBadgeLevel" placeholder="奖章等级" />
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="showAddBadgeModal = false">取消</n-button>
<n-button type="primary" @click="handleAddBadge">确认</n-button>
</n-flex>
</template>
</n-modal>
</div> </div>
</template> </template>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { NForm, NFormItem, NInput, NSelect, NSwitch, NButton, NCard } from "naive-ui" import { CreateProblemSetData, EditProblemSetData } from "utils/types"
import { ProblemSet, CreateProblemSetData, EditProblemSetData } from "utils/types"
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api" import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
const route = useRoute() const route = useRoute()
@@ -10,12 +9,12 @@ const message = useMessage()
const problemSetId = computed(() => Number(route.params.problemSetId)) const problemSetId = computed(() => Number(route.params.problemSetId))
const isEdit = computed(() => !!problemSetId.value) const isEdit = computed(() => !!problemSetId.value)
const formData = ref<CreateProblemSetData & EditProblemSetData>({ const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
title: "", title: "",
description: "", description: "",
difficulty: "Easy", difficulty: "Easy",
is_public: true, status: "draft",
status: "active", visible: false,
}) })
const difficultyOptions = [ const difficultyOptions = [
@@ -43,7 +42,6 @@ async function loadProblemSetDetail() {
title: data.title, title: data.title,
description: data.description, description: data.description,
difficulty: data.difficulty, difficulty: data.difficulty,
is_public: data.is_public,
status: data.status, status: data.status,
visible: data.visible, visible: data.visible,
} }
@@ -53,12 +51,12 @@ async function loadProblemSetDetail() {
} }
async function handleSubmit() { async function handleSubmit() {
if (!formData.value.title.trim()) { if (!formData.value.title?.trim()) {
message.error("请输入题单标题") message.error("请输入题单标题")
return return
} }
if (!formData.value.description.trim()) { if (!formData.value.description?.trim()) {
message.error("请输入题单描述") message.error("请输入题单描述")
return return
} }
@@ -75,7 +73,11 @@ async function handleSubmit() {
} }
router.push({ name: "admin problemset list" }) router.push({ name: "admin problemset list" })
} catch (err: any) { } catch (err: any) {
message.error((isEdit.value ? "更新" : "创建") + "题单失败:" + (err.data || "未知错误")) message.error(
(isEdit.value ? "更新" : "创建") +
"题单失败:" +
(err.data || "未知错误"),
)
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -89,25 +91,39 @@ onMounted(() => {
</script> </script>
<template> <template>
<n-card> <div>
<template #header> <h2 class="title">{{ isEdit ? "编辑题单" : "创建题单" }}</h2>
<n-flex align="center">
<n-button @click="router.back()" secondary>
返回
</n-button>
<h2 class="title">{{ isEdit ? '编辑题单' : '创建题单' }}</h2>
</n-flex>
</template>
<n-form :model="formData" label-placement="left" label-width="100px"> <n-form :model="formData" label-placement="top">
<n-form-item label="题单标题" required> <n-flex>
<n-input <n-form-item label="题单标题" required>
v-model:value="formData.title" <n-input
placeholder="请输入题单标题" v-model:value="formData.title"
maxlength="200" placeholder="请输入题单标题"
show-count maxlength="200"
/> show-count
</n-form-item> />
</n-form-item>
<n-form-item label="难度">
<n-select
style="width: 100px"
v-model:value="formData.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
/>
</n-form-item>
<n-form-item label="状态">
<n-select
style="width: 100px"
v-model:value="formData.status"
:options="statusOptions"
placeholder="选择状态"
/>
</n-form-item>
<n-form-item v-if="isEdit" label="是否可见">
<n-switch v-model:value="formData.visible" />
</n-form-item>
</n-flex>
<n-form-item label="题单描述" required> <n-form-item label="题单描述" required>
<n-input <n-input
@@ -117,51 +133,17 @@ onMounted(() => {
:rows="4" :rows="4"
/> />
</n-form-item> </n-form-item>
<n-form-item label="难度">
<n-select
v-model:value="formData.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
/>
</n-form-item>
<n-form-item label="状态">
<n-select
v-model:value="formData.status"
:options="statusOptions"
placeholder="选择状态"
/>
</n-form-item>
<n-form-item label="是否公开">
<n-switch v-model:value="formData.is_public" />
</n-form-item>
<n-form-item v-if="isEdit" label="是否可见">
<n-switch v-model:value="formData.visible" />
</n-form-item>
<n-form-item> <n-form-item>
<n-flex gap="medium"> <n-button type="primary" :loading="loading" @click="handleSubmit">
<n-button {{ isEdit ? "更新" : "创建" }}
type="primary" </n-button>
:loading="loading"
@click="handleSubmit"
>
{{ isEdit ? '更新' : '创建' }}
</n-button>
<n-button @click="router.back()">
取消
</n-button>
</n-flex>
</n-form-item> </n-form-item>
</n-form> </n-form>
</n-card> </div>
</template> </template>
<style scoped> <style scoped>
.title { .title {
margin: 0; margin: 0 0 16px 0;
} }
</style> </style>

View File

@@ -106,7 +106,7 @@ const columns: DataTableColumn<ProblemSetList>[] = [
{ {
title: "选项", title: "选项",
key: "actions", key: "actions",
width: 200, width: 300,
render: (row) => render: (row) =>
h(Actions, { h(Actions, {
problemSetId: row.id, problemSetId: row.id,
@@ -162,7 +162,7 @@ watch(() => [query.page, query.limit, query.difficulty, query.status], listProbl
新建题单 新建题单
</n-button> </n-button>
</n-flex> </n-flex>
<n-flex align="center" gap="medium"> <n-flex align="center">
<n-flex align="center"> <n-flex align="center">
<span>难度</span> <span>难度</span>
<n-select <n-select

View File

@@ -111,6 +111,7 @@ const active = computed(() => {
if (path === "/") return "return to OJ" if (path === "/") return "return to OJ"
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/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"

View File

@@ -191,7 +191,6 @@ export interface ProblemSet {
create_time: Date create_time: Date
difficulty: "Easy" | "Medium" | "Hard" difficulty: "Easy" | "Medium" | "Hard"
status: "active" | "archived" | "draft" status: "active" | "archived" | "draft"
is_public: boolean
visible: boolean visible: boolean
problems_count: number problems_count: number
completed_count: number completed_count: number
@@ -234,7 +233,6 @@ export interface ProblemSetBadge {
icon: string icon: string
condition_type: "all_problems" | "problem_count" | "score" condition_type: "all_problems" | "problem_count" | "score"
condition_value: number condition_value: number
level: number
} }
export interface ProblemSetProgress { export interface ProblemSetProgress {
@@ -252,7 +250,6 @@ export interface CreateProblemSetData {
title: string title: string
description: string description: string
difficulty: "Easy" | "Medium" | "Hard" difficulty: "Easy" | "Medium" | "Hard"
is_public: boolean
status: "active" | "archived" | "draft" status: "active" | "archived" | "draft"
} }
@@ -261,7 +258,6 @@ export interface EditProblemSetData {
title?: string title?: string
description?: string description?: string
difficulty?: "Easy" | "Medium" | "Hard" difficulty?: "Easy" | "Medium" | "Hard"
is_public?: boolean
status?: "active" | "archived" | "draft" status?: "active" | "archived" | "draft"
visible?: boolean visible?: boolean
} }