feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码

This commit is contained in:
xuyue committed 2026-08-06 21:18:16 -06:00
1 parent 3c975e85ee
commit ae1fb329b5
258 files changed
+40490 -91

No files matched your search

@@ -0,0 +1,105 @@
<script lang="ts" setup>
import { deleteProblemSet, updateProblemSetStatus } from "admin/api"
interface Props {
problemSetId: number
}
const props = defineProps<Props>()
const emit = defineEmits(["updated"])
const router = useRouter()
const message = useMessage()
const showStatusModal = ref(false)
const newStatus = ref<"active" | "archived" | "draft">("active")
const statusOptions = [
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
{ label: "草稿", value: "draft" },
]
async function handleDeleteProblemSet() {
try {
await deleteProblemSet(props.problemSetId)
message.success("删除成功")
emit("updated")
} catch (err: any) {
message.error("删除失败:" + (err.data || "未知错误"))
}
}
function goEdit() {
router.push({
name: "admin problemset edit",
params: { problemSetId: props.problemSetId },
})
}
function goDetail() {
router.push({
name: "admin problemset detail",
params: { problemSetId: props.problemSetId },
})
}
function openStatusModal() {
showStatusModal.value = true
}
async function handleUpdateStatus() {
try {
await updateProblemSetStatus(props.problemSetId, newStatus.value)
message.success("状态更新成功")
showStatusModal.value = false
emit("updated")
} catch (err: any) {
message.error("状态更新失败:" + (err.data || "未知错误"))
}
}
</script>
<template>
<n-flex>
<n-button size="small" secondary type="primary" @click="goEdit">
编辑
</n-button>
<n-button size="small" secondary type="info" @click="goDetail">
详情
</n-button>
<n-button size="small" secondary type="warning" @click="openStatusModal">
状态
</n-button>
<n-popconfirm @positive-click="handleDeleteProblemSet">
<template #trigger>
<n-button secondary size="small" type="error">删除</n-button>
</template>
确定删除这个题单吗?删除后题单将不可见。
</n-popconfirm>
</n-flex>
<n-modal
v-model:show="showStatusModal"
preset="card"
title="更新题单状态"
style="width: 400px"
>
<n-space vertical>
<n-form>
<n-form-item label="状态" required>
<n-select
v-model:value="newStatus"
:options="statusOptions"
placeholder="选择状态"
/>
</n-form-item>
</n-form>
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="showStatusModal = false">取消</n-button>
<n-button type="primary" @click="handleUpdateStatus">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>
@@ -0,0 +1,162 @@
<script setup lang="ts">
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 BADGE_LEN = 6
const badgeIconOptions = []
for (let i = 1; i <= BADGE_LEN; i++) {
badgeIconOptions.push({
label: `奖章${i}`,
value: `/badge-${i}.png`,
icon: `/badge-${i}.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="奖章描述"
required
/>
</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>
@@ -0,0 +1,103 @@
<script setup lang="ts">
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="请输入题目的显示ID(如:1001)"
/>
</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>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { h } from "vue"
import type { ProblemSetBadge } from "utils/types"
import { NButton, NImage } from "naive-ui"
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>
@@ -0,0 +1,167 @@
<script setup lang="ts">
import type { 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 BADGE_LEN = 6
const badgeIconOptions = []
for (let i = 1; i <= BADGE_LEN; i++) {
badgeIconOptions.push({
label: `奖章${i}`,
value: `/badge-${i}.png`,
icon: `/badge-${i}.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="奖章描述"
required
/>
</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>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { 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>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { h } from "vue"
import { NDataTable, NButton, NFlex } from "naive-ui"
import type { 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>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types"
interface Props {
problemSet: ProblemSet
}
defineProps<Props>()
</script>
<template>
<n-card title="题单信息" style="margin-bottom: 16px">
<n-descriptions :column="4" bordered>
<n-descriptions-item label="描述">
{{ problemSet.description }}
</n-descriptions-item>
<n-descriptions-item label="创建者">
{{ problemSet.created_by.username }}
</n-descriptions-item>
<n-descriptions-item label="难度">
<n-tag
:type="
problemSet.difficulty === 'Easy'
? 'success'
: problemSet.difficulty === 'Medium'
? 'warning'
: 'error'
"
>
{{
problemSet.difficulty === "Easy"
? "简单"
: problemSet.difficulty === "Medium"
? "中等"
: "困难"
}}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="状态">
<n-tag
:type="
problemSet.status === 'active'
? 'success'
: problemSet.status === 'archived'
? 'default'
: 'info'
"
>
{{
problemSet.status === "active"
? "活跃"
: problemSet.status === "archived"
? "已归档"
: "草稿"
}}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="可见">
{{ problemSet.visible ? "是" : "否" }}
</n-descriptions-item>
<n-descriptions-item label="题目数量">
{{ problemSet.problems_count }}
</n-descriptions-item>
<n-descriptions-item label="创建时间">
{{ parseTime(problemSet.create_time, "YYYY-MM-DD HH:mm:ss") }}
</n-descriptions-item>
</n-descriptions>
</n-card>
</template>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { h } from "vue"
import { NDataTable, NButton, NFlex } from "naive-ui"
import { parseTime } from "utils/functions"
import type { 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.toFixed(0)}%`,
},
{
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>
+270
View File
@@ -0,0 +1,270 @@
<script setup lang="ts">
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
import type {
ProblemSet,
ProblemSetProblem,
ProblemSetBadge,
ProblemSetProgress,
} from "utils/types"
import {
getProblemSetDetail,
getProblemSetProblems,
getProblemSetBadges,
getProblemSetProgress,
addProblemToSet,
editProblemInSet,
removeProblemFromSet,
createProblemSetBadge,
editProblemSetBadge,
deleteProblemSetBadge,
removeUserFromProblemSet,
} 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 router = useRouter()
const message = useMessage()
const problemSetId = computed(() => Number(route.params.problemSetId))
const problemSet = ref<ProblemSet | null>(null)
const problems = ref<ProblemSetProblem[]>([])
const badges = ref<ProblemSetBadge[]>([])
const progress = ref<ProblemSetProgress[]>([])
// 模态框状态
const showAddProblemModal = ref(false)
const showEditProblemModal = ref(false)
const showAddBadgeModal = ref(false)
const showEditBadgeModal = ref(false)
// 编辑数据
const editingProblem = ref<ProblemSetProblem | null>(null)
const editingBadge = ref<ProblemSetBadge | null>(null)
async function loadProblemSetDetail() {
try {
const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res.data
} 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 loadBadges() {
try {
const res = await getProblemSetBadges(problemSetId.value)
badges.value = res.data
} catch (err: any) {
message.error("加载奖章列表失败:" + (err.data || "未知错误"))
}
}
async function loadProgress() {
try {
const res = await getProblemSetProgress(problemSetId.value)
progress.value = res.data
} catch (err: any) {
message.error("加载进度列表失败:" + (err.data || "未知错误"))
}
}
async function handleAddProblem(data: any) {
try {
await addProblemToSet(problemSetId.value, data)
message.success("题目添加成功")
showAddProblemModal.value = false
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err: any) {
message.error("添加题目失败:" + (err.data || "未知错误"))
}
}
async function handleRemoveProblem(problemSetProblemId: number) {
try {
await removeProblemFromSet(problemSetId.value, problemSetProblemId)
message.success("题目移除成功")
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err: any) {
message.error("移除题目失败:" + (err.data || "未知错误"))
}
}
async function handleEditProblem(data: any) {
if (!editingProblem.value) return
try {
await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
message.success("题目编辑成功")
showEditProblemModal.value = false
loadProblems()
} catch (err: any) {
message.error("编辑题目失败:" + (err.data || "未知错误"))
}
}
async function handleAddBadge(data: any) {
try {
await createProblemSetBadge(problemSetId.value, data)
message.success("奖章创建成功")
showAddBadgeModal.value = false
loadBadges()
} catch (err: any) {
message.error("创建奖章失败:" + (err.data || "未知错误"))
}
}
async function handleDeleteBadge(badgeId: number) {
try {
await deleteProblemSetBadge(problemSetId.value, badgeId)
message.success("奖章删除成功")
loadBadges()
} catch (err: any) {
message.error("删除奖章失败:" + (err.data || "未知错误"))
}
}
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) {
try {
await removeUserFromProblemSet(problemSetId.value, userId)
message.success("用户移除成功")
loadProgress()
} catch (err: any) {
message.error("移除用户失败:" + (err.data || "未知错误"))
}
}
function openAddProblemModal() {
showAddProblemModal.value = true
}
function openAddBadgeModal() {
showAddBadgeModal.value = true
}
function openEditProblemModal(problem: ProblemSetProblem) {
editingProblem.value = problem
showEditProblemModal.value = true
}
function openEditBadgeModal(badge: ProblemSetBadge) {
editingBadge.value = badge
showEditBadgeModal.value = true
}
onMounted(() => {
loadProblemSetDetail()
loadProblems()
loadBadges()
loadProgress()
})
</script>
<template>
<div v-if="problemSet">
<n-flex class="titleWrapper" justify="space-between" align="center">
<h2 class="title">{{ problemSet.title }}</h2>
<n-button
type="primary"
@click="
router.push({
name: 'admin problemset edit',
params: { problemSetId },
})
"
>
编辑题单
</n-button>
</n-flex>
<ProblemSetInfo :problem-set="problemSet" />
<n-tabs type="line">
<n-tab-pane name="problems" tab="题目管理">
<ProblemManagement
:problems="problems"
@add-problem="openAddProblemModal"
@edit-problem="openEditProblemModal"
@remove-problem="handleRemoveProblem"
/>
</n-tab-pane>
<n-tab-pane name="badges" tab="奖章管理">
<BadgeManagement
:badges="badges"
@add-badge="openAddBadgeModal"
@edit-badge="openEditBadgeModal"
@delete-badge="handleDeleteBadge"
/>
</n-tab-pane>
<n-tab-pane name="progress" tab="进度管理">
<ProgressManagement
:progress="progress"
@remove-user="handleRemoveUser"
/>
</n-tab-pane>
</n-tabs>
<!-- 模态框组件 -->
<AddProblemModal
v-model:show="showAddProblemModal"
@confirm="handleAddProblem"
/>
<EditProblemModal
v-model:show="showEditProblemModal"
:problem="editingProblem"
@confirm="handleEditProblem"
/>
<AddBadgeModal v-model:show="showAddBadgeModal" @confirm="handleAddBadge" />
<EditBadgeModal
v-model:show="showEditBadgeModal"
:badge="editingBadge"
@confirm="handleEditBadge"
/>
</div>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
import type { CreateProblemSetData, EditProblemSetData } from "utils/types"
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const problemSetId = computed(() => Number(route.params.problemSetId))
const isEdit = computed(() => !!problemSetId.value)
const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
title: "",
description: "",
difficulty: "Easy",
status: "draft",
visible: false,
end_time: null,
})
const endTimeTimestamp = computed({
get: () =>
formData.value.end_time
? new Date(formData.value.end_time).getTime()
: null,
set: (val: number | null) => {
formData.value.end_time = val ? new Date(val) : null
},
})
const difficultyOptions = [
{ label: "简单", value: "Easy" },
{ label: "中等", value: "Medium" },
{ label: "困难", value: "Hard" },
]
const statusOptions = [
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
{ label: "草稿", value: "draft" },
]
const loading = ref(false)
async function loadProblemSetDetail() {
if (!isEdit.value) return
try {
const res = await getProblemSetDetail(problemSetId.value)
const data = res.data
formData.value = {
id: data.id,
title: data.title,
description: data.description,
difficulty: data.difficulty,
status: data.status,
visible: data.visible,
end_time: data.end_time ? new Date(data.end_time) : null,
}
} catch (err: any) {
message.error("加载题单详情失败:" + (err.data || "未知错误"))
}
}
async function handleSubmit() {
if (!formData.value.title?.trim()) {
message.error("请输入题单标题")
return
}
if (!formData.value.description?.trim()) {
message.error("请输入题单描述")
return
}
loading.value = true
try {
if (isEdit.value) {
await editProblemSet(formData.value as EditProblemSetData)
message.success("题单更新成功")
} else {
await createProblemSet(formData.value as CreateProblemSetData)
message.success("题单创建成功")
}
router.push({ name: "admin problemset list" })
} catch (err: any) {
message.error(
(isEdit.value ? "更新" : "创建") +
"题单失败:" +
(err.data || "未知错误"),
)
} finally {
loading.value = false
}
}
onMounted(() => {
if (isEdit.value) {
loadProblemSetDetail()
}
})
</script>
<template>
<div>
<h2 class="title">{{ isEdit ? "编辑题单" : "创建题单" }}</h2>
<n-form :model="formData" label-placement="top">
<n-flex>
<n-form-item label="题单标题" required>
<n-input
v-model:value="formData.title"
placeholder="请输入题单标题"
maxlength="200"
show-count
/>
</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 label="截止时间">
<n-date-picker
v-model:value="endTimeTimestamp"
type="datetime"
clearable
placeholder="不设置则无截止时间"
/>
</n-form-item>
<n-form-item v-if="isEdit" label="是否可见">
<n-switch v-model:value="formData.visible" />
</n-form-item>
</n-flex>
<n-form-item label="题单描述" required>
<n-input
v-model:value="formData.description"
type="textarea"
placeholder="请输入题单描述"
:rows="4"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? "更新" : "创建" }}
</n-button>
</n-form-item>
</n-form>
</div>
</template>
<style scoped>
.title {
margin: 0 0 16px 0;
}
</style>
+212
View File
@@ -0,0 +1,212 @@
<script setup lang="ts">
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions"
import type { ProblemSetList } from "utils/types"
import { getProblemSetList, toggleProblemSetVisible } from "../api"
import Actions from "./components/Actions.vue"
import { NTag, NSwitch } from "naive-ui"
const total = ref(0)
const problemSets = ref<ProblemSetList[]>([])
interface ProblemSetQuery {
keyword: string
difficulty: string
status: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemSetQuery>({
keyword: "",
difficulty: "",
status: "",
})
const difficultyOptions = [
{ label: "全部", value: "" },
{ label: "简单", value: "Easy" },
{ label: "中等", value: "Medium" },
{ label: "困难", value: "Hard" },
]
const statusOptions = [
{ label: "全部", value: "" },
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
{ label: "草稿", value: "draft" },
]
const columns: DataTableColumn<ProblemSetList>[] = [
{ title: "ID", key: "id", width: 80 },
{ title: "标题", key: "title", minWidth: 200 },
{ title: "描述", key: "description", minWidth: 300, ellipsis: true },
{
title: "创建者",
key: "created_by",
width: 120,
render: (row) => row.created_by.username,
},
{
title: "难度",
key: "difficulty",
width: 100,
render: (row) => {
const difficultyMap = {
Easy: { type: "success" as const, text: "简单" },
Medium: { type: "warning" as const, text: "中等" },
Hard: { type: "error" as const, text: "困难" },
}
const config = difficultyMap[row.difficulty]
return h(
NTag,
{ type: config.type, size: "small" },
{ default: () => config.text },
)
},
},
{
title: "状态",
key: "status",
width: 100,
render: (row) => {
const statusMap = {
active: { type: "success" as const, text: "活跃" },
archived: { type: "default" as const, text: "已归档" },
draft: { type: "info" as const, text: "草稿" },
}
const config = statusMap[row.status]
return h(
NTag,
{ type: config.type, size: "small" },
{ default: () => config.text },
)
},
},
{
title: "创建时间",
key: "create_time",
width: 180,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "可见",
key: "visible",
width: 100,
render: (row) =>
h(NSwitch, {
value: row.visible,
size: "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row.id),
}),
},
{
title: "选项",
key: "actions",
width: 300,
render: (row) =>
h(Actions, {
problemSetId: row.id,
onUpdated: listProblemSets,
}),
},
]
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
}
async function toggleVisible(problemSetId: number) {
await toggleProblemSetVisible(problemSetId)
problemSets.value = problemSets.value.map((it) => {
if (it.id === problemSetId) {
it.visible = !it.visible
}
return it
})
}
onMounted(listProblemSets)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listProblemSets, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(
() => [query.page, query.limit, query.difficulty, query.status],
listProblemSets,
)
</script>
<template>
<n-flex class="titleWrapper" justify="space-between">
<n-flex align="center">
<h2 class="title">题单管理</h2>
<n-button
type="primary"
@click="$router.push({ name: 'admin problemset create' })"
>
新建题单
</n-button>
</n-flex>
<n-flex align="center">
<n-flex align="center">
<span>难度:</span>
<n-select
v-model:value="query.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
style="width: 120px"
clearable
/>
</n-flex>
<n-flex align="center">
<span>状态:</span>
<n-select
v-model:value="query.status"
:options="statusOptions"
placeholder="选择状态"
style="width: 120px"
clearable
/>
</n-flex>
<n-input
v-model:value="query.keyword"
placeholder="输入标题关键字"
clearable
@clear="clearQuery"
style="width: 200px"
/>
</n-flex>
</n-flex>
<n-data-table striped :columns="columns" :data="problemSets" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>