feat: 重构各级导航
chore: 配置 prettier
This commit is contained in:
parent
7727166bf5
commit
28f84bca92
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSameLine": false,
|
||||
"singleAttributePerLine": true
|
||||
}
|
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -1,6 +1,5 @@
|
||||
{
|
||||
"eslint.useFlatConfig": true,
|
||||
"prettier.bracketSameLine": true,
|
||||
"prettier.semi": false,
|
||||
"prettier.singleAttributePerLine": true,
|
||||
"prettier.singleQuote": true
|
||||
|
104
components/SubNav.vue
Normal file
104
components/SubNav.vue
Normal 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>
|
29
components/app/Container.vue
Normal file
29
components/app/Container.vue
Normal 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>
|
@ -1,20 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronRight, type LucideIcon } from 'lucide-vue-next'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import type { SidebarNavItem } from './Sidebar.vue'
|
||||
|
||||
defineProps<{
|
||||
nav: {
|
||||
label?: string
|
||||
items: {
|
||||
title: string
|
||||
url?: RouteLocationRaw | string
|
||||
icon: LucideIcon | string
|
||||
isActive?: boolean
|
||||
items?: {
|
||||
title: string
|
||||
url: string
|
||||
}[]
|
||||
}[]
|
||||
items: SidebarNavItem[]
|
||||
}[]
|
||||
}>()
|
||||
</script>
|
||||
@ -41,16 +32,16 @@ defineProps<{
|
||||
<NuxtLink
|
||||
v-if="item.url"
|
||||
v-slot="{ isActive, href, navigate }"
|
||||
class="py-6"
|
||||
class="py-5"
|
||||
:to="item.url"
|
||||
custom
|
||||
>
|
||||
<SidebarMenuButton
|
||||
as="a"
|
||||
class="flex justify-start text-base pl-8"
|
||||
:tooltip="item.title"
|
||||
:is-active="isActive"
|
||||
:href
|
||||
:target="item.isExternal ? '_blank' : undefined"
|
||||
@click="navigate"
|
||||
>
|
||||
<!-- 图标名 -->
|
||||
@ -71,6 +62,13 @@ defineProps<{
|
||||
v-if="item.items"
|
||||
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>
|
||||
</NuxtLink>
|
||||
<!-- 无跳转链接 -->
|
||||
|
@ -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>
|
@ -8,6 +8,7 @@ export interface SidebarNavItem {
|
||||
url?: string | RouteLocationRaw
|
||||
icon: LucideIcon | string
|
||||
isActive?: boolean
|
||||
isExternal?: boolean
|
||||
items?: {
|
||||
title: string
|
||||
url: string
|
||||
|
@ -1,7 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { navigationMenuTriggerStyle } from '@/components/ui/navigation-menu'
|
||||
import type { IBreadcrumbItem } from '~/types'
|
||||
|
||||
defineProps({
|
||||
breadcrumbs: {
|
||||
type: Array as () => IBreadcrumbItem[],
|
||||
default: () => [],
|
||||
},
|
||||
hideTrigger: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@ -48,16 +52,43 @@ export const topbarNavDefaults = [
|
||||
|
||||
<template>
|
||||
<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 -->
|
||||
<div class="flex items-center gap-2 px-4 w-full">
|
||||
<SidebarTrigger
|
||||
v-if="!hideTrigger"
|
||||
class="-ml-1"
|
||||
/>
|
||||
<div class="flex justify-between items-center gap-2 px-4 w-full">
|
||||
<div
|
||||
class="flex items-center gap-2"
|
||||
: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" />
|
||||
<div class="flex-1 flex justify-center">
|
||||
</div>
|
||||
<!-- <div class="flex-1 flex justify-center">
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
<NavigationMenuItem
|
||||
@ -87,21 +118,18 @@ export const topbarNavDefaults = [
|
||||
</NavigationMenuItem>
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
</div> -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
size="icon">
|
||||
<Icon
|
||||
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
|
||||
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>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
@ -1,4 +1,5 @@
|
||||
// @ts-check
|
||||
import prettier from 'prettier'
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt(
|
||||
@ -6,6 +7,10 @@ export default withNuxt(
|
||||
{
|
||||
rules: {
|
||||
'vue/html-self-closing': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
},
|
||||
plugins: {
|
||||
prettier,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
@ -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>
|
||||
<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 />
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
@ -2,17 +2,7 @@
|
||||
|
||||
<template>
|
||||
<div class="w-full min-h-screen flex flex-col font-sans">
|
||||
<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>
|
||||
<AppTopbar hide-trigger />
|
||||
<div class="min-h-[100vh] flex-1 md:min-h-min p-4">
|
||||
<slot />
|
||||
</div>
|
||||
|
@ -35,6 +35,7 @@ export default defineNuxtConfig({
|
||||
indent: 2,
|
||||
quotes: 'single',
|
||||
semi: false,
|
||||
commaDangle: 'only-multiline',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -18,6 +18,9 @@
|
||||
"@nuxt/test-utils": "3.17.2",
|
||||
"@tanstack/vue-table": "^8.21.2",
|
||||
"@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",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@ -49,7 +52,9 @@
|
||||
"@vueuse/nuxt": "^13.0.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"dayjs-nuxt": "^2.1.11",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"pinia-plugin-persistedstate": "^4.2.0",
|
||||
"prettier": "^3.5.3",
|
||||
"shadcn-nuxt": "2.0.1",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
|
@ -1,8 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { IResponse } from '~/api'
|
||||
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'
|
||||
|
||||
const {
|
||||
@ -10,61 +8,44 @@ const {
|
||||
params: { id },
|
||||
} = useRoute()
|
||||
const router = useRouter()
|
||||
const { setBreadcrumbs } = useBreadcrumbs()
|
||||
|
||||
const {
|
||||
data: course,
|
||||
status: courseStatus,
|
||||
error: courseError,
|
||||
} = await useAsyncData<
|
||||
} = useAsyncData<
|
||||
IResponse<{
|
||||
data: ICourse
|
||||
}>,
|
||||
IResponse
|
||||
>(() => getCourseDetail(id as string))
|
||||
|
||||
definePageMeta({
|
||||
requiresAuth: true,
|
||||
hideSidebar: true,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => course.value,
|
||||
(data) => {
|
||||
useHead({
|
||||
title: `${course.value?.data.courseName || '课程不存在'} - 课程管理`,
|
||||
})
|
||||
|
||||
definePageMeta({
|
||||
requiresAuth: true,
|
||||
})
|
||||
|
||||
const sideNav: SidebarNavGroup[] = [
|
||||
if (data?.data.courseName) {
|
||||
setBreadcrumbs([
|
||||
{
|
||||
items: [
|
||||
{
|
||||
title: '课程章节',
|
||||
url: `/course/${id}/chapters`,
|
||||
icon: 'tabler:books',
|
||||
label: '课程管理',
|
||||
path: '/course',
|
||||
},
|
||||
{
|
||||
title: '教师团队',
|
||||
url: `/course/${id}/team`,
|
||||
icon: 'tabler:users-group',
|
||||
label: data.data.courseName || '课程不存在',
|
||||
},
|
||||
{
|
||||
title: '学生班级',
|
||||
url: `/course/${id}/classes`,
|
||||
icon: 'tabler:school',
|
||||
])
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '学生评价',
|
||||
url: `/course/${id}/evaluation`,
|
||||
icon: 'tabler:mood-smile',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const topNav = [
|
||||
{
|
||||
title: '课程管理',
|
||||
to: `/course/${id}`,
|
||||
icon: 'tabler:layout-dashboard',
|
||||
},
|
||||
...topbarNavDefaults.slice(1),
|
||||
]
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (fullPath === `/course/${id}`) {
|
||||
@ -72,42 +53,37 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => useRoute().fullPath, (newPath) => {
|
||||
watch(
|
||||
() => useRoute().fullPath,
|
||||
(newPath) => {
|
||||
if (newPath === `/course/${id}`) {
|
||||
router.replace(`/course/${id}/chapters`)
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPageWithSidebar :sidebar-nav="sideNav">
|
||||
<template #topbar>
|
||||
<AppTopbar :nav="topNav" />
|
||||
</template>
|
||||
|
||||
<template #sidebar="{ sidebarNav }">
|
||||
<AppSidebar :nav="sidebarNav">
|
||||
<template #extra-header>
|
||||
<div class="px-4">
|
||||
<div class="flex flex-col items-center gap-1 overflow-hidden">
|
||||
<NuxtImg
|
||||
:src="course?.data.previewUrl || '/images/bg_home.jpg'"
|
||||
alt="课程封面"
|
||||
class="w-full aspect-video rounded-md shadow-md"
|
||||
/>
|
||||
<h1
|
||||
class="text-base font-medium drop-shadow-md text-ellipsis line-clamp-1"
|
||||
<AppContainer
|
||||
:subnavs="[
|
||||
{
|
||||
label: '课程章节',
|
||||
to: `/course/${id}/chapters`,
|
||||
},
|
||||
{
|
||||
label: '教师团队',
|
||||
to: `/course/${id}/team`,
|
||||
},
|
||||
{
|
||||
label: '学生班级',
|
||||
to: `/course/${id}/classes`,
|
||||
},
|
||||
{
|
||||
label: '学生评价',
|
||||
to: `/course/${id}/evaluation`,
|
||||
},
|
||||
]"
|
||||
>
|
||||
{{ course?.data.courseName || "未知课程" }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AppSidebar>
|
||||
</template>
|
||||
|
||||
<ClientOnly>
|
||||
<Suspense>
|
||||
<div v-if="courseStatus === 'error'">
|
||||
<EmptyScreen
|
||||
title="课程加载失败"
|
||||
@ -124,9 +100,7 @@ watch(() => useRoute().fullPath, (newPath) => {
|
||||
v-else
|
||||
:page-key="fullPath"
|
||||
/>
|
||||
</Suspense>
|
||||
</ClientOnly>
|
||||
</AppPageWithSidebar>
|
||||
</AppContainer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
@ -6,7 +6,6 @@ import type { FetchError } from 'ofetch'
|
||||
import { createCourse, deleteCourse, listUserCourses } from '~/api/course'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'no-sidebar',
|
||||
requiresAuth: true,
|
||||
})
|
||||
|
||||
@ -100,7 +99,8 @@ const onDeleteCourse = (courseId: number) => {
|
||||
</script>
|
||||
|
||||
<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 items-center gap-2">
|
||||
<Form
|
||||
@ -302,13 +302,13 @@ const onDeleteCourse = (courseId: number) => {
|
||||
size="sm"
|
||||
@click="deleteMode = !deleteMode"
|
||||
>
|
||||
{{ deleteMode ? "退出删除" : "删除课程" }}
|
||||
{{ deleteMode ? '退出删除' : '删除课程' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="coursesList?.rows && coursesList.rows.length > 0"
|
||||
class="grid grid-cols-5 gap-8"
|
||||
class="grid grid-cols-6 gap-8"
|
||||
>
|
||||
<CourseCard
|
||||
v-for="course in coursesList?.rows"
|
||||
@ -323,9 +323,7 @@ const onDeleteCourse = (courseId: number) => {
|
||||
title="暂无课程"
|
||||
icon="fluent-color:people-list-24"
|
||||
>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
还没有创建或加入课程
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground">还没有创建或加入课程</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@ -339,6 +337,7 @@ const onDeleteCourse = (courseId: number) => {
|
||||
</Button>
|
||||
</EmptyScreen>
|
||||
</div>
|
||||
</AppContainer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({
|
||||
layout: 'no-sidebar',
|
||||
hideSidebar: true,
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -1,6 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
definePageMeta({
|
||||
layout: 'no-sidebar',
|
||||
requiresAuth: true,
|
||||
})
|
||||
</script>
|
||||
|
@ -1,9 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
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 {
|
||||
params: { resource_url },
|
||||
} = useRoute()
|
||||
const { setBreadcrumbs } = useBreadcrumbs()
|
||||
|
||||
const url = computed(() => {
|
||||
return atob(resource_url as string)
|
||||
@ -28,36 +38,76 @@ const fileType = computed(() => {
|
||||
if (ext === 'mp3' || ext === 'wav') return 'audio'
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-screen">
|
||||
<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"
|
||||
>
|
||||
<VueOfficePptx
|
||||
v-if="fileType === 'ppt'"
|
||||
:src="url"
|
||||
class="w-full h-full"
|
||||
:options="{
|
||||
onMounted(() => {
|
||||
useHead({
|
||||
title: `${fileType.value.toUpperCase()} 资源预览`,
|
||||
})
|
||||
|
||||
setBreadcrumbs([
|
||||
{
|
||||
label: `${fileType.value.toUpperCase()} 资源预览`,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
const vueOfficeOptions = {
|
||||
autoSize: true,
|
||||
autoHeight: true,
|
||||
autoWidth: true,
|
||||
autoScale: true,
|
||||
autoRotate: 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>
|
||||
</AppContainer>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
@ -6,6 +6,10 @@ import * as z from 'zod'
|
||||
import type { FetchError } from 'ofetch'
|
||||
import { userLogin, type LoginResponse } from '~/api'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'blank',
|
||||
})
|
||||
|
||||
const loginState = useLoginState()
|
||||
const {
|
||||
query: { redirect },
|
||||
|
116
pnpm-lock.yaml
generated
116
pnpm-lock.yaml
generated
@ -26,6 +26,15 @@ importers:
|
||||
'@vee-validate/zod':
|
||||
specifier: ^4.15.0
|
||||
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':
|
||||
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))
|
||||
@ -111,9 +120,15 @@ importers:
|
||||
dayjs-nuxt:
|
||||
specifier: ^2.1.11
|
||||
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:
|
||||
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)))
|
||||
prettier:
|
||||
specifier: ^3.5.3
|
||||
version: 3.5.3
|
||||
shadcn-nuxt:
|
||||
specifier: 2.0.1
|
||||
version: 2.0.1(magicast@0.3.5)
|
||||
@ -959,6 +974,10 @@ packages:
|
||||
resolution: {integrity: sha512-25L86MyPvnlQoX2MTIV2OiUcb6vJ6aRbFa9pbwByn95INKD5mFH2smgjDhq+fwJoqAgvgbdJLj6Tz7V9X5CFAQ==}
|
||||
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':
|
||||
resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==}
|
||||
|
||||
@ -1404,6 +1423,36 @@ packages:
|
||||
vue:
|
||||
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':
|
||||
resolution: {integrity: sha512-+V7Kctzl6f6+Yk4NaD/wQGRIkqLWcowe0jEhPexWQb8Oilbzt1OyhWRWcMsxNDTdrgm6aMLP+0/tmw27cxddMg==}
|
||||
peerDependencies:
|
||||
@ -2268,6 +2317,20 @@ packages:
|
||||
peerDependencies:
|
||||
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:
|
||||
resolution: {integrity: sha512-U8oZI77SBtH8U3ulZ05iu0qEzIizyEDXd+BWHvyVxTOjGwcDcvy/kEpgFG4DYca2ByRLiVPFZ2GeH7j1pdvZTA==}
|
||||
engines: {node: ^18 || >=20}
|
||||
@ -2379,6 +2442,9 @@ packages:
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-diff@1.3.0:
|
||||
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
|
||||
|
||||
fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
|
||||
@ -3632,6 +3698,15 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
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:
|
||||
resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==}
|
||||
engines: {node: ^14.13.1 || >=16.0.0}
|
||||
@ -4038,6 +4113,10 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
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:
|
||||
resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
@ -5759,6 +5838,8 @@ snapshots:
|
||||
|
||||
'@pkgr/core@0.2.2': {}
|
||||
|
||||
'@pkgr/core@0.2.4': {}
|
||||
|
||||
'@polka/url@1.0.0-next.28': {}
|
||||
|
||||
'@poppinss/colors@4.1.4':
|
||||
@ -6178,6 +6259,21 @@ snapshots:
|
||||
optionalDependencies:
|
||||
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))':
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.8.2)
|
||||
@ -7091,6 +7187,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- 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)):
|
||||
dependencies:
|
||||
'@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-diff@1.3.0: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
@ -8741,6 +8846,12 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
@ -9216,6 +9327,11 @@ snapshots:
|
||||
csso: 5.0.5
|
||||
picocolors: 1.1.1
|
||||
|
||||
synckit@0.11.4:
|
||||
dependencies:
|
||||
'@pkgr/core': 0.2.4
|
||||
tslib: 2.8.1
|
||||
|
||||
synckit@0.9.2:
|
||||
dependencies:
|
||||
'@pkgr/core': 0.1.2
|
||||
|
24
stores/breadcrumbs.ts
Normal file
24
stores/breadcrumbs.ts
Normal 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,
|
||||
}
|
||||
})
|
@ -2,3 +2,8 @@ export * from './user'
|
||||
export * from './course'
|
||||
|
||||
export type { FetchError } from 'ofetch'
|
||||
|
||||
export interface IBreadcrumbItem {
|
||||
label: string
|
||||
path?: string
|
||||
}
|
||||
|
8
types/nuxt.d.ts
vendored
Normal file
8
types/nuxt.d.ts
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
declare module '#app' {
|
||||
interface PageMeta {
|
||||
/** 隐藏侧边栏 */
|
||||
hideSidebar?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
Loading…
Reference in New Issue
Block a user