feat(admin): 新增题目类型、优化题目库管理并添加拼音助手
- 新增6种题目类型枚举:逆向接诗句、场景猜诗句、联想对对碰、字词飞花令、诗词常识、换字组成语 - 新增QuestionDrawer组件,支持富文本编辑和拼音转换功能 - 优化CategoryTree组件,增加分类选择本地存储和自动填充题目类型 - 更新question API接口,简化新增和更新题目库的参数传递 - 扩展QuestionRenderer组件,支持新增的题目类型渲染 - 添加多个题目库JSON文件,包含成语、诗词常识、字词飞花令等题目数据 - 调整环境配置,更新开发环境API地址
This commit is contained in:
@ -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. 换字组成语
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
12
apps/admin/src/typings/api/question.d.ts
vendored
12
apps/admin/src/typings/api/question.d.ts
vendored
@ -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 */
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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="分类描述">
|
||||
|
||||
282
apps/admin/src/views/question-store/modules/QuestionDrawer.vue
Normal file
282
apps/admin/src/views/question-store/modules/QuestionDrawer.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user