feat(admin): 新建题库管理页面,添加分类树和题目管理功能

实现题库分类树结构,支持多级分类管理
添加题目列表展示和搜索功能
新增分类和题目的增删改查操作
优化页面布局和交互体验
This commit is contained in:
2026-01-21 17:42:52 +08:00
parent 89773bd49b
commit d950bb4018
8 changed files with 542 additions and 536 deletions

View File

@ -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']

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>