feat(admin): 新增题库、排行榜、实时结果和模板管理功能
- 添加题库管理页面及相关组件 - 实现排行榜管理功能,包括列表和详情页 - 新增实时结果展示页面 - 添加模板制作和管理功能 - 完善路由配置和国际化支持 - 新增阿里云OSS文件上传服务 - 添加多种工具函数和类型定义 - 优化表格和表单组件 - 调整布局和样式细节
This commit is contained in:
63
apps/admin/src/views/question-store/index.vue
Normal file
63
apps/admin/src/views/question-store/index.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import CategoryTree from './modules/CategoryTree.vue'
|
||||
import QuestionList from './modules/QuestionList.vue'
|
||||
|
||||
defineOptions({ name: 'QuestionStore' })
|
||||
|
||||
const currentCategory = ref<any>(null)
|
||||
|
||||
// Resize logic
|
||||
const siderWidth = ref(450)
|
||||
const minWidth = 300
|
||||
const maxWidth = 800
|
||||
const isDragging = ref(false)
|
||||
const startX = ref(0)
|
||||
const startWidth = ref(0)
|
||||
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
isDragging.value = true
|
||||
startX.value = e.clientX
|
||||
startWidth.value = siderWidth.value
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.body.style.userSelect = 'none'
|
||||
document.body.style.cursor = 'col-resize'
|
||||
}
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
const dx = e.clientX - startX.value
|
||||
const newWidth = startWidth.value + dx
|
||||
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
siderWidth.value = newWidth
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isDragging.value = false
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.body.style.userSelect = ''
|
||||
document.body.style.cursor = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex overflow-hidden rounded-2xl bg-white shadow-sm">
|
||||
<!-- 分类树区域 -->
|
||||
<div class="h-full flex-shrink-0 relative" :style="{ width: `${siderWidth}px` }">
|
||||
<CategoryTree @update:category="val => currentCategory = val" />
|
||||
<!-- 分类树宽度调整手柄 -->
|
||||
<div
|
||||
class="absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/40 transition-colors z-10"
|
||||
@mousedown="onMouseDown"></div>
|
||||
</div>
|
||||
<!-- 题目列表区域 -->
|
||||
<div class="h-full flex-1 overflow-hidden">
|
||||
<QuestionList :current-category="currentCategory" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
380
apps/admin/src/views/question-store/modules/CategoryTree.vue
Normal file
380
apps/admin/src/views/question-store/modules/CategoryTree.vue
Normal file
@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, h, watch } from 'vue'
|
||||
import {
|
||||
NButton,
|
||||
NTree,
|
||||
NModal,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
useMessage,
|
||||
useDialog,
|
||||
type TreeOption
|
||||
} from 'naive-ui'
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:category', node: any): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
// Mock Data - 模拟分类树数据
|
||||
const treeData = ref<TreeOption[]>([
|
||||
{
|
||||
key: 'stage-1',
|
||||
label: '汉字听写',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-1',
|
||||
label: '请根据提示书写正确的汉字。',
|
||||
isLeaf: true,
|
||||
},
|
||||
{
|
||||
key: 'q-type-2',
|
||||
label: '请根据拼音书写同音字。',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'stage-2',
|
||||
label: '汉字加一加',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-3',
|
||||
label: '请写出含有“”的汉字。',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'poem',
|
||||
label: '词语大比拼',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-4',
|
||||
label: '词语听写',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-4-1',
|
||||
label: '请根据拼音书写正确的词语。',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'q-type-5',
|
||||
label: '成语写一写',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-5-1',
|
||||
label: '请写出含有反义字的四字成语。',
|
||||
isLeaf: true,
|
||||
},
|
||||
{
|
||||
key: 'q-type-5-2',
|
||||
label: '请根据图片书写正确的成语',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'stage-extra-1',
|
||||
label: '换字组成语',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-extra-1',
|
||||
label: '请换一个字,组成新的成语。',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const expandedKeys = ref<string[]>(['root', 'stage-1'])
|
||||
|
||||
// Modal State
|
||||
const showCategoryModal = ref(false)
|
||||
const categoryModalType = ref<1 | 2>(1) // 1级或2级分类
|
||||
const categoryForm = ref({ name: '' })
|
||||
|
||||
// 记录当前操作类型:add-root, add-child, edit
|
||||
const categoryOperation = ref<'add-root' | 'add-child' | 'edit'>('add-root')
|
||||
// 记录当前操作的目标节点(编辑时为该节点,添加子节点时为父节点)
|
||||
const currentOperationNode = ref<TreeOption | null>(null)
|
||||
|
||||
// 递归查找节点
|
||||
function findNodeByKey(key: string, nodes: TreeOption[]): TreeOption | null {
|
||||
for (const node of nodes) {
|
||||
if (node.key === key)
|
||||
return node
|
||||
if (node.children) {
|
||||
const found = findNodeByKey(key, node.children)
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
watch(selectedKeys, (newKeys) => {
|
||||
if (newKeys.length === 0) {
|
||||
emit('update:category', null)
|
||||
return
|
||||
}
|
||||
const key = newKeys[0]
|
||||
const node = findNodeByKey(key, treeData.value)
|
||||
if (node) {
|
||||
emit('update:category', {
|
||||
label: node.label,
|
||||
level: node.isLeaf ? 3 : 1,
|
||||
key: node.key,
|
||||
isLeaf: node.isLeaf,
|
||||
children: node.children
|
||||
})
|
||||
} else {
|
||||
emit('update:category', null)
|
||||
}
|
||||
})
|
||||
|
||||
// 打开新增根节点弹窗
|
||||
function handleAddRootCategory() {
|
||||
categoryOperation.value = 'add-root'
|
||||
categoryModalType.value = 1
|
||||
categoryForm.value.name = ''
|
||||
currentOperationNode.value = null
|
||||
showCategoryModal.value = true
|
||||
}
|
||||
|
||||
// 打开新增子节点弹窗
|
||||
function handleAddChildCategory(parentNode: TreeOption) {
|
||||
categoryOperation.value = 'add-child'
|
||||
categoryModalType.value = 2 // 视为下一级
|
||||
categoryForm.value.name = ''
|
||||
currentOperationNode.value = parentNode
|
||||
showCategoryModal.value = true
|
||||
}
|
||||
|
||||
// 打开编辑节点弹窗
|
||||
function handleEditCategory(node: TreeOption) {
|
||||
categoryOperation.value = 'edit'
|
||||
categoryForm.value.name = node.label as string
|
||||
currentOperationNode.value = node
|
||||
showCategoryModal.value = true
|
||||
}
|
||||
|
||||
// 递归删除节点 如果节点有子节点,无法删除
|
||||
function deleteNode(nodes: TreeOption[], key: string | number): boolean {
|
||||
const index = nodes.findIndex(n => n.key === key)
|
||||
if (index !== -1) {
|
||||
// 检查是否有子节点
|
||||
if (nodes[index].children && nodes[index].children.length > 0) {
|
||||
throw new Error('该分类下有子分类,无法删除')
|
||||
}
|
||||
nodes.splice(index, 1)
|
||||
return true
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (node.children) {
|
||||
if (deleteNode(node.children, key))
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function handleDeleteCategory(node: TreeOption) {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: `确定要删除分类 "${node.label}" 吗?此操作无法撤销。`,
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
try {
|
||||
deleteNode(treeData.value, node.key!)
|
||||
// 如果删除的是当前选中的节点,清空选中
|
||||
if (selectedKeys.value.includes(node.key as string)) {
|
||||
selectedKeys.value = []
|
||||
}
|
||||
message.success('删除成功')
|
||||
}
|
||||
catch (error: any) {
|
||||
message.error(error.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function submitCategory() {
|
||||
if (!categoryForm.value.name) {
|
||||
message.error('请输入分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (categoryOperation.value === 'add-root') {
|
||||
// 新增根节点
|
||||
const newKey = `root-${Date.now()}`
|
||||
treeData.value.push({
|
||||
key: newKey,
|
||||
label: categoryForm.value.name,
|
||||
children: [],
|
||||
})
|
||||
message.success('分类添加成功')
|
||||
}
|
||||
else if (categoryOperation.value === 'add-child' && currentOperationNode.value) {
|
||||
// 新增子节点
|
||||
if (!currentOperationNode.value.children) {
|
||||
currentOperationNode.value.children = []
|
||||
}
|
||||
// 确保父节点不再是叶子节点,否则无法展开显示子节点
|
||||
if (currentOperationNode.value.isLeaf) {
|
||||
currentOperationNode.value.isLeaf = false
|
||||
}
|
||||
const newKey = `node-${Date.now()}`
|
||||
// 如果是第三级(叶子),标记 isLeaf
|
||||
currentOperationNode.value.children.push({
|
||||
key: newKey,
|
||||
label: categoryForm.value.name,
|
||||
isLeaf: true,
|
||||
})
|
||||
// 展开父节点
|
||||
if (!expandedKeys.value.includes(currentOperationNode.value.key as string)) {
|
||||
expandedKeys.value.push(currentOperationNode.value.key as string)
|
||||
}
|
||||
message.success('子分类添加成功')
|
||||
}
|
||||
else if (categoryOperation.value === 'edit' && currentOperationNode.value) {
|
||||
// 编辑节点
|
||||
currentOperationNode.value.label = categoryForm.value.name
|
||||
message.success('分类修改成功')
|
||||
}
|
||||
|
||||
showCategoryModal.value = false
|
||||
}
|
||||
|
||||
// Tree Rendering
|
||||
function renderPrefix({ option }: { option: TreeOption }) {
|
||||
// 根据层级或类型显示不同图标
|
||||
if (option.children && option.children.length > 0) {
|
||||
return h(SvgIcon, { icon: 'carbon:folder', class: 'text-gray-400 text-lg' })
|
||||
}
|
||||
// 如果明确标记为叶子节点,或者没有 children
|
||||
return h(SvgIcon, { icon: 'carbon:document', class: 'text-gray-400 text-lg' })
|
||||
}
|
||||
|
||||
function renderSuffix({ option }: { option: TreeOption }) {
|
||||
// 悬浮时显示操作按钮
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: 'flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
onClick: (e: Event) => e.stopPropagation(),
|
||||
},
|
||||
[
|
||||
h('div', {
|
||||
class: 'text-gray-400 hover:text-blue-500 cursor-pointer flex items-center',
|
||||
title: '编辑',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleEditCategory(option)
|
||||
},
|
||||
}, [h(SvgIcon, { icon: 'carbon:edit', class: 'text-lg' })]),
|
||||
// 允许所有节点添加子节点,如果添加了子节点,它就变成文件夹
|
||||
h('div', {
|
||||
class: 'text-gray-400 hover:text-green-500 cursor-pointer flex items-center',
|
||||
title: '添加子分类',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleAddChildCategory(option)
|
||||
},
|
||||
}, [h(SvgIcon, { icon: 'carbon:add', class: 'text-lg' })]),
|
||||
h('div', {
|
||||
class: 'text-gray-400 hover:text-red-500 cursor-pointer flex items-center',
|
||||
title: '删除',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteCategory(option)
|
||||
},
|
||||
}, [h(SvgIcon, { icon: 'carbon:trash-can', class: 'text-lg' })]),
|
||||
],
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="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="handleAddRootCategory">
|
||||
<template #icon>
|
||||
<SvgIcon icon="ic:baseline-add" class="text-icon" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
<NTree block-line :data="treeData" :selected-keys="selectedKeys" :expanded-keys="expandedKeys"
|
||||
:render-prefix="renderPrefix" :render-suffix="renderSuffix" selectable expand-on-click class="px-2"
|
||||
@update:selected-keys="(keys) => (selectedKeys = keys)"
|
||||
@update:expanded-keys="(keys) => (expandedKeys = keys)" />
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 bg-gray-50 p-3 text-xs text-gray-400">
|
||||
提示:请勿随意删除维护原有分类,三级分类为最终题目目录。
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Category Modal -->
|
||||
<NModal v-model:show="showCategoryModal" preset="card"
|
||||
:title="categoryOperation === 'edit' ? '编辑分类' : `新增 ${categoryModalType} 级分类`" class="w-[500px]">
|
||||
<NForm>
|
||||
<NFormItem label="分类显示名称">
|
||||
<NInput v-model:value="categoryForm.name" placeholder="请输入内容" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<NButton @click="showCategoryModal = false">
|
||||
取消操作
|
||||
</NButton>
|
||||
<NButton type="primary" @click="submitCategory">
|
||||
确认提交
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-tree-node-content__text) {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node--selected) {
|
||||
background-color: #eff6ff !important;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node--selected .n-tree-node-content__text) {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Ensure icons in tree are visible on hover */
|
||||
:deep(.n-tree-node-content:hover .group-hover\:opacity-100) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom group class for tree node content wrapper to handle hover state */
|
||||
:deep(.n-tree-node-content) {
|
||||
@apply group;
|
||||
}
|
||||
</style>
|
||||
329
apps/admin/src/views/question-store/modules/QuestionList.vue
Normal file
329
apps/admin/src/views/question-store/modules/QuestionList.vue
Normal file
@ -0,0 +1,329 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
NBreadcrumb,
|
||||
NBreadcrumbItem,
|
||||
NButton,
|
||||
NCard,
|
||||
NEmpty,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NDrawer,
|
||||
NDrawerContent,
|
||||
NTag,
|
||||
|
||||
} from 'naive-ui'
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue'
|
||||
import RestBasicEditor from '@/components/common/rest-basic-editor/rest-basic-editor.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
currentCategory: any
|
||||
}>()
|
||||
|
||||
// Mock Data - 模拟题目列表数据
|
||||
const questionList = ref([
|
||||
{
|
||||
id: 'Q-0325',
|
||||
categoryKey: 'q-type-1',
|
||||
content: 'jiū 表示小鸟的叫声',
|
||||
answer: '碧',
|
||||
score: 10,
|
||||
time: 30,
|
||||
},
|
||||
{
|
||||
id: 'Q-0326',
|
||||
categoryKey: 'q-type-1',
|
||||
content: '“春色满园关不住,一枝红杏出墙来”。请书写“杏”字。',
|
||||
answer: '杏',
|
||||
score: 10,
|
||||
time: 30,
|
||||
},
|
||||
{
|
||||
id: 'Q-0327',
|
||||
categoryKey: 'q-type-1',
|
||||
content: '“春色满园关不住,一枝红杏出墙来”。请书写“㽱”字。',
|
||||
answer: '㽱',
|
||||
score: 10,
|
||||
time: 30,
|
||||
},
|
||||
{
|
||||
id: 'Q-0328',
|
||||
categoryKey: 'q-type-1',
|
||||
content: '“春色满园关不住,一枝红杏出墙来”。请书写“不”字。',
|
||||
answer: '不',
|
||||
score: 10,
|
||||
time: 30,
|
||||
},
|
||||
{
|
||||
id: 'Q-0329',
|
||||
categoryKey: 'q-type-2',
|
||||
content: 'táng',
|
||||
answer: '糖',
|
||||
score: 10,
|
||||
time: 30,
|
||||
},
|
||||
{
|
||||
id: 'Q-0330',
|
||||
categoryKey: 'q-type-2',
|
||||
content: 'lái',
|
||||
answer: '莱',
|
||||
score: 10,
|
||||
time: 30,
|
||||
},
|
||||
{
|
||||
id: 'Q-0331',
|
||||
categoryKey: 'q-type-3',
|
||||
content: '请写出含有“木”字的汉字。',
|
||||
answer: '林',
|
||||
score: 10,
|
||||
time: 30,
|
||||
},
|
||||
])
|
||||
|
||||
const searchText = ref('')
|
||||
const showQuestionModal = ref(false)
|
||||
const questionOperation = ref<'add' | 'edit'>('add')
|
||||
const currentQuestionId = ref<string | null>(null)
|
||||
const questionForm = ref({
|
||||
content: '',
|
||||
answer: '',
|
||||
score: 10,
|
||||
time: 30,
|
||||
})
|
||||
|
||||
const isLeafSelected = computed(() => {
|
||||
if (!props.currentCategory)
|
||||
return false
|
||||
return !!props.currentCategory.isLeaf || (props.currentCategory.children && props.currentCategory.children.length === 0 && props.currentCategory.level === 3)
|
||||
})
|
||||
|
||||
const filteredQuestions = computed(() => {
|
||||
let list = questionList.value
|
||||
|
||||
// 1. Filter by Category Key
|
||||
if (props.currentCategory && props.currentCategory.key) {
|
||||
list = list.filter(q => q.categoryKey === props.currentCategory.key)
|
||||
}
|
||||
|
||||
// 2. Filter by Search Text
|
||||
if (searchText.value) {
|
||||
list = list.filter(q => q.content.includes(searchText.value))
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
function handleAddQuestion() {
|
||||
if (!props.currentCategory) {
|
||||
window.$message?.warning('请先选择一个分类')
|
||||
return
|
||||
}
|
||||
questionOperation.value = 'add'
|
||||
currentQuestionId.value = null
|
||||
questionForm.value = { content: '', answer: '', score: 10, time: 30 }
|
||||
showQuestionModal.value = true
|
||||
}
|
||||
|
||||
function handleEditQuestion(id: string) {
|
||||
const question = questionList.value.find(q => q.id === id)
|
||||
if (question) {
|
||||
questionOperation.value = 'edit'
|
||||
currentQuestionId.value = id
|
||||
questionForm.value = {
|
||||
content: question.content,
|
||||
answer: question.answer,
|
||||
score: question.score,
|
||||
time: question.time,
|
||||
}
|
||||
showQuestionModal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteQuestion(id: string) {
|
||||
window.$dialog?.warning({
|
||||
title: '警告',
|
||||
content: '确定要删除这道题目吗?此操作无法撤销。',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
const index = questionList.value.findIndex(q => q.id === id)
|
||||
if (index !== -1) {
|
||||
questionList.value.splice(index, 1)
|
||||
window.$message?.success('删除成功')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function submitQuestion() {
|
||||
if (!questionForm.value.content || !questionForm.value.answer) {
|
||||
window.$message?.error('请填写完整信息')
|
||||
return
|
||||
}
|
||||
|
||||
if (questionOperation.value === 'add') {
|
||||
// Mock add
|
||||
questionList.value.push({
|
||||
id: `Q-${Math.floor(Math.random() * 10000)}`,
|
||||
...questionForm.value,
|
||||
categoryKey: props.currentCategory?.key // Add categoryKey
|
||||
})
|
||||
window.$message?.success('题目添加成功')
|
||||
}
|
||||
else if (questionOperation.value === 'edit' && currentQuestionId.value) {
|
||||
const index = questionList.value.findIndex(q => q.id === currentQuestionId.value)
|
||||
if (index !== -1) {
|
||||
questionList.value[index] = {
|
||||
...questionList.value[index],
|
||||
...questionForm.value,
|
||||
}
|
||||
window.$message?.success('题目修改成功')
|
||||
}
|
||||
}
|
||||
showQuestionModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col flex-1 overflow-hidden bg-white">
|
||||
<!-- 分类面包屑导航 -->
|
||||
<div class="flex flex-col gap-4 border-b border-gray-100 px-6 py-4">
|
||||
<NBreadcrumb>
|
||||
<NBreadcrumbItem>题库全集</NBreadcrumbItem>
|
||||
<NBreadcrumbItem v-if="currentCategory && currentCategory.level === 1">
|
||||
{{ currentCategory.label }}
|
||||
</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="m-0 text-xl text-gray-800 font-bold">
|
||||
{{ currentCategory ? currentCategory.label : '' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div v-if="currentCategory && currentCategory.level === 3" class="mt-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<NInput v-model:value="searchText" placeholder="输入题目关键字在当前分类下搜索..." class="!w-80">
|
||||
<template #prefix>
|
||||
<SvgIcon icon="carbon:search" class="text-gray-400" />
|
||||
</template>
|
||||
</NInput>
|
||||
</div>
|
||||
|
||||
<NButton type="primary" @click="handleAddQuestion">
|
||||
<template #icon>
|
||||
<SvgIcon icon="carbon:add" />
|
||||
</template>
|
||||
新增题目
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 题目列表区域 -->
|
||||
<div class="flex-1 overflow-y-auto bg-gray-50/50 p-6">
|
||||
<template v-if="!isLeafSelected">
|
||||
<div class="h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<NEmpty description="暂无数据">
|
||||
<template #extra>
|
||||
请从左侧选择一个最后一级(三级)分类以管理题目数据
|
||||
</template>
|
||||
</NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="filteredQuestions.length === 0">
|
||||
<div class="mt-20 flex justify-center">
|
||||
<NEmpty description="该分类下暂无题目" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 题目列表 -->
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<NCard v-for="q in filteredQuestions" :key="q.id" size="small" hoverable class="rounded-xl">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<NTag size="small" type="primary" :bordered="false">
|
||||
ID: {{ q.id }}
|
||||
</NTag>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="text-xs text-gray-400 font-mono">{{ q.score }} PTS / {{ q.time }} S</span>
|
||||
</template>
|
||||
|
||||
<div class="py-2 text-base text-gray-700 font-medium">
|
||||
{{ q.content }}
|
||||
</div>
|
||||
|
||||
<!-- 标准答案 -->
|
||||
<div class="mt-3 border border-green-100 rounded-lg bg-green-50 p-3">
|
||||
<div class="mb-1 text-xs text-green-600 font-bold tracking-wider uppercase">
|
||||
STANDARD ANSWER
|
||||
</div>
|
||||
<div class="text-green-800 font-bold">
|
||||
{{ q.answer }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<template #action>
|
||||
<div class="flex justify-end gap-2">
|
||||
<NButton size="tiny" quaternary type="primary" @click="handleEditQuestion(q.id)">
|
||||
<template #icon>
|
||||
<SvgIcon icon="carbon:edit" />
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary type="error" @click="handleDeleteQuestion(q.id)">
|
||||
<template #icon>
|
||||
<SvgIcon icon="carbon:trash-can" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add Question Drawer -->
|
||||
<NDrawer v-model:show="showQuestionModal" :width="800">
|
||||
<NDrawerContent :title="questionOperation === 'edit' ? '编辑题目详情' : '新增题目详情'">
|
||||
<NForm label-placement="top">
|
||||
<NFormItem label="题目正文内容">
|
||||
<div class="w-full border border-gray-200 rounded-lg overflow-hidden">
|
||||
<!-- <RestBasicEditor v-model="questionForm.content" /> -->
|
||||
<NInput type="textarea" v-model:value="questionForm.content" class="w-full h-64" />
|
||||
</div>
|
||||
</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="参考标准答案">
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,548 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { TreeOption } from 'naive-ui'
|
||||
import {
|
||||
NBreadcrumb,
|
||||
NBreadcrumbItem,
|
||||
NButton,
|
||||
NCard,
|
||||
NEmpty,
|
||||
NForm,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
NModal,
|
||||
NTag,
|
||||
NTree,
|
||||
useDialog,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, ref } from 'vue'
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue'
|
||||
|
||||
// Mock Data - 模拟分类树数据
|
||||
const treeData = ref<TreeOption[]>([
|
||||
{
|
||||
key: 'root',
|
||||
label: '汉字听写大赛',
|
||||
children: [
|
||||
{
|
||||
key: 'stage-1',
|
||||
label: '第一阶段:基础训练',
|
||||
children: [
|
||||
{
|
||||
key: 'q-type-1',
|
||||
label: '根据提示书写汉字',
|
||||
isLeaf: true,
|
||||
},
|
||||
{
|
||||
key: 'q-type-2',
|
||||
label: '根据拼音书写汉字',
|
||||
isLeaf: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'stage-2',
|
||||
label: '第二阶段:进阶比拼',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'poem',
|
||||
label: '古诗词大会',
|
||||
children: [],
|
||||
},
|
||||
])
|
||||
|
||||
// Mock Data - 模拟题目列表数据
|
||||
const questionList = ref([
|
||||
{
|
||||
id: 'Q-0325',
|
||||
content: '“接天莲叶无穷碧,映日荷花别样红”。请书写“碧”字。',
|
||||
answer: '碧',
|
||||
score: 10,
|
||||
time: 30,
|
||||
},
|
||||
{
|
||||
id: 'Q-0326',
|
||||
content: '“春色满园关不住,一枝红杏出墙来”。请书写“杏”字。',
|
||||
answer: '杏',
|
||||
score: 10,
|
||||
time: 30,
|
||||
},
|
||||
])
|
||||
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const expandedKeys = ref<string[]>(['root', 'stage-1'])
|
||||
const searchText = ref('')
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
// Modal State
|
||||
const showCategoryModal = ref(false)
|
||||
const categoryModalType = ref<1 | 2>(1) // 1级或2级分类
|
||||
const categoryForm = ref({ name: '' })
|
||||
|
||||
// 记录当前操作类型:add-root, add-child, edit
|
||||
const categoryOperation = ref<'add-root' | 'add-child' | 'edit'>('add-root')
|
||||
// 记录当前操作的目标节点(编辑时为该节点,添加子节点时为父节点)
|
||||
const currentOperationNode = ref<TreeOption | null>(null)
|
||||
|
||||
const showQuestionModal = ref(false)
|
||||
const questionForm = ref({
|
||||
content: '',
|
||||
answer: '',
|
||||
score: 10,
|
||||
time: 30,
|
||||
})
|
||||
|
||||
// 递归查找节点
|
||||
function findNodeByKey(key: string, nodes: TreeOption[]): TreeOption | null {
|
||||
for (const node of nodes) {
|
||||
if (node.key === key)
|
||||
return node
|
||||
if (node.children) {
|
||||
const found = findNodeByKey(key, node.children)
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const currentCategory = computed(() => {
|
||||
if (!selectedKeys.value.length)
|
||||
return null
|
||||
const key = selectedKeys.value[0]
|
||||
const node = findNodeByKey(key, treeData.value)
|
||||
if (node) {
|
||||
// 假设没有 children 的就是叶子节点,level 简单判定为 3 (实际应该根据深度)
|
||||
// 这里为了兼容之前的逻辑,如果有 isLeaf 属性则视为 3 级
|
||||
return {
|
||||
label: node.label,
|
||||
level: node.isLeaf ? 3 : 1, // 简化逻辑,仅用于显示和判断是否可添加题目
|
||||
key: node.key,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const isLeafSelected = computed(() => {
|
||||
if (!currentCategory.value)
|
||||
return false
|
||||
// 查找实际节点判断是否有 children
|
||||
const node = findNodeByKey(currentCategory.value.key as string, treeData.value)
|
||||
return !!node?.isLeaf || (node?.children && node.children.length === 0 && currentCategory.value.level === 3)
|
||||
})
|
||||
|
||||
const filteredQuestions = computed(() => {
|
||||
if (!searchText.value)
|
||||
return questionList.value
|
||||
return questionList.value.filter(q => q.content.includes(searchText.value))
|
||||
})
|
||||
|
||||
// 打开新增根节点弹窗
|
||||
function handleAddRootCategory() {
|
||||
categoryOperation.value = 'add-root'
|
||||
categoryModalType.value = 1
|
||||
categoryForm.value.name = ''
|
||||
currentOperationNode.value = null
|
||||
showCategoryModal.value = true
|
||||
}
|
||||
|
||||
// 打开新增子节点弹窗
|
||||
function handleAddChildCategory(parentNode: TreeOption) {
|
||||
categoryOperation.value = 'add-child'
|
||||
categoryModalType.value = 2 // 视为下一级
|
||||
categoryForm.value.name = ''
|
||||
currentOperationNode.value = parentNode
|
||||
showCategoryModal.value = true
|
||||
}
|
||||
|
||||
// 打开编辑节点弹窗
|
||||
function handleEditCategory(node: TreeOption) {
|
||||
categoryOperation.value = 'edit'
|
||||
categoryForm.value.name = node.label as string
|
||||
currentOperationNode.value = node
|
||||
showCategoryModal.value = true
|
||||
}
|
||||
|
||||
// 递归删除节点 如果节点有子节点,无法删除
|
||||
function deleteNode(nodes: TreeOption[], key: string | number): boolean {
|
||||
const index = nodes.findIndex(n => n.key === key)
|
||||
if (index !== -1) {
|
||||
// 检查是否有子节点
|
||||
if (nodes[index].children && nodes[index].children.length > 0) {
|
||||
// message.warning('该分类下有子分类,无法删除')
|
||||
throw new Error('该分类下有子分类,无法删除')
|
||||
}
|
||||
nodes.splice(index, 1)
|
||||
return true
|
||||
}
|
||||
for (const node of nodes) {
|
||||
if (node.children) {
|
||||
if (deleteNode(node.children, key))
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function handleDeleteCategory(node: TreeOption) {
|
||||
dialog.warning({
|
||||
title: '警告',
|
||||
content: `确定要删除分类 "${node.label}" 吗?此操作无法撤销。`,
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => {
|
||||
try {
|
||||
deleteNode(treeData.value, node.key!)
|
||||
// 如果删除的是当前选中的节点,清空选中
|
||||
if (selectedKeys.value.includes(node.key as string)) {
|
||||
selectedKeys.value = []
|
||||
}
|
||||
message.success('删除成功')
|
||||
}
|
||||
catch (error: any) {
|
||||
message.error(error.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleAddQuestion() {
|
||||
if (!currentCategory.value) {
|
||||
message.warning('请先选择一个分类')
|
||||
return
|
||||
}
|
||||
questionForm.value = { content: '', answer: '', score: 10, time: 30 }
|
||||
showQuestionModal.value = true
|
||||
}
|
||||
|
||||
function submitCategory() {
|
||||
if (!categoryForm.value.name) {
|
||||
message.error('请输入分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
if (categoryOperation.value === 'add-root') {
|
||||
// 新增根节点
|
||||
const newKey = `root-${Date.now()}`
|
||||
treeData.value.push({
|
||||
key: newKey,
|
||||
label: categoryForm.value.name,
|
||||
children: [],
|
||||
})
|
||||
message.success('分类添加成功')
|
||||
}
|
||||
else if (categoryOperation.value === 'add-child' && currentOperationNode.value) {
|
||||
// 新增子节点
|
||||
if (!currentOperationNode.value.children) {
|
||||
currentOperationNode.value.children = []
|
||||
}
|
||||
const newKey = `node-${Date.now()}`
|
||||
// 如果是第三级(叶子),标记 isLeaf
|
||||
// 这里简单逻辑:如果有 children 数组则不是 leaf,但在 UI 上我们允许无限层级,
|
||||
// 为了匹配题目管理逻辑,我们假设用户手动添加的最后一级可以作为叶子
|
||||
currentOperationNode.value.children.push({
|
||||
key: newKey,
|
||||
label: categoryForm.value.name,
|
||||
// 可以在这里根据业务逻辑决定是否初始化 children,或者默认为叶子节点
|
||||
// 这里暂定新添加的子节点如果有下一级需求再添加 children,否则视为叶子
|
||||
isLeaf: true,
|
||||
})
|
||||
// 展开父节点
|
||||
if (!expandedKeys.value.includes(currentOperationNode.value.key as string)) {
|
||||
expandedKeys.value.push(currentOperationNode.value.key as string)
|
||||
}
|
||||
message.success('子分类添加成功')
|
||||
}
|
||||
else if (categoryOperation.value === 'edit' && currentOperationNode.value) {
|
||||
// 编辑节点
|
||||
currentOperationNode.value.label = categoryForm.value.name
|
||||
message.success('分类修改成功')
|
||||
}
|
||||
|
||||
showCategoryModal.value = false
|
||||
}
|
||||
|
||||
function submitQuestion() {
|
||||
if (!questionForm.value.content || !questionForm.value.answer) {
|
||||
message.error('请填写完整信息')
|
||||
return
|
||||
}
|
||||
// Mock add
|
||||
questionList.value.push({
|
||||
id: `Q-${Math.floor(Math.random() * 10000)}`,
|
||||
...questionForm.value,
|
||||
})
|
||||
message.success('题目添加成功')
|
||||
showQuestionModal.value = false
|
||||
}
|
||||
|
||||
// Tree Rendering
|
||||
function renderPrefix({ option }: { option: TreeOption }) {
|
||||
// 根据层级或类型显示不同图标
|
||||
if (option.children && option.children.length > 0) {
|
||||
return h(SvgIcon, { icon: 'carbon:folder', class: 'text-gray-400 text-lg' })
|
||||
}
|
||||
// 如果明确标记为叶子节点,或者没有 children
|
||||
return h(SvgIcon, { icon: 'carbon:document', class: 'text-gray-400 text-lg' })
|
||||
}
|
||||
|
||||
function renderSuffix({ option }: { option: TreeOption }) {
|
||||
// 悬浮时显示操作按钮
|
||||
return h(
|
||||
'div',
|
||||
{
|
||||
class: 'flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
onClick: (e: Event) => e.stopPropagation(),
|
||||
},
|
||||
[
|
||||
h('div', {
|
||||
class: 'text-gray-400 hover:text-blue-500 cursor-pointer flex items-center',
|
||||
title: '编辑',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleEditCategory(option)
|
||||
},
|
||||
}, [h(SvgIcon, { icon: 'carbon:edit', class: 'text-lg' })]),
|
||||
// 允许所有节点添加子节点,如果添加了子节点,它就变成文件夹
|
||||
h('div', {
|
||||
class: 'text-gray-400 hover:text-green-500 cursor-pointer flex items-center',
|
||||
title: '添加子分类',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleAddChildCategory(option)
|
||||
},
|
||||
}, [h(SvgIcon, { icon: 'carbon:add', class: 'text-lg' })]),
|
||||
h('div', {
|
||||
class: 'text-gray-400 hover:text-red-500 cursor-pointer flex items-center',
|
||||
title: '删除',
|
||||
onClick: (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteCategory(option)
|
||||
},
|
||||
}, [h(SvgIcon, { icon: 'carbon:trash-can', class: 'text-lg' })]),
|
||||
],
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex overflow-hidden border border-gray-100 rounded-2xl bg-white shadow-sm">
|
||||
<!-- Sidebar -->
|
||||
<div class="w-100 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="handleAddRootCategory">
|
||||
<template #icon>
|
||||
<icon-ic-baseline-add class="text-icon" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto py-2">
|
||||
<NTree
|
||||
block-line :data="treeData" :selected-keys="selectedKeys" :expanded-keys="expandedKeys"
|
||||
:render-prefix="renderPrefix" :render-suffix="renderSuffix" selectable expand-on-click class="px-2"
|
||||
@update:selected-keys="(keys) => (selectedKeys = keys)"
|
||||
@update:expanded-keys="(keys) => (expandedKeys = keys)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-100 bg-gray-50 p-3 text-xs text-gray-400">
|
||||
提示:请勿随意删除维护原有分类,三级分类为最终题目目录。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="h-full flex flex-col flex-1 overflow-hidden bg-white">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 border-b border-gray-100 px-6 py-4">
|
||||
<NBreadcrumb>
|
||||
<NBreadcrumbItem>题库全集</NBreadcrumbItem>
|
||||
<NBreadcrumbItem v-if="currentCategory && currentCategory.level === 1">
|
||||
{{ currentCategory.label }}
|
||||
</NBreadcrumbItem>
|
||||
</NBreadcrumb>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="m-0 text-xl text-gray-800 font-bold">
|
||||
{{ currentCategory ? currentCategory.label : '' }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div v-if="currentCategory && currentCategory.level === 3" class="mt-2 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<NInput v-model:value="searchText" placeholder="输入题目关键字在当前分类下搜索..." class="!w-80">
|
||||
<template #prefix>
|
||||
<SvgIcon icon="carbon:search" class="text-gray-400" />
|
||||
</template>
|
||||
</NInput>
|
||||
</div>
|
||||
|
||||
<NButton type="primary" @click="handleAddQuestion">
|
||||
<template #icon>
|
||||
<SvgIcon icon="carbon:add" />
|
||||
</template>
|
||||
新增题目
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto bg-gray-50/50 p-6">
|
||||
<template v-if="!isLeafSelected">
|
||||
<div class="h-full flex flex-col items-center justify-center text-gray-400">
|
||||
<NEmpty description="暂无数据">
|
||||
<template #extra>
|
||||
请从左侧选择一个最后一级(三级)分类以管理题目数据
|
||||
</template>
|
||||
</NEmpty>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="filteredQuestions.length === 0">
|
||||
<div class="mt-20 flex justify-center">
|
||||
<NEmpty description="该分类下暂无题目" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<NCard v-for="q in filteredQuestions" :key="q.id" size="small" hoverable class="rounded-xl">
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<NTag size="small" type="primary" :bordered="false">
|
||||
ID: {{ q.id }}
|
||||
</NTag>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<span class="text-xs text-gray-400 font-mono">{{ q.score }} PTS / {{ q.time }} S</span>
|
||||
</template>
|
||||
|
||||
<div class="py-2 text-base text-gray-700 font-medium">
|
||||
{{ q.content }}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 border border-green-100 rounded-lg bg-green-50 p-3">
|
||||
<div class="mb-1 text-xs text-green-600 font-bold tracking-wider uppercase">
|
||||
STANDARD ANSWER
|
||||
</div>
|
||||
<div class="text-green-800 font-bold">
|
||||
{{ q.answer }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #action>
|
||||
<div class="flex justify-end gap-2">
|
||||
<NButton size="tiny" quaternary type="primary">
|
||||
<template #icon>
|
||||
<SvgIcon icon="carbon:edit" />
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary type="error">
|
||||
<template #icon>
|
||||
<SvgIcon icon="carbon:trash-can" />
|
||||
</template>
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modals -->
|
||||
<!-- Add/Edit Category Modal -->
|
||||
<NModal
|
||||
v-model:show="showCategoryModal" preset="card"
|
||||
:title="categoryOperation === 'edit' ? '编辑分类' : `新增 ${categoryModalType} 级分类`" class="w-[500px]"
|
||||
>
|
||||
<NForm>
|
||||
<NFormItem label="分类显示名称">
|
||||
<NInput v-model:value="categoryForm.name" placeholder="请输入内容" />
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<NButton @click="showCategoryModal = false">
|
||||
取消操作
|
||||
</NButton>
|
||||
<NButton type="primary" @click="submitCategory">
|
||||
确认提交
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NModal>
|
||||
|
||||
<!-- Add Question Modal -->
|
||||
<NModal v-model:show="showQuestionModal" preset="card" title="新增题目详情" class="w-[600px]">
|
||||
<NForm label-placement="top">
|
||||
<NFormItem label="题目正文内容">
|
||||
<NInput
|
||||
v-model:value="questionForm.content" 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="参考标准答案">
|
||||
<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>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-tree-node-content__text) {
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node--selected) {
|
||||
background-color: #eff6ff !important;
|
||||
}
|
||||
|
||||
:deep(.n-tree-node--selected .n-tree-node-content__text) {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Ensure icons in tree are visible on hover */
|
||||
:deep(.n-tree-node-content:hover .group-hover\:opacity-100) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom group class for tree node content wrapper to handle hover state */
|
||||
:deep(.n-tree-node-content) {
|
||||
@apply group;
|
||||
}
|
||||
</style>
|
||||
154
apps/admin/src/views/rank/rank-detail/index.vue
Normal file
154
apps/admin/src/views/rank/rank-detail/index.vue
Normal file
@ -0,0 +1,154 @@
|
||||
<script setup lang="tsx">
|
||||
import { NCard, NDivider } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import RankHeader from './modules/rank-header.vue'
|
||||
import TeamItem from './modules/team-item.vue'
|
||||
import TeamListHeader from './modules/team-list-header.vue'
|
||||
|
||||
const competitionInfo = {
|
||||
name: '阅读之星-辞海遨游环节 (2026)',
|
||||
date: '2026.01.12',
|
||||
}
|
||||
|
||||
interface TeamData {
|
||||
id: number
|
||||
rank: number
|
||||
name: string
|
||||
group: string
|
||||
correctCount: string
|
||||
totalScore: number
|
||||
updateTime: string
|
||||
isExpanded: boolean
|
||||
scores: Record<string, number>
|
||||
}
|
||||
|
||||
const teamList = ref<TeamData[]>([
|
||||
{
|
||||
id: 1,
|
||||
rank: 1,
|
||||
name: '清华学霸团',
|
||||
group: '第一组',
|
||||
correctCount: '15 / 15',
|
||||
totalScore: 150,
|
||||
updateTime: '2026-01-22 16:28:55',
|
||||
isExpanded: true,
|
||||
scores: {
|
||||
q1: 10,
|
||||
q2: 10,
|
||||
q3: 10,
|
||||
q4: 10,
|
||||
q5: 10,
|
||||
q6: 10,
|
||||
q7: 10,
|
||||
q8: 5,
|
||||
q9: 10,
|
||||
q10: 10,
|
||||
q11: 15,
|
||||
q12: 10,
|
||||
q13: 15,
|
||||
q14: 10,
|
||||
q15: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
rank: 2,
|
||||
name: '无敌先锋队',
|
||||
group: '第一组',
|
||||
correctCount: '13 / 15',
|
||||
totalScore: 145,
|
||||
updateTime: '2026-01-22 16:30:12',
|
||||
isExpanded: false,
|
||||
scores: {},
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
rank: 3,
|
||||
name: '明日之星社',
|
||||
group: '第二组',
|
||||
correctCount: '13 / 15',
|
||||
totalScore: 130,
|
||||
updateTime: '2026-01-22 16:25:22',
|
||||
isExpanded: false,
|
||||
scores: {},
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
rank: 4,
|
||||
name: '火箭竞技团',
|
||||
group: '第一组',
|
||||
correctCount: '13 / 15',
|
||||
totalScore: 125,
|
||||
updateTime: '2026-01-22 16:22:10',
|
||||
isExpanded: false,
|
||||
scores: {},
|
||||
},
|
||||
])
|
||||
|
||||
function toggleExpand(team: TeamData) {
|
||||
// Collapse others if needed, or allow multiple. Screenshot shows one.
|
||||
// Let's toggle.
|
||||
team.isExpanded = !team.isExpanded
|
||||
|
||||
// If expanding, maybe populate scores if empty (mock logic)
|
||||
if (team.isExpanded && Object.keys(team.scores).length === 0) {
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
team.scores[`q${i}`] = 10
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveScores(team: TeamData) {
|
||||
window.$message?.success(`已保存 ${team.name} 的分数变动`)
|
||||
team.isExpanded = false
|
||||
}
|
||||
|
||||
function handlePublish() {
|
||||
window.$message?.success('本场榜单发布成功')
|
||||
}
|
||||
|
||||
function updateScore(team: TeamData, key: string, value: number | null) {
|
||||
if (value !== null) {
|
||||
team.scores[key] = value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<NCard :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
|
||||
<!-- Header -->
|
||||
<RankHeader
|
||||
:title="competitionInfo.name"
|
||||
:date="competitionInfo.date"
|
||||
@publish="handlePublish"
|
||||
/>
|
||||
|
||||
<NDivider />
|
||||
|
||||
<!-- List Header -->
|
||||
<TeamListHeader />
|
||||
|
||||
<!-- Custom Table / List -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Data Rows -->
|
||||
<TeamItem
|
||||
v-for="team in teamList"
|
||||
:key="team.id"
|
||||
:team="team"
|
||||
@toggle-expand="toggleExpand"
|
||||
@save-scores="saveScores"
|
||||
@update-score="updateScore"
|
||||
/>
|
||||
</div>
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-input-number .n-input__input-el) {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
date: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'publish'): void
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goBack() {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-start gap-4">
|
||||
<NButton text class="mt-1 text-24px" @click="goBack">
|
||||
<template #icon>
|
||||
<icon-ic-round-arrow-back class="text-icon" />
|
||||
</template>
|
||||
</NButton>
|
||||
<div>
|
||||
<h1 class="text-20px font-bold">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<div class="mt-1 flex items-center gap-2 text-gray-500">
|
||||
<icon-ic-round-access-time class="text-icon" />
|
||||
<span>活动日期:{{ date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NButton type="primary" class="px-6" @click="emit('publish')">
|
||||
发布本场榜单
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
129
apps/admin/src/views/rank/rank-detail/modules/team-item.vue
Normal file
129
apps/admin/src/views/rank/rank-detail/modules/team-item.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NGrid, NGridItem, NInputNumber } from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface TeamData {
|
||||
id: number
|
||||
rank: number
|
||||
name: string
|
||||
group: string
|
||||
correctCount: string
|
||||
totalScore: number
|
||||
updateTime: string
|
||||
isExpanded: boolean
|
||||
scores: Record<string, number>
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
team: TeamData
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleExpand', team: TeamData): void
|
||||
(e: 'saveScores', team: TeamData): void
|
||||
(e: 'updateScore', team: TeamData, key: string, value: number | null): void
|
||||
}>()
|
||||
|
||||
const rankStyle = computed(() => {
|
||||
const rank = props.team.rank
|
||||
if (rank === 1)
|
||||
return { backgroundColor: '#f59e0b', color: '#fff', border: 'none' }
|
||||
if (rank === 2)
|
||||
return { backgroundColor: '#9ca3af', color: '#fff', border: 'none' }
|
||||
if (rank === 3)
|
||||
return { backgroundColor: '#d97706', color: '#fff', border: 'none' }
|
||||
return {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border rounded-lg transition-all duration-200"
|
||||
:class="team.isExpanded ? 'border-primary bg-blue-50/10' : 'border-gray-100 hover:border-gray-300'"
|
||||
>
|
||||
<!-- Main Row -->
|
||||
<div class="grid grid-cols-[80px_200px_150px_150px_150px_200px_auto] items-center gap-4 px-4 py-4">
|
||||
<!-- Rank -->
|
||||
<div>
|
||||
<div
|
||||
class="h-8 w-8 flex items-center justify-center rounded-full text-14px font-bold"
|
||||
:style="rankStyle"
|
||||
>
|
||||
{{ team.rank }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="text-15px font-bold">
|
||||
{{ team.name }}
|
||||
</div>
|
||||
|
||||
<!-- Group -->
|
||||
<div class="text-gray-500">
|
||||
{{ team.group }}
|
||||
</div>
|
||||
|
||||
<!-- Correct Count -->
|
||||
<div class="font-bold font-mono">
|
||||
{{ team.correctCount }}
|
||||
</div>
|
||||
|
||||
<!-- Score -->
|
||||
<div class="text-16px text-primary font-bold">
|
||||
{{ team.totalScore }} Pts
|
||||
</div>
|
||||
|
||||
<!-- Time -->
|
||||
<div class="text-13px text-gray-400">
|
||||
{{ team.updateTime }}
|
||||
</div>
|
||||
|
||||
<!-- Action -->
|
||||
<div class="text-right">
|
||||
<NButton v-if="team.isExpanded" type="primary" size="small" @click="emit('saveScores', team)">
|
||||
完成核对
|
||||
</NButton>
|
||||
<NButton v-else size="small" @click="emit('toggleExpand', team)">
|
||||
详情/修正分数
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Content (Score Grid) -->
|
||||
<div v-if="team.isExpanded" class="mx-1 mb-1 border-t border-gray-100 rounded-b-lg bg-white px-6 pb-6 pt-2">
|
||||
<div class="mb-4 text-13px text-gray-500 font-bold uppercase">
|
||||
Question Breakdown For {{ team.name }}
|
||||
</div>
|
||||
|
||||
<NGrid :x-gap="16" :y-gap="16" :cols="5">
|
||||
<NGridItem v-for="i in 15" :key="i">
|
||||
<div class="border rounded bg-white p-3">
|
||||
<div class="mb-2 flex justify-between text-12px text-gray-400">
|
||||
<span>Q{{ i }} SCORE</span>
|
||||
<span>/ 10</span>
|
||||
</div>
|
||||
<NInputNumber
|
||||
:value="team.scores[`q${i}`]" :min="0" :max="10" button-placement="both"
|
||||
class="text-center text-primary font-bold"
|
||||
@update:value="(val) => emit('updateScore', team, `q${i}`, val)"
|
||||
/>
|
||||
</div>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<NButton type="primary" class="w-120px" @click="emit('saveScores', team)">
|
||||
保存变动并收起
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.n-input-number .n-input__input-el) {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { NTooltip } from 'naive-ui'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-14px text-gray-500 font-bold tracking-wide uppercase">
|
||||
实时排行数据与修正 (LIVE CORRECTION)
|
||||
</div>
|
||||
<div class="text-12px text-red-500">
|
||||
* 修改题目得分后,总分与名次将实时重算
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-[80px_200px_150px_150px_150px_200px_auto] gap-4 bg-gray-50 px-4 py-2 text-13px text-gray-600 font-bold"
|
||||
>
|
||||
<div>排名</div>
|
||||
<div>队伍名称</div>
|
||||
<div>所属小组</div>
|
||||
<div class="flex items-center">
|
||||
正确题数
|
||||
<NTooltip placement="top" trigger="hover">
|
||||
<template #trigger>
|
||||
<span>
|
||||
<SvgIcon icon="mdi:information-variant" class="text-xl text-yellow-500" />
|
||||
</span>
|
||||
</template>
|
||||
做题数量因加时赛可能有所不同
|
||||
</NTooltip>
|
||||
</div>
|
||||
<div>总积分 (Total)</div>
|
||||
<div>更新时间</div>
|
||||
<div class="text-right">
|
||||
操作
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
175
apps/admin/src/views/rank/rank-list/index.vue
Normal file
175
apps/admin/src/views/rank/rank-list/index.vue
Normal file
@ -0,0 +1,175 @@
|
||||
<script setup lang="tsx">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { NButton, NTag, NCard, NSpace } from 'naive-ui';
|
||||
import { fetchGetUserList } from '@/service/api';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table';
|
||||
import RankSearch from './modules/rank-search.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter();
|
||||
|
||||
const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
||||
current: 1,
|
||||
size: 10,
|
||||
status: null,
|
||||
userName: null,
|
||||
userGender: null,
|
||||
nickName: null,
|
||||
userPhone: null,
|
||||
userEmail: null
|
||||
});
|
||||
|
||||
const rankTitle = '排行榜管理列表';
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
1: '已发布',
|
||||
2: '待审核'
|
||||
};
|
||||
|
||||
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
||||
api: () => fetchGetUserList(searchParams),
|
||||
transform: response => {
|
||||
const transformed = defaultTransform(response);
|
||||
// Mocking data for Rank Management based on User API response
|
||||
const mockCompetitions = ['阅读之星-辞海遨游环节 (2026)', '古诗词大会年度精英巅峰赛', '趣味百科常识挑战周'];
|
||||
const mockDates = ['2026.01.12', '2026.01.15', '2026.01.18'];
|
||||
const mockScales = ['40 支队伍', '24 支队伍', '60 支队伍'];
|
||||
const mockScores = ['145', '180', '120'];
|
||||
const mockTimes = ['2026-01-22 16:30:12', '2026-01-22 12:00:00', '2026-01-21 18:22:45'];
|
||||
|
||||
transformed.data.forEach((item, index) => {
|
||||
const mockIndex = index % 3;
|
||||
item.userName = mockCompetitions[mockIndex]; // Competition Name
|
||||
item.userEmail = mockDates[mockIndex]; // Activity Date (using userEmail field)
|
||||
item.userPhone = mockScales[mockIndex]; // Team Scale (using userPhone field)
|
||||
item.nickName = mockScores[mockIndex]; // Max Score (using nickName field)
|
||||
// item.createTime is typically available, we'll simulate update time
|
||||
item.createTime = mockTimes[mockIndex];
|
||||
// Status: 1 (Published), 2 (Pending)
|
||||
item.status = (index % 2 === 0) ? '2' : '1';
|
||||
});
|
||||
return transformed;
|
||||
},
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.current = params.page;
|
||||
searchParams.size = params.pageSize;
|
||||
},
|
||||
columns: () => [
|
||||
{
|
||||
type: 'selection',
|
||||
align: 'center',
|
||||
width: 48
|
||||
},
|
||||
{
|
||||
key: 'userName',
|
||||
title: '比赛活动名称',
|
||||
align: 'left',
|
||||
minWidth: 200
|
||||
},
|
||||
{
|
||||
key: 'userEmail',
|
||||
title: '活动日期',
|
||||
align: 'center',
|
||||
minWidth: 120
|
||||
},
|
||||
{
|
||||
key: 'userPhone',
|
||||
title: '队伍规模',
|
||||
align: 'center',
|
||||
minWidth: 100
|
||||
},
|
||||
{
|
||||
key: 'nickName',
|
||||
title: '最高积分',
|
||||
align: 'center',
|
||||
minWidth: 100,
|
||||
render: row => (
|
||||
<span class="text-primary font-bold">{row.nickName} Pts</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'createTime',
|
||||
title: '最后更新时间',
|
||||
align: 'center',
|
||||
minWidth: 160
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '发布状态',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: row => {
|
||||
if (row.status === null) return null;
|
||||
const label = statusMap[row.status] || '未知';
|
||||
// 1: Published (Success/Green), 2: Pending (Warning/Orange)
|
||||
return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'operate',
|
||||
title: '操作',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
render: row => (
|
||||
<div class="flex-center">
|
||||
<NButton type="primary" text onClick={() => edit(row.id)}>
|
||||
进入排行详情
|
||||
</NButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const {
|
||||
drawerVisible,
|
||||
operateType,
|
||||
editingData,
|
||||
handleEdit,
|
||||
checkedRowKeys,
|
||||
onBatchDeleted,
|
||||
} = useTableOperate(data, 'id', getData);
|
||||
|
||||
|
||||
|
||||
function edit(id: number) {
|
||||
router.push({ name: 'rank_rank-detail', query: { id } });
|
||||
}
|
||||
|
||||
function handleBatchPublish() {
|
||||
window.$message?.success('批量发布成功');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<RankSearch v-model:model="searchParams" @search="getDataByPage" />
|
||||
<NCard :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-16px font-bold">{{ rankTitle }}</div>
|
||||
<div class="text-12px text-gray-500 mt-4px">您可以对所有比赛场次的最终积分进行核对、修正,并正式发布结果。</div>
|
||||
</div>
|
||||
<NButton type="primary" ghost class="ml-auto" @click="handleBatchPublish">
|
||||
<template #icon>
|
||||
<icon-ic-round-upload class="text-icon" />
|
||||
</template>
|
||||
批量发布至终端
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
<!-- <template #header-extra>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
|
||||
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
|
||||
</template> -->
|
||||
<NDataTable v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
|
||||
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
|
||||
:pagination="mobilePagination" class="sm:h-full" />
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
91
apps/admin/src/views/rank/rank-list/modules/rank-search.vue
Normal file
91
apps/admin/src/views/rank/rank-list/modules/rank-search.vue
Normal file
@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRaw } from 'vue';
|
||||
import { jsonClone } from '@sa/utils';
|
||||
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'RankSearch'
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const { formRef, validate, restoreValidation } = useNaiveForm();
|
||||
|
||||
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true });
|
||||
|
||||
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>;
|
||||
|
||||
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
|
||||
const { patternRules } = useFormRules();
|
||||
|
||||
return {
|
||||
userEmail: patternRules.email,
|
||||
userPhone: patternRules.phone
|
||||
};
|
||||
});
|
||||
|
||||
const defaultModel = jsonClone(toRaw(model.value));
|
||||
|
||||
function resetModel() {
|
||||
Object.assign(model.value, defaultModel);
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
await restoreValidation();
|
||||
resetModel();
|
||||
}
|
||||
|
||||
async function search() {
|
||||
await validate();
|
||||
emit('search');
|
||||
}
|
||||
|
||||
const publishStatusOptions = [
|
||||
{ label: '待审核', value: '2' }, // Mapping to '2' (Warning)
|
||||
{ label: '已发布', value: '1' } // Mapping to '1' (Success)
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" size="small" class="card-wrapper">
|
||||
<NCollapse>
|
||||
<NCollapseItem title="筛选查询" name="rank-search">
|
||||
<NForm ref="formRef" :model="model" :rules="rules" label-placement="left" :label-width="100">
|
||||
<NGrid responsive="screen" item-responsive>
|
||||
<NFormItemGi span="24 s:12 m:8" label="比赛活动名称" path="userName" class="pr-24px">
|
||||
<NInput v-model:value="model.userName" placeholder="请输入比赛活动名称" />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="24 s:12 m:8" label="发布状态" path="status" class="pr-24px">
|
||||
<NSelect
|
||||
v-model:value="model.status"
|
||||
placeholder="请选择"
|
||||
:options="publishStatusOptions"
|
||||
clearable
|
||||
/>
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="24 s:12 m:8" class="pr-24px">
|
||||
<NSpace class="w-full" justify="end">
|
||||
<NButton @click="reset">
|
||||
<template #icon>
|
||||
<icon-ic-round-refresh class="text-icon" />
|
||||
</template>
|
||||
重置
|
||||
</NButton>
|
||||
<NButton type="primary" ghost @click="search">
|
||||
<template #icon>
|
||||
<icon-ic-round-search class="text-icon" />
|
||||
</template>
|
||||
搜索
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
</NForm>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</NCard>
|
||||
</template>
|
||||
9
apps/admin/src/views/results/index.vue
Normal file
9
apps/admin/src/views/results/index.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
// 结果列表
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1 class="m-0 text-xl text-gray-800 font-bold">
|
||||
实时结果
|
||||
</h1>
|
||||
</template>
|
||||
186
apps/admin/src/views/template/index.vue
Normal file
186
apps/admin/src/views/template/index.vue
Normal file
@ -0,0 +1,186 @@
|
||||
<script setup lang="tsx">
|
||||
import { reactive } from 'vue';
|
||||
import { NButton, NPopconfirm, NTag } from 'naive-ui';
|
||||
import { fetchGetUserList } from '@/service/api';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { defaultTransform, useNaivePaginatedTable, useTableOperate } from '@/hooks/common/table';
|
||||
import TemplateOperateDrawer from './modules/template-operate-drawer.vue';
|
||||
import TemplateSearch from './modules/template-search.vue';
|
||||
|
||||
const appStore = useAppStore();
|
||||
|
||||
const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
||||
current: 1,
|
||||
size: 10,
|
||||
status: null,
|
||||
userName: null,
|
||||
userGender: null,
|
||||
nickName: null,
|
||||
userPhone: null,
|
||||
userEmail: null
|
||||
});
|
||||
|
||||
const templateTitle = '模板管理';
|
||||
|
||||
const competitionMap: Record<string, string> = {
|
||||
1: '第九届阅读之星大赛',
|
||||
2: '科普阅读大赛'
|
||||
};
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
1: '铺码成功',
|
||||
2: '未铺码'
|
||||
};
|
||||
|
||||
const { columns, columnChecks, data, getData, getDataByPage, loading, mobilePagination } = useNaivePaginatedTable({
|
||||
api: () => fetchGetUserList(searchParams),
|
||||
transform: response => {
|
||||
const transformed = defaultTransform(response);
|
||||
transformed.data.forEach((item, index) => {
|
||||
item.nickName = `MD${String(index + 1).padStart(6, '0')}`;
|
||||
item.userPhone = '210mmx297mm';
|
||||
item.userGender = (item.userGender === '1' || item.userGender === '2') ? item.userGender : '1';
|
||||
});
|
||||
return transformed;
|
||||
},
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.current = params.page;
|
||||
searchParams.size = params.pageSize;
|
||||
},
|
||||
columns: () => [
|
||||
{
|
||||
type: 'selection',
|
||||
align: 'center',
|
||||
width: 48
|
||||
},
|
||||
{
|
||||
key: 'index',
|
||||
title: '序号',
|
||||
align: 'center',
|
||||
width: 60,
|
||||
render: (_, index) => index + 1
|
||||
},
|
||||
{
|
||||
key: 'userName',
|
||||
title: '名称',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
key: 'nickName',
|
||||
title: '模板ID',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
key: 'userGender',
|
||||
title: '关联比赛',
|
||||
align: 'center',
|
||||
render: row => {
|
||||
const label = competitionMap[row.userGender as string] || '未知比赛';
|
||||
return <span>{label}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'userPhone',
|
||||
title: '设计尺寸',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: '状态',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
render: row => {
|
||||
if (row.status === null) {
|
||||
return null;
|
||||
}
|
||||
const label = statusMap[row.status] || '未知';
|
||||
return <NTag type={row.status === '1' ? 'success' : 'warning'}>{label}</NTag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'operate',
|
||||
title: '操作',
|
||||
align: 'center',
|
||||
// width: 430,
|
||||
render: row => (
|
||||
<div class="flex-center gap-8px">
|
||||
<NButton type="primary" ghost size="small" onClick={() => edit(row.id)}>
|
||||
基础信息
|
||||
</NButton>
|
||||
<NButton size="small" onClick={() => { }}>
|
||||
铺码
|
||||
</NButton>
|
||||
<NButton size="small" onClick={() => { }}>
|
||||
预览
|
||||
</NButton>
|
||||
<NButton size="small" onClick={() => { }}>
|
||||
页面信息
|
||||
</NButton>
|
||||
<NButton size="small" onClick={() => { }}>
|
||||
打印
|
||||
</NButton>
|
||||
<NPopconfirm onPositiveClick={() => handleDelete(row.id)}>
|
||||
{{
|
||||
default: () => '确认删除?',
|
||||
trigger: () => (
|
||||
<NButton type="error" ghost size="small">
|
||||
删除
|
||||
</NButton>
|
||||
)
|
||||
}}
|
||||
</NPopconfirm>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const {
|
||||
drawerVisible,
|
||||
operateType,
|
||||
editingData,
|
||||
handleAdd,
|
||||
handleEdit,
|
||||
checkedRowKeys,
|
||||
onBatchDeleted,
|
||||
onDeleted
|
||||
// closeDrawer
|
||||
} = useTableOperate(data, 'id', getData);
|
||||
|
||||
async function handleBatchDelete() {
|
||||
// request
|
||||
console.log(checkedRowKeys.value);
|
||||
|
||||
onBatchDeleted();
|
||||
}
|
||||
|
||||
function handleDelete(id: number) {
|
||||
// request
|
||||
console.log(id);
|
||||
|
||||
onDeleted();
|
||||
}
|
||||
|
||||
function edit(id: number) {
|
||||
handleEdit(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<TemplateSearch v-model:model="searchParams" @search="getDataByPage" />
|
||||
<NCard :title="templateTitle" :bordered="false" size="small" class="card-wrapper sm:flex-1-hidden">
|
||||
<template #header-extra>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :disabled-delete="checkedRowKeys.length === 0"
|
||||
:loading="loading" @add="handleAdd" @delete="handleBatchDelete" @refresh="getData" />
|
||||
</template>
|
||||
<NDataTable v-model:checked-row-keys="checkedRowKeys" :columns="columns" :data="data" size="small" striped
|
||||
:flex-height="!appStore.isMobile" :scroll-x="962" :loading="loading" remote :row-key="row => row.id"
|
||||
:pagination="mobilePagination" class="sm:h-full" />
|
||||
<TemplateOperateDrawer v-model:visible="drawerVisible" :operate-type="operateType" :row-data="editingData"
|
||||
@submitted="getDataByPage" />
|
||||
</NCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { jsonClone } from '@sa/utils';
|
||||
import { enableStatusOptions } from '@/constants/business';
|
||||
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'TemplateOperateDrawer'
|
||||
});
|
||||
|
||||
interface Props {
|
||||
operateType: NaiveUI.TableOperateType;
|
||||
rowData?: Api.SystemManage.User | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate, restoreValidation } = useNaiveForm();
|
||||
const { defaultRequiredRule } = useFormRules();
|
||||
|
||||
const title = computed(() => {
|
||||
const titles: Record<NaiveUI.TableOperateType, string> = {
|
||||
add: '新增模板',
|
||||
edit: '编辑模板'
|
||||
};
|
||||
return titles[props.operateType];
|
||||
});
|
||||
|
||||
type Model = Pick<
|
||||
Api.SystemManage.User,
|
||||
'userName' | 'userGender' | 'nickName' | 'userPhone' | 'userEmail' | 'userRoles' | 'status'
|
||||
>;
|
||||
|
||||
const model = ref(createDefaultModel());
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
userName: '',
|
||||
userGender: null,
|
||||
nickName: '',
|
||||
userPhone: '',
|
||||
userEmail: '',
|
||||
userRoles: [],
|
||||
status: null
|
||||
};
|
||||
}
|
||||
|
||||
type RuleKey = Extract<keyof Model, 'userName' | 'status'>;
|
||||
|
||||
const rules: Record<RuleKey, App.Global.FormRule> = {
|
||||
userName: defaultRequiredRule,
|
||||
status: defaultRequiredRule
|
||||
};
|
||||
|
||||
function handleInitModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
if (props.operateType === 'edit' && props.rowData) {
|
||||
Object.assign(model.value, jsonClone(props.rowData));
|
||||
}
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
// request
|
||||
window.$message?.success('更新成功');
|
||||
closeDrawer();
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, () => {
|
||||
if (visible.value) {
|
||||
handleInitModel();
|
||||
restoreValidation();
|
||||
}
|
||||
});
|
||||
|
||||
const competitionOptions = [
|
||||
{ label: '第九届阅读之星大赛', value: '1' },
|
||||
{ label: '科普阅读大赛', value: '2' }
|
||||
];
|
||||
|
||||
const sizeOptions = [
|
||||
{ label: '210mmx297mm', value: '1' },
|
||||
{ label: 'A3', value: '2' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer v-model:show="visible" display-directive="show" :width="360">
|
||||
<NDrawerContent :title="title" :native-scrollbar="false" closable>
|
||||
<NForm ref="formRef" :model="model" :rules="rules">
|
||||
<NFormItem label="名称" path="userName">
|
||||
<NInput v-model:value="model.userName" placeholder="请输入名称" />
|
||||
</NFormItem>
|
||||
<NFormItem label="模板ID" path="nickName">
|
||||
<NInput v-model:value="model.nickName" placeholder="请输入模板ID" />
|
||||
</NFormItem>
|
||||
<NFormItem label="关联比赛" path="userGender">
|
||||
<NSelect
|
||||
v-model:value="model.userGender"
|
||||
:options="competitionOptions"
|
||||
placeholder="请选择关联比赛"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="设计尺寸" path="userPhone">
|
||||
<NSelect
|
||||
v-model:value="model.userPhone"
|
||||
:options="sizeOptions"
|
||||
placeholder="请选择设计尺寸"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem label="状态" path="status">
|
||||
<NRadioGroup v-model:value="model.status">
|
||||
<NRadio v-for="item in enableStatusOptions" :key="item.value" :value="item.value" :label="item.label" />
|
||||
</NRadioGroup>
|
||||
</NFormItem>
|
||||
</NForm>
|
||||
<template #footer>
|
||||
<NSpace :size="16">
|
||||
<NButton @click="closeDrawer">取消</NButton>
|
||||
<NButton type="primary" @click="handleSubmit">确认</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
101
apps/admin/src/views/template/modules/template-search.vue
Normal file
101
apps/admin/src/views/template/modules/template-search.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRaw } from 'vue';
|
||||
import { jsonClone } from '@sa/utils';
|
||||
import { enableStatusOptions } from '@/constants/business';
|
||||
import { useFormRules, useNaiveForm } from '@/hooks/common/form';
|
||||
|
||||
defineOptions({
|
||||
name: 'TemplateSearch'
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const { formRef, validate, restoreValidation } = useNaiveForm();
|
||||
|
||||
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true });
|
||||
|
||||
type RuleKey = Extract<keyof Api.SystemManage.UserSearchParams, 'userEmail' | 'userPhone'>;
|
||||
|
||||
const rules = computed<Record<RuleKey, App.Global.FormRule>>(() => {
|
||||
const { patternRules } = useFormRules();
|
||||
|
||||
return {
|
||||
userEmail: patternRules.email,
|
||||
userPhone: patternRules.phone
|
||||
};
|
||||
});
|
||||
|
||||
const defaultModel = jsonClone(toRaw(model.value));
|
||||
|
||||
function resetModel() {
|
||||
Object.assign(model.value, defaultModel);
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
await restoreValidation();
|
||||
resetModel();
|
||||
}
|
||||
|
||||
async function search() {
|
||||
await validate();
|
||||
emit('search');
|
||||
}
|
||||
|
||||
const competitionOptions = [
|
||||
{ label: '第九届阅读之星大赛', value: '1' },
|
||||
{ label: '科普阅读大赛', value: '2' }
|
||||
];
|
||||
|
||||
const sizeOptions = [
|
||||
{ label: '210mmx297mm', value: '1' },
|
||||
{ label: 'A3', value: '2' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" size="small" class="card-wrapper">
|
||||
<NCollapse>
|
||||
<NCollapseItem title="基础信息" name="template-search">
|
||||
<NForm ref="formRef" :model="model" :rules="rules" label-placement="left" :label-width="80">
|
||||
<NGrid responsive="screen" item-responsive>
|
||||
<NFormItemGi span="24 s:12 m:6" label="模板名称" path="userName" class="pr-24px">
|
||||
<NInput v-model:value="model.userName" placeholder="请输入名称" />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="24 s:12 m:6" label="关联比赛" path="userGender" class="pr-24px">
|
||||
<NSelect v-model:value="model.userGender" placeholder="请选择" :options="competitionOptions as any"
|
||||
clearable />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="24 s:12 m:6" label="状态" path="userStatus" class="pr-24px">
|
||||
<NSelect v-model:value="model.status" placeholder="请选择" :options="enableStatusOptions as any" clearable />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="24 s:12 m:6" label="尺寸" path="userPhone" class="pr-24px">
|
||||
<NSelect v-model:value="model.userPhone" placeholder="请选择" :options="sizeOptions" clearable />
|
||||
</NFormItemGi>
|
||||
<NFormItemGi span="24" class="pr-24px">
|
||||
<NSpace class="w-full" justify="end">
|
||||
<NButton @click="reset">
|
||||
<template #icon>
|
||||
<icon-ic-round-refresh class="text-icon" />
|
||||
</template>
|
||||
重置
|
||||
</NButton>
|
||||
<NButton type="primary" ghost @click="search">
|
||||
<template #icon>
|
||||
<icon-ic-round-search class="text-icon" />
|
||||
</template>
|
||||
搜索
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NFormItemGi>
|
||||
</NGrid>
|
||||
</NForm>
|
||||
</NCollapseItem>
|
||||
</NCollapse>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user