feat(题库管理): 重构题库管理页面,添加分类树和题目管理功能
实现题库分类树结构,支持多级分类管理 添加题目列表展示和搜索功能 新增分类和题目的增删改查操作 优化页面布局和交互体验
This commit is contained in:
2
apps/admin/src/typings/components.d.ts
vendored
2
apps/admin/src/typings/components.d.ts
vendored
@ -24,6 +24,7 @@ declare module 'vue' {
|
||||
IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||
IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||
IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']
|
||||
IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
|
||||
IconIcBaselineArrowForward: typeof import('~icons/ic/baseline-arrow-forward')['default']
|
||||
IconIcBaselineCalendarMonth: typeof import('~icons/ic/baseline-calendar-month')['default']
|
||||
@ -129,6 +130,7 @@ declare global {
|
||||
const IconGridiconsFullscreen: typeof import('~icons/gridicons/fullscreen')['default']
|
||||
const IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||
const IconIcBaselineAdd: typeof import('~icons/ic/baseline-add')['default']
|
||||
const IconIcBaselineAddPlus: typeof import('~icons/ic/baseline-add-plus')['default']
|
||||
const IconIcBaselineArrowBack: typeof import('~icons/ic/baseline-arrow-back')['default']
|
||||
const IconIcBaselineArrowForward: typeof import('~icons/ic/baseline-arrow-forward')['default']
|
||||
const IconIcBaselineCalendarMonth: typeof import('~icons/ic/baseline-calendar-month')['default']
|
||||
|
||||
@ -1,46 +1,548 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import CardData from './modules/card-data.vue'
|
||||
import CreativityBanner from './modules/creativity-banner.vue'
|
||||
import HeaderBanner from './modules/header-banner.vue'
|
||||
import LineChart from './modules/line-chart.vue'
|
||||
import PieChart from './modules/pie-chart.vue'
|
||||
import ProjectNews from './modules/project-news.vue'
|
||||
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'
|
||||
|
||||
const appStore = useAppStore()
|
||||
// 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: [],
|
||||
},
|
||||
])
|
||||
|
||||
const gap = computed(() => (appStore.isMobile ? 0 : 16))
|
||||
// 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>
|
||||
<NSpace vertical :size="16">
|
||||
<NAlert :title="$t('common.tip')" type="warning">
|
||||
{{ $t('page.home.branchDesc') }}
|
||||
</NAlert>
|
||||
<HeaderBanner />
|
||||
<CardData />
|
||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:14">
|
||||
<NCard :bordered="false" class="card-wrapper">
|
||||
<LineChart />
|
||||
</NCard>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:10">
|
||||
<NCard :bordered="false" class="card-wrapper">
|
||||
<PieChart />
|
||||
</NCard>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:14">
|
||||
<ProjectNews />
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:10">
|
||||
<CreativityBanner />
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NSpace>
|
||||
<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></style>
|
||||
<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>
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { createReusableTemplate } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { $t } from '@/locales'
|
||||
import { useThemeStore } from '@/store/modules/theme'
|
||||
|
||||
defineOptions({
|
||||
name: 'CardData',
|
||||
})
|
||||
|
||||
interface CardData {
|
||||
key: string
|
||||
title: string
|
||||
value: number
|
||||
unit: string
|
||||
color: {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
icon: string
|
||||
}
|
||||
|
||||
const cardData = computed<CardData[]>(() => [
|
||||
{
|
||||
key: 'visitCount',
|
||||
title: $t('page.home.visitCount'),
|
||||
value: 9725,
|
||||
unit: '',
|
||||
color: {
|
||||
start: '#ec4786',
|
||||
end: '#b955a4',
|
||||
},
|
||||
icon: 'ant-design:bar-chart-outlined',
|
||||
},
|
||||
{
|
||||
key: 'turnover',
|
||||
title: $t('page.home.turnover'),
|
||||
value: 1026,
|
||||
unit: '$',
|
||||
color: {
|
||||
start: '#865ec0',
|
||||
end: '#5144b4',
|
||||
},
|
||||
icon: 'ant-design:money-collect-outlined',
|
||||
},
|
||||
{
|
||||
key: 'downloadCount',
|
||||
title: $t('page.home.downloadCount'),
|
||||
value: 970925,
|
||||
unit: '',
|
||||
color: {
|
||||
start: '#56cdf3',
|
||||
end: '#719de3',
|
||||
},
|
||||
icon: 'carbon:document-download',
|
||||
},
|
||||
{
|
||||
key: 'dealCount',
|
||||
title: $t('page.home.dealCount'),
|
||||
value: 9527,
|
||||
unit: '',
|
||||
color: {
|
||||
start: '#fcbc25',
|
||||
end: '#f68057',
|
||||
},
|
||||
icon: 'ant-design:trademark-circle-outlined',
|
||||
},
|
||||
])
|
||||
|
||||
interface GradientBgProps {
|
||||
gradientColor: string
|
||||
}
|
||||
|
||||
const [DefineGradientBg, GradientBg] = createReusableTemplate<GradientBgProps>()
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
|
||||
function getGradientColor(color: CardData['color']) {
|
||||
return `linear-gradient(to bottom right, ${color.start}, ${color.end})`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" size="small" class="card-wrapper">
|
||||
<DefineGradientBg v-slot="{ $slots, gradientColor }">
|
||||
<div
|
||||
class="px-16px pb-4px pt-8px text-white"
|
||||
:style="{ backgroundImage: gradientColor, borderRadius: `${themeStore.themeRadius}px` }"
|
||||
>
|
||||
<component :is="$slots.default" />
|
||||
</div>
|
||||
</DefineGradientBg>
|
||||
<NGrid cols="s:1 m:2 l:4" responsive="screen" :x-gap="16" :y-gap="16">
|
||||
<NGi v-for="item in cardData" :key="item.key">
|
||||
<GradientBg :gradient-color="getGradientColor(item.color)" class="flex-1">
|
||||
<h3 class="text-16px">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<div class="flex justify-between pt-12px">
|
||||
<SvgIcon :icon="item.icon" class="text-32px" />
|
||||
<CountTo
|
||||
:prefix="item.unit" :start-value="1" :end-value="item.value"
|
||||
class="text-30px text-white dark:text-dark"
|
||||
/>
|
||||
</div>
|
||||
</GradientBg>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@ -1,17 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { $t } from '@/locales'
|
||||
|
||||
defineOptions({
|
||||
name: 'CreativityBanner',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :title="$t('page.home.creativity')" :bordered="false" size="small" class="h-full card-wrapper">
|
||||
<div class="h-full flex-center">
|
||||
<icon-local-banner class="text-400px text-primary sm:text-320px" />
|
||||
</div>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@ -1,68 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { $t } from '@/locales'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { useAuthStore } from '@/store/modules/auth'
|
||||
|
||||
defineOptions({
|
||||
name: 'HeaderBanner',
|
||||
})
|
||||
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const gap = computed(() => (appStore.isMobile ? 0 : 16))
|
||||
|
||||
interface StatisticData {
|
||||
id: number
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const statisticData = computed<StatisticData[]>(() => [
|
||||
{
|
||||
id: 0,
|
||||
label: $t('page.home.projectCount'),
|
||||
value: '25',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
label: $t('page.home.todo'),
|
||||
value: '4/16',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: $t('page.home.message'),
|
||||
value: '12',
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" class="card-wrapper">
|
||||
<NGrid :x-gap="gap" :y-gap="16" responsive="screen" item-responsive>
|
||||
<NGi span="24 s:24 m:18">
|
||||
<div class="flex-y-center">
|
||||
<div class="size-72px shrink-0 overflow-hidden rd-1/2">
|
||||
<img src="@/assets/imgs/reader-star.jpg" class="size-full">
|
||||
</div>
|
||||
<div class="pl-12px">
|
||||
<h3 class="text-18px font-semibold">
|
||||
{{ $t('page.home.greeting', { userName: authStore.userInfo.userName }) }}
|
||||
</h3>
|
||||
<p class="text-#999 leading-30px">
|
||||
{{ $t('page.home.weatherDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</NGi>
|
||||
<NGi span="24 s:24 m:6">
|
||||
<NSpace :size="24" justify="end">
|
||||
<NStatistic v-for="item in statisticData" :key="item.id" class="whitespace-nowrap" v-bind="item" />
|
||||
</NSpace>
|
||||
</NGi>
|
||||
</NGrid>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@ -1,152 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useEcharts } from '@/hooks/common/echarts'
|
||||
import { $t } from '@/locales'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
defineOptions({
|
||||
name: 'LineChart',
|
||||
})
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const { domRef, updateOptions } = useEcharts(() => ({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985',
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: [$t('page.home.downloadCount'), $t('page.home.registerCount')],
|
||||
top: '0',
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: '15%',
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: [] as string[],
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
color: '#8e9dff',
|
||||
name: $t('page.home.downloadCount'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0.25,
|
||||
color: '#8e9dff',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#fff',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
data: [] as number[],
|
||||
},
|
||||
{
|
||||
color: '#26deca',
|
||||
name: $t('page.home.registerCount'),
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0.25,
|
||||
color: '#26deca',
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#fff',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series',
|
||||
},
|
||||
data: [],
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
async function mockData() {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
|
||||
updateOptions((opts) => {
|
||||
opts.xAxis.data = ['06:00', '08:00', '10:00', '12:00', '14:00', '16:00', '18:00', '20:00', '22:00', '24:00']
|
||||
opts.series[0].data = [4623, 6145, 6268, 6411, 1890, 4251, 2978, 3880, 3606, 4311]
|
||||
opts.series[1].data = [2208, 2016, 2916, 4512, 8281, 2008, 1963, 2367, 2956, 678]
|
||||
|
||||
return opts
|
||||
})
|
||||
}
|
||||
|
||||
function updateLocale() {
|
||||
updateOptions((opts, factory) => {
|
||||
const originOpts = factory()
|
||||
|
||||
opts.legend.data = originOpts.legend.data
|
||||
opts.series[0].name = originOpts.series[0].name
|
||||
opts.series[1].name = originOpts.series[1].name
|
||||
|
||||
return opts
|
||||
})
|
||||
}
|
||||
|
||||
async function init() {
|
||||
mockData()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => appStore.locale,
|
||||
() => {
|
||||
updateLocale()
|
||||
},
|
||||
)
|
||||
|
||||
// init
|
||||
init()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" class="card-wrapper">
|
||||
<div ref="domRef" class="h-360px overflow-hidden" />
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@ -1,109 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { useEcharts } from '@/hooks/common/echarts'
|
||||
import { $t } from '@/locales'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
|
||||
defineOptions({
|
||||
name: 'PieChart',
|
||||
})
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const { domRef, updateOptions } = useEcharts(() => ({
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
},
|
||||
legend: {
|
||||
bottom: '1%',
|
||||
left: 'center',
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
color: ['#5da8ff', '#8e9dff', '#fedc69', '#26deca'],
|
||||
name: $t('page.home.schedule'),
|
||||
type: 'pie',
|
||||
radius: ['45%', '75%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 1,
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center',
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '12',
|
||||
},
|
||||
},
|
||||
labelLine: {
|
||||
show: false,
|
||||
},
|
||||
data: [] as { name: string, value: number }[],
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
async function mockData() {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1000)
|
||||
})
|
||||
|
||||
updateOptions((opts) => {
|
||||
opts.series[0].data = [
|
||||
{ name: $t('page.home.study'), value: 20 },
|
||||
{ name: $t('page.home.entertainment'), value: 10 },
|
||||
{ name: $t('page.home.work'), value: 40 },
|
||||
{ name: $t('page.home.rest'), value: 30 },
|
||||
]
|
||||
|
||||
return opts
|
||||
})
|
||||
}
|
||||
|
||||
function updateLocale() {
|
||||
updateOptions((opts, factory) => {
|
||||
const originOpts = factory()
|
||||
|
||||
opts.series[0].name = originOpts.series[0].name
|
||||
|
||||
opts.series[0].data = [
|
||||
{ name: $t('page.home.study'), value: 20 },
|
||||
{ name: $t('page.home.entertainment'), value: 10 },
|
||||
{ name: $t('page.home.work'), value: 40 },
|
||||
{ name: $t('page.home.rest'), value: 30 },
|
||||
]
|
||||
|
||||
return opts
|
||||
})
|
||||
}
|
||||
|
||||
async function init() {
|
||||
mockData()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => appStore.locale,
|
||||
() => {
|
||||
updateLocale()
|
||||
},
|
||||
)
|
||||
|
||||
// init
|
||||
init()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :bordered="false" class="card-wrapper">
|
||||
<div ref="domRef" class="h-360px overflow-hidden" />
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@ -1,40 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { $t } from '@/locales'
|
||||
|
||||
defineOptions({
|
||||
name: 'ProjectNews',
|
||||
})
|
||||
|
||||
interface NewsItem {
|
||||
id: number
|
||||
content: string
|
||||
time: string
|
||||
}
|
||||
|
||||
const newses = computed<NewsItem[]>(() => [
|
||||
{ id: 1, content: $t('page.home.projectNews.desc1'), time: '2021-05-28 22:22:22' },
|
||||
{ id: 2, content: $t('page.home.projectNews.desc2'), time: '2021-10-27 10:24:54' },
|
||||
{ id: 3, content: $t('page.home.projectNews.desc3'), time: '2021-10-31 22:43:12' },
|
||||
{ id: 4, content: $t('page.home.projectNews.desc4'), time: '2021-11-03 20:33:31' },
|
||||
{ id: 5, content: $t('page.home.projectNews.desc5'), time: '2021-11-07 22:45:32' },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NCard :title="$t('page.home.projectNews.title')" :bordered="false" size="small" segmented class="card-wrapper">
|
||||
<template #header-extra>
|
||||
<a class="text-primary" href="javascript:;">{{ $t('page.home.projectNews.moreNews') }}</a>
|
||||
</template>
|
||||
<NList>
|
||||
<NListItem v-for="item in newses" :key="item.id">
|
||||
<template #prefix>
|
||||
<ReaderStarAvatar class="size-48px!" />
|
||||
</template>
|
||||
<NThing :title="item.content" :description="item.time" />
|
||||
</NListItem>
|
||||
</NList>
|
||||
</NCard>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user