feat(admin): 新增题目类型、优化题目库管理并添加拼音助手

- 新增6种题目类型枚举:逆向接诗句、场景猜诗句、联想对对碰、字词飞花令、诗词常识、换字组成语
- 新增QuestionDrawer组件,支持富文本编辑和拼音转换功能
- 优化CategoryTree组件,增加分类选择本地存储和自动填充题目类型
- 更新question API接口,简化新增和更新题目库的参数传递
- 扩展QuestionRenderer组件,支持新增的题目类型渲染
- 添加多个题目库JSON文件,包含成语、诗词常识、字词飞花令等题目数据
- 调整环境配置,更新开发环境API地址
This commit is contained in:
2026-03-16 17:59:49 +08:00
parent 782c61a38c
commit 771fb807eb
25 changed files with 2482 additions and 2933 deletions

View File

@ -21,11 +21,17 @@ export enum QuestionType {
}
export enum QuestionCategoryEnum {
ChineseCharacterDictation1 = 'ChineseCharacterDictation1', // 汉字听写-提示 (jiu表示小鸟的叫声)
ChineseCharacterDictation2 = 'ChineseCharacterDictation2', // 根据拼音写汉字-提示 (tang)
CharacterRadicalAddition = 'CharacterRadicalAddition', // 汉字加一加 (车)
WordDictation = 'WordDictation', // 词语听写 (zhi re)
IdiomWriting1 = 'IdiomWriting1', // 成语-文字要求 (反义字)
IdiomWriting2 = 'IdiomWriting2', // 成语-文字要求 (看图写成语)
PoetryComprehension = 'PoetryComprehension', // 诗词理解-选择题
ChineseCharacterDictation1 = 'ChineseCharacterDictation1', // 1. 汉字听写-提示 (jiu表示小鸟的叫声)
ChineseCharacterDictation2 = 'ChineseCharacterDictation2', // 2. 根据拼音写汉字-提示 (tang)
CharacterRadicalAddition = 'CharacterRadicalAddition', // 3. 汉字加一加 (车)
WordDictation = 'WordDictation', // 4. 词语听写 (zhi re)
IdiomWriting1 = 'IdiomWriting1', // 5. 成语-文字要求
IdiomWriting2 = 'IdiomWriting2', // 6. 成语-文字要求 (看图写成语)
PoetryComprehension = 'PoetryComprehension', // 7. 诗词理解-选择题
ReversePoemMatching = 'ReversePoemMatching', // 8. 逆向接诗句
SceneBasedPoemGuessing = 'SceneBasedPoemGuessing', // 9. 场景猜诗句
AssociationMatching = 'AssociationMatching', // 10. 联想对对碰
CharacterWordFlowerDrinkingGame = 'CharacterWordFlowerDrinkingGame', // 11. 字词飞花令
PoetryCommonKnowledge = 'PoetryCommonKnowledge', // 12. 诗词常识
ChangeWords = 'ChangeWords', // 13. 换字组成语
}

View File

