feat(字典管理): 新增字典管理功能及相关基础设施

- 新增字典管理页面,支持字典项的增删改查
- 添加 pinia-plugin-persistedstate 依赖,实现状态持久化
- 创建字典相关的 API 类型定义和服务接口
- 实现字典数据存储模块,支持字典数据的缓存和复用
- 添加 useDict 组合式函数,方便组件中使用字典数据
- 在题目分类和比赛创建模块中使用字典数据替代硬编码选项
- 优化分类树组件,添加拖拽手柄和题目类型选择
This commit is contained in:
2026-01-30 17:45:46 +08:00
parent b39761d58e
commit e1a19c48e0
21 changed files with 678 additions and 16 deletions

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { NButton, NModal, NStep, NSteps } from 'naive-ui'
import { computed, onMounted, ref, useTemplateRef, watch } from 'vue'
import { roundTypeOptions, scoreTypeOptions } from '@/constants/business'
import { useDict } from '@/hooks/business/useDict'
import { fetchCreateActivity, fetchCreateQuestion, fetchCreateTeamList, fetchGetRoomList } from '@/service/api/competition'
/** 房间相关接口 */
import BasicInfo from './modules/BasicInfo.vue'
@ -19,6 +19,9 @@ const emit = defineEmits<{
(e: 'success'): void
}>()
const { options: roundTypeOptions } = useDict('round_type')
const { options: scoreTypeOptions } = useDict('score_type')
const currentStep = ref<number>(1)
/** 房间列表 */
const roomListOptions = ref<{ label: string, value: number }[]>([])
@ -42,13 +45,13 @@ const competitionData = ref({
{
id: 'round_default',
name: '星·辞海遨游',
roundType: roundTypeOptions[0].value,
roundType: roundTypeOptions.value?.[0]?.value,
questions: Array.from({ length: 10 }).map(() => ({
value: null,
score: 5,
time: 30,
title: '第1题',
scoreType: scoreTypeOptions[0].value,
scoreType: scoreTypeOptions.value?.[0]?.value,
})),
},
],

View File

@ -2,7 +2,7 @@
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSelect, type SelectOption } from 'naive-ui'
import { onMounted, ref, watch } from 'vue'
import { VueDraggable } from 'vue-draggable-plus'
import { roundTypeOptions, scoreTypeOptions, timeOptions, uiTypeOptions } from '@/constants/business'
import { useDict } from '@/hooks/business/useDict'
import { fetchGetQuestionListAll } from '@/service/api/question'
import { fetchTemplateList } from '@/service/api/template'
import { useQuestionConfig } from './useQuestionConfig'
@ -26,6 +26,12 @@ const props = defineProps<{
}>
}
}>()
const { options: roundTypeOptions } = useDict('round_type')
const { options: scoreTypeOptions } = useDict('score_type')
const { options: timeOptions } = useDict('time_out')
const { options: uiTypeOptions } = useDict('ui_type')
const {
// getRoundScore,
getRoundTime,
@ -47,14 +53,14 @@ const showAddModal = ref(false)
const newRoundForm = ref({
name: '',
count: 10,
roundType: roundTypeOptions[0].value,
roundType: roundTypeOptions?.value?.[0].value || '',
})
function handleOpenAddModal() {
newRoundForm.value = {
name: `${(props.modelValue?.rounds?.length || 0) + 1}轮 星·诗词大会`,
count: 10,
roundType: roundTypeOptions[0].value,
roundType: roundTypeOptions?.value?.[0].value || '',
}
showAddModal.value = true
}

View File

@ -0,0 +1,218 @@
<script setup lang="ts">
import type { DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NForm, NFormItem, NInput, NModal, NPopconfirm, NSpace, useMessage } from 'naive-ui'
import { h, onMounted, ref } from 'vue'
import { addOrUpdateDictionary, deleteDictionary, getDictionaryList } from '@/service/api/dictionary'
import DictionaryTypeModal from './modules/DictionaryTypeModal.vue'
// 表格数据
const loading = ref(false)
const tableData = ref<Api.Dictionary.DictionaryItem[]>([])
// 字典类型模态框
const showTypeModal = ref(false)
const currentDictionaryId = ref(0)
function handleOpenTypeModal(row: Api.Dictionary.DictionaryItem) {
currentDictionaryId.value = row.Id
showTypeModal.value = true
}
// 模态框控制
const showModal = ref(false)
const modalTitle = ref('新增字典')
const formRef = ref()
const formData = ref<Api.Dictionary.AddOrUpdateParams>({
Id: 0,
DicKey: '',
DicValue: '',
bakValue: '',
})
// 表单验证规则
const rules = {
dicKey: { required: true, message: '请输入字典键', trigger: 'blur' },
dicValue: { required: true, message: '请输入字典值', trigger: 'blur' },
}
const message = useMessage()
// 获取列表数据
async function fetchData() {
loading.value = true
try {
const { data: res, error } = await getDictionaryList()
if (!error && res) {
tableData.value = res.data || []
}
}
catch (error) {
console.error(error)
}
finally {
loading.value = false
}
}
// 列定义
const columns: DataTableColumns<Api.Dictionary.DictionaryItem> = [
{ title: 'ID', key: 'Id' },
{
title: '字典键 (Key)',
key: 'DicKey',
render(row) {
return h(
NButton,
{
text: true,
type: 'primary',
onClick: () => handleOpenTypeModal(row),
},
{ default: () => row.DicKey },
)
},
},
{ title: '字典值 (Value)', key: 'DicValue' },
{ title: '备注/备份值', key: 'BakValue' },
{
title: '操作',
key: 'actions',
render(row) {
return h(NSpace, null, {
default: () => [
h(
NButton,
{
size: 'small',
type: 'primary',
onClick: () => handleEdit(row),
},
{ default: () => '编辑' },
),
h(
NPopconfirm,
{
onPositiveClick: () => handleDelete(row),
},
{
default: () => '确认删除该条数据吗?',
trigger: () => h(
NButton,
{
size: 'small',
type: 'error',
},
{ default: () => '删除' },
),
},
),
],
})
},
},
]
// 新增
function handleAdd() {
modalTitle.value = '新增字典'
formData.value = {
Id: 0,
DicKey: '',
DicValue: '',
bakValue: '',
}
showModal.value = true
}
// 编辑
function handleEdit(row: Api.Dictionary.DictionaryItem) {
modalTitle.value = '编辑字典'
formData.value = { ...row }
showModal.value = true
}
// 删除
async function handleDelete(row: Api.Dictionary.DictionaryItem) {
try {
await deleteDictionary(row.Id)
message.success('删除成功')
fetchData()
}
catch (error) {
console.error(error)
}
}
// 提交表单
function handleSubmit() {
formRef.value?.validate(async (errors: any) => {
if (!errors) {
try {
await addOrUpdateDictionary(formData.value)
message.success(formData.value.Id ? '更新成功' : '新增成功')
showModal.value = false
fetchData()
}
catch (error) {
console.error(error)
}
}
})
}
onMounted(() => {
fetchData()
})
</script>
<template>
<div class="h-full p-4">
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<!-- 这里可以放搜索框等 -->
</div>
<NButton type="primary" @click="handleAdd">
新增字典
</NButton>
</div>
<NDataTable
:columns="columns" :data="tableData" :loading="loading" :pagination="{ pageSize: 10 }"
:row-key="(row) => row.id" bordered class="h-[calc(100%-3rem)]"
/>
<DictionaryTypeModal
v-model:visible="showTypeModal"
:dictionary-id="currentDictionaryId"
/>
<NModal v-model:show="showModal" :title="modalTitle" preset="card" class="w-[600px]">
<NForm
ref="formRef" :model="formData" :rules="rules" label-placement="left" label-width="100"
require-mark-placement="right-hanging"
>
<NFormItem label="字典名称" path="DicKey">
<NInput v-model:value="formData.DicKey" placeholder="请输入字典名称" />
</NFormItem>
<NFormItem label="字典类型" path="DicValue">
<NInput v-model:value="formData.DicValue" placeholder="请输入字典值" type="textarea" />
</NFormItem>
<NFormItem label="备注" path="bakValue">
<NInput v-model:value="formData.bakValue" placeholder="请输入备注或备份值" />
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="showModal = false">
取消
</NButton>
<NButton type="primary" @click="handleSubmit">
确定
</NButton>
</NSpace>
</template>
</NModal>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,176 @@
<script setup lang="ts">
import type { DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NForm, NFormItem, NInput, NModal, NPopconfirm, NSpace, useMessage } from 'naive-ui'
import { h, ref, watch } from 'vue'
import { addOrUpdateDictionaryUIType, deleteDictionaryItem, getDictionaryListUITypeByDicID } from '@/service/api/dictionary'
const props = defineProps<{
visible: boolean
dictionaryId: number
}>()
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void
}>()
const loading = ref(false)
const tableData = ref<any[]>([])
const message = useMessage()
// Modal control for Add/Edit UI Type
const showEditModal = ref(false)
const modalTitle = ref('新增字典类型')
const formRef = ref()
const formData = ref<Api.Dictionary.AddOrUpdateDictionaryUITypeParams>({
id: 0,
uI_Key: '',
uI_Value: '',
bakValue: '',
dictionaryID: 0,
})
const rules = {
uI_Key: { required: true, message: '请输入键', trigger: 'blur' },
uI_Value: { required: true, message: '请输入值', trigger: 'blur' },
}
async function fetchData() {
if (!props.dictionaryId)
return
loading.value = true
try {
const { data: res, error } = await getDictionaryListUITypeByDicID(props.dictionaryId)
if (!error && res) {
tableData.value = res.data || []
}
}
catch (error) {
console.error(error)
}
finally {
loading.value = false
}
}
watch(() => props.visible, (val) => {
if (val) {
fetchData()
}
})
const columns: DataTableColumns<any> = [
{ title: 'ID', key: 'Id', width: 80 },
{ title: '键 (Key)', key: 'UI_Key' },
{ title: '值 (Value)', key: 'UI_Value' },
{ title: '备注', key: 'BakValue' },
{
title: '操作',
key: 'actions',
width: 150,
render(row) {
return h(NSpace, null, {
default: () => [
h(NButton, { size: 'small', type: 'primary', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
h(
NPopconfirm,
{ onPositiveClick: () => handleDelete(row) },
{
default: () => '确认删除?',
trigger: () => h(NButton, { size: 'small', type: 'error' }, { default: () => '删除' }),
},
),
],
})
},
},
]
function handleAdd() {
modalTitle.value = '新增字典类型'
formData.value = {
id: 0,
uI_Key: '',
uI_Value: '',
bakValue: '',
dictionaryID: props.dictionaryId,
}
showEditModal.value = true
}
function handleEdit(row: any) {
modalTitle.value = '编辑字典类型'
const { BakValue, ID, UI_Key, UI_Value } = row
formData.value = { ...row, bakValue: BakValue, id: ID, uI_Key: UI_Key, uI_Value: UI_Value, dictionaryID: props.dictionaryId }
showEditModal.value = true
}
async function handleDelete(row: any) {
try {
const { error } = await deleteDictionaryItem(row.ID)
if (!error) {
message.success('删除成功')
fetchData()
}
}
catch (e) {
console.error(e)
}
}
function handleSubmit() {
formRef.value?.validate(async (errors: any) => {
if (!errors) {
try {
const { error } = await addOrUpdateDictionaryUIType(formData.value)
if (!error) {
message.success('操作成功')
showEditModal.value = false
fetchData()
}
}
catch (e) {
console.error(e)
}
}
})
}
function handleClose() {
emit('update:visible', false)
}
</script>
<template>
<NModal :show="visible" preset="card" title="字典类型管理" class="w-[800px]" @update:show="handleClose">
<div class="mb-4">
<NButton type="primary" @click="handleAdd">
新增类型
</NButton>
</div>
<NDataTable :columns="columns" :data="tableData" :loading="loading" bordered :row-key="(row) => row.id" />
<NModal v-model:show="showEditModal" :title="modalTitle" preset="card" class="w-[500px]">
<NForm ref="formRef" :model="formData" :rules="rules" label-placement="left" label-width="80">
<NFormItem label="" path="uI_Key">
<NInput v-model:value="formData.uI_Key" placeholder="请输入键" />
</NFormItem>
<NFormItem label="" path="uI_Value">
<NInput v-model:value="formData.uI_Value" placeholder="请输入值" />
</NFormItem>
<NFormItem label="备注" path="bakValue">
<NInput v-model:value="formData.bakValue" placeholder="请输入备注" />
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="showEditModal = false">
取消
</NButton>
<NButton type="primary" @click="handleSubmit">
确定
</NButton>
</NSpace>
</template>
</NModal>
</NModal>
</template>

View File

@ -50,10 +50,10 @@ function onMouseUp() {
<div class="h-full flex overflow-hidden rounded-2xl bg-white shadow-sm">
<!-- 分类树区域 -->
<div class="relative h-full flex-shrink-0" :style="{ width: `${siderWidth}px` }">
<CategoryTree @update:category="val => currentCategory = val" />
<CategoryTree @update:category="val => currentCategory = val" @drag-start="onMouseDown" />
<!-- 分类树宽度调整手柄 -->
<div
class="absolute right-0 top-0 z-10 h-full w-1 cursor-col-resize transition-colors active:bg-primary/40 hover:bg-primary/20"
class="drag-handle absolute right-0 top-0 z-10 h-full w-1 cursor-col-resize transition-colors active:bg-primary/40 hover:bg-primary/20"
@mousedown="onMouseDown"
/>
</div>

View File

@ -10,12 +10,16 @@ import {
} from 'naive-ui'
import { onMounted, ref, watch } from 'vue'
import SvgIcon from '@/components/custom/svg-icon.vue'
import { useDict } from '@/hooks/business/useDict'
import { fetchAddQuestion, fetchDeleteQuestion, fetchGetQuestionListAll, fetchUpdateQuestion } from '@/service/api/question'
const emit = defineEmits<{
(e: 'update:category', node: any): void
(e: 'dragStart', event: MouseEvent): void
}>()
const { options: questionTypeOptions } = useDict('question_type')
const message = useMessage()
const dialog = useDialog()
@ -30,9 +34,8 @@ const categoryList = ref<CategoryItem[]>([])
const selectedKey = ref<number | null>(null)
// Modal State
const showCategoryModal = ref(false)
const categoryForm = ref({ questionContent: '', name: '', Id: 0 })
const categoryForm = ref({ questionContent: '', name: '', Id: 0, questionType: 'SingleChoice' })
const categoryOperation = ref<'add' | 'edit'>('add')
const currentOperationNode = ref<CategoryItem | null>(null)
@ -170,7 +173,7 @@ onMounted(() => {
</script>
<template>
<div class="h-full flex flex-col border-r border-gray-100 bg-gray-50/30">
<div class="drag-handle relative h-full flex flex-col border-r border-gray-100 bg-gray-50/30">
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-4">
<span class="text-lg text-gray-700 font-bold">题目分类</span>
<NButton size="tiny" secondary type="primary" @click="handleAddCategory">
@ -227,9 +230,15 @@ onMounted(() => {
class="w-[500px]"
>
<NForm>
<!-- 分类显示名称 -->
<NFormItem label="分类名称">
<NInput v-model:value="categoryForm.name" placeholder="请输入分类名称" @keyup.enter="submitCategory" />
</NFormItem>
<!-- 题目类型 -->
<NFormItem label="题目类型">
<NSelect v-model:value="categoryForm.questionType" :options="questionTypeOptions" placeholder="请选择题目类型" />
</NFormItem>
<!-- 分类描述 -->
<NFormItem label="分类描述">
<NInput v-model:value="categoryForm.questionContent" placeholder="请输入分类描述" @keyup.enter="submitCategory" />
</NFormItem>
@ -245,9 +254,13 @@ onMounted(() => {
</div>
</template>
</NModal>
<!-- 拖动把手图标 -->
<div
class="absolute right-0 top-1/2 z-50 h-8 w-4 flex translate-x-1/2 cursor-col-resize items-center justify-center border border-gray-200 rounded-full bg-white shadow-sm transition-all -translate-y-1/2 hover:scale-110 hover:bg-gray-50"
@mousedown="(e) => emit('dragStart', e)"
>
<SvgIcon icon="carbon:draggable" class="text-[10px] text-gray-400" />
</div>
</div>
</template>
<style scoped>
/* No specific styles needed as we use Tailwind classes */
</style>