feat: 重构各级导航

chore: 配置 prettier
This commit is contained in:
Timothy Yin 2025-04-19 01:32:50 +08:00
parent 7727166bf5
commit 28f84bca92
Signed by: HoshinoSuzumi
GPG Key ID: 4052E565F04B122A
23 changed files with 784 additions and 416 deletions

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"bracketSameLine": false,
"singleAttributePerLine": true
}

View File

@ -1,6 +1,5 @@
{ {
"eslint.useFlatConfig": true, "eslint.useFlatConfig": true,
"prettier.bracketSameLine": true,
"prettier.semi": false, "prettier.semi": false,
"prettier.singleAttributePerLine": true, "prettier.singleAttributePerLine": true,
"prettier.singleQuote": true "prettier.singleQuote": true

104
components/SubNav.vue Normal file
View File

@ -0,0 +1,104 @@
<script lang="ts">
export interface SubNavItem {
label: string
to: string
}
</script>
<script lang="ts" setup>
defineProps<{
navs: SubNavItem[]
}>()
const route = useRoute()
const isCurrentPath = (path: string) => {
return route.path === path
}
</script>
<template>
<div class="flex items-end ml-4 z-0">
<div
v-for="(nav, i) in navs"
:key="i"
class="subnav-item"
:class="`${isCurrentPath(nav.to) ? 'active' : ''}`"
:style="{
'z-index': isCurrentPath(nav.to) ? 10 : -i,
}"
>
<svg
class="absolute inset-0 aspect-auto"
viewBox="0 0 206.5 72"
fill="none"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<defs>
<linearGradient
:id="`paint_linear_${i}`"
gradient-units="objectBoundingBox"
x1="0.5"
y1="0"
x2="0.5"
y2="1"
>
<stop stop-color="var(--svg-stop1)" />
<stop
offset="1"
stop-color="var(--svg-stop2)"
/>
</linearGradient>
</defs>
<g>
<path
id="subnav_item_active"
d="M51.9 0L154.6 0C172.19 0 187.72 11.48 192.86 28.31L206.5 72L0 72L13.35 28.31C18.49 11.48 34.02 0 51.9 0Z"
:fill="`url(#paint_linear_${i})`"
fill-opacity="1.000000"
fill-rule="evenodd"
/>
</g>
</svg>
<NuxtLink
class="text-lg font-medium z-10 select-none"
:class="{
'text-secondary': isCurrentPath(nav.to),
'text-neutral-400 dark:text-neutral-500': !isCurrentPath(nav.to),
}"
:to="nav.to"
>
{{ nav.label }}
</NuxtLink>
</div>
</div>
</template>
<style scoped>
.subnav-item {
@apply relative flex justify-center items-center px-4 pt-1 drop-shadow-md;
--svg-stop1: #ffffff;
--svg-stop2: #f3f3f3;
&:not(:first-of-type) {
@apply -ml-3;
}
&.active {
--svg-stop1: #6059f4;
--svg-stop2: #8e59f4;
}
}
.dark .subnav-item {
--svg-stop1: #000000;
--svg-stop2: #0c0c0c;
&.active {
--svg-stop1: #6d7ca6;
--svg-stop2: #6f6da6;
}
}
</style>

View File