@ -49,31 +49,11 @@ export function fetchGetQuestionLibraryListAll(params: Api.Question.GetQuestionL
}
/** 新增一条问题库 */
export function fetchAddQuestionLibrary(params: Api.Question.AddQuestionLibraryParams, file?: File | null) {
const formData = new FormData()
if (file) {
formData.append('Image', file)
}
export function fetchAddQuestionLibrary(params: Api.Question.AddQuestionLibraryParams) {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/AddQuestionListDetail',
method: 'post',
// params: {
// QuestionId: params.questionId,
// Name: params.name,
// Answer: params.answer,
// Type: params.type,
// IsGood: params.IsGood,
// ImageUrl: params.imageUrl,
// },
data: {
QuestionId: params.questionId,
Name: params.name,
Answer: params.answer,
Type: params.type,
IsGood: params.IsGood,
ImageUrl: params.imageUrl,
IsDisabled: params.IsDisabled,
},
data: params,
})
}
@ -87,33 +67,11 @@ export function fetchDisableQuestionLibrary(params: Api.Question.DisableQuestion
}
/** 更新一条问题库 */
export function fetchUpdateQuestionLibrary(params: Api.Question.AddQuestionLibraryParams, file?: File | null) {
const formData = new FormData()
if (file) {
formData.append('Image', file)
}
export function fetchUpdateQuestionLibrary(params: Api.Question.UpdateQuestionLibraryParams) {
return request<Api.Question.CommonRecord>({
url: '/Base/ActivityMain/UpdateQuestionListDetail',
method: 'post',
// params: {
// Id: params.id,
// QuestionId: params.questionId,
// Name: params.name,
// Answer: params.answer,
// Type: params.type,
// IsGood: params.IsGood,
// ImageUrl: params.imageUrl,
// },
data: {
Id: params.id,
QuestionId: params.questionId,
Name: params.name,
Answer: params.answer,
Type: params.type,
IsGood: params.IsGood,
ImageUrl: params.imageUrl,
IsDisabled: params.IsDisabled,
},
data: params,
})
}

View File

@ -65,11 +65,21 @@ declare namespace Api {
/** question library image url */
imageUrl: string
/** question library type */
type: QuestionScoreType
// type?: QuestionScoreType
/** is priority 0: no 1: yes */
IsGood: number
/** is disabled */
IsDisabled: boolean
/** keyword */
KeyWord: string
/** keyword index */
KeyWordsIndex: number
}
/** update question library params */
interface UpdateQuestionLibraryParams extends AddQuestionLibraryParams {
/** question library id */
id: number
}
/** common search params of table */

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import CategoryTree from './modules/CategoryTree.vue'
import QuestionList from './modules/QuestionList.vue'
@ -8,7 +8,8 @@ defineOptions({ name: 'QuestionStore' })
const currentCategory = ref<any>(null)
// Resize logic
const siderWidth = ref(450)
const siderWidth = ref(525)
const LOCAL_STORAGE_KEY = 'question-store-sider-width'
const minWidth = 300
const maxWidth = 800
const isDragging = ref(false)
@ -43,7 +44,15 @@ function onMouseUp() {
document.removeEventListener('mouseup', onMouseUp)
document.body.style.userSelect = ''
document.body.style.cursor = ''
localStorage.setItem(LOCAL_STORAGE_KEY, siderWidth.value.toString())
}
onMounted(() => {
const savedWidth = localStorage.getItem(LOCAL_STORAGE_KEY)
if (savedWidth) {
siderWidth.value = Number(savedWidth)
}
})
</script>
<template>

View File

@ -6,6 +6,7 @@ import {
NInput,
NModal,
NSelect,
type SelectOption,
useDialog,
useMessage,
} from 'naive-ui'
@ -20,9 +21,10 @@ const emit = defineEmits<{
(e: 'dragStart', event: MouseEvent): void
}>()
const { options: questionTypeOptions } = useDict('question_type', 'string')
const { options: categoryOptions } = useDict('question_category_name', 'string')
const LOCAL_STORAGE_KEY = 'question-store-selected-category-id'
const message = useMessage()
const dialog = useDialog()
@ -46,6 +48,7 @@ const currentOperationNode = ref<CategoryItem | null>(null)
watch(selectedKey, (newKey) => {
if (!newKey) {
emit('update:category', null)
localStorage.removeItem(LOCAL_STORAGE_KEY)
return
}
const node = categoryList.value.find(item => item.Id === newKey)
@ -53,9 +56,11 @@ watch(selectedKey, (newKey) => {
emit('update:category', {
...node,
})
localStorage.setItem(LOCAL_STORAGE_KEY, newKey.toString())
}
else {
emit('update:category', null)
localStorage.removeItem(LOCAL_STORAGE_KEY)
}
})
@ -63,6 +68,16 @@ function handleSelect(key: number) {
selectedKey.value = key
}
/**
* 分类名称选择更新
* @param _value
* @param option
*/
function handleCategoryNameChange(_value: string, option: SelectOption) {
if (option)
categoryForm.value.questionType = option.value as QuestionType
}
function handleAddCategory() {
categoryOperation.value = 'add'
categoryForm.value.questionContent = ''
@ -100,7 +115,7 @@ function handleDeleteCategory(item: CategoryItem) {
selectedKey.value = null
}
// 获取最新分类列表
await fetchCategoryList()
await loadAndSelectCategory()
message.success('删除成功')
}
},
@ -128,7 +143,7 @@ async function submitCategory() {
message.error('添加分类失败')
return
}
await fetchCategoryList()
await loadAndSelectCategory()
message.success('分类添加成功')
}
else if (categoryOperation.value === 'edit' && currentOperationNode.value) {
@ -144,7 +159,7 @@ async function submitCategory() {
}
currentOperationNode.value.QuestionContent = categoryForm.value.questionContent
message.success('分类修改成功')
await fetchCategoryList()
await loadAndSelectCategory()
// 更新选中分类的信息
if (selectedKey.value === currentOperationNode.value.Id) {
const node = currentOperationNode.value
@ -160,21 +175,35 @@ async function submitCategory() {
}
/**
* 获取分类列表
* 获取并选择分类列表
*/
async function fetchCategoryList() {
async function loadAndSelectCategory() {
try {
const { data } = await fetchGetQuestionListAll()
categoryList.value = data?.data || []
// Ensure Id is always a number to prevent type mismatch issues
categoryList.value = (data?.data || []).map((c: any) => ({ ...c, Id: Number(c.Id) }))
const savedKeyRaw = localStorage.getItem(LOCAL_STORAGE_KEY)
const savedKey = savedKeyRaw ? Number(savedKeyRaw) : null
const savedCategoryExists = savedKey ? categoryList.value.some(c => c.Id === savedKey) : false
if (savedKey && savedCategoryExists) {
selectedKey.value = savedKey
}
else if (categoryList.value.length > 0) {
selectedKey.value = categoryList.value[0].Id
}
else {
selectedKey.value = null
}
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (error) {
message.error('获取分类列表失败')
catch (error: any) {
message.error(error.message || '获取分类列表失败')
}
}
onMounted(() => {
fetchCategoryList()
loadAndSelectCategory()
})
</script>
@ -243,11 +272,12 @@ onMounted(() => {
:options="categoryOptions"
value-field="label"
placeholder="请选择分类名称"
@update:value="handleCategoryNameChange"
/>
</NFormItem>
<!-- 题目类型 -->
<NFormItem label="题目类型">
<NSelect v-model:value="categoryForm.questionType" :options="questionTypeOptions" placeholder="请选择题目类型" />
<NInput v-model:value="categoryForm.questionType" placeholder="请选择分类名称自动带出" :readonly="true" />
</NFormItem>
<!-- 分类描述 -->
<NFormItem label="分类描述">

View File

@ -0,0 +1,282 @@
<script setup lang="ts">
import type { FormInst, FormItemRule } from 'naive-ui'
import Clipboard from 'clipboard'
import {
NButton,
NCard,
NDrawer,
NDrawerContent,
NForm,
NFormItem,
NInput,
NInputNumber,
NRadio,
NRadioGroup,
} from 'naive-ui'
import { pinyin } from 'pinyin-pro'
import { computed, onUnmounted, ref, watch } from 'vue'
import OssImageUpload from '@/components/common/oss-image-upload/index.vue'
import WangEditor from '@/components/common/wang-editor.vue'
import { QuestionCategoryEnum, QuestionType } from '@/enum/business'
import { fetchAddQuestionLibrary, fetchUpdateQuestionLibrary } from '@/service/api/question'
const props = defineProps<{
show: boolean
operation: 'add' | 'edit'
questionData?: any
currentCategory: any
}>()
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'success'): void
}>()
const showQuestionModal = computed({
get: () => props.show,
set: val => emit('update:show', val),
})
// 拼音转换工具
const pinyinTool = ref({
input: '',
output: '',
})
// 监听输入变化自动转换
watch(() => pinyinTool.value.input, (val) => {
if (val) {
pinyinTool.value.output = pinyin(val)
}
else {
pinyinTool.value.output = ''
}
})
// 复制拼音
const copyBtnRef = ref<any>(null)
let clipboard: Clipboard | null = null
watch(copyBtnRef, (inst) => {
if (clipboard) {
clipboard.destroy()
clipboard = null
}
if (inst) {
const domEl = inst.$el || inst
clipboard = new Clipboard(domEl)
clipboard.on('success', () => {
window?.$message?.success('拼音已复制到剪贴板')
})
clipboard.on('error', () => {
window?.$message?.error('复制失败,请手动复制')
})
}
})
onUnmounted(() => {
clipboard?.destroy()
})
const questionForm = ref({
name: '',
answer: '',
type: QuestionCategoryEnum.ChineseCharacterDictation1 as QuestionCategoryEnum | number,
imageUrl: '',
IsGood: 0,
IsDisabled: false,
KeyWord: '',
KeyWordsIndex: 0,
})
const questionFormRef = ref<FormInst | null>(null)
const rules = computed(() => {
const dynamicRules: any = {
answer: [{ required: true, message: '请输入题目答案', trigger: ['blur'] }],
type: [{ required: true, message: '请选择题目类型', trigger: ['change'] }],
}
// 题目正文内容和图片至少要有一个
if (!questionForm.value.imageUrl) {
dynamicRules.name = [{ required: true, message: '请输入题目正文内容或上传图片', trigger: ['blur'] }]
}
// 只有“联想对对碰”类型才需要关键词
if (questionForm.value.type === QuestionCategoryEnum.AssociationMatching) {
dynamicRules.KeyWord = [{ required: true, message: '请输入关键词', trigger: ['blur'] }]
dynamicRules.KeyWordsIndex = [
{
required: true,
validator: (_rule: FormItemRule, value: number) => {
return typeof value === 'number'
},
message: '请输入关键词索引',
trigger: ['blur', 'change'],
},
]
}
return dynamicRules
})
watch(() => props.show, (val) => {
if (val) {
if (props.operation === 'add') {
questionForm.value = {
name: '',
answer: '',
type: props.currentCategory?.QuestionType || QuestionType.SingleChoice,
imageUrl: '',
IsGood: 0,
IsDisabled: false,
KeyWord: '',
KeyWordsIndex: 0,
}
pinyinTool.value.input = ''
pinyinTool.value.output = ''
}
else if (props.operation === 'edit' && props.questionData) {
const question = props.questionData
questionForm.value = {
name: question.Name,
answer: question.Answer,
type: Number(question.Type),
KeyWord: question.KeyWord || '',
KeyWordsIndex: question.KeyWordsIndex || 0,
imageUrl: question.ImageUrl || '',
IsGood: question.IsPriority || 0,
IsDisabled: !!question.IsDisabled,
}
pinyinTool.value.input = ''
pinyinTool.value.output = ''
}
}
})
async function submitQuestion() {
const valid = await questionFormRef.value?.validate()
if (!valid) {
window?.$message?.error('请填写完整信息')
return
}
const params = {
...questionForm.value,
questionId: props.currentCategory.Id,
// type: String(questionForm.value.type) as Api.Question.QuestionScoreType,
}
if (questionForm.value.type !== QuestionCategoryEnum.AssociationMatching) {
params.KeyWord = ''
params.KeyWordsIndex = 0
}
if (props.operation === 'add') {
const { error } = await fetchAddQuestionLibrary({ ...params, id: 0 })
if (!error) {
window?.$message?.success('题目添加成功')
emit('success')
}
else {
window?.$message?.error('添加失败')
}
}
else if (props.operation === 'edit' && props.questionData) {
const { error } = await fetchUpdateQuestionLibrary({ ...params, id: props.questionData.Id })
if (!error) {
window?.$message?.success('题目修改成功')
emit('success')
}
else {
window?.$message?.error('修改失败')
}
}
}
</script>
<template>
<NDrawer v-model:show="showQuestionModal" :width="800">
<NDrawerContent :title="props.operation === 'edit' ? '编辑题目详情' : '新增题目详情'">
<div class="mb-4">
<NCard size="small" title="🛠️ 拼音助手 (输入汉字获取带音标拼音)" class="bg-gray-50/50">
<div class="flex gap-2">
<NInput v-model:value="pinyinTool.input" placeholder="输入汉字..." class="flex-1" />
<div class="flex flex-1 items-center justify-between border border-gray-200 rounded bg-white px-3 py-1">
<span class="text-gray-600">{{ pinyinTool.output || '拼音结果将显示在这里' }}</span>
<textarea id="pinyinCopyTarget" v-model="pinyinTool.output" class="absolute opacity-0 -z-1" />
<div v-if="pinyinTool.output" ref="copyBtnRef" data-clipboard-target="#pinyinCopyTarget">
<NButton size="tiny" type="primary" secondary>
复制
</NButton>
</div>
</div>
</div>
</NCard>
</div>
<NForm ref="questionFormRef" label-placement="top" :rules="rules" :model="questionForm" size="small">
<NFormItem label="题目正文内容" path="name">
<div
v-if="[QuestionCategoryEnum.PoetryComprehension, QuestionCategoryEnum.PoetryCommonKnowledge, QuestionCategoryEnum.ReversePoemMatching, QuestionCategoryEnum.ChineseCharacterDictation1].includes(props.currentCategory?.QuestionType)"
class="w-full overflow-hidden border border-gray-200 rounded-lg"
>
<WangEditor
v-model="questionForm.name" placeholder="请输入题目内容..." height="300px"
:exclude-keys="['header1', 'fontSize', 'fontFamily', 'lineHeight', 'justifyLeft', 'justifyRight', 'justifyCenter', 'justifyJustify', 'group-image', 'group-video', 'insertTable', 'headerSelect']"
/>
</div>
<NInput v-else v-model:value="questionForm.name" type="textarea" placeholder="请输入题目内容" :rows="3" />
</NFormItem>
<NFormItem label="是否优先使用" path="IsGood">
<NRadioGroup v-model:value="questionForm.IsGood">
<NRadio :value="0">
</NRadio>
<NRadio :value="1">
</NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem label="是否禁用" path="IsDisabled">
<NRadioGroup v-model:value="questionForm.IsDisabled">
<NRadio :value="false">
</NRadio>
<NRadio :value="true">
</NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem label="上传图片">
<OssImageUpload v-model="questionForm.imageUrl" />
</NFormItem>
<template v-if="currentCategory?.QuestionType === QuestionCategoryEnum.AssociationMatching">
<NFormItem label="关键词" path="KeyWord">
<NInput v-model:value="questionForm.KeyWord" placeholder="请输入关键词" />
</NFormItem>
<NFormItem label="关键词索引" path="KeyWordsIndex">
<NInputNumber v-model:value="questionForm.KeyWordsIndex" placeholder="请输入关键词索引" />
</NFormItem>
</template>
<NFormItem label="参考标准答案" path="answer">
<NInput v-model:value="questionForm.answer" placeholder="正确答案" />
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-3">
<NButton @click="showQuestionModal = false">
取消并返回
</NButton>
<NButton type="primary" @click="submitQuestion">
保存并入库
</NButton>
</div>
</template>
</NDrawerContent>
</NDrawer>
</template>

View File

@ -1,84 +1,25 @@
<script setup lang="ts">
import type { FormInst, UploadCustomRequestOptions, UploadFileInfo } from 'naive-ui'
import Clipboard from 'clipboard'
import {
NBreadcrumb,
NBreadcrumbItem,
NButton,
NCard,
NCheckbox,
NDrawer,
NDrawerContent,
NEmpty,
NForm,
NFormItem,
NImage,
NInput,
NPagination,
NRadio,
NRadioGroup,
NTag,
NUpload,
} from 'naive-ui'
import { pinyin } from 'pinyin-pro'
import { computed, onUnmounted, ref, watch } from 'vue'
import WangEditor from '@/components/common/wang-editor.vue'
import { computed, ref, watch } from 'vue'
import SvgIcon from '@/components/custom/svg-icon.vue'
import { QuestionCategoryEnum, QuestionType } from '@/enum/business'
import { fetchAddQuestionLibrary, fetchDeleteQuestionLibrary, fetchDisableQuestionLibrary, fetchGetQuestionLibraryListAll, fetchUpdateQuestionLibrary } from '@/service/api/question'
/**
* 为了user端题目展示的效果以及布局可控指定规则
* 1.普通类型 =》用存文字存 ['汉字加一加','词语听写''汉字听写','成语写一写']
* 2.复杂样式 =》用富文本 ['诗词理解']
*
*/
import { fetchDeleteQuestionLibrary, fetchDisableQuestionLibrary, fetchGetQuestionLibraryListAll } from '@/service/api/question'
import QuestionDrawer from './QuestionDrawer.vue'
const props = defineProps<{
currentCategory: any
}>()
// 拼音转换工具
const pinyinTool = ref({
input: '',
output: '',
})
// 监听输入变化自动转换
watch(() => pinyinTool.value.input, (val) => {
if (val) {
pinyinTool.value.output = pinyin(val)
}
else {
pinyinTool.value.output = ''
}
})
// 复制拼音
const copyBtnRef = ref<any>(null)
let clipboard: Clipboard | null = null
watch(copyBtnRef, (inst) => {
if (clipboard) {
clipboard.destroy()
clipboard = null
}
if (inst) {
const domEl = inst.$el || inst
clipboard = new Clipboard(domEl)
clipboard.on('success', () => {
window?.$message?.success('拼音已复制到剪贴板')
})
clipboard.on('error', () => {
window?.$message?.error('复制失败,请手动复制')
})
}
})
onUnmounted(() => {
clipboard?.destroy()
})
// 题目列表数据
const questionList = ref<any[]>([])
const loading = ref(false)
@ -106,19 +47,7 @@ const pagination = ref({
const searchText = ref('')
const showQuestionModal = ref(false)
const questionOperation = ref<'add' | 'edit'>('add')
const currentQuestionId = ref<number | null>(null)
const questionForm = ref({
name: '',
answer: '',
type: QuestionType.SingleChoice as QuestionType | number, // 题目类型
imageUrl: '',
IsGood: 0,
IsDisabled: false,
})
const fileList = ref<UploadFileInfo[]>([])
const questionFormRef = ref<FormInst | null>(null)
const currentQuestion = ref<any>(null)
// 批量选择
const selectedQuestionIds = ref<number[]>([])
@ -203,12 +132,6 @@ async function handleBatchDisable(disabled: boolean) {
}
}
const rules = {
name: [{ required: true, message: '请输入题目正文内容', trigger: ['blur'] }],
answer: [{ required: true, message: '请输入题目答案', trigger: ['blur'] }],
type: [{ required: true, message: '请选择题目类型', trigger: ['change'] }],
}
const hasCategory = computed(() => {
return !!props.currentCategory
})
@ -224,14 +147,6 @@ watch(() => props.currentCategory, (newVal) => {
}
}, { immediate: true })
function customRequest({ onFinish }: UploadCustomRequestOptions) {
onFinish()
}
function handleUploadChange(data: { fileList: UploadFileInfo[] }) {
fileList.value = data.fileList
}
async function fetchData(id: number) {
loading.value = true
const { data, error } = await fetchGetQuestionLibraryListAll({
@ -260,49 +175,15 @@ function handleAddQuestion() {
return
}
questionOperation.value = 'add'
currentQuestionId.value = null
questionForm.value = {
name: '',
answer: '',
type: props.currentCategory?.QuestionType || QuestionType.SingleChoice,
imageUrl: '',
IsGood: 0,
IsDisabled: false,
}
fileList.value = []
currentQuestion.value = null
showQuestionModal.value = true
pinyinTool.value.input = ''
pinyinTool.value.output = ''
}
/**
* 处理编辑题目
* @param id 题目ID
*/
function handleEditQuestion(id: number) {
pinyinTool.value.input = ''
pinyinTool.value.output = ''
const question = questionList.value.find(q => q.Id === id)
if (question) {
questionOperation.value = 'edit'
currentQuestionId.value = id
questionForm.value = {
name: question.Name,
answer: question.Answer,
type: question.Type,
imageUrl: question.ImageUrl || '',
IsGood: question.IsPriority || 0,
IsDisabled: !!question.IsDisabled,
}
fileList.value = []
if (question.ImageUrl) {
fileList.value = [{
id: 'existing',
name: 'Existing Image',
status: 'finished',
url: question.ImageUrl,
}]
}
currentQuestion.value = question
showQuestionModal.value = true
}
}
@ -328,69 +209,10 @@ function handleDeleteQuestion(id: number) {
})
}
async function submitQuestion() {
const valid = await questionFormRef.value?.validate()
if (!questionForm.value.name) {
if (!valid) {
window?.$message?.error('请填写完整信息')
return
}
}
const commonParams = {
name: questionForm.value.name,
answer: questionForm.value.answer,
imageUrl: '',
type: questionForm.value.type as any,
questionId: props.currentCategory.Id,
IsGood: questionForm.value.IsGood,
// 传给后端为 boolean
IsDisabled: questionForm.value.IsDisabled,
}
let fileToUpload: File | null = null
if (questionForm.value.type === QuestionType.Image) { // 图片类型
if (fileList.value.length > 0 && fileList.value[0].file) {
fileToUpload = fileList.value[0].file
}
// else if (questionOperation.value === 'add' && fileList.value.length === 0) {
// window?.$message?.error('请上传图片')
// return
// }
// else if (questionOperation.value === 'edit' && fileList.value.length === 0) {
// window?.$message?.error('请上传图片')
// return
// }
}
if (questionOperation.value === 'add') {
const { error } = await fetchAddQuestionLibrary({
...commonParams,
id: 0,
}, fileToUpload)
if (!error) {
window?.$message?.success('题目添加成功')
showQuestionModal.value = false
fetchData(props.currentCategory.Id)
}
else {
window?.$message?.error('添加失败')
}
}
else if (questionOperation.value === 'edit' && currentQuestionId.value) {
const { error } = await fetchUpdateQuestionLibrary({
...commonParams,
id: currentQuestionId.value,
}, fileToUpload)
if (!error) {
window?.$message?.success('题目修改成功')
showQuestionModal.value = false
fetchData(props.currentCategory.Id)
}
else {
window?.$message?.error('修改失败')
}
function onDrawerSuccess() {
showQuestionModal.value = false
if (props.currentCategory?.Id) {
fetchData(props.currentCategory.Id)
}
}
</script>
@ -556,102 +378,13 @@ async function submitQuestion() {
</template>
</div>
<!-- Add Question Drawer -->
<NDrawer v-model:show="showQuestionModal" :width="800">
<NDrawerContent :title="questionOperation === 'edit' ? '编辑题目详情' : '新增题目详情'">
<div class="mb-4">
<NCard size="small" title="🛠️ 拼音助手 (输入汉字获取带音标拼音)" class="bg-gray-50/50">
<div class="flex gap-2">
<NInput v-model:value="pinyinTool.input" placeholder="输入汉字..." class="flex-1" />
<div class="flex flex-1 items-center justify-between border border-gray-200 rounded bg-white px-3 py-1">
<span class="text-gray-600">{{ pinyinTool.output || '拼音结果将显示在这里' }}</span>
<textarea id="pinyinCopyTarget" v-model="pinyinTool.output" class="absolute opacity-0 -z-1" />
<div v-if="pinyinTool.output" ref="copyBtnRef" data-clipboard-target="#pinyinCopyTarget">
<NButton size="tiny" type="primary" secondary>
复制
</NButton>
</div>
</div>
</div>
</NCard>
</div>
<NForm ref="questionFormRef" label-placement="top" :rules="rules" :model="questionForm" size="small">
<NFormItem label="题目正文内容" path="name">
<div
v-if="[QuestionCategoryEnum.PoetryComprehension].includes(props.currentCategory?.QuestionType)"
class="w-full overflow-hidden border border-gray-200 rounded-lg"
>
<WangEditor
v-model="questionForm.name" placeholder="请输入题目内容..." height="300px"
:exclude-keys="['header1', 'fontSize', 'fontFamily', 'lineHeight', 'justifyLeft', 'justifyRight', 'justifyCenter', 'justifyJustify', 'group-image', 'group-video', 'insertTable', 'headerSelect']"
/>
</div>
<NInput v-else v-model:value="questionForm.name" type="textarea" placeholder="请输入题目内容" :rows="3" />
</NFormItem>
<!-- <div class="grid grid-cols-2 gap-4">
<NFormItem label="参考分值 (Pts)">
<NInputNumber v-model:value="questionForm.score" class="w-full" :min="1" />
</NFormItem>
<NFormItem label="限时 (S)">
<NInputNumber v-model:value="questionForm.time" class="w-full" :min="1" />
</NFormItem>
</div> -->
<!-- 类型 -->
<!-- <NFormItem label="类型" path="type">
<NSelect v-model:value="questionForm.type" class="w-full" :options="questionTypeOptionsDict" />
</NFormItem> -->
<!-- 是否优先使用 -->
<NFormItem label="是否优先使用" path="IsGood">
<NRadioGroup v-model:value="questionForm.IsGood">
<NRadio :value="0">
</NRadio>
<NRadio :value="1">
</NRadio>
</NRadioGroup>
</NFormItem>
<!-- 是否禁用 -->
<NFormItem label="是否禁用" path="IsDisabled">
<NRadioGroup v-model:value="questionForm.IsDisabled">
<NRadio :value="false">
</NRadio>
<NRadio :value="true">
</NRadio>
</NRadioGroup>
</NFormItem>
<!-- 上传图片 -->
<NFormItem v-if="questionForm.type === QuestionType.Image" label="上传图片">
<NUpload
v-model:file-list="fileList" accept="image/*" :max="1" list-type="image-card"
:custom-request="customRequest" @change="handleUploadChange"
>
点击上传
</NUpload>
</NFormItem>
<NFormItem label="参考标准答案" path="answer">
<NInput v-model:value="questionForm.answer" placeholder="正确答案" />
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-3">
<NButton @click="showQuestionModal = false">
取消并返回
</NButton>
<NButton type="primary" @click="submitQuestion">
保存并入库
</NButton>
</div>
</template>
</NDrawerContent>
</NDrawer>
<QuestionDrawer
v-model:show="showQuestionModal"
:operation="questionOperation"
:question-data="currentQuestion"
:current-category="currentCategory"
@success="onDrawerSuccess"
/>
</div>
</template>

View File

@ -144,7 +144,75 @@ watch(
</div>
</div>
<!-- 模板ChangeWords: 换字组成语 -->
<div v-else-if="template === QuestionCategoryEnum.ChangeWords" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content" v-html="content" />
</div>
</div>
<!-- 模板: 场景猜诗句 -->
<div v-else-if="template === QuestionCategoryEnum.SceneBasedPoemGuessing" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content" v-html="content" />
</div>
</div>
<!-- 模板: 联想对对碰 -->
<div v-else-if="template === QuestionCategoryEnum.AssociationMatching" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content" v-html="content" />
</div>
</div>
<!-- 模板: 字词飞花令 -->
<div v-else-if="template === QuestionCategoryEnum.CharacterWordFlowerDrinkingGame" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content" v-html="content" />
</div>
</div>
<!-- 模板: 诗词常识 -->
<div v-else-if="template === QuestionCategoryEnum.PoetryCommonKnowledge" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content" v-html="content" />
</div>
</div>
<!-- 模板: 逆向接诗句 -->
<div v-else-if="template === QuestionCategoryEnum.ReversePoemMatching" class="w-full flex flex-col items-center">
<div class="options-container text-2xl">
{{ title }}
</div>
<div class="mb-1 text-3xl text-gray-800 font-bold leading-relaxed">
<div class="rich-content" v-html="content" />
</div>
</div>
<div v-else class="text-red-500">
<div class="mb-6 inline-block text-5xl font-bold leading-none">
<div class="ptions-container1 mb-2 text-center text-2xl">
{{ title }}
</div>
<div class="rich-content ml-3 mt-4 flex gap-1 color-red-600">
{{ content }}
</div>
</div>
未知的题目模板: {{ template }}
</div>
</div>