Files
reader-star/apps/admin/src/views/competition/competition-detail/modules/QuestionConfigCard.vue
rjl f1ff053b68 fix(admin): 移除题目标题必填验证并优化诗词理解题目展示
移除题目配置中的标题输入框及相关必填验证,使标题字段变为可选
诗词理解题目类型使用富文本编辑器,其他类型使用普通文本域
优化用户端汉字听写和词语听写题目的米字格展示样式
统一图片展示样式,限制最大宽度为50%并居中显示
2026-02-13 08:34:09 +08:00

483 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { NButton, NCard, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, NTag, type SelectOption } from 'naive-ui'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { PublishStatus } from '@/enum/business'
import { useDict } from '@/hooks/business/useDict'
import { fetchGetQuestionListAll } from '@/service/api/question'
import { fetchTemplateList } from '@/service/api/template'
import { useQuestionConfig } from '../../competition-add/modules/useQuestionConfig'
const props = defineProps<{
isEdit: boolean
questions: Array<Api.Competition.QuestionListRecord>
publishStatus: number
}>()
const emit = defineEmits(['enterEdit', 'save', 'cancel'])
// 本地数据模型,用于适配 useQuestionConfig
const modelValue = ref<{
rounds: Array<{
id: string
name: string
roundType: string
questions: Array<{
id: string
QuestionID: number
QuestionTime: number
ActitvityQuestionName: string
QuestionRule: number
Point: number
UIType?: Api.Competition.QuestionCategoryName
TemplateID?: number
// 额外字段用于回显时保留原始信息
ID?: number
}>
}>
}>({
rounds: [],
})
const { options: roundTypeOptions } = useDict('round_type')
const { options: scoreTypeOptions } = useDict('score_type')
const { options: timeOptions } = useDict('time_out')
// const { options: uiTypeOptions } = useDict('ui_type', 'string')
const { options: uiTypeOptions } = useDict('question_category_name', 'string')
const hookProps = reactive({
modelValue,
})
const {
getRoundTime,
totalStats,
addRound,
removeRound,
addQuestion,
removeQuestion,
handleDragEnd,
handleTypeChange,
validate,
reset,
} = useQuestionConfig(hookProps)
const questionOptions = ref<SelectOption[]>([])
const templateIdOptions = ref<SelectOption[]>([])
// 模态框控制
const showAddModal = ref(false)
const newRoundForm = ref({
count: 10,
roundType: roundTypeOptions?.value?.[0]?.value || 0,
})
function handleOpenAddModal() {
newRoundForm.value = {
count: 10,
roundType: roundTypeOptions?.value?.[0]?.value || 0,
}
showAddModal.value = true
}
function handleConfirmAdd() {
addRound(undefined, newRoundForm.value.count, newRoundForm.value.roundType as Api.Competition.CompetitionRoundType)
showAddModal.value = false
}
// 数据转换逻辑Flat List -> Rounds
watch(() => props.questions, (val) => {
if (!val || val.length === 0) {
modelValue.value.rounds = []
return
}
const roundsMap = new Map<string, typeof modelValue.value.rounds[0]>()
val.forEach((q) => {
const roundTypeStr = String(q.RoundType)
if (!roundsMap.has(roundTypeStr)) {
roundsMap.set(roundTypeStr, {
id: `round_restored_${roundTypeStr}`,
name: `${roundTypeOptions.value?.find((opt: any) => opt.value === roundTypeStr)?.label || '未知类型'}`,
roundType: roundTypeStr,
questions: [],
})
}
const round = roundsMap.get(roundTypeStr)
if (round) {
round.questions.push({
id: `q_restored_${q.ID}`,
ID: q.ID,
QuestionID: q.QuestionID,
ActitvityQuestionName: q.ActitvityQuestionName || `${round.questions.length + 1}`,
QuestionTime: q.QuestionTime,
Point: q.Point,
QuestionRule: q.QuestionRule,
UIType: q.UIType,
TemplateID: q.TemplateID,
})
}
})
modelValue.value.rounds = Array.from(roundsMap.values())
}, { immediate: true, deep: true })
const isDataLoaded = ref(false)
/** 获取所有题目 */
async function getAllQuestions() {
const { data: res, error } = await fetchGetQuestionListAll()
if (error) {
window.$message?.error(error.message)
return
}
const { data } = res || []
questionOptions.value = data?.map((item: any) => ({
label: `${item.Name}-${item.QuestionContent}`,
value: item.Id, // Ensure value is number if QuestionID is number
})) || []
}
/** 获取所有模板 */
async function getAllTemplates() {
const { data: res, error } = await fetchTemplateList()
if (error) {
window.$message?.error(error.message)
return
}
const { data } = res || []
templateIdOptions.value = Array.isArray(data)
? data.map((item: any) => ({
label: item.Name,
value: item.ID || item.id,
})) || []
: []
}
onMounted(() => {
if (!isDataLoaded.value) {
getAllQuestions()
getAllTemplates()
isDataLoaded.value = true
}
})
function getRoundTypeName(roundType: string) {
const option = roundTypeOptions.value?.find((item: any) => item.value === roundType)
return option?.label || '未知类型'
}
/** 是否显示编辑按钮 */
const showEnterEditButton = computed(() => {
return !props.isEdit && [PublishStatus.Unpublished, PublishStatus.Published].includes(props.publishStatus)
})
/** 是否显示保存/取消按钮 */
const showEditActions = computed(() => props.isEdit)
defineExpose({ validate, reset, modelValue })
</script>
<template>
<div class="flex flex-col gap-6">
<NCard :bordered="false" class="rounded-xl shadow-sm">
<div class="mb-6 flex items-center justify-between">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3 border-l-4 border-blue-600 pl-3">
<span class="text-lg text-gray-800 font-bold">赛程题包配置</span>
</div>
<span class="pl-4 text-xs text-gray-400">您可以为比赛添加多个阶段的题包并自定义题目分值与限时</span>
</div>
<div class="flex items-center gap-3">
<div
v-if="totalStats.count > 0"
class="flex items-center gap-3 border border-gray-100 rounded-lg bg-gray-50 px-3 py-1 text-xs font-bold"
>
<div class="flex items-center gap-1">
<span class="text-gray-400">总题数</span>
<span class="text-sm text-gray-700">{{ totalStats.count }}</span>
</div>
<div class="h-3 w-[1px] bg-gray-200" />
<div class="h-3 w-[1px] bg-gray-200" />
<div class="flex items-center gap-1">
<span class="text-gray-400">总时长</span>
<span class="text-sm text-blue-500">{{ totalStats.time }}s</span>
</div>
</div>
<div class="flex gap-2">
<NButton v-if="showEnterEditButton" size="small" type="primary" secondary @click="emit('enterEdit')">
编辑
</NButton>
<template v-else-if="showEditActions">
<NButton size="small" secondary @click="emit('cancel')">
取消
</NButton>
<NButton size="small" type="primary" @click="emit('save')">
保存
</NButton>
</template>
</div>
<NButton
v-if="isEdit"
type="primary" secondary size="medium" class="!font-bold"
@click="handleOpenAddModal"
>
<template #icon>
<icon-ic-round-plus />
</template>
新增赛程题包
</NButton>
</div>
</div>
<!-- 空状态 -->
<div
v-if="!modelValue.rounds?.length"
class="flex flex-col flex-1 items-center justify-center border-2 border-gray-100 rounded-2xl border-dashed bg-gray-50/30 py-12"
>
<div class="h-34 w-34 flex items-center justify-center rounded-3xl bg-gray-100 text-gray-300">
<icon-ic-outline-folder-off class="text-5xl" />
</div>
<div class="mt-6 text-lg text-gray-400 font-bold">
暂无配置题包
</div>
<div class="mt-2 text-sm text-gray-500">
当前暂无配置
</div>
</div>
<!-- 列表内容 -->
<div v-else class="flex flex-col gap-6 pb-4">
<div
v-for="(round, roundIndex) in modelValue.rounds" :key="round.id"
class="group relative overflow-hidden border border-gray-100 rounded-2xl bg-white p-1 shadow-sm transition-all hover:shadow-md"
>
<!-- 侧边装饰条 -->
<div class="absolute bottom-0 left-0 top-0 w-1.5 bg-blue-500" />
<div class="p-6 pl-8">
<!-- 头部信息 -->
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="h-8 w-1.5 rounded-full bg-blue-500" />
<NTag type="primary" class="!text-blue-500">
{{ getRoundTypeName(round.roundType) }}
</NTag>
<div class="flex items-center gap-3">
<NInput
v-model:value="round.name" placeholder="请输入环节名称" :bordered="false"
:disabled="!isEdit"
class="border transition-all !w-80 !border-transparent !rounded-lg !bg-transparent !text-xl !font-bold focus-within:shadow-sm focus-within:!border-blue-500 hover:!border-gray-200 focus-within:!bg-white"
/>
<div class="flex items-center gap-2">
<div
class="flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs text-gray-500 font-bold"
>
<icon-ic-outline-format-list-numbered class="text-sm" />
{{ round.questions.length }}
</div>
<div
class="flex items-center gap-1 border border-blue-100 rounded-full bg-blue-50 px-3 py-1 text-xs text-blue-500 font-bold"
>
<icon-ic-outline-access-time class="text-sm" />
{{ getRoundTime(roundIndex) }}
</div>
</div>
</div>
</div>
<div v-if="isEdit" class="flex items-center gap-3">
<NButton
secondary circle class="!text-gray-400 hover:!bg-blue-50 hover:!text-blue-500"
@click="addQuestion(roundIndex)"
>
<template #icon>
<icon-ic-round-plus class="!text-xl" />
</template>
</NButton>
<NButton
secondary circle class="!text-gray-400 hover:!bg-red-50 hover:!text-red-500"
@click="removeRound(roundIndex)"
>
<template #icon>
<icon-ic-outline-delete class="!text-xl" />
</template>
</NButton>
</div>
</div>
<!-- 题目列表 -->
<VueDraggable
v-model="round.questions" :animation="300" handle=".drag-handle" class="flex flex-col gap-3"
ghost-class="ghost"
:disabled="!isEdit"
@end="(evt) => handleDragEnd(roundIndex, evt)"
>
<div
v-for="(item, qIdx) in round.questions" :key="item.id"
class="group/item flex flex-col gap-3 border border-gray-100 rounded-xl bg-white p-3 transition-all hover:border-blue-200 hover:shadow-sm"
>
<!-- 第一行序号标题 -->
<div class="flex items-center justify-between gap-3">
<div class="flex flex-1 items-center gap-3">
<div class="flex items-center gap-2">
<div
v-if="isEdit"
class="drag-handle flex cursor-grab items-center justify-center rounded p-1 text-gray-400 transition-colors active:cursor-grabbing hover:bg-gray-100 hover:text-blue-500"
>
<icon-mdi-drag class="text-xl" />
</div>
<span
class="w-6 flex-shrink-0 text-center text-sm text-gray-300 font-bold transition-colors group-hover/item:text-blue-500"
>
<!-- {{ String(qIdx + 1).padStart(2, '0') }} -->
<NTag type="primary" class="!text-blue-500">{{ item.QuestionID }}</NTag>
</span>
</div>
<!-- 标题 -->
<!-- <div class="flex flex-1 items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-outline-title class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">标题</span>
<NInput
v-model:value="item.ActitvityQuestionName" class="flex-1 !bg-transparent" size="small" :bordered="false"
placeholder="请输入题目标题"
:disabled="!isEdit"
/>
</div> -->
</div>
<!-- 删除按钮 -->
<NButton
v-if="isEdit"
text class="transition-opacity !text-gray-300 hover:!text-red-500"
@click="removeQuestion(roundIndex, qIdx)"
>
<icon-ic-outline-delete class="!text-xl" />
</NButton>
</div>
<!-- 第二行配置项 -->
<div class="grid grid-cols-12 gap-3">
<!-- 题型 -->
<div class="col-span-4">
<NSelect
v-model:value="item.QuestionID" :options="questionOptions" placeholder="请选择题目类型" size="small"
class="font-medium"
:disabled="!isEdit"
@update:value="() => handleTypeChange(roundIndex)"
/>
</div>
<!-- UItype -->
<div class="col-span-4">
<NSelect
v-model:value="item.UIType" :options="uiTypeOptions" placeholder="请选择UI类型" size="small"
class="font-medium"
:disabled="!isEdit"
/>
</div>
<!-- templateId -->
<div class="col-span-4">
<NSelect
v-model:value="item.TemplateID" :options="templateIdOptions" placeholder="请选择模板" size="small"
class="font-medium"
:disabled="!isEdit"
/>
</div>
<!-- 时间 -->
<div class="col-span-4 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-outline-timer class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">答题时间</span>
<NSelect
v-model:value="item.QuestionTime" :options="timeOptions" class="flex-1 !bg-transparent" size="small"
:bordered="false"
:disabled="!isEdit"
/>
<span class="text-xs text-gray-400 font-bold">S</span>
</div>
<!-- 分数规则 -->
<div class="col-span-4 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-streamline-sharp:type-area-remix class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数规则</span>
<NSelect
v-model:value="item.QuestionRule" :options="scoreTypeOptions as unknown as SelectOption[]"
class="flex-1 !bg-transparent" size="small" :bordered="false"
:disabled="!isEdit"
/>
</div>
<!-- 分数 -->
<div class="col-span-4 flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-1.5">
<icon-ic-round-star-border class="text-gray-400" />
<span class="whitespace-nowrap text-sm text-gray-400 font-bold">分数</span>
<NInputNumber
v-model:value="item.Point" :min="0" :show-button="false" class="flex-1 !bg-transparent"
size="small" :bordered="false" placeholder="0"
:disabled="!isEdit"
/>
<span class="text-xs text-gray-400 font-bold"></span>
</div>
</div>
</div>
</VueDraggable>
</div>
</div>
</div>
</NCard>
<!-- 新增题包弹窗 -->
<NModal v-model:show="showAddModal" transform-origin="center">
<div class="w-[480px] rounded-2xl bg-white p-8 shadow-2xl">
<div class="mb-8 flex flex-col items-center gap-4 text-center">
<div class="h-14 w-14 flex items-center justify-center rounded-2xl bg-blue-50 text-blue-500">
<icon-ic-round-library-add class="text-3xl" />
</div>
<div>
<div class="text-xl text-gray-800 font-bold">
新建环节题包
</div>
<div class="mt-1 text-sm text-gray-500">
请输入环节名称与题目预设数量
</div>
</div>
</div>
<NForm size="large">
<NFormItem label="环节/题包类型">
<NSelect
v-model:value="newRoundForm.roundType" :options="roundTypeOptions as unknown as SelectOption[]"
placeholder="请选择环节类型..." :bordered="true" class="font-medium"
/>
</NFormItem>
<NFormItem label="初始化题目数量">
<NInputNumber v-model:value="newRoundForm.count" :min="1" :max="50" class="w-full" />
</NFormItem>
</NForm>
<NButton
type="primary" block size="large" class="mt-4 !h-12 !rounded-xl !text-lg !font-bold"
@click="handleConfirmAdd"
>
生成题包卡片
</NButton>
</div>
</NModal>
</div>
</template>
<style scoped>
.ghost {
@apply opacity-50 bg-blue-50 border-2 border-dashed border-blue-300;
}
</style>