@ -0,0 +1,29 @@
<script lang="ts" setup>
import type { SubNavItem } from '../SubNav.vue'
defineProps<{ subnavs?: SubNavItem[] }>()
</script>
<template>
<div class="flex flex-1 flex-col p-8 page-bg-gradient">
<!-- <h1 class="pl-2 text-xl font-medium">外部标题</h1> -->
<slot name="subnav">
<SubNav
v-if="subnavs && subnavs.length"
:navs="subnavs"
/>
</slot>
<div
class="bg-white h-full rounded-lg shadow-sm p-8 dark:bg-neutral-900 z-20"
>
<slot />
</div>
</div>
</template>
<style scoped>
.page-bg-gradient {
@apply bg-gradient-to-br from-[#D5DEF9]/50 to-[#D6C9F9]/50;
@apply dark:from-[#36477A]/50 dark:to-[#7C6DA6]/50;
}
</style>

View File

@ -1,20 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ChevronRight, type LucideIcon } from 'lucide-vue-next' import { ChevronRight } from 'lucide-vue-next'
import type { RouteLocationRaw } from 'vue-router' import type { SidebarNavItem } from './Sidebar.vue'
defineProps<{ defineProps<{
nav: { nav: {
label?: string label?: string
items: { items: SidebarNavItem[]
title: string
url?: RouteLocationRaw | string
icon: LucideIcon | string
isActive?: boolean
items?: {
title: string
url: string
}[]
}[]
}[] }[]
}>() }>()
</script> </script>
@ -41,16 +32,16 @@ defineProps<{
<NuxtLink <NuxtLink
v-if="item.url" v-if="item.url"
v-slot="{ isActive, href, navigate }" v-slot="{ isActive, href, navigate }"
class="py-6" class="py-5"
:to="item.url" :to="item.url"
custom custom
> >
<SidebarMenuButton <SidebarMenuButton
as="a" as="a"
class="flex justify-start text-base pl-8"
:tooltip="item.title" :tooltip="item.title"
:is-active="isActive" :is-active="isActive"
:href :href
:target="item.isExternal ? '_blank' : undefined"
@click="navigate" @click="navigate"
> >
<!-- 图标名 --> <!-- 图标名 -->
@ -71,6 +62,13 @@ defineProps<{
v-if="item.items" v-if="item.items"
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/> />
<!-- 外部链接 -->
<Icon
v-if="item.isExternal"
name="tabler:external-link"
class="ml-auto !size-4 opacity-50"
size="16px"
/>
</SidebarMenuButton> </SidebarMenuButton>
</NuxtLink> </NuxtLink>
<!-- 无跳转链接 --> <!-- 无跳转链接 -->

View File

@ -1,32 +0,0 @@
<script lang="ts" setup>
import type { SidebarNavGroup } from './Sidebar.vue'
const props = defineProps<{
sidebarNav: SidebarNavGroup[]
}>()
defineExpose({
props,
})
</script>
<template>
<SidebarProvider style="--sidebar-width: 200px">
<slot
name="sidebar"
:sidebar-nav="sidebarNav"
>
<AppSidebar :nav="sidebarNav" />
</slot>
<SidebarInset>
<slot name="topbar">
<AppTopbar />
</slot>
<div class="flex flex-1 flex-col gap-4 p-4">
<slot />
</div>
</SidebarInset>
</SidebarProvider>
</template>
<style scoped></style>

View File

@ -8,6 +8,7 @@ export interface SidebarNavItem {
url?: string | RouteLocationRaw url?: string | RouteLocationRaw
icon: LucideIcon | string icon: LucideIcon | string
isActive?: boolean isActive?: boolean
isExternal?: boolean
items?: { items?: {
title: string title: string
url: string url: string

View File

@ -1,7 +1,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { navigationMenuTriggerStyle } from '@/components/ui/navigation-menu' import type { IBreadcrumbItem } from '~/types'
defineProps({ defineProps({
breadcrumbs: {
type: Array as () => IBreadcrumbItem[],
default: () => [],
},
hideTrigger: { hideTrigger: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -48,16 +52,43 @@ export const topbarNavDefaults = [
<template> <template>
<header <header
class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear border-b sticky top-0 z-30 bg-background" class="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear border-b sticky top-0 z-30 bg-background">
>
<!-- group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 --> <!-- group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12 -->
<div class="flex items-center gap-2 px-4 w-full"> <div class="flex justify-between items-center gap-2 px-4 w-full">
<SidebarTrigger <div
v-if="!hideTrigger" class="flex items-center gap-2"
class="-ml-1" :class="`${hideTrigger ? 'px-7' : ''}`">
/> <SidebarTrigger v-if="!hideTrigger" class="-ml-1" />
<img
v-if="hideTrigger"
src="/images/xsh_logo.png"
alt="Logo"
class="w-10 aspect-square" />
<Separator
orientation="vertical"
class="mr-2 h-4" />
<Breadcrumb v-if="breadcrumbs.length > 0">
<BreadcrumbList>
<template
v-for="(crumb, i) in breadcrumbs"
:key="i">
<BreadcrumbItem>
<BreadcrumbLink
v-if="crumb.path"
:href="crumb.path">
{{ crumb.label }}
</BreadcrumbLink>
<BreadcrumbPage v-else>
{{ crumb.label }}
</BreadcrumbPage>
</BreadcrumbItem>
<BreadcrumbSeparator v-if="i < breadcrumbs.length - 1" />
</template>
</BreadcrumbList>
</Breadcrumb>
<slot name="title-area" /> <slot name="title-area" />
<div class="flex-1 flex justify-center"> </div>
<!-- <div class="flex-1 flex justify-center">
<NavigationMenu> <NavigationMenu>
<NavigationMenuList> <NavigationMenuList>
<NavigationMenuItem <NavigationMenuItem
@ -87,21 +118,18 @@ export const topbarNavDefaults = [
</NavigationMenuItem> </NavigationMenuItem>
</NavigationMenuList> </NavigationMenuList>
</NavigationMenu> </NavigationMenu>
</div> </div> -->
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger as-child> <DropdownMenuTrigger as-child>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon">
>
<Icon <Icon
name="tabler:moon" name="tabler:moon"
class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
/>
<Icon <Icon
name="tabler:sun" name="tabler:sun"
class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
/>
<span class="sr-only">Toggle theme</span> <span class="sr-only">Toggle theme</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>

View File

@ -1,4 +1,5 @@
// @ts-check // @ts-check
import prettier from 'prettier'
import withNuxt from './.nuxt/eslint.config.mjs' import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt( export default withNuxt(
@ -6,6 +7,10 @@ export default withNuxt(
{ {
rules: { rules: {
'vue/html-self-closing': 'off', 'vue/html-self-closing': 'off',
'vue/singleline-html-element-content-newline': 'off',
},
plugins: {
prettier,
}, },
}, },
) )

View File

@ -1,9 +1,63 @@
<script lang="ts" setup></script> <script lang="ts" setup>
import type { SidebarNavGroup } from '~/components/app/Sidebar.vue'
const route = useRoute()
const { breadcrumbs } = toRefs(useBreadcrumbs())
const sidebarNav: SidebarNavGroup[] = [
{
label: '备课制课',
items: [
{
title: '课程管理',
url: `/course`,
icon: 'tabler:books',
},
{
title: '课程资源',
url: `/course/resources`,
icon: 'tabler:users-group',
},
],
},
{
label: 'AI 资源',
items: [
{
title: 'AI 备课',
url: `/course/prep`,
icon: 'tabler:books',
isExternal: true,
},
{
title: 'AI 教科研',
url: `/course/research`,
icon: 'tabler:users-group',
isExternal: true,
},
],
},
]
</script>
<template> <template>
<div class="w-full min-h-screen flex font-sans"> <div class="w-full min-h-screen flex font-sans">
<SidebarProvider style="--sidebar-width: 200px">
<AppSidebar
v-if="!route.meta.hideSidebar"
:nav="sidebarNav"
/>
<SidebarInset>
<AppTopbar
:hide-trigger="route.meta.hideSidebar"
:breadcrumbs
/>
<div class="flex flex-1 flex-col gap-4">
<slot /> <slot />
</div> </div>
</SidebarInset>
</SidebarProvider>
</div>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@ -2,17 +2,7 @@
<template> <template>
<div class="w-full min-h-screen flex flex-col font-sans"> <div class="w-full min-h-screen flex flex-col font-sans">
<AppTopbar hide-trigger> <AppTopbar hide-trigger />
<template #title-area>
<div
class="flex items-center gap-2 w-[calc(var(--sidebar-width)+24px)] overflow-hidden"
>
<h1 class="text-lg font-medium">
智课教学平台
</h1>
</div>
</template>
</AppTopbar>
<div class="min-h-[100vh] flex-1 md:min-h-min p-4"> <div class="min-h-[100vh] flex-1 md:min-h-min p-4">
<slot /> <slot />
</div> </div>

View File

@ -35,6 +35,7 @@ export default defineNuxtConfig({
indent: 2, indent: 2,
quotes: 'single', quotes: 'single',
semi: false, semi: false,
commaDangle: 'only-multiline',
}, },
}, },
}, },

View File

@ -18,6 +18,9 @@
"@nuxt/test-utils": "3.17.2", "@nuxt/test-utils": "3.17.2",
"@tanstack/vue-table": "^8.21.2", "@tanstack/vue-table": "^8.21.2",
"@vee-validate/zod": "^4.15.0", "@vee-validate/zod": "^4.15.0",
"@vue-office/docx": "^1.6.3",
"@vue-office/excel": "^1.7.14",
"@vue-office/pdf": "^2.0.10",
"@vue-office/pptx": "^1.0.1", "@vue-office/pptx": "^1.0.1",
"@vueuse/core": "^13.0.0", "@vueuse/core": "^13.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -49,7 +52,9 @@
"@vueuse/nuxt": "^13.0.0", "@vueuse/nuxt": "^13.0.0",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dayjs-nuxt": "^2.1.11", "dayjs-nuxt": "^2.1.11",
"eslint-plugin-prettier": "^5.2.6",
"pinia-plugin-persistedstate": "^4.2.0", "pinia-plugin-persistedstate": "^4.2.0",
"prettier": "^3.5.3",
"shadcn-nuxt": "2.0.1", "shadcn-nuxt": "2.0.1",
"typescript": "^5.8.2" "typescript": "^5.8.2"
} }

View File

@ -1,8 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { IResponse } from '~/api' import type { IResponse } from '~/api'
import { getCourseDetail } from '~/api/course' import { getCourseDetail } from '~/api/course'
import type { SidebarNavGroup } from '~/components/app/Sidebar.vue'
import { topbarNavDefaults } from '~/components/app/Topbar.vue'
import type { ICourse } from '~/types' import type { ICourse } from '~/types'
const { const {
@ -10,61 +8,44 @@ const {
params: { id }, params: { id },
} = useRoute() } = useRoute()
const router = useRouter() const router = useRouter()
const { setBreadcrumbs } = useBreadcrumbs()
const { const {
data: course, data: course,
status: courseStatus, status: courseStatus,
error: courseError, error: courseError,
} = await useAsyncData< } = useAsyncData<
IResponse<{ IResponse<{
data: ICourse data: ICourse
}>, }>,
IResponse IResponse
>(() => getCourseDetail(id as string)) >(() => getCourseDetail(id as string))
useHead({
title: `${course.value?.data.courseName || '课程不存在'} - 课程管理`,
})
definePageMeta({ definePageMeta({
requiresAuth: true, requiresAuth: true,
hideSidebar: true,
}) })
const sideNav: SidebarNavGroup[] = [ watch(
{ () => course.value,
items: [ (data) => {
{ useHead({
title: '课程章节', title: `${course.value?.data.courseName || '课程不存在'} - 课程管理`,
url: `/course/${id}/chapters`, })
icon: 'tabler:books',
},
{
title: '教师团队',
url: `/course/${id}/team`,
icon: 'tabler:users-group',
},
{
title: '学生班级',
url: `/course/${id}/classes`,
icon: 'tabler:school',
},
{
title: '学生评价',
url: `/course/${id}/evaluation`,
icon: 'tabler:mood-smile',
},
],
},
]
const topNav = [ if (data?.data.courseName) {
setBreadcrumbs([
{ {
title: '课程管理', label: '课程管理',
to: `/course/${id}`, path: '/course',
icon: 'tabler:layout-dashboard',
}, },
...topbarNavDefaults.slice(1), {
] label: data.data.courseName || '课程不存在',
},
])
}
},
)
onMounted(() => { onMounted(() => {
if (fullPath === `/course/${id}`) { if (fullPath === `/course/${id}`) {
@ -72,42 +53,37 @@ onMounted(() => {
} }
}) })
watch(() => useRoute().fullPath, (newPath) => { watch(
() => useRoute().fullPath,
(newPath) => {
if (newPath === `/course/${id}`) { if (newPath === `/course/${id}`) {
router.replace(`/course/${id}/chapters`) router.replace(`/course/${id}/chapters`)
} }
}) },
)
</script> </script>
<template> <template>
<AppPageWithSidebar :sidebar-nav="sideNav"> <AppContainer
<template #topbar> :subnavs="[
<AppTopbar :nav="topNav" /> {
</template> label: '课程章节',
to: `/course/${id}/chapters`,
<template #sidebar="{ sidebarNav }"> },
<AppSidebar :nav="sidebarNav"> {
<template #extra-header> label: '教师团队',
<div class="px-4"> to: `/course/${id}/team`,
<div class="flex flex-col items-center gap-1 overflow-hidden"> },
<NuxtImg {
:src="course?.data.previewUrl || '/images/bg_home.jpg'" label: '学生班级',
alt="课程封面" to: `/course/${id}/classes`,
class="w-full aspect-video rounded-md shadow-md" },
/> {
<h1 label: '学生评价',
class="text-base font-medium drop-shadow-md text-ellipsis line-clamp-1" to: `/course/${id}/evaluation`,
},
]"
> >
{{ course?.data.courseName || "未知课程" }}
</h1>
</div>
</div>
</template>
</AppSidebar>
</template>
<ClientOnly>
<Suspense>
<div v-if="courseStatus === 'error'"> <div v-if="courseStatus === 'error'">
<EmptyScreen <EmptyScreen
title="课程加载失败" title="课程加载失败"
@ -124,9 +100,7 @@ watch(() => useRoute().fullPath, (newPath) => {
v-else v-else
:page-key="fullPath" :page-key="fullPath"
/> />
</Suspense> </AppContainer>
</ClientOnly>
</AppPageWithSidebar>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@ -6,7 +6,6 @@ import type { FetchError } from 'ofetch'
import { createCourse, deleteCourse, listUserCourses } from '~/api/course' import { createCourse, deleteCourse, listUserCourses } from '~/api/course'
definePageMeta({ definePageMeta({
layout: 'no-sidebar',
requiresAuth: true, requiresAuth: true,
}) })
@ -100,7 +99,8 @@ const onDeleteCourse = (courseId: number) => {
</script> </script>
<template> <template>
<div class="container mx-auto flex flex-col gap-8"> <AppContainer>
<div class="flex flex-col gap-8">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Form <Form
@ -302,13 +302,13 @@ const onDeleteCourse = (courseId: number) => {
size="sm" size="sm"
@click="deleteMode = !deleteMode" @click="deleteMode = !deleteMode"
> >
{{ deleteMode ? "退出删除" : "删除课程" }} {{ deleteMode ? '退出删除' : '删除课程' }}
</Button> </Button>
</div> </div>
</div> </div>
<div <div
v-if="coursesList?.rows && coursesList.rows.length > 0" v-if="coursesList?.rows && coursesList.rows.length > 0"
class="grid grid-cols-5 gap-8" class="grid grid-cols-6 gap-8"
> >
<CourseCard <CourseCard
v-for="course in coursesList?.rows" v-for="course in coursesList?.rows"
@ -323,9 +323,7 @@ const onDeleteCourse = (courseId: number) => {
title="暂无课程" title="暂无课程"
icon="fluent-color:people-list-24" icon="fluent-color:people-list-24"
> >
<p class="text-sm text-muted-foreground"> <p class="text-sm text-muted-foreground">还没有创建或加入课程</p>
还没有创建或加入课程
</p>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -339,6 +337,7 @@ const onDeleteCourse = (courseId: number) => {
</Button> </Button>
</EmptyScreen> </EmptyScreen>
</div> </div>
</AppContainer>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
definePageMeta({ definePageMeta({
layout: 'no-sidebar', hideSidebar: true,
}) })
</script> </script>

View File

@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
definePageMeta({ definePageMeta({
layout: 'no-sidebar',
requiresAuth: true, requiresAuth: true,
}) })
</script> </script>

View File

@ -1,9 +1,19 @@
<script lang="ts" setup> <script lang="ts" setup>
import VueOfficePptx from '@vue-office/pptx' import VueOfficePptx from '@vue-office/pptx'
import VueOfficeDocx from '@vue-office/docx'
import VueOfficeExcel from '@vue-office/excel'
import VueOfficePdf from '@vue-office/pdf'
import '@vue-office/docx/lib/index.css'
import '@vue-office/excel/lib/index.css'
definePageMeta({
hideSidebar: true,
})
const { const {
params: { resource_url }, params: { resource_url },
} = useRoute() } = useRoute()
const { setBreadcrumbs } = useBreadcrumbs()
const url = computed(() => { const url = computed(() => {
return atob(resource_url as string) return atob(resource_url as string)
@ -28,36 +38,76 @@ const fileType = computed(() => {
if (ext === 'mp3' || ext === 'wav') return 'audio' if (ext === 'mp3' || ext === 'wav') return 'audio'
return '' return ''
}) })
</script>
<template> onMounted(() => {
<div class="w-full h-screen"> useHead({
<div v-if="!url"> title: `${fileType.value.toUpperCase()} 资源预览`,
<div class="flex items-center justify-center h-full"> })
<p class="text-muted-foreground">
资源链接无效 setBreadcrumbs([
</p> {
</div> label: `${fileType.value.toUpperCase()} 资源预览`,
</div> },
<div ])
v-else })
class="w-full h-full"
> const vueOfficeOptions = {
<VueOfficePptx
v-if="fileType === 'ppt'"
:src="url"
class="w-full h-full"
:options="{
autoSize: true, autoSize: true,
autoHeight: true, autoHeight: true,
autoWidth: true, autoWidth: true,
autoScale: true, autoScale: true,
autoRotate: true, autoRotate: true,
autoFit: true, autoFit: true,
}" }
</script>
<template>
<AppContainer>
<div v-if="!url">
<div class="flex items-center justify-center h-full">
<p class="text-muted-foreground">资源链接无效</p>
</div>
</div>
<div
v-else
class="w-full h-full border rounded-lg overflow-hidden"
>
<VueOfficeDocx
v-if="fileType === 'word'"
class="w-full h-full"
:src="url"
:options="vueOfficeOptions"
/> />
<VueOfficePptx
v-else-if="fileType === 'ppt'"
class="w-full h-full"
:src="url"
:options="vueOfficeOptions"
/>
<VueOfficeExcel
v-else-if="fileType === 'excel'"
class="w-full h-full"
:src="url"
:options="vueOfficeOptions"
/>
<VueOfficePdf
v-else-if="fileType === 'pdf'"
class="w-full h-full"
:src="url"
:options="vueOfficeOptions"
/>
<div
v-else
class="flex flex-col items-center justify-center h-full gap-4 text-muted-foreground"
>
<Icon
name="fluent-color:notebook-question-mark-24"
class="text-6xl"
/>
<p>暂不支持该资源类型</p>
</div> </div>
</div> </div>
</AppContainer>
</template> </template>
<style scoped></style> <style scoped></style>

View File

@ -6,6 +6,10 @@ import * as z from 'zod'
import type { FetchError } from 'ofetch' import type { FetchError } from 'ofetch'
import { userLogin, type LoginResponse } from '~/api' import { userLogin, type LoginResponse } from '~/api'
definePageMeta({
layout: 'blank',
})
const loginState = useLoginState() const loginState = useLoginState()
const { const {
query: { redirect }, query: { redirect },

116
pnpm-lock.yaml generated
View File

@ -26,6 +26,15 @@ importers:
'@vee-validate/zod': '@vee-validate/zod':
specifier: ^4.15.0 specifier: ^4.15.0
version: 4.15.0(vue@3.5.13(typescript@5.8.2))(zod@3.24.2) version: 4.15.0(vue@3.5.13(typescript@5.8.2))(zod@3.24.2)
'@vue-office/docx':
specifier: ^1.6.3
version: 1.6.3(vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
'@vue-office/excel':
specifier: ^1.7.14
version: 1.7.14(vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
'@vue-office/pdf':
specifier: ^2.0.10
version: 2.0.10(vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
'@vue-office/pptx': '@vue-office/pptx':
specifier: ^1.0.1 specifier: ^1.0.1
version: 1.0.1(vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2)) version: 1.0.1(vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
@ -111,9 +120,15 @@ importers:
dayjs-nuxt: dayjs-nuxt:
specifier: ^2.1.11 specifier: ^2.1.11
version: 2.1.11(magicast@0.3.5) version: 2.1.11(magicast@0.3.5)
eslint-plugin-prettier:
specifier: ^5.2.6
version: 5.2.6(eslint@9.23.0(jiti@2.4.2))(prettier@3.5.3)
pinia-plugin-persistedstate: pinia-plugin-persistedstate:
specifier: ^4.2.0 specifier: ^4.2.0
version: 4.2.0(@pinia/nuxt@0.10.1(magicast@0.3.5)(pinia@3.0.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))))(magicast@0.3.5)(pinia@3.0.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))) version: 4.2.0(@pinia/nuxt@0.10.1(magicast@0.3.5)(pinia@3.0.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))))(magicast@0.3.5)(pinia@3.0.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2)))
prettier:
specifier: ^3.5.3
version: 3.5.3
shadcn-nuxt: shadcn-nuxt:
specifier: 2.0.1 specifier: 2.0.1
version: 2.0.1(magicast@0.3.5) version: 2.0.1(magicast@0.3.5)
@ -959,6 +974,10 @@ packages:
resolution: {integrity: sha512-25L86MyPvnlQoX2MTIV2OiUcb6vJ6aRbFa9pbwByn95INKD5mFH2smgjDhq+fwJoqAgvgbdJLj6Tz7V9X5CFAQ==} resolution: {integrity: sha512-25L86MyPvnlQoX2MTIV2OiUcb6vJ6aRbFa9pbwByn95INKD5mFH2smgjDhq+fwJoqAgvgbdJLj6Tz7V9X5CFAQ==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@pkgr/core@0.2.4':
resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@polka/url@1.0.0-next.28': '@polka/url@1.0.0-next.28':
resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==}
@ -1404,6 +1423,36 @@ packages:
vue: vue:
optional: true optional: true
'@vue-office/docx@1.6.3':
resolution: {integrity: sha512-Cs+3CAaRBOWOiW4XAhTwwxJ0dy8cPIf6DqfNvYcD3YACiLwO4kuawLF2IAXxyijhbuOeoFsfvoVbOc16A/4bZA==}
peerDependencies:
'@vue/composition-api': ^1.7.1
vue: ^2.0.0 || >=3.0.0
vue-demi: ^0.14.6
peerDependenciesMeta:
'@vue/composition-api':
optional: true
'@vue-office/excel@1.7.14':
resolution: {integrity: sha512-pVUgt+emDQUnW7q22CfnQ+jl43mM/7IFwYzOg7lwOwPEbiVB4K4qEQf+y/bc4xGXz75w1/e3Kz3G6wAafmFBFg==}
peerDependencies:
'@vue/composition-api': ^1.7.1
vue: ^2.0.0 || >=3.0.0
vue-demi: ^0.14.6
peerDependenciesMeta:
'@vue/composition-api':
optional: true
'@vue-office/pdf@2.0.10':
resolution: {integrity: sha512-yHVLrMAKpMPBkhBwofFyGEtEeJF0Zd7oGmf56Pe5aj/xObdRq3E1CIZqTqhWJNgHV8oLQqaX0vs4p5T1zq+GIA==}
peerDependencies:
'@vue/composition-api': ^1.7.1
vue: ^2.0.0 || >=3.0.0
vue-demi: ^0.14.6
peerDependenciesMeta:
'@vue/composition-api':
optional: true
'@vue-office/pptx@1.0.1': '@vue-office/pptx@1.0.1':
resolution: {integrity: sha512-+V7Kctzl6f6+Yk4NaD/wQGRIkqLWcowe0jEhPexWQb8Oilbzt1OyhWRWcMsxNDTdrgm6aMLP+0/tmw27cxddMg==} resolution: {integrity: sha512-+V7Kctzl6f6+Yk4NaD/wQGRIkqLWcowe0jEhPexWQb8Oilbzt1OyhWRWcMsxNDTdrgm6aMLP+0/tmw27cxddMg==}
peerDependencies: peerDependencies:
@ -2268,6 +2317,20 @@ packages:
peerDependencies: peerDependencies:
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 eslint: ^7.0.0 || ^8.0.0 || ^9.0.0
eslint-plugin-prettier@5.2.6:
resolution: {integrity: sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
'@types/eslint': '>=8.0.0'
eslint: '>=8.0.0'
eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0'
prettier: '>=3.0.0'
peerDependenciesMeta:
'@types/eslint':
optional: true
eslint-config-prettier:
optional: true
eslint-plugin-regexp@2.7.0: eslint-plugin-regexp@2.7.0:
resolution: {integrity: sha512-U8oZI77SBtH8U3ulZ05iu0qEzIizyEDXd+BWHvyVxTOjGwcDcvy/kEpgFG4DYca2ByRLiVPFZ2GeH7j1pdvZTA==} resolution: {integrity: sha512-U8oZI77SBtH8U3ulZ05iu0qEzIizyEDXd+BWHvyVxTOjGwcDcvy/kEpgFG4DYca2ByRLiVPFZ2GeH7j1pdvZTA==}
engines: {node: ^18 || >=20} engines: {node: ^18 || >=20}
@ -2379,6 +2442,9 @@ packages:
fast-deep-equal@3.1.3: fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
fast-fifo@1.3.2: fast-fifo@1.3.2:
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
@ -3632,6 +3698,15 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
prettier-linter-helpers@1.0.0:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'}
prettier@3.5.3:
resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==}
engines: {node: '>=14'}
hasBin: true
pretty-bytes@6.1.1: pretty-bytes@6.1.1:
resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
engines: {node: ^14.13.1 || >=16.0.0} engines: {node: ^14.13.1 || >=16.0.0}
@ -4038,6 +4113,10 @@ packages:
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
hasBin: true hasBin: true
synckit@0.11.4:
resolution: {integrity: sha512-Q/XQKRaJiLiFIBNN+mndW7S/RHxvwzuZS6ZwmRzUBqJBv/5QIKCEwkBC8GBf8EQJKYnaFs0wOZbKTXBPj8L9oQ==}
engines: {node: ^14.18.0 || >=16.0.0}
synckit@0.9.2: synckit@0.9.2:
resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
@ -5759,6 +5838,8 @@ snapshots:
'@pkgr/core@0.2.2': {} '@pkgr/core@0.2.2': {}
'@pkgr/core@0.2.4': {}
'@polka/url@1.0.0-next.28': {} '@polka/url@1.0.0-next.28': {}
'@poppinss/colors@4.1.4': '@poppinss/colors@4.1.4':
@ -6178,6 +6259,21 @@ snapshots:
optionalDependencies: optionalDependencies:
vue: 3.5.13(typescript@5.8.2) vue: 3.5.13(typescript@5.8.2)
'@vue-office/docx@1.6.3(vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))':
dependencies:
vue: 3.5.13(typescript@5.8.2)
vue-demi: 0.14.6(vue@3.5.13(typescript@5.8.2))
'@vue-office/excel@1.7.14(vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))':
dependencies:
vue: 3.5.13(typescript@5.8.2)
vue-demi: 0.14.6(vue@3.5.13(typescript@5.8.2))
'@vue-office/pdf@2.0.10(vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))':
dependencies:
vue: 3.5.13(typescript@5.8.2)
vue-demi: 0.14.6(vue@3.5.13(typescript@5.8.2))
'@vue-office/pptx@1.0.1(vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))': '@vue-office/pptx@1.0.1(vue-demi@0.14.6(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))':
dependencies: dependencies:
vue: 3.5.13(typescript@5.8.2) vue: 3.5.13(typescript@5.8.2)
@ -7091,6 +7187,13 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
eslint-plugin-prettier@5.2.6(eslint@9.23.0(jiti@2.4.2))(prettier@3.5.3):
dependencies:
eslint: 9.23.0(jiti@2.4.2)
prettier: 3.5.3
prettier-linter-helpers: 1.0.0
synckit: 0.11.4
eslint-plugin-regexp@2.7.0(eslint@9.23.0(jiti@2.4.2)): eslint-plugin-regexp@2.7.0(eslint@9.23.0(jiti@2.4.2)):
dependencies: dependencies:
'@eslint-community/eslint-utils': 4.5.1(eslint@9.23.0(jiti@2.4.2)) '@eslint-community/eslint-utils': 4.5.1(eslint@9.23.0(jiti@2.4.2))
@ -7254,6 +7357,8 @@ snapshots:
fast-deep-equal@3.1.3: {} fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
fast-fifo@1.3.2: {} fast-fifo@1.3.2: {}
fast-glob@3.3.3: fast-glob@3.3.3:
@ -8741,6 +8846,12 @@ snapshots:
prelude-ls@1.2.1: {} prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.0:
dependencies:
fast-diff: 1.3.0
prettier@3.5.3: {}
pretty-bytes@6.1.1: {} pretty-bytes@6.1.1: {}
process-nextick-args@2.0.1: {} process-nextick-args@2.0.1: {}
@ -9216,6 +9327,11 @@ snapshots:
csso: 5.0.5 csso: 5.0.5
picocolors: 1.1.1 picocolors: 1.1.1
synckit@0.11.4:
dependencies:
'@pkgr/core': 0.2.4
tslib: 2.8.1
synckit@0.9.2: synckit@0.9.2:
dependencies: dependencies:
'@pkgr/core': 0.1.2 '@pkgr/core': 0.1.2

24
stores/breadcrumbs.ts Normal file
View File

@ -0,0 +1,24 @@
import type { IBreadcrumbItem } from '~/types'
export const useBreadcrumbs = defineStore('breadcrumbs', () => {
const breadcrumbs = ref<IBreadcrumbItem[]>([])
const setBreadcrumbs = (items: IBreadcrumbItem[]) => {
breadcrumbs.value = items
}
const clearBreadcrumbs = () => {
breadcrumbs.value = []
}
const getBreadcrumbs = () => {
return breadcrumbs.value
}
return {
breadcrumbs,
getBreadcrumbs,
setBreadcrumbs,
clearBreadcrumbs,
}
})

View File

@ -2,3 +2,8 @@ export * from './user'
export * from './course' export * from './course'
export type { FetchError } from 'ofetch' export type { FetchError } from 'ofetch'
export interface IBreadcrumbItem {
label: string
path?: string
}

8
types/nuxt.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
declare module '#app' {
interface PageMeta {
/** 隐藏侧边栏 */
hideSidebar?: boolean
}
}
export {}