refactor(题单后台): 添加 / 编辑弹窗两两合并成 ProblemModal、BadgeModal

AddBadgeModal / EditBadgeModal、AddProblemModal / EditProblemModal 各是两份八成相同
的组件,改一处表单项另一处总要漏。合并成一个组件,传了 badge / problem 就是编辑、
null 就是添加;两种模式分别 emit create / update,detail.vue 的四个 handler 不变。

表单项、校验、文案、提交的数据形状都照旧(题目弹窗添加时带 problemId、编辑时只显示
标题)。有一处刻意的行为变化:编辑弹窗原来只在换了一条记录时才重新填表,取消后再打开
同一条会看到上次没保存的改动;现在每次打开都按库里的值重新填。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 15:16:03 +08:00
1 parent f49834f19f
commit 6b2da41882
6 files changed
+182 -436

No files matched your search

@@ -1,159 +0,0 @@
<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
conditionType: "all_problems" | "problem_count" | "score"
conditionValue?: 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() {
emit("confirm", {
name: newBadgeName.value,
description: newBadgeDescription.value,
icon: newBadgeIcon.value,
conditionType: newBadgeConditionType.value,
// 只有非"完成所有题目"时才带条件值
...(newBadgeConditionType.value === "all_problems"
? {}
: { conditionValue: newBadgeConditionValue.value }),
})
}
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>
@@ -1,103 +0,0 @@
<script setup lang="ts">
interface Props {
show: boolean
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
problemId: string
order: number
isRequired: 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", {
problemId: newProblemId.value,
order: newProblemOrder.value,
isRequired: 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>
@@ -1,35 +1,37 @@
<script setup lang="ts">
import type { AdminProblemSetBadge } from "utils/types"
// 添加和编辑共用一个弹窗:传了 badge 就是编辑,null 就是添加。
// 原来是两份八成相同的组件,改一处表单项另一处总要漏
interface Props {
show: boolean
badge: AdminProblemSetBadge | null
}
type BadgeFormData = {
name: string
description: string
icon: string
conditionType: "all_problems" | "problem_count" | "score"
conditionValue?: number
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
name: string
description: string
icon: string
conditionType: "all_problems" | "problem_count" | "score"
conditionValue?: number
},
): void
(e: "create", data: BadgeFormData): void
(e: "update", data: BadgeFormData): 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">(
const badgeName = ref("")
const badgeDescription = ref("")
const badgeIcon = ref("")
const badgeConditionType = ref<"all_problems" | "problem_count" | "score">(
"all_problems",
)
const editBadgeConditionValue = ref(1)
const badgeConditionValue = ref(1)
// 预设奖章图标选项
const BADGE_LEN = 6
@@ -49,33 +51,36 @@ const conditionTypeOptions = [
]
function handleConfirm() {
emit("confirm", {
name: editBadgeName.value,
description: editBadgeDescription.value,
icon: editBadgeIcon.value,
conditionType: editBadgeConditionType.value,
const data: BadgeFormData = {
name: badgeName.value,
description: badgeDescription.value,
icon: badgeIcon.value,
conditionType: badgeConditionType.value,
// 只有非"完成所有题目"时才带条件值
...(editBadgeConditionType.value === "all_problems"
...(badgeConditionType.value === "all_problems"
? {}
: { conditionValue: editBadgeConditionValue.value }),
})
: { conditionValue: badgeConditionValue.value }),
}
if (props.badge) emit("update", data)
else emit("create", 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.conditionType
editBadgeConditionValue.value = newBadge.conditionValue
}
() => props.show,
(open) => {
if (!open) return
const badge = props.badge
badgeName.value = badge?.name ?? ""
badgeDescription.value = badge?.description ?? ""
badgeIcon.value = badge?.icon ?? ""
badgeConditionType.value = badge?.conditionType ?? "all_problems"
badgeConditionValue.value = badge?.conditionValue ?? 1
},
{ immediate: true },
)
@@ -85,17 +90,17 @@ watch(
<n-modal
:show="show"
preset="card"
title="编辑奖章"
:title="badge ? '编辑奖章' : '添加奖章'"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form v-if="badge">
<n-form>
<n-form-item label="奖章名称" required>
<n-input v-model:value="editBadgeName" placeholder="请输入奖章名称" />
<n-input v-model:value="badgeName" placeholder="请输入奖章名称" />
</n-form-item>
<n-form-item label="描述">
<n-input
v-model:value="editBadgeDescription"
v-model:value="badgeDescription"
type="textarea"
placeholder="奖章描述"
required
@@ -106,12 +111,12 @@ watch(
<div
v-for="option in badgeIconOptions"
:key="option.value"
@click="editBadgeIcon = option.value"
@click="badgeIcon = option.value"
:style="{
width: '60px',
height: '60px',
border:
editBadgeIcon === option.value
badgeIcon === option.value
? '2px solid #1890ff'
: '1px solid #d9d9d9',
borderRadius: '4px',
@@ -120,7 +125,7 @@ watch(
alignItems: 'center',
justifyContent: 'center',
backgroundColor:
editBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
badgeIcon === option.value ? '#f0f8ff' : 'transparent',
}"
>
<n-image
@@ -128,8 +133,8 @@ watch(
width="50"
height="50"
object-fit="cover"
style="border-radius: 2px"
preview-disabled
style="border-radius: 2px"
/>
</div>
</n-flex>
@@ -138,17 +143,17 @@ watch(
<n-form-item label="获得条件">
<n-select
style="width: 200px"
v-model:value="editBadgeConditionType"
v-model:value="badgeConditionType"
:options="conditionTypeOptions"
/>
</n-form-item>
<n-form-item
label="条件值"
v-if="editBadgeConditionType !== 'all_problems'"
v-if="badgeConditionType !== 'all_problems'"
>
<n-input-number
style="width: 120px"
v-model:value="editBadgeConditionValue"
v-model:value="badgeConditionValue"
placeholder="条件值"
/>
</n-form-item>
@@ -1,100 +0,0 @@
<script setup lang="ts">
import type { AdminProblemSetProblem } from "utils/types"
interface Props {
show: boolean
problem: AdminProblemSetProblem | null
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
order: number
isRequired: 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,
isRequired: 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.isRequired
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.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,110 @@
<script setup lang="ts">
import type { AdminProblemSetProblem } from "utils/types"
// 添加和编辑共用一个弹窗:传了 problem 就是编辑,null 就是添加。
// 两种模式只差第一栏 —— 添加时填题目 ID,编辑时题目已定、只显示标题
interface Props {
show: boolean
problem: AdminProblemSetProblem | null
}
type ProblemFormData = {
order: number
isRequired: boolean
score: number
hint: string
}
interface Emits {
(e: "update:show", value: boolean): void
(e: "create", data: ProblemFormData & { problemId: string }): void
(e: "update", data: ProblemFormData): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const problemId = ref("")
const problemOrder = ref(0)
const problemRequired = ref(true)
const problemScore = ref(0)
const problemHint = ref("")
function handleConfirm() {
const data: ProblemFormData = {
order: problemOrder.value,
isRequired: problemRequired.value,
score: problemScore.value,
hint: problemHint.value,
}
if (props.problem) emit("update", data)
else emit("create", { problemId: problemId.value, ...data })
}
function handleCancel() {
emit("update:show", false)
}
// 每次打开都按当前模式重新填表:编辑就填这道题的值,添加就清空。
// 编辑取消后再打开同一道,看到的是库里的值,而不是上次没保存的改动
watch(
() => props.show,
(open) => {
if (!open) return
const problem = props.problem
problemId.value = ""
problemOrder.value = problem?.order ?? 0
problemRequired.value = problem?.isRequired ?? true
problemScore.value = problem?.score ?? 0
problemHint.value = problem?.hint || ""
},
{ immediate: true },
)
</script>
<template>
<n-modal
:show="show"
preset="card"
:title="problem ? '编辑题目' : '添加题目'"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form>
<n-form-item v-if="problem" label="题目标题">
<n-input :value="problem.title" disabled />
</n-form-item>
<n-form-item v-else label="题目ID" required>
<n-input
v-model:value="problemId"
placeholder="请输入题目的显示ID(如:1001)"
/>
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="problemOrder"
placeholder="题目在题单中的顺序"
/>
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="problemRequired" />
</n-form-item>
<n-form-item label="分数">
<n-input-number v-model:value="problemScore" placeholder="题目分数" />
</n-form-item>
<n-form-item label="提示">
<n-input
v-model:value="problemHint"
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>
+23 -30
View File
@@ -27,10 +27,8 @@ 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"
import ProblemModal from "./components/ProblemModal.vue"
import BadgeModal from "./components/BadgeModal.vue"
const route = useRoute()
const router = useRouter()
@@ -48,11 +46,9 @@ const problems = ref<AdminProblemSetProblem[]>([])
const badges = ref<AdminProblemSetBadge[]>([])
const progress = ref<AdminProblemSetProgress[]>([])
// 模态框状态
const showAddProblemModal = ref(false)
const showEditProblemModal = ref(false)
const showAddBadgeModal = ref(false)
const showEditBadgeModal = ref(false)
// 模态框状态。添加和编辑共用一个弹窗,editing* 为 null 就是添加
const showProblemModal = ref(false)
const showBadgeModal = ref(false)
// 编辑数据
const editingProblem = ref<AdminProblemSetProblem | null>(null)
@@ -98,7 +94,7 @@ async function handleAddProblem(data: AddProblemToSetRequest) {
try {
await addProblemToSet(problemSetId.value, data)
message.success("题目添加成功")
showAddProblemModal.value = false
showProblemModal.value = false
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err) {
@@ -123,7 +119,7 @@ async function handleEditProblem(data: UpdateProblemInSetRequest) {
try {
await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
message.success("题目编辑成功")
showEditProblemModal.value = false
showProblemModal.value = false
loadProblems()
} catch (err) {
message.error("编辑题目失败:" + errorMessage(err, "未知错误"))
@@ -134,7 +130,7 @@ async function handleAddBadge(data: BadgeFormData) {
try {
await createProblemSetBadge(problemSetId.value, data)
message.success("奖章创建成功")
showAddBadgeModal.value = false
showBadgeModal.value = false
loadBadges()
} catch (err) {
message.error("创建奖章失败:" + errorMessage(err, "未知错误"))
@@ -157,7 +153,7 @@ async function handleEditBadge(data: BadgeFormData) {
try {
await editProblemSetBadge(problemSetId.value, editingBadge.value.id, data)
message.success("奖章编辑成功")
showEditBadgeModal.value = false
showBadgeModal.value = false
loadBadges()
} catch (err) {
message.error("编辑奖章失败:" + errorMessage(err, "未知错误"))
@@ -175,21 +171,23 @@ async function handleRemoveUser(userId: number) {
}
function openAddProblemModal() {
showAddProblemModal.value = true
editingProblem.value = null
showProblemModal.value = true
}
function openAddBadgeModal() {
showAddBadgeModal.value = true
editingBadge.value = null
showBadgeModal.value = true
}
function openEditProblemModal(problem: AdminProblemSetProblem) {
editingProblem.value = problem
showEditProblemModal.value = true
showProblemModal.value = true
}
function openEditBadgeModal(badge: AdminProblemSetBadge) {
editingBadge.value = badge
showEditBadgeModal.value = true
showBadgeModal.value = true
}
onMounted(() => {
@@ -247,23 +245,18 @@ onMounted(() => {
</n-tabs>
<!-- 模态框组件 -->
<AddProblemModal
v-model:show="showAddProblemModal"
@confirm="handleAddProblem"
/>
<EditProblemModal
v-model:show="showEditProblemModal"
<ProblemModal
v-model:show="showProblemModal"
:problem="editingProblem"
@confirm="handleEditProblem"
@create="handleAddProblem"
@update="handleEditProblem"
/>
<AddBadgeModal v-model:show="showAddBadgeModal" @confirm="handleAddBadge" />
<EditBadgeModal
v-model:show="showEditBadgeModal"
<BadgeModal
v-model:show="showBadgeModal"
:badge="editingBadge"
@confirm="handleEditBadge"
@create="handleAddBadge"
@update="handleEditBadge"
/>
</div>
</template>