feat: v6 pre-release

This commit is contained in:
Hooray
2026-04-01 03:38:00 +08:00
commit ed1ff3e2ab
790 changed files with 88886 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import dayjs from '@/utils/dayjs'
import { ua } from '@/utils/ua'
import Provider from './ui/provider/index.vue'
import 'dayjs/locale/zh-cn'
const route = useRoute()
const appSettingsStore = useAppSettingsStore()
const { auth } = useAppAuth()
const { generateTitle } = useAppMenu()
document.body.setAttribute('data-os', ua.getOS().name || '')
const isAuth = computed(() => {
return route.matched.every((item) => {
return auth(item.meta.auth ?? '')
})
})
// 设置网页 title
watch([
() => appSettingsStore.settings.app.dynamicTitle,
() => appSettingsStore.title,
], () => {
nextTick(() => {
if (appSettingsStore.settings.app.dynamicTitle && appSettingsStore.title) {
document.title = `${generateTitle(appSettingsStore.title)} - ${import.meta.env.VITE_APP_TITLE}`
}
else {
document.title = import.meta.env.VITE_APP_TITLE
}
})
}, {
immediate: true,
deep: true,
})
onMounted(() => {
appSettingsStore.setMode(document.documentElement.clientWidth)
dayjs.locale('zh-cn')
window.addEventListener('resize', () => {
appSettingsStore.setMode(document.documentElement.clientWidth)
})
})
</script>
<template>
<Provider>
<RouterView v-slot="{ Component }">
<AppNotSupportedMobile v-if="!appSettingsStore.settings.app.mobile && appSettingsStore.mode === 'mobile'" />
<Component :is="Component" v-else-if="isAuth" />
<AppNotAllowed v-else />
</RouterView>
<AppBackToTop />
<FaToast :theme="appSettingsStore.currentColorScheme" />
<AppSystemInfo />
</Provider>
</template>
+109
View File
@@ -0,0 +1,109 @@
import axios from 'axios'
// import qs from 'qs'
// 请求重试配置
const MAX_RETRY_COUNT = 3 // 最大重试次数
const RETRY_DELAY = 1000 // 重试延迟时间(毫秒)
// 扩展 AxiosRequestConfig 类型
declare module 'axios' {
export interface AxiosRequestConfig {
retry?: boolean
retryCount?: number
fake?: boolean
}
}
const api = axios.create({
baseURL: (import.meta.env.DEV && import.meta.env.VITE_ENABLE_PROXY) ? '/proxy/' : import.meta.env.VITE_APP_API_BASEURL,
timeout: 1000 * 60,
responseType: 'json',
})
api.interceptors.request.use(
(request) => {
// 如果设置了 fake 属性,强制使用 fake 的 baseURL
if (request.fake) {
request.baseURL = '/fake/'
}
// 全局拦截请求发送前提交的参数
const appAccountStore = useAppAccountStore()
// 设置请求头
if (request.headers) {
request.headers['Accept-Language'] = 'zh-CN'
if (appAccountStore.isLogin) {
request.headers.Token = appAccountStore.token
}
}
// 是否将 POST 请求参数进行字符串化处理
if (request.method === 'post') {
// request.data = qs.stringify(request.data, {
// arrayFormat: 'brackets',
// })
}
return request
},
)
// 处理错误信息的函数
function handleError(error: any) {
if (error.status === 401) {
useAppAccountStore().requestLogout()
}
else {
faToast.error('Error', {
description: error.message,
})
}
return Promise.reject(error)
}
api.interceptors.response.use(
(response) => {
/**
* 全局拦截请求发送后返回的数据,如果数据有报错则在这做全局的错误提示
* 假设返回数据格式为:{ status: 1, error: '', data: {} }
* 规则是当 status 为 1 时表示请求成功,为 0 时表示接口需要登录或者登录状态失效,需要重新登录
* 请求出错时 error 会返回错误信息
*/
if (typeof response.data === 'object') {
if (response.data.status === 1) {
if (response.data.error !== '') {
faToast.warning('Warning', {
description: response.data.error,
})
return Promise.reject(response.data)
}
}
else {
useAppAccountStore().requestLogout()
}
return Promise.resolve(response.data)
}
else {
return Promise.reject(response.data)
}
},
async (error) => {
// 获取请求配置
const config = error.config
// 如果配置不存在或未启用重试,则直接处理错误
if (!config || !config.retry) {
return handleError(error)
}
// 设置重试次数
config.retryCount = config.retryCount || 0
// 判断是否超过重试次数
if (config.retryCount >= MAX_RETRY_COUNT) {
return handleError(error)
}
// 重试次数自增
config.retryCount += 1
// 延迟重试
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY))
// 重新发起请求
return api(config)
},
)
export default api
+253
View File
@@ -0,0 +1,253 @@
import { faker } from '@faker-js/faker'
import { defineFakeRoute } from 'vite-plugin-fake-server/client'
export default defineFakeRoute([
{
url: '/fake/app/route/list',
method: 'get',
response: () => {
return {
error: '',
status: 1,
data: [
// 主导航
{
meta: {
title: '演示',
icon: 'uim:box',
},
children: [
// 次导航(一级路由)
{
path: '/multilevel_menu_example',
component: 'Layout',
name: 'multilevelMenuExample',
meta: {
title: 'route.multimenu.root',
icon: 'heroicons-solid:menu-alt-3',
},
children: [
// 次导航(二级路由)
{
path: 'page',
name: 'multilevelMenuExample1',
component: 'multilevel_menu_example/page.vue',
meta: {
title: 'route.multimenu.page',
},
},
{
path: 'level2',
name: 'multilevelMenuExample2',
meta: {
title: 'route.multimenu.level2.root',
},
children: [
// 次导航(三级路由)
{
path: 'page',
name: 'multilevelMenuExample2-1',
component: 'multilevel_menu_example/level2/page.vue',
meta: {
title: 'route.multimenu.level2.page',
},
},
{
path: 'level3',
name: 'multilevelMenuExample2-2',
meta: {
title: 'route.multimenu.level2.level3.root',
},
children: [
// 次导航(四级路由)
{
path: 'page1',
name: 'multilevelMenuExample2-2-1',
component: 'multilevel_menu_example/level2/level3/page1.vue',
meta: {
title: 'route.multimenu.level2.level3.page1',
},
},
{
path: 'page2',
name: 'multilevelMenuExample2-2-2',
component: 'multilevel_menu_example/level2/level3/page2.vue',
meta: {
title: 'route.multimenu.level2.level3.page2',
},
},
],
},
],
},
],
},
{
path: '/permission_example',
component: 'Layout',
name: 'permissionExample',
meta: {
title: 'route.permission',
icon: 'ri:shield-keyhole-line',
},
children: [
{
path: 'index',
name: 'permissionExampleIndex',
component: 'permission_example/index.vue',
meta: {
title: 'route.permission',
menu: false,
breadcrumb: false,
activeMenu: '/permission_example',
},
},
{
path: 'test',
name: 'permissionExampleTest',
component: 'permission_example/test.vue',
meta: {
title: '测试页面',
auth: ['permission.browse'],
menu: false,
breadcrumb: false,
activeMenu: '/permission_example',
},
},
],
},
],
},
],
}
},
},
{
url: '/fake/app/menu/list',
method: 'get',
response: () => {
return {
error: '',
status: 1,
data: [
{
meta: {
title: '演示',
icon: 'uim:box',
},
children: [
{
meta: {
title: '多级导航',
icon: 'heroicons-solid:menu-alt-3',
},
children: [
{
path: '/multilevel_menu_example/page',
meta: {
title: '导航1',
},
},
{
meta: {
title: '导航2',
},
children: [
{
path: '/multilevel_menu_example/level2/page',
meta: {
title: '导航2-1',
},
},
{
meta: {
title: '导航2-2',
},
children: [
{
path: '/multilevel_menu_example/level2/level3/page1',
meta: {
title: '导航2-2-1',
},
},
{
path: '/multilevel_menu_example/level2/level3/page2',
meta: {
title: '导航2-2-2',
},
},
],
},
],
},
],
},
{
path: '/permission_example',
meta: {
title: '权限验证',
icon: 'ri:shield-keyhole-line',
},
},
],
},
],
}
},
},
{
url: '/fake/app/account/login',
method: 'post',
response: ({ body }) => {
return {
error: '',
status: 1,
data: {
account: body.account,
token: `${body.account}:${faker.internet.jwt()}`,
avatar: `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${body.account}`,
},
}
},
},
{
url: '/fake/app/account/permission',
method: 'get',
response: ({ headers }) => {
let permissions: string[] = []
if (headers.token?.indexOf('admin') === 0) {
permissions = [
'pages.general:browse',
'pages.form:browse',
'pages.list:browse',
'pages.shop:browse',
]
}
else if (headers.token?.indexOf('test') === 0) {
permissions = [
'pages.general:browse',
]
}
return {
error: '',
status: 1,
data: {
permissions,
},
}
},
},
{
url: '/fake/app/account/password/edit',
method: 'post',
response: () => {
return {
error: '',
status: 1,
data: {
isSuccess: true,
},
}
},
},
])
+30
View File
@@ -0,0 +1,30 @@
import api from '../index'
export default {
// 后端获取路由数据
routeList: () => api.get('app/route/list', {
fake: true,
}),
// 登录
login: (data: {
account: string
password: string
}) => api.post('app/account/login', data, {
fake: true,
}),
// 获取权限
permission: () => api.get('app/account/permission', {
fake: true,
}),
// 修改密码
passwordEdit: (data: {
password: string
newPassword: string
}) => api.post('app/account/password/edit', data, {
fake: true,
}),
}
+86
View File
@@ -0,0 +1,86 @@
import { faker } from '@faker-js/faker'
import { defineFakeRoute } from 'vite-plugin-fake-server/client'
// 标准模块
const standardModuleList: any[] = []
for (let i = 0; i < 50; i++) {
standardModuleList.push({
id: i + 1,
title: faker.book.title(),
})
}
export default defineFakeRoute([
{
url: '/fake/standard_module/list',
method: 'get',
response: ({ query }) => {
const { title, from, limit } = query
const list = standardModuleList.filter((item) => {
return title ? item.title.includes(title) : true
})
const pageList = list.filter((_item, index) => {
return index >= ~~from && index < (~~from + ~~limit)
})
return {
error: '',
status: 1,
data: {
list: pageList,
total: list.length,
},
}
},
},
{
url: '/fake/standard_module/detail',
method: 'get',
response: ({ query }) => {
const info = standardModuleList.filter(item => item.id === ~~query.id)
return {
error: '',
status: 1,
data: info[0],
}
},
},
{
url: '/fake/standard_module/create',
method: 'post',
response: () => {
return {
error: '',
status: 1,
data: {
isSuccess: true,
},
}
},
},
{
url: '/fake/standard_module/edit',
method: 'post',
response: () => {
return {
error: '',
status: 1,
data: {
isSuccess: true,
},
}
},
},
{
url: '/fake/standard_module/delete',
method: 'post',
response: () => {
return {
error: '',
status: 1,
data: {
isSuccess: true,
},
}
},
},
])
+33
View File
@@ -0,0 +1,33 @@
import api from '../index'
export default {
list: (data: {
title?: string
from: number
limit: number
}) => api.get('standard_module/list', {
params: data,
fake: true,
}),
detail: (id: number | string) => api.get('standard_module/detail', {
params: {
id,
},
fake: true,
}),
create: (data: any) => api.post('standard_module/create', data, {
fake: true,
}),
edit: (data: any) => api.post('standard_module/edit', data, {
fake: true,
}),
delete: (id: number | string) => api.post('standard_module/delete', {
id,
}, {
fake: true,
}),
}
+17
View File
@@ -0,0 +1,17 @@
import { defineFakeRoute } from 'vite-plugin-fake-server/client'
export default defineFakeRoute([
{
url: '/fake/upload',
method: 'post',
response: () => {
return {
error: '',
status: 1,
data: {
url: 'https://fantastic-admin.hurui.me/logo.svg',
},
}
},
},
])
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200.781" height="200" class="icon" viewBox="0 0 1028 1024"><path d="M989.867 234.667H499.2c-17.067 0-34.133-21.334-34.133-42.667 0-25.6 12.8-42.667 34.133-42.667h490.667c17.066 0 34.133 17.067 34.133 42.667 0 21.333-12.8 42.667-34.133 42.667m-473.6 128h465.066c25.6 0 46.934 21.333 46.934 42.666 0 25.6-21.334 42.667-46.934 42.667H516.267c-25.6 0-46.934-17.067-46.934-42.667s21.334-42.666 46.934-42.666m0 298.666c-25.6 0-46.934-21.333-46.934-42.666 0-25.6 21.334-42.667 46.934-42.667h465.066c25.6 0 46.934 17.067 46.934 42.667s-21.334 42.666-46.934 42.666zm4.266 128H972.8c29.867 0 51.2 17.067 51.2 42.667s-21.333 42.667-51.2 42.667H520.533c-29.866 0-51.2-17.067-51.2-42.667s21.334-42.667 51.2-42.667m-192 25.6c-17.066 17.067-46.933 17.067-64 0L12.8 541.867c-17.067-17.067-17.067-51.2 0-68.267l251.733-273.067c17.067-17.066 46.934-17.066 64 0s17.067 51.2 0 68.267L106.667 507.733l221.866 238.934c17.067 21.333 17.067 51.2 0 68.266"/></svg>

After

Width:  |  Height:  |  Size: 986 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#dca54f" d="m136.533 273.067 669.048 422.058c10.65 6.725 15.838 20.48 12.697 33.588-3.106 13.073-13.824 22.186-26.077 22.22H238.42c-27.306 0-50.346-22.425-53.726-52.326l-48.162-425.54z"/><path fill="#dca54f" d="M834.219 698.607c-3.345 29.9-26.385 52.326-53.692 52.326H245.794c-12.288 0-41.984-9.113-45.124-22.186-3.175-13.073 2.048-26.863 12.697-33.588l668.98-422.092-48.128 425.506z"/><path fill="#e7b15c" d="m512 170.667 298.428 490.598a61.44 61.44 0 0 1 2.184 59.324c-9.489 18.705-27.818 30.344-47.718 30.344H512z"/><path fill="#f2d59c" d="M512 170.667 196.062 666.01a61.44 61.44 0 0 0-2.185 59.323c9.49 18.705 27.341 25.6 47.24 25.6H512z"/><path fill="#e7b15c" d="M459.776 153.327c0 18.193 9.967 34.987 26.112 44.1a53.35 53.35 0 0 0 52.224 0c16.145-9.113 26.112-25.941 26.112-44.1 0-28.126-23.381-50.927-52.224-50.927s-52.224 22.801-52.224 50.927M851.319 255.18c-.41 18.432 9.455 35.67 25.771 45.022 16.316 9.318 36.523 9.318 52.873 0 16.315-9.353 26.18-26.59 25.77-45.056-.614-27.648-23.825-49.8-52.224-49.8-28.364 0-51.541 22.152-52.19 49.834m-783.018 0c-.444 18.432 9.42 35.67 25.736 45.022 16.316 9.318 36.523 9.318 52.873 0 16.316-9.353 26.18-26.59 25.77-45.056-.614-27.648-23.825-49.8-52.223-49.8-28.365 0-51.542 22.152-52.19 49.834z"/><path fill="#dca54f" d="M238.933 819.2h546.134q34.133 0 34.133 34.133t-34.133 34.134H238.933q-34.133 0-34.133-34.134t34.133-34.133"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M512 85.333c235.648 0 426.667 191.019 426.667 426.667S747.648 938.667 512 938.667 85.333 747.648 85.333 512 276.352 85.333 512 85.333m0 85.334a341.333 341.333 0 1 0 0 682.666 341.333 341.333 0 0 0 0-682.666m0 298.666c85.333 0 156.459 14.208 213.333 42.667a213.333 213.333 0 0 1-426.666 0c56.874-28.459 128-42.667 213.333-42.667M362.667 298.667A106.67 106.67 0 0 1 467.2 384H258.133a106.67 106.67 0 0 1 104.534-85.333m298.666 0A106.67 106.67 0 0 1 765.867 384H556.8a106.67 106.67 0 0 1 104.533-85.333"/></svg>

After

Width:  |  Height:  |  Size: 619 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M512 938.667C276.352 938.667 85.333 747.648 85.333 512S276.352 85.333 512 85.333 938.667 276.352 938.667 512 747.648 938.667 512 938.667m0-85.334a341.333 341.333 0 1 0 0-682.666 341.333 341.333 0 0 0 0 682.666M341.333 554.667h341.334a170.667 170.667 0 1 1-341.334 0m0-85.334a64 64 0 1 1 0-128 64 64 0 0 1 0 128m341.334 0a64 64 0 1 1 0-128 64 64 0 0 1 0 128"/></svg>

After

Width:  |  Height:  |  Size: 476 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M512 938.667C276.352 938.667 85.333 747.648 85.333 512S276.352 85.333 512 85.333 938.667 276.352 938.667 512 747.648 938.667 512 938.667m0-85.334a341.333 341.333 0 1 0 0-682.666 341.333 341.333 0 0 0 0 682.666m-213.333-128a213.333 213.333 0 0 1 426.666 0H640a128 128 0 0 0-256 0zm42.666-256a64 64 0 1 1 0-128 64 64 0 0 1 0 128m341.334 0a64 64 0 1 1 0-128 64 64 0 0 1 0 128"/></svg>

After

Width:  |  Height:  |  Size: 492 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#ffc500" d="M512 768 262.059 899.413a28.444 28.444 0 0 1-41.245-30.009l47.702-278.3L66.332 394.012a28.444 28.444 0 0 1 15.759-48.526l279.438-40.59L486.485 51.684a28.444 28.444 0 0 1 51.03 0L662.47 304.896l279.438 40.59a28.444 28.444 0 0 1 15.759 48.526l-202.184 197.12 47.73 278.272a28.444 28.444 0 0 1-41.273 29.98z"/><path fill="#fed902" d="M512 768 262.059 899.413a28.444 28.444 0 0 1-41.245-30.009l47.702-278.3q54.186-286.208 93.013-286.208c38.827 0 393.955 261.774 393.955 286.208 0 16.299-81.18 75.264-243.484 176.896"/></svg>

After

Width:  |  Height:  |  Size: 646 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#ffa100" d="M270.219 121.212h483.474a29.26 29.26 0 0 1 23.347 11.645l188.416 249.885a29.26 29.26 0 0 1-1.843 37.42l-429.67 467.587a29.257 29.257 0 0 1-43.037.059L60.416 421.595a29.26 29.26 0 0 1-1.931-37.39l188.328-251.26a29.26 29.26 0 0 1 23.406-11.703z"/><path fill="#ffc663" d="m768.293 121.212 197.163 261.56a29.26 29.26 0 0 1-1.843 37.39L532.714 889.066a11.703 11.703 0 0 1-20.304-7.9L512 257.025l256.293-135.84z"/><path fill="#fff" d="M721.598 386.34a29 29 0 0 1 .995 1.025l22.733 23.873a29.257 29.257 0 0 1 0 40.346l-189.411 198.89-22.733 23.874a29 29 0 0 1-1.726 1.668l1.755-1.668a29.5 29.5 0 0 1-19.456 9.011 28.94 28.94 0 0 1-18.08-4.915 30.2 30.2 0 0 1-4.857-4.096l1.96 1.872-.965-.877-.995-.995-22.733-23.874-189.41-198.89a29.257 29.257 0 0 1 0-40.375l22.732-23.844a29.257 29.257 0 0 1 42.364 0L512 563.96l168.229-176.596a29.257 29.257 0 0 1 41.37-1.024z"/></svg>

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-25 -46 256 256" xml:space="preserve">
<path d="M0,82.57C0,58.4,0,34.23,0,10.06C0,1.35,1.35,0,10.03,0C45.36,0,80.7-0.01,116.03,0.03c1.77,0,3.59,0.26,5.29,0.74
c3.07,0.87,3.57,3.21,2.45,5.8c-4.37,10.1-8.83,20.15-13.31,30.2c-1.44,3.23-4.06,4.26-7.54,4.25c-18.5-0.09-37,0.1-55.5-0.14
c-4.95-0.06-6.49,2.12-6.46,6.47c0.02,3,0.11,6.01-0.02,9c-0.3,6.8,0.34,9.29,9.51,9.62c5.36,0.19,10.73,0.04,16.09,0.04
c7.57,0,15.15-0.04,22.72,0.01c5.13,0.03,6.6,2.12,4.59,6.89C90.43,81.09,86.27,89,83.4,97.35c-2.59,7.55-8.57,7.27-14.28,7.56
c-7.48,0.38-15,0.17-22.5,0.06c-3.69-0.05-4.96,1.04-4.86,5.18c0.36,15.83,0.26,31.67,0.12,47.5c-0.04,4.2-2.26,6.53-6.94,6.42
c-9-0.21-18-0.06-27-0.06c-6.05,0-7.91-1.8-7.92-7.94C-0.02,131.57,0,107.07,0,82.57z" fill="#35495e" />
<path d="M132.98,94.13c-8.21,18.88-16.33,37.1-24.06,55.48c-6.02,14.31-5.81,14.4-21.31,14.4c-6.67,0-13.33,0.05-20-0.01
c-5.91-0.05-7.71-2.44-5.46-7.61c6.86-15.8,13.86-31.55,20.82-47.31c9.41-21.32,18.81-42.63,28.24-63.94
c5.59-12.62,11.14-25.25,16.94-37.77c0.82-1.78,2.95-4.09,4.47-4.09c1.63,0,4,2.11,4.78,3.86c13.4,29.78,26.56,59.66,39.87,89.49
c6.48,14.53,13.16,28.97,19.72,43.46c2.57,5.68,5.11,11.38,7.61,17.1c1.61,3.68-0.13,6.75-4.12,6.77
c-23.83,0.08-47.66,0.09-71.49-0.01c-4.03-0.02-5.46-2.47-3.98-6.17c2.26-5.63,4.77-11.16,6.95-16.82c1.74-4.54,4.92-6.06,9.6-5.98
c9.65,0.16,9.89-0.55,5.89-9.13c-4.57-9.81-8.76-19.8-13.14-29.7C134.08,95.62,133.68,95.17,132.98,94.13z" fill="#42b883" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+80
View File
@@ -0,0 +1,80 @@
/* 页面布局 CSS 变量 */
:root {
color-scheme: light;
/* 头部宽度(默认自适应宽度,可固定宽度,固定宽度后为居中显示) */
--g-header-width: 100%;
/* 头部高度 */
--g-header-height: 60px;
/* 侧边栏宽度 */
--g-main-sidebar-width: 80px;
--g-sub-sidebar-width: 220px;
--g-sub-sidebar-collapse-width: 64px;
/* 侧边栏 Logo 区域高度 */
--g-sidebar-logo-height: 50px;
/* 标签栏高度 */
--g-tabbar-height: 50px;
/* 工具栏高度 */
--g-toolbar-height: 50px;
&.dark {
color-scheme: dark;
}
}
/* 明暗模式过渡动画 */
::view-transition-old(root),
::view-transition-new(root) {
mix-blend-mode: normal;
animation: none;
}
::view-transition-old(root) {
z-index: 0;
}
::view-transition-new(root) {
z-index: 1;
}
.dark {
&::view-transition-old(root) {
z-index: 1;
}
&::view-transition-new(root) {
z-index: 0;
}
}
.disable-color-scheme-transition-duration *,
.disable-color-scheme-transition-duration *::before,
.disable-color-scheme-transition-duration *::after {
transition-duration: 0ms !important;
}
html,
body {
height: 100%;
text-autospace: normal;
}
body {
box-sizing: border-box;
margin: 0;
-webkit-tap-highlight-color: transparent;
}
#app {
height: 100%;
}
/* textarea 字体跟随系统 */
textarea {
font-family: inherit;
}
+63
View File
@@ -0,0 +1,63 @@
#nprogress {
pointer-events: none;
.bar {
position: fixed;
top: 0;
left: 0;
z-index: 3000;
width: 100%;
height: 2px;
background: oklch(var(--primary));
}
.peg {
position: absolute;
right: 0;
display: block;
width: 100px;
height: 100%;
box-shadow: 0 0 10px oklch(var(--primary)), 0 0 5px oklch(var(--primary));
opacity: 1;
transform: rotate(3deg) translate(0, -4px);
}
.spinner {
position: fixed;
top: 11px;
right: 14px;
z-index: 2000;
display: block;
.spinner-icon {
box-sizing: border-box;
width: 18px;
height: 18px;
border: solid 2px transparent;
border-top-color: oklch(var(--primary));
border-left-color: oklch(var(--primary));
border-radius: 50%;
animation: nprogress-spinner 400ms linear infinite;
}
}
}
.nprogress-custom-parent {
position: relative;
overflow: hidden;
#nprogress .spinner,
#nprogress .bar {
position: absolute;
}
}
@keyframes nprogress-spinner {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes nprogress-spinner {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
+53
View File
@@ -0,0 +1,53 @@
// 文字超出隐藏,默认为单行超出隐藏,可设置多行
@mixin text-overflow($line: 1, $fixed-width: true) {
@if $line == 1 and $fixed-width == true {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} @else {
/* stylelint-disable-next-line value-no-vendor-prefix */
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: $line;
}
}
// 定位居中,默认水平居中,可选择垂直居中,或者水平垂直都居中
@mixin position-center($type: x) {
position: absolute;
@if $type == x {
left: 50%;
transform: translateX(-50%);
}
@if $type == y {
top: 50%;
transform: translateY(-50%);
}
@if $type == xy {
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
}
// 文字两端对齐
%justify-align {
text-align: justify;
text-align-last: justify;
}
// 清除浮动
%clearfix {
zoom: 1;
&::before,
&::after {
clear: both;
display: block;
content: "";
}
}
+1
View File
@@ -0,0 +1 @@
// 全局变量
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import eventBus from '@/utils/eventBus'
import Profile from './profile.vue'
defineOptions({
name: 'AppAccountButton',
})
const props = withDefaults(defineProps<{
onlyAvatar?: boolean
dropdownAlign?: 'start' | 'center' | 'end'
dropdownSide?: 'left' | 'right' | 'top' | 'bottom'
buttonVariant?: 'secondary' | 'ghost'
class?: HTMLAttributes['class']
}>(), {
dropdownAlign: 'end',
dropdownSide: 'right',
buttonVariant: 'ghost',
})
const router = useRouter()
const appSettingsStore = useAppSettingsStore()
const appAccountStore = useAppAccountStore()
const { generateTitle } = useAppMenu()
const profileModal = useFaModal().create({
alignCenter: true,
header: false,
footer: false,
closeOnClickOverlay: false,
closeOnPressEscape: false,
class: 'h-[500px] sm:max-w-xl overflow-hidden',
contentClass: 'min-h-full p-0 flex',
content: () => h(Profile),
})
</script>
<template>
<FaDropdown
:align="dropdownAlign" :side="dropdownSide" :items="[
[
...(appSettingsStore.settings.app.home.enable
? [{ label: generateTitle(appSettingsStore.settings.app.home.title), icon: 'i-mdi:home', handle: () => router.push({ path: appSettingsStore.settings.app.home.fullPath }) }]
: []),
{ label: '个人设置', icon: 'i-mdi:account', handle: () => profileModal.open() },
],
[
...(appSettingsStore.mode === 'pc'
? [{ label: '快捷键', icon: 'i-mdi:keyboard', handle: () => eventBus.emit('global-hotkeys-intro-toggle') }]
: []),
],
[
{
label: '退出登录',
icon: 'i-mdi:logout',
handle: () => appAccountStore.logout(appSettingsStore.settings.app.home.fullPath),
},
],
]" class="flex-center"
>
<template #header>
<div class="flex-center-start gap-2">
<FaAvatar :src="appAccountStore.avatar" :fallback="appAccountStore.account.slice(0, 2)" shape="square" />
<div class="min-w-0 space-y-1">
<div class="text-base lh-none truncate">
{{ appAccountStore.account }}
</div>
<div class="text-xs text-secondary-foreground/50 font-normal">
[ xyz@xyz.com ]
</div>
</div>
</div>
</template>
<FaButton
:variant="buttonVariant" size="icon-sm" :class="cn('flex-center gap-1 p-2', {
'p-1': onlyAvatar,
}, props.class)"
>
<FaAvatar :src="appAccountStore.avatar" :class="cn('size-6', { 'size-full': onlyAvatar })">
<FaIcon name="i-carbon:user-avatar-filled" class="text-secondary-foreground/50 size-6" />
</FaAvatar>
<div v-if="!onlyAvatar" class="flex-center-between flex-1 gap-2 min-w-0">
<div class="text-start flex-1 truncate">
{{ appAccountStore.account }}
</div>
<FaIcon name="i-material-symbols:expand-all-rounded" />
</div>
</FaButton>
</FaDropdown>
</template>
+36
View File
@@ -0,0 +1,36 @@
<script setup lang="ts">
import EditPassword from '@/components/AppAccountForm/edit-password.vue'
const active = ref(0)
const tabs = ref([
{
title: '基本设置',
description: '账号的基本信息,头像、昵称等',
},
{
title: '安全设置',
description: '定期修改密码可以提高帐号安全性',
},
])
</script>
<template>
<div class="min-h-full w-full">
<div class="border-b border-e bg-background flex flex-row right-0 top-0 fixed z-1 overflow-auto md:(flex-col h-full w-40 inset-s-0 bottom-0)">
<div v-for="(tab, index) in tabs" :key="index" class="px-4 py-3 flex-shrink-0 cursor-pointer transition-background-color space-y-2 hover-bg-accent/50" :class="{ 'bg-accent hover-bg-accent!': active === index }" @click="active = index">
<div class="text-base text-accent-foreground leading-tight">
{{ tab.title }}
</div>
<div class="text-xs text-accent-foreground/50">
{{ tab.description }}
</div>
</div>
</div>
<div class="p-10 pt-20 flex-col-center min-h-full md:(ms-40 pt-10)">
<div v-if="active === 0">
请开发者自行扩展
</div>
<EditPassword v-if="active === 1" />
</div>
</div>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import * as z from 'zod'
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
defineOptions({
name: 'EditPasswordForm',
})
const appAccountStore = useAppAccountStore()
const loading = ref(false)
const form = useForm({
validationSchema: toTypedSchema(
z.object({
password: z.string().min(1, '请输入原密码'),
newPassword: z.string().min(1, '请输入新密码').min(6, '密码长度为6到18位').max(18, '密码长度为6到18位'),
checkPassword: z.string().min(1, '请确认新密码'),
}).refine(data => data.newPassword === data.checkPassword, {
message: '两次输入的密码不一致',
path: ['checkPassword'],
}),
),
initialValues: {
password: '',
newPassword: '',
checkPassword: '',
},
})
const onSubmit = form.handleSubmit((values) => {
loading.value = true
appAccountStore.editPassword(values).then(async () => {
faToast.success('模拟修改成功,请重新登录')
appAccountStore.logout()
}).finally(() => {
loading.value = false
})
})
</script>
<template>
<div class="flex-col-stretch-center w-full">
<div class="mb-6 space-y-2">
<h3 class="text-4xl font-bold">
修改密码
</h3>
<p class="text-sm text-muted-foreground lg:text-base">
请输入原密码、新密码和确认密码
</p>
</div>
<form @submit="onSubmit">
<FormField v-slot="{ componentField, errors }" name="password">
<FormItem class="pb-6 relative space-y-0">
<FormControl>
<FaInput type="password" placeholder="原密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
<template #start>
<FaIcon name="i-lucide:lock" />
</template>
</FaInput>
</FormControl>
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<FormMessage class="text-xs bottom-1 absolute" />
</Transition>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, errors }" name="newPassword">
<FormItem class="pb-6 relative space-y-0">
<FormControl>
<FaInput type="password" placeholder="新密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
<template #start>
<FaIcon name="i-lucide:lock" />
</template>
</FaInput>
</FormControl>
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<FormMessage class="text-xs bottom-1 absolute" />
</Transition>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, errors }" name="checkPassword">
<FormItem class="pb-6 relative space-y-0">
<FormControl>
<FaInput type="password" placeholder="确认密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
<template #start>
<FaIcon name="i-lucide:lock" />
</template>
</FaInput>
</FormControl>
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<FormMessage class="text-xs bottom-1 absolute" />
</Transition>
</FormItem>
</FormField>
<FaButton :loading="loading" size="lg" class="mt-8 w-full" type="submit">
保存
</FaButton>
</form>
</div>
</template>
+158
View File
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import * as z from 'zod'
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
defineOptions({
name: 'LoginForm',
})
const props = defineProps<{
account?: string
}>()
const emits = defineEmits<{
onLogin: [account?: string]
onRegister: [account?: string]
onResetPassword: [account?: string]
}>()
const appAccountStore = useAppAccountStore()
const title = import.meta.env.VITE_APP_TITLE
const loading = ref(false)
// 登录方式,default 账号密码登录,qrcode 扫码登录
const type = ref<'default' | 'qrcode'>('default')
const form = useForm({
validationSchema: toTypedSchema(z.object({
account: z.string().min(1, '请输入用户名'),
password: z.string().min(1, '请输入密码'),
remember: z.boolean(),
})),
initialValues: {
account: props.account ?? localStorage.getItem('login_account') ?? '',
password: '',
remember: localStorage.getItem('login_account') !== null,
},
})
const onSubmit = form.handleSubmit((values) => {
loading.value = true
appAccountStore.login(values).then(() => {
if (values.remember) {
localStorage.setItem('login_account', values.account)
}
else {
localStorage.removeItem('login_account')
}
emits('onLogin', values.account)
}).finally(() => {
loading.value = false
})
})
function testAccount(account: string) {
form.setFieldValue('account', account)
form.setFieldValue('password', '123456')
onSubmit()
}
</script>
<template>
<div class="p-12 flex-col-stretch-center min-h-500px w-full">
<div class="mb-6 space-y-2">
<h3 class="text-4xl font-bold">
欢迎使用 👋🏻
</h3>
<p class="text-sm text-muted-foreground lg:text-base">
{{ title }}
</p>
</div>
<div class="mb-4">
<FaTabs
v-model="type" :list="[
{ label: '账号密码登录', value: 'default' },
{ label: '扫码登录', value: 'qrcode' },
]" class="inline-flex"
/>
</div>
<div v-show="type === 'default'">
<form @submit="onSubmit">
<FormField v-slot="{ componentField, errors }" name="account">
<FormItem class="pb-6 relative space-y-0">
<FormControl>
<FaInput type="text" placeholder="用户名" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
<template #start>
<FaIcon name="i-lucide:user" />
</template>
</FaInput>
</FormControl>
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<FormMessage class="text-xs bottom-1 absolute" />
</Transition>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, errors }" name="password">
<FormItem class="pb-6 relative space-y-0">
<FormControl>
<FaInput type="password" placeholder="密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
<template #start>
<FaIcon name="i-lucide:lock" />
</template>
</FaInput>
</FormControl>
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<FormMessage class="text-xs bottom-1 absolute" />
</Transition>
</FormItem>
</FormField>
<div class="mb-4 flex-center-between">
<div class="flex-center-start">
<FormField v-slot="{ componentField }" type="checkbox" name="remember">
<FormItem>
<FormControl>
<FaCheckbox v-bind="componentField">
记住我
</FaCheckbox>
</FormControl>
</FormItem>
</FormField>
</div>
<FaButton variant="link" class="p-0 h-auto" type="button" @click="emits('onResetPassword', form.values.account)">
忘记密码了?
</FaButton>
</div>
<FaButton :loading="loading" size="lg" class="w-full" type="submit">
登录
</FaButton>
<div class="text-sm mt-4 flex-center gap-2">
<span class="text-secondary-foreground op-50">还没有帐号?</span>
<FaButton variant="link" class="p-0 h-auto" type="button" @click="emits('onRegister', form.values.account)">
注册新帐号
</FaButton>
</div>
</form>
<div class="mt-4 text-center -mb-4">
<FaDivider>演示账号一键登录</FaDivider>
<div class="space-x-2">
<FaButton variant="default" size="sm" plain @click="testAccount('admin')">
admin
</FaButton>
<FaButton variant="outline" size="sm" plain @click="testAccount('test')">
test
</FaButton>
</div>
</div>
</div>
<div v-show="type === 'qrcode'">
<div class="flex-col-center">
<img src="https://s2.loli.net/2024/04/26/GsahtuIZ9XOg5jr.png" class="h-[250px] w-[250px]">
<div class="text-sm text-secondary-foreground mt-2 op-50">
请使用微信扫码登录
</div>
</div>
</div>
</div>
</template>
+112
View File
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import * as z from 'zod'
import { FormControl, FormDescription, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
defineOptions({
name: 'RegisterForm',
})
const props = defineProps<{
account?: string
}>()
const emits = defineEmits<{
onLogin: [account?: string]
onRegister: [account?: string]
}>()
const loading = ref(false)
const form = useForm({
validationSchema: toTypedSchema(
z.object({
account: z.string().min(1, '请输入用户名'),
password: z.string().min(1, '请输入密码').min(6, '密码长度为6到18位').max(18, '密码长度为6到18位'),
checkPassword: z.string().min(1, '请再次输入密码'),
}).refine(data => data.password === data.checkPassword, {
message: '两次输入的密码不一致',
path: ['checkPassword'],
}),
),
initialValues: {
account: props.account ?? '',
password: '',
checkPassword: '',
},
})
const onSubmit = form.handleSubmit((values) => {
loading.value = true
emits('onRegister', values.account)
})
</script>
<template>
<div class="p-12 flex-col-stretch-center min-h-500px w-full">
<form @submit="onSubmit">
<div class="mb-8 space-y-2">
<h3 class="text-4xl font-bold">
探索从这里开始 🚀
</h3>
<p class="text-sm text-muted-foreground lg:text-base">
演示系统未提供该功能
</p>
</div>
<FormField v-slot="{ componentField, errors }" name="account">
<FormItem class="pb-6 relative space-y-0">
<FormControl>
<FaInput type="text" placeholder="用户名" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
<template #start>
<FaIcon name="i-lucide:user" />
</template>
</FaInput>
</FormControl>
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<FormMessage class="text-xs bottom-1 absolute" />
</Transition>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, value, errors }" name="password">
<FormItem class="pb-6 relative space-y-0">
<FormControl>
<FaInput type="password" placeholder="密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
<template #start>
<FaIcon name="i-lucide:lock" />
</template>
</FaInput>
</FormControl>
<FormDescription>
<FaPasswordStrength :password="value" />
</FormDescription>
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<FormMessage class="text-xs bottom-1 absolute" />
</Transition>
</FormItem>
</FormField>
<FormField v-slot="{ componentField, errors }" name="checkPassword">
<FormItem class="pb-6 relative space-y-0">
<FormControl>
<FaInput type="password" placeholder="确认密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
<template #start>
<FaIcon name="i-lucide:lock" />
</template>
</FaInput>
</FormControl>
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<FormMessage class="text-xs bottom-1 absolute" />
</Transition>
</FormItem>
</FormField>
<FaButton :loading="loading" size="lg" class="mt-4 w-full" type="submit">
注册
</FaButton>
<div class="text-sm mt-4 flex-center gap-2">
<span class="text-secondary-foreground op-50">已经有帐号?</span>
<FaButton variant="link" class="p-0 h-auto" @click="emits('onLogin', form.values.account)">
去登录
</FaButton>
</div>
</form>
</div>
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import * as z from 'zod'
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
defineOptions({
name: 'ResetPasswordForm',
})
const props = defineProps<{
account?: string
}>()
const emits = defineEmits<{
onLogin: [account?: string]
onResetPassword: [account?: string]
}>()
const loading = ref(false)
const form = useForm({
validationSchema: toTypedSchema(z.object({
account: z.string().min(1, '请输入用户名'),
captcha: z.string().min(6, '请输入验证码'),
newPassword: z.string().min(1, '请输入新密码').min(6, '密码长度为6到18位').max(18, '密码长度为6到18位'),
})),
initialValues: {
account: props.account ?? '',
captcha: '',
newPassword: '',
},
})
const onSubmit = form.handleSubmit((values) => {
loading.value = true
emits('onResetPassword', values.account)
})
const countdown = ref(0)
const countdownInterval = ref(Number.NaN)
function handleSendCaptcha() {
countdown.value = 60
countdownInterval.value = window.setInterval(() => {
countdown.value--
if (countdown.value === 0) {
clearInterval(countdownInterval.value)
}
}, 1000)
}
</script>
<template>
<div class="p-12 flex-col-stretch-center min-h-500px w-full">
<form @submit="onSubmit">
<div class="mb-8 space-y-2">
<h3 class="text-4xl font-bold">
忘记密码了? 🔒
</h3>
<p class="text-sm text-muted-foreground lg:text-base">
演示系统未提供该功能
</p>
</div>
<FormField v-slot="{ componentField, errors }" name="account">
<FormItem class="pb-6 relative space-y-0">
<FormControl>
<FaInput type="text" placeholder="用户名" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
<template #start>
<FaIcon name="i-lucide:user" />
</template>
</FaInput>
</FormControl>
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<FormMessage class="text-xs bottom-1 absolute" />
</Transition>
</FormItem>
</FormField>
<div class="flex-start-between gap-2">
<FormField v-slot="{ componentField, value, setValue }" name="captcha">
<FormItem class="pb-6 relative space-y-0">
<FormControl>
<FaInputOTP :model-value="value" :name="componentField.name" :length="6" class="border-destructive" @update:model-value="val => setValue(val)" />
</FormControl>
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<FormMessage class="text-xs bottom-1 absolute" />
</Transition>
</FormItem>
</FormField>
<FaButton variant="outline" :disabled="countdown > 0" class="px-4 flex-1" @click="handleSendCaptcha">
{{ countdown === 0 ? '发送验证码' : `${countdown} 秒后重发` }}
</FaButton>
</div>
<FormField v-slot="{ componentField, errors }" name="newPassword">
<FormItem class="pb-6 relative space-y-0">
<FormControl>
<FaInput type="password" placeholder="新密码" class="w-full" :class="{ 'border-destructive': errors.length }" v-bind="componentField">
<template #start>
<FaIcon name="i-lucide:lock" />
</template>
</FaInput>
</FormControl>
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<FormMessage class="text-xs bottom-1 absolute" />
</Transition>
</FormItem>
</FormField>
<FaButton :loading="loading" size="lg" class="mt-4 w-full" type="submit">
确认
</FaButton>
<div class="text-sm mt-4 flex-center gap-2">
<FaButton variant="link" class="p-0 h-auto" @click="emits('onLogin', form.values.account)">
去登录
</FaButton>
</div>
</form>
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
defineOptions({
name: 'AppAuth',
})
const props = defineProps<{
value: string | string[]
all?: boolean
}>()
const isCheck = computed(() => {
return props.all
? useAppAuth().authAll(typeof props.value === 'string' ? [props.value] : props.value)
: useAppAuth().auth(props.value)
})
</script>
<template>
<slot v-if="isCheck" />
<slot v-else name="no-auth" />
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
defineOptions({
name: 'AppBackToTop',
})
const transitionClass = {
enterActiveClass: 'ease-out duration-300',
enterFromClass: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
enterToClass: 'opacity-100 translate-y-0 lg-scale-100',
leaveActiveClass: 'ease-in duration-200',
leaveFromClass: 'opacity-100 translate-y-0 lg-scale-100',
leaveToClass: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
}
onMounted(() => {
window.addEventListener('scroll', handleScroll)
handleScroll()
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', handleScroll)
})
let timeout: (() => void) | undefined
const show = ref(false)
const scrollTop = ref(0)
function handleScroll() {
scrollTop.value = document.documentElement.scrollTop
}
watch(scrollTop, (val) => {
if (val >= 200) {
handleMouseenter()
handleMouseleave()
}
})
function handleMouseenter() {
timeout?.()
// 此处setTimeout是为了避免移动端下和click事件同时触发
setTimeout(() => {
show.value = true
}, 0)
}
function handleMouseleave() {
;({ stop: timeout } = useTimeoutFn(() => {
show.value = false
}, 2000))
}
function handleBackToTop() {
show.value && document.documentElement.scrollTo({
top: 0,
behavior: 'smooth',
})
}
</script>
<template>
<Teleport to="body">
<Transition v-bind="transitionClass">
<FaButton v-if="scrollTop >= 200" variant="outline" size="icon" class="rounded-full h-12 w-12 transition-all inset-b-4 fixed z-2000 dark:bg-background -inset-e-9 dark:hover:bg-background/50" :class="{ 'inset-e-3!': show }" @mouseenter="handleMouseenter" @mouseleave="handleMouseleave" @click="handleBackToTop">
<FaIcon name="i-icon-park-outline:to-top-one" class="size-6" />
</FaButton>
</Transition>
</Teleport>
</template>
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
defineOptions({
name: 'AppCopyright',
})
const route = useRoute()
const appSettingsStore = useAppSettingsStore()
</script>
<template>
<footer v-if="route.meta.copyright ?? appSettingsStore.settings.app.copyright.enable" class="text-sm text-secondary-foreground/50 my-4 px-4 flex flex-wrap items-center justify-center">
<span class="px-1">Copyright</span>
<FaIcon name="i-ri:copyright-line" class="size-5" />
<span v-if="appSettingsStore.settings.app.copyright.dates" class="px-1">{{ appSettingsStore.settings.app.copyright.dates }}</span>
<template v-if="appSettingsStore.settings.app.copyright.company">
<a v-if="appSettingsStore.settings.app.copyright.website" :href="appSettingsStore.settings.app.copyright.website" target="_blank" rel="noopener" class="px-1 text-center no-underline transition-colors hover-text-secondary-foreground">{{ appSettingsStore.settings.app.copyright.company }}</a>
<span v-else class="px-1">{{ appSettingsStore.settings.app.copyright.company }}</span>
</template>
</footer>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
defineOptions({
name: 'AppNotAllowed',
})
const route = useRoute()
const router = useRouter()
const appSettingsStore = useAppSettingsStore()
const appTabbarStore = useAppTabbarStore()
onMounted(() => {
if (appSettingsStore.settings.topbar.tabbar) {
appTabbarStore.remove(route.meta.activeMenu || route.fullPath)
}
})
function goBack() {
router.push(appSettingsStore.settings.app.home.fullPath)
}
</script>
<template>
<div class="flex flex-col h-full">
<div class="flex h-[5vh] lg:h-[10vh]">
<div class="w-[5vh] lg:w-[10vh]" />
<div class="border-inline border-dashed flex-1" />
<div class="w-[5vh] lg:w-[10vh]" />
</div>
<div class="border-block border-dashed flex flex-1">
<div class="w-[5vh] lg:w-[10vh]" />
<div class="border-inline border-dashed flex-center flex-1 relative">
<div class="p-4 flex-col-center gap-4 lg:p-12">
<h1 class="text-6xl font-bold m-0 lg:text-9xl">
403
</h1>
<div class="text-xl text-secondary-foreground/50 mx-0 text-center">
抱歉,你无权访问该页面
</div>
<FaButton variant="link" class="text-unset" @click="goBack">
返回主页
</FaButton>
</div>
</div>
<div class="w-[5vh] lg:w-[10vh]" />
</div>
<div class="flex h-[5vh] lg:h-[10vh]">
<div class="w-[5vh] lg:w-[10vh]" />
<div class="border-inline border-dashed flex-1" />
<div class="w-[5vh] lg:w-[10vh]" />
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
defineOptions({
name: 'AppNotSupportedMobile',
})
</script>
<template>
<div class="flex flex-col h-full">
<div class="flex h-[5vh] lg:h-[10vh]">
<div class="w-[5vh] lg:w-[10vh]" />
<div class="border-inline border-dashed flex-1" />
<div class="w-[5vh] lg:w-[10vh]" />
</div>
<div class="border-block border-dashed flex flex-1">
<div class="w-[5vh] lg:w-[10vh]" />
<div class="border-inline border-dashed flex-center flex-1 relative">
<div class="p-4 flex-col-center gap-4 lg:p-12">
<h1 class="text-6xl font-bold m-0 lg:text-9xl">
<FaIcon name="i-tdesign:mobile-blocked-filled" />
</h1>
<div class="text-xl text-secondary-foreground/50 mx-0 text-center">
抱歉,本网站不支持移动设备访问,请切换到桌面设备
</div>
</div>
</div>
<div class="w-[5vh] lg:w-[10vh]" />
</div>
<div class="flex h-[5vh] lg:h-[10vh]">
<div class="w-[5vh] lg:w-[10vh]" />
<div class="border-inline border-dashed flex-1" />
<div class="w-[5vh] lg:w-[10vh]" />
</div>
</div>
</template>
+72
View File
@@ -0,0 +1,72 @@
<script setup lang="ts">
import eventBus from '@/utils/eventBus'
defineOptions({
name: 'AppSystemInfo',
})
const isShow = ref(false)
const { pkg, lastBuildTime } = __SYSTEM_INFO__
onMounted(() => {
eventBus.on('global-system-info-open', () => {
isShow.value = true
})
})
onUnmounted(() => {
eventBus.off('global-system-info-open')
})
</script>
<template>
<FaDrawer v-model="isShow" title="系统信息" :footer="false">
<div v-if="pkg.version">
<FaDivider>
版本号
</FaDivider>
<div class="text-lg font-bold font-sans text-center">
{{ pkg.version }}
</div>
</div>
<div>
<FaDivider>
最后编译时间
</FaDivider>
<div class="text-lg font-bold font-sans text-center">
{{ lastBuildTime }}
</div>
</div>
<div>
<FaDivider>
生产环境依赖
</FaDivider>
<ul class="text-sm list-none">
<li v-for="(val, key) in (pkg.dependencies as object)" :key="key" class="px-2 py-1.5 rounded-lg flex items-center justify-between hover-bg-secondary">
<div class="font-bold">
{{ key }}
</div>
<div class="font-sans">
{{ val }}
</div>
</li>
</ul>
</div>
<div>
<FaDivider>
开发环境依赖
</FaDivider>
<ul class="text-sm list-none">
<li v-for="(val, key) in (pkg.devDependencies as object)" :key="key" class="px-2 py-1.5 rounded-lg flex items-center justify-between hover-bg-secondary">
<div class="font-bold">
{{ key }}
</div>
<div class="font-sans">
{{ val }}
</div>
</li>
</ul>
</div>
</FaDrawer>
</template>
+32
View File
@@ -0,0 +1,32 @@
export function useAppAuth() {
function hasPermission(permission: string) {
const appSettingsStore = useAppSettingsStore()
const appAccountStore = useAppAccountStore()
if (appSettingsStore.settings.app.account.auth) {
return appAccountStore.permissions.includes(permission)
}
else {
return true
}
}
function auth(value: string | string[]) {
let auth
if (typeof value === 'string') {
auth = value !== '' ? hasPermission(value) : true
}
else {
auth = value.length > 0 ? value.some(item => hasPermission(item)) : true
}
return auth
}
function authAll(value: string[]) {
return value.length > 0 ? value.every(item => hasPermission(item)) : true
}
return {
auth,
authAll,
}
}
+27
View File
@@ -0,0 +1,27 @@
export function useAppMenu() {
const router = useRouter()
const appSettingsStore = useAppSettingsStore()
const appMenuStore = useAppMenuStore()
function generateTitle(title: string | (() => any) = '[ 无标题 ]') {
return typeof title === 'function'
? title()
: title
}
function switchTo(index: number) {
appMenuStore.setActived(index)
if (
appSettingsStore.settings.menu.mainMenuClickMode === 'jump'
|| (appSettingsStore.settings.menu.mainMenuClickMode === 'smart' && appMenuStore.sidebarMenusHasOnlyMenu)
) {
router.push(appMenuStore.sidebarMenusFirstDeepestPath)
}
}
return {
generateTitle,
switchTo,
}
}
+20
View File
@@ -0,0 +1,20 @@
export function useAppPage() {
const router = useRouter()
const appSettingsStore = useAppSettingsStore()
function reload() {
appSettingsStore.setIsReloading(true)
router.push({
name: 'reload',
}).then(() => {
setTimeout(() => {
appSettingsStore.setIsReloading(false)
}, 100)
})
}
return {
reload,
}
}
+150
View File
@@ -0,0 +1,150 @@
export function useAppTabbar() {
const route = useRoute()
const router = useRouter()
const appTabbarStore = useAppTabbarStore()
function getId() {
return route.fullPath
}
function closeById(tabId = getId()) {
if (checkClose(tabId, false)) {
const activedTabId = getId()
// 如果关闭的标签正好是当前路由
if (tabId === activedTabId) {
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
if (index > 0) {
router.close(appTabbarStore.list[index - 1].fullPath)
}
else {
router.close(appTabbarStore.list[index + 1].fullPath)
}
}
else {
appTabbarStore.remove(tabId)
}
}
}
/**
* 关闭两侧标签页
*/
function closeOtherSide(tabId = getId()) {
const activedTabId = getId()
// 如果操作的是非当前路由标签页,则先跳转到指定路由标签页
if (tabId !== activedTabId) {
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
router.push(appTabbarStore.list[index].fullPath)
}
appTabbarStore.removeOtherSide(tabId)
}
/**
* 关闭左侧标签页
*/
function closeLeftSide(tabId = getId()) {
const activedTabId = getId()
// 如果操作的是非当前路由标签页,需要判断当前标签页是否在指定标签页左侧,如果是则先跳转到指定路由标签页
if (tabId !== activedTabId) {
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
const activedIndex = appTabbarStore.list.findIndex(item => item.tabId === activedTabId)
if (activedIndex < index) {
router.push(appTabbarStore.list[index].fullPath)
}
}
appTabbarStore.removeLeftSide(tabId)
}
/**
* 关闭右侧标签页
*/
function closeRightSide(tabId = getId()) {
const activedTabId = getId()
// 如果操作的是非当前路由标签页,需要判断当前标签页是否在指定标签页右侧,如果是则先跳转到指定路由标签页
if (tabId !== activedTabId) {
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
const activedIndex = appTabbarStore.list.findIndex(item => item.tabId === activedTabId)
if (activedIndex > index) {
router.push(appTabbarStore.list[index].fullPath)
}
}
appTabbarStore.removeRightSide(tabId)
}
/**
* 校验指定标签是否可关闭
*/
function checkClose(tabId = getId(), checkOnly = true) {
let flag = true
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
if (index < 0) {
flag = false
!checkOnly && faToast.warning('关闭的标签页不存在', {
position: 'top-center',
})
}
else if (appTabbarStore.list.length <= 1) {
flag = false
!checkOnly && faToast.warning('当前只有一个标签页,不可关闭', {
position: 'top-center',
})
}
return flag
}
/**
* 校验指定标签两侧是否有可关闭的标签
*/
function checkCloseOtherSide(tabId = getId()) {
return appTabbarStore.list.some((item) => {
return item.tabId !== tabId
})
}
/**
* 校验指定标签左侧是否有可关闭的标签
*/
function checkCloseLeftSide(tabId = getId()) {
let flag = true
if (tabId === appTabbarStore.list[0]?.tabId) {
flag = false
}
else {
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
flag = appTabbarStore.list.some((item, i) => {
return i < index && item.tabId !== tabId
})
}
return flag
}
/**
* 校验指定标签右侧是否有可关闭的标签
*/
function checkCloseRightSide(tabId = getId()) {
let flag = true
if (tabId === appTabbarStore.list.at(-1)?.tabId) {
flag = false
}
else {
const index = appTabbarStore.list.findIndex(item => item.tabId === tabId)
flag = appTabbarStore.list.some((item, i) => {
return i >= index && item.tabId !== tabId
})
}
return flag
}
return {
getId,
closeById,
closeOtherSide,
closeLeftSide,
closeRightSide,
checkClose,
checkCloseOtherSide,
checkCloseLeftSide,
checkCloseRightSide,
}
}
+46
View File
@@ -0,0 +1,46 @@
export function usePagination() {
const pagination = ref({
page: 1,
size: 10,
total: 0,
sort: null as string | null,
order: null as string | null,
})
function getParams() {
return {
// page,size 和 from,limit 是分页的两种方式,方便兼容两种接口
page: pagination.value.page,
size: pagination.value.size,
from: (pagination.value.page - 1) * pagination.value.size,
limit: pagination.value.size,
...(
pagination.value.sort && pagination.value.order && {
sort: pagination.value.sort,
order: pagination.value.order,
}
),
}
}
async function onSizeChange(size: number) {
pagination.value.size = size
}
async function onCurrentChange(page: number) {
pagination.value.page = page
}
async function onSortChange(prop: string, order: string) {
pagination.value.sort = prop
pagination.value.order = order === 'ascending' ? 'asc' : 'desc'
}
return {
pagination,
getParams,
onSizeChange,
onCurrentChange,
onSortChange,
}
}
+107
View File
@@ -0,0 +1,107 @@
# Hotkeys 设计说明
这个目录参考 `fantastic-admin/v6-dev/apps/example/src/hotkeys` 的设计,目标是把当前项目里分散在各组件中的快捷键逻辑收拢成一套轻量方案。
## 设计目标
当前方案只解决 4 件事:
1. 快捷键定义不要散落在多个组件里重复写
2. 快捷键帮助面板不要再手写一份展示文案
3. 业务组件不要反复手写 `hotkeys()` / `unbind()`
4. 不引入过重的 command system
所以采用:
- `registry.ts` 统一定义
- `useHotkeys.ts` 统一注册
- 业务 handler 仍留在业务组件
这是一种“集中定义 + 就地执行”的轻量折中方案。
## 目录职责
### `registry.ts`
统一维护当前项目的快捷键元数据:
- id
- keys
- enabled
- help
这里负责:
- 统一的 id 常量
- 真实键位
- 是否启用的条件
- 帮助面板展示文案
- 聚合业务扩展
> 注意:这里不放业务逻辑 handler。
### `registry.extend.ts`
用于放置项目自定义扩展快捷键,避免把业务项全部堆进内建定义中。
### `types.ts`
定义快捷键系统的基础类型,包括:
- `HotkeyBinding`
- `HotkeyContext`
- `HotkeyHelpMeta`
- `HotkeyHandler`
### `useHotkeys.ts`
对 `hotkeys-js` 的薄封装,提供:
- `useHotkey`:注册单个快捷键
- `useHotkeyBindings`:批量注册快捷键
并统一处理:
- 注册 / 卸载
- `active` 启停
- `binding.enabled` 可用性判断
- 默认 `preventDefault`
## 当前项目已落地的快捷键
### 全局
- `system.info.open`
- `menuSearch.open`
- `page.reload`
### 主导航
- `menu.next`
- `menu.prev`
### 标签栏
- `tabbar.prev`
- `tabbar.next`
- `tabbar.closeCurrent`
- `tabbar.gotoVisibleIndex`
- `tabbar.gotoLast`
### 菜单搜索弹窗
- `menuSearch.moveUp`
- `menuSearch.moveDown`
- `menuSearch.confirm`
- `menuSearch.close`
## 使用约定
1. 新增快捷键,优先在 `registry.ts` 或 `registry.extend.ts` 补定义
2. 展示在帮助面板中的快捷键,必须配置 `help`
3. handler 尽量保留在最了解业务的组件中
4. 不直接在业务组件里继续散写 `hotkeys('xxx')`
## 现阶段差异
参考项目里还有页面最大化相关快捷键;当前项目暂未引入对应页面最大化能力,所以本次迁移只保留当前项目已有功能,并把原有 `F5` 页面刷新也纳入统一 registry 管理。
@@ -0,0 +1,22 @@
import type { HotkeyBinding } from './types'
/**
* 业务扩展快捷键 id
*
* 使用方式:
*
* export const EXT_HOTKEY_ID = {
* demoOpen: 'demo.open',
* } as const
*/
export const EXT_HOTKEY_ID = {} as const
/**
* 业务扩展全局快捷键
*/
export const extendGlobalHotkeyBindings: HotkeyBinding[] = []
/**
* 业务扩展局部快捷键
*/
export const extendScopedHotkeyBindings: HotkeyBinding[] = []
+210
View File
@@ -0,0 +1,210 @@
import type { HotkeyBinding } from './types'
import { EXT_HOTKEY_ID, extendGlobalHotkeyBindings, extendScopedHotkeyBindings } from './registry.extend'
export const HOTKEY_ID = {
systemInfoOpen: 'system.info.open',
menuSearchOpen: 'menuSearch.open',
menuNext: 'menu.next',
menuPrev: 'menu.prev',
tabbarPrev: 'tabbar.prev',
tabbarNext: 'tabbar.next',
tabbarCloseCurrent: 'tabbar.closeCurrent',
tabbarGotoVisibleIndex: 'tabbar.gotoVisibleIndex',
tabbarGotoLast: 'tabbar.gotoLast',
pageReload: 'page.reload',
menuSearchMoveUp: 'menuSearch.moveUp',
menuSearchMoveDown: 'menuSearch.moveDown',
menuSearchConfirm: 'menuSearch.confirm',
menuSearchClose: 'menuSearch.close',
...EXT_HOTKEY_ID,
} as const
export const hotkeyIds = Object.values(HOTKEY_ID)
/**
* Hotkeys registry 维护约定
*
* 1. 新增快捷键优先在这里补充定义,不要在业务组件里直接写死键位字符串
* 2. 这里仅维护“定义”和“展示元数据”,不要把业务 handler 塞进 registry
* 3. 配置类开关尽量写在 enabled / help.visible 中,避免帮助面板与真实行为不一致
* 4. 需要展示在快捷键帮助面板中的项,必须补充 help
* 5. 仅局部场景使用的快捷键,也建议在这里定义,再由对应组件按需注册
*/
export const globalHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.systemInfoOpen,
keys: ['command+i', 'ctrl+i'],
help: {
group: 'global',
titleKey: 'global.system',
order: 10,
displayKeys: {
default: ['Ctrl', 'I'],
mac: ['⌘', 'I'],
},
},
},
{
id: HOTKEY_ID.menuSearchOpen,
keys: ['command+k', 'ctrl+k'],
enabled: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
help: {
group: 'global',
titleKey: 'global.search',
order: 20,
visible: ctx => ctx.settings.toolbar.menuSearch.enable && ctx.settings.toolbar.menuSearch.hotkeys,
displayKeys: {
default: ['Ctrl', 'K'],
mac: ['⌘', 'K'],
},
},
},
{
id: HOTKEY_ID.menuNext,
keys: ['alt+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.next',
order: 10,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', '`'],
mac: ['⌥', '`'],
},
},
},
{
id: HOTKEY_ID.menuPrev,
keys: ['alt+shift+`'],
enabled: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
help: {
group: 'nav',
titleKey: 'nav.prev',
order: 20,
visible: ctx => ctx.settings.menu.hotkeys && ['side', 'head'].includes(ctx.settings.menu.mode),
displayKeys: {
default: ['Alt', 'Shift', '`'],
mac: ['⌥', '⇧', '`'],
},
},
},
{
id: HOTKEY_ID.tabbarPrev,
keys: ['alt+left'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.prev',
order: 10,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '←'],
mac: ['⌥', '←'],
},
},
},
{
id: HOTKEY_ID.tabbarNext,
keys: ['alt+right'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.next',
order: 20,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '→'],
mac: ['⌥', '→'],
},
},
},
{
id: HOTKEY_ID.tabbarCloseCurrent,
keys: ['alt+w'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.close',
order: 30,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', 'W'],
mac: ['⌥', 'W'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoVisibleIndex,
keys: ['alt+1', 'alt+2', 'alt+3', 'alt+4', 'alt+5', 'alt+6', 'alt+7', 'alt+8', 'alt+9'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.n',
order: 40,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '1~9'],
mac: ['⌥', '1~9'],
},
},
},
{
id: HOTKEY_ID.tabbarGotoLast,
keys: ['alt+0'],
enabled: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
help: {
group: 'tabbar',
titleKey: 'tabbar.last',
order: 50,
visible: ctx => ctx.settings.topbar.tabbar && ctx.settings.tabbar.hotkeys,
displayKeys: {
default: ['Alt', '0'],
mac: ['⌥', '0'],
},
},
},
{
id: HOTKEY_ID.pageReload,
keys: ['f5'],
},
...extendGlobalHotkeyBindings,
]
export const menuSearchHotkeyBindings: HotkeyBinding[] = [
{
id: HOTKEY_ID.menuSearchMoveUp,
keys: ['up'],
},
{
id: HOTKEY_ID.menuSearchMoveDown,
keys: ['down'],
},
{
id: HOTKEY_ID.menuSearchConfirm,
keys: ['enter'],
},
{
id: HOTKEY_ID.menuSearchClose,
keys: ['esc'],
},
]
export const hotkeyBindings: HotkeyBinding[] = [
...globalHotkeyBindings,
...menuSearchHotkeyBindings,
...extendScopedHotkeyBindings,
]
if (import.meta.env.DEV) {
const registeredIds = hotkeyBindings.map(item => item.id)
const missingIds = hotkeyIds.filter(id => !registeredIds.includes(id))
const duplicateIds = registeredIds.filter((id, index) => registeredIds.indexOf(id) !== index)
if (missingIds.length || duplicateIds.length) {
console.warn('[hotkeys] registry consistency check failed', {
missingIds,
duplicateIds: [...new Set(duplicateIds)],
})
}
}
+38
View File
@@ -0,0 +1,38 @@
import type { useAppSettingsStore } from '@/store/modules/app/settings'
export type HotkeyHelpGroup = 'global' | 'nav' | 'tabbar' | 'page'
export type HotkeyId = typeof import('./registry').HOTKEY_ID[keyof typeof import('./registry').HOTKEY_ID]
export interface HotkeyContext {
settings: ReturnType<typeof useAppSettingsStore>['settings']
}
export interface HotkeyDisplayKeys {
default: string[]
mac?: string[]
}
export interface HotkeyHelpMeta {
group: HotkeyHelpGroup
titleKey: string
order?: number
visible?: (ctx: HotkeyContext) => boolean
displayKeys: HotkeyDisplayKeys
}
export interface HotkeyBinding {
id: HotkeyId
keys: string[]
preventDefault?: boolean
enabled?: (ctx: HotkeyContext) => boolean
help?: HotkeyHelpMeta
}
export interface HotkeyHandlerArgs {
event: KeyboardEvent
hotkey: string
}
export type HotkeyHandler = (args: HotkeyHandlerArgs) => void
export type HotkeyHandlersMap = Partial<Record<HotkeyId, HotkeyHandler>>
+69
View File
@@ -0,0 +1,69 @@
import type { HotkeyHandler, HotkeyHandlersMap, HotkeyId } from './types'
import hotkeys from 'hotkeys-js'
import { hotkeyBindings, hotkeyIds } from './registry'
export function useHotkey(id: HotkeyId, handler: HotkeyHandler, active: MaybeRefOrGetter<boolean> = true) {
const binding = hotkeyBindings.find(item => item.id === id)
const appSettingsStore = useAppSettingsStore()
const hotkeyContext = {
settings: appSettingsStore.settings,
}
let currentHandler: ((event: KeyboardEvent, hotkeyHandler: { key: string }) => void) | undefined
function bind() {
if (currentHandler || !binding) {
return
}
const currentBinding = binding
currentHandler = (event, hotkeyHandler) => {
if (currentBinding.enabled && !currentBinding.enabled(hotkeyContext)) {
return
}
if (currentBinding.preventDefault !== false) {
event.preventDefault()
}
handler({
event,
hotkey: hotkeyHandler.key,
})
}
hotkeys(currentBinding.keys.join(','), currentHandler)
}
function unbind() {
if (!currentHandler || !binding) {
return
}
hotkeys.unbind(binding.keys.join(','), currentHandler)
currentHandler = undefined
}
watch(() => toValue(active), (val) => {
if (val) {
bind()
}
else {
unbind()
}
}, {
immediate: true,
})
onUnmounted(() => {
unbind()
})
}
export function useHotkeyBindings(handlers: HotkeyHandlersMap, active: MaybeRefOrGetter<boolean> = true) {
hotkeyIds.forEach((id) => {
const handler = handlers[id]
if (handler) {
useHotkey(id, handler, active)
}
})
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{ "collections": ["ant-design", "ep", "flagpack", "icon-park", "mdi", "ri", "logos", "twemoji", "vscode-icons"], "isOfflineUse": false }
+9
View File
@@ -0,0 +1,9 @@
import { addCollection } from '@iconify/vue'
import data from './data.json'
export async function downloadAndInstall(name: string) {
const data = Object.freeze(await fetch(`./icons/${name}-raw.json`).then(r => r.json()))
addCollection(data)
}
export const icons = data.sort((a, b) => a.info.name.localeCompare(b.info.name))
+440
View File
@@ -0,0 +1,440 @@
<script setup lang="ts">
import { diffTwoObj, setSettings } from '@fantastic-admin/settings'
import { useClipboard } from '@vueuse/core'
import eventBus from '@/utils/eventBus'
defineOptions({
name: 'AppSetting',
})
const route = useRoute()
const appSettingsStore = useAppSettingsStore()
const settingsDefault = setSettings({})
const appMenuStore = useAppMenuStore()
const isShow = ref(false)
const themeRadius = computed<number[]>({
get() {
return [appSettingsStore.settings.theme.radius]
},
set(value) {
appSettingsStore.settings.theme.radius = value[0]
},
})
watch(() => appSettingsStore.settings.menu.mode, (value) => {
if (value === 'single') {
appMenuStore.setActived(0)
}
else {
appMenuStore.setActived(route.fullPath)
}
})
onMounted(() => {
eventBus.on('global-app-setting-toggle', () => {
isShow.value = !isShow.value
})
})
const { copy, copied, isSupported } = useClipboard()
function handleCopy() {
copy(JSON.stringify(diffTwoObj(settingsDefault, appSettingsStore.settings), null, 2))
}
</script>
<template>
<FaModal v-model="isShow" title="应用配置" description="在生产环境中应关闭该模块" :footer="isSupported" :destroy-on-close="false" class="sm:max-w-4xl" content-class="bg-[var(--g-main-area-bg)] transition-background-color">
<div
:class="{
'columns-1': appSettingsStore.mode === 'mobile',
'columns-2': appSettingsStore.mode === 'pc',
}"
>
<FaPageMain title="主题" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="pb-0 border-none font-bold" main-class="space-y-4">
<div class="setting-item">
<div class="label">
颜色方案
</div>
<FaButtonGroup>
<FaButton
v-for="(item, index) in [
{ icon: 'i-ri:sun-line', value: 'light' },
{ icon: 'i-ri:moon-line', value: 'dark' },
{ icon: 'i-codicon:color-mode', value: '' },
]" :key="index" :variant="appSettingsStore.settings.theme.colorScheme === item.value ? 'default' : 'outline'" size="sm" :class="{ 'z-1': appSettingsStore.settings.theme.colorScheme === item.value }" @click="appSettingsStore.settings.theme.colorScheme = (item.value as any)"
>
<FaIcon :name="item.icon" />
</FaButton>
</FaButtonGroup>
</div>
<div class="setting-item">
<div class="label">
圆角
</div>
<FaSlider v-model="themeRadius" :min="0" :max="1" :step="0.25" class="w-1/2" />
</div>
<div class="setting-item">
<div class="label">
色弱模式
</div>
<FaSwitch v-model="appSettingsStore.settings.theme.colorAmblyopia" />
</div>
</FaPageMain>
<FaPageMain v-if="appSettingsStore.mode === 'pc'" title="导航菜单" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="pb-0 border-none font-bold" main-class="space-y-4">
<div class="menu-mode">
<FaTooltip text="侧边栏模式 (有主导航菜单)" :delay="500">
<FaButton variant="outline" class="mode mode-side" :class="{ active: appSettingsStore.settings.menu.mode === 'side' }" @click="appSettingsStore.settings.menu.mode = 'side'">
<div class="mode-container" />
</FaButton>
</FaTooltip>
<FaTooltip text="顶部模式" :delay="500">
<FaButton variant="outline" class="mode mode-head" :class="{ active: appSettingsStore.settings.menu.mode === 'head' }" @click="appSettingsStore.settings.menu.mode = 'head'">
<div class="mode-container" />
</FaButton>
</FaTooltip>
<FaTooltip text="侧边栏模式 (无主导航菜单)" :delay="500">
<FaButton variant="outline" class="mode mode-single" :class="{ active: appSettingsStore.settings.menu.mode === 'single' }" @click="appSettingsStore.settings.menu.mode = 'single'">
<div class="mode-container" />
</FaButton>
</FaTooltip>
</div>
<div class="setting-item">
<div class="label" :class="{ 'op-50': !['single', 'side', 'head'].includes(appSettingsStore.settings.menu.mode) }">
点击主导航菜单
<FaTooltip text="智能模式下默认执行切换操作,当次导航菜单只有一个可访问的导航菜单时执行跳转操作">
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
</FaTooltip>
</div>
<FaButtonGroup>
<FaButton
v-for="(item, index) in [
{ label: '切换', value: 'switch' },
{ label: '跳转', value: 'jump' },
{ label: '智能', value: 'smart' },
]" :key="index" :variant="appSettingsStore.settings.menu.mainMenuClickMode === item.value ? 'default' : 'outline'" size="sm" :disabled="!['single', 'side', 'head'].includes(appSettingsStore.settings.menu.mode)" :class="{ 'z-1': appSettingsStore.settings.menu.mainMenuClickMode === item.value }" @click="appSettingsStore.settings.menu.mainMenuClickMode = (item.value as any)"
>
{{ item.label }}
</FaButton>
</FaButtonGroup>
</div>
<div class="setting-item">
<div class="label">
次导航菜单唯一展开
</div>
<FaSwitch v-model="appSettingsStore.settings.menu.subMenuUniqueExpand" />
</div>
<div class="setting-item">
<div class="label">
次导航菜单收起
</div>
<FaSwitch v-model="appSettingsStore.settings.menu.subMenuCollapse" />
</div>
<div v-if="appSettingsStore.mode === 'pc'" class="setting-item">
<div class="label">
次导航菜单展开/收起按钮
</div>
<FaSwitch v-model="appSettingsStore.settings.menu.subMenuCollapseButton" />
</div>
<div class="setting-item">
<div class="label" :class="{ 'op-50': appSettingsStore.settings.menu.mode === 'single' }">
快捷键
</div>
<FaSwitch v-model="appSettingsStore.settings.menu.hotkeys" :disabled="appSettingsStore.settings.menu.mode === 'single'" />
</div>
</FaPageMain>
<FaPageMain title="顶栏" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="pb-0 border-none font-bold" main-class="space-y-4">
<div class="setting-item">
<div class="label">
标签栏
</div>
<FaSwitch v-model="appSettingsStore.settings.topbar.tabbar" />
</div>
<div class="setting-item">
<div class="label">
工具栏
</div>
<FaSwitch v-model="appSettingsStore.settings.topbar.toolbar" />
</div>
<div class="setting-item">
<div class="label">
模式
</div>
<FaButtonGroup>
<FaButton
v-for="(item, index) in [
{ label: '静态', value: 'static' },
{ label: '固定', value: 'fixed' },
{ label: '粘性', value: 'sticky' },
]" :key="index" :variant="appSettingsStore.settings.topbar.mode === item.value ? 'default' : 'outline'" size="sm" :class="{ 'z-1': appSettingsStore.settings.topbar.mode === item.value }" :disabled="!appSettingsStore.settings.topbar.tabbar && !appSettingsStore.settings.topbar.toolbar" @click="appSettingsStore.settings.topbar.mode = (item.value as any)"
>
{{ item.label }}
</FaButton>
</FaButtonGroup>
</div>
</FaPageMain>
<FaPageMain title="标签栏" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="pb-0 border-none font-bold" main-class="space-y-4">
<div class="setting-item">
<div class="label">
显示图标
</div>
<FaSwitch v-model="appSettingsStore.settings.tabbar.icon" />
</div>
<div class="setting-item">
<div class="label">
快捷键
</div>
<FaSwitch v-model="appSettingsStore.settings.tabbar.hotkeys" />
</div>
</FaPageMain>
<FaPageMain title="工具栏" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="pb-0 border-none font-bold" main-class="space-y-4">
<div v-if="appSettingsStore.mode === 'pc'" class="setting-item">
<div class="label">
<FaIcon name="i-ic:twotone-double-arrow" />
面包屑导航
</div>
<FaSwitch v-model="appSettingsStore.settings.toolbar.breadcrumb" />
</div>
<div class="setting-item">
<div class="label">
<FaIcon name="i-ri:search-line" />
导航搜索
</div>
<FaSwitch v-model="appSettingsStore.settings.toolbar.menuSearch.enable" />
</div>
<div class="ps-8 space-y-4">
<div class="setting-item">
<div class="label" :class="{ 'op-50': !appSettingsStore.settings.toolbar.menuSearch.enable }">
快捷键
</div>
<FaSwitch v-model="appSettingsStore.settings.toolbar.menuSearch.hotkeys" :disabled="!appSettingsStore.settings.toolbar.menuSearch.enable" />
</div>
</div>
<div v-if="appSettingsStore.mode === 'pc'" class="setting-item">
<div class="label">
<FaIcon name="i-ri:fullscreen-line" />
全屏
</div>
<FaSwitch v-model="appSettingsStore.settings.toolbar.fullscreen" />
</div>
<div class="setting-item">
<div class="label">
<FaIcon name="i-iconoir:refresh-double" />
页面刷新
<FaTooltip text="重新载入当前页面,并且不刷新浏览器">
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
</FaTooltip>
</div>
<FaSwitch v-model="appSettingsStore.settings.toolbar.pageReload" />
</div>
<div class="setting-item">
<div class="label">
<FaIcon name="i-ri:sun-line" />
颜色主题
</div>
<FaSwitch v-model="appSettingsStore.settings.toolbar.colorScheme" />
</div>
</FaPageMain>
<FaPageMain title="页面" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="pb-0 border-none font-bold" main-class="space-y-4">
<div class="setting-item">
<div class="label">
载入进度条
<FaTooltip text="路由跳转时会在页面顶部显示进度条,该进度仅为模拟效果,并非真实加载进度">
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
</FaTooltip>
</div>
<FaSwitch v-model="appSettingsStore.settings.page.progress" />
</div>
</FaPageMain>
<FaPageMain title="应用" class="m-0 mb-4 break-inside-avoid light:border-none" title-class="pb-0 border-none font-bold" main-class="space-y-4">
<div class="p-4 pb-4 pt-14 border rounded-lg relative space-y-4">
<div class="font-bold px-4 py-2 border-b border-e rounded-rb-lg inset-s-0 inset-t-0 absolute">
账号
</div>
<div class="setting-item">
<div class="label">
权限验证
</div>
<FaSwitch v-model="appSettingsStore.settings.app.account.auth" />
</div>
</div>
<div class="setting-item">
<div class="label">
动态标题
<FaTooltip text="开启时页面标题会显示当前路由标题,格式为“页面标题 - 网站名称”;关闭时则显示网站名称,网站名称在项目根目录下 .env.* 文件里配置">
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
</FaTooltip>
</div>
<FaSwitch v-model="appSettingsStore.settings.app.dynamicTitle" />
</div>
<div class="setting-item">
<div class="label">
哀悼模式
<FaTooltip text="网站整体变灰色">
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
</FaTooltip>
</div>
<FaSwitch v-model="appSettingsStore.settings.app.rip" />
</div>
<div class="setting-item">
<div class="label">
移动端访问
<FaTooltip text="关闭后,将禁用移动端访问">
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
</FaTooltip>
</div>
<FaSwitch v-model="appSettingsStore.settings.app.mobile" />
</div>
<div class="p-4 pb-4 pt-14 border rounded-lg relative space-y-4">
<div class="font-bold px-4 py-2 border-b border-e rounded-rb-lg inset-s-0 inset-t-0 absolute">
主页
</div>
<div class="setting-item">
<div class="label">
启用
<FaTooltip text="登录后默认进入主页,否则默认进入第一个导航页面">
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
</FaTooltip>
</div>
<FaSwitch v-model="appSettingsStore.settings.app.home.enable" />
</div>
<div class="setting-item">
<div class="label">
标题
<FaTooltip text="用于设置首页显示标题,支持直接输入简体中文内容">
<FaIcon name="i-ri:question-line" class="text-base text-orange cursor-help" />
</FaTooltip>
</div>
<FaInput v-model="appSettingsStore.settings.app.home.title" />
</div>
</div>
<div class="p-4 pb-4 pt-14 border rounded-lg relative space-y-4">
<div class="font-bold px-4 py-2 border-b border-e rounded-rb-lg inset-s-0 inset-t-0 absolute">
版权
</div>
<div class="setting-item">
<div class="label">
启用
</div>
<FaSwitch v-model="appSettingsStore.settings.app.copyright.enable" />
</div>
<div class="setting-item">
<div class="label">
日期
</div>
<FaInput v-model="appSettingsStore.settings.app.copyright.dates" :disabled="!appSettingsStore.settings.app.copyright.enable" />
</div>
<div class="setting-item">
<div class="label">
公司
</div>
<FaInput v-model="appSettingsStore.settings.app.copyright.company" :disabled="!appSettingsStore.settings.app.copyright.enable" />
</div>
<div class="setting-item">
<div class="label">
网站
</div>
<FaInput v-model="appSettingsStore.settings.app.copyright.website" :disabled="!appSettingsStore.settings.app.copyright.enable" />
</div>
</div>
</FaPageMain>
</div>
<template #footer>
<div class="w-full">
<div class="text-sm/6 c-rose mb-2 px-4 py-2 text-center rounded-lg bg-rose/20">
在此处调整配置只是临时生效,要想真正应用于项目,请点击「复制配置」按钮,并粘贴到
<code class="text-sm font-mono font-semibold px-[0.3rem] py-[0.2rem] rounded bg-muted relative">src/settings.ts</code>
文件中。
</div>
<FaButton class="w-full" @click="handleCopy">
<FaIcon :name="copied ? 'i-tabler:clipboard-check' : 'i-tabler:clipboard'" class="size-5" />
复制配置
</FaButton>
</div>
</template>
</FaModal>
</template>
<style scoped>
.menu-mode {
--uno: flex items-center justify-center gap-4;
.mode {
--uno: relative w-16 h-12;
&.active {
--uno: ring-primary ring-2;
}
&::before,
&::after,
.mode-container {
--uno: absolute pointer-events-none;
}
&::before {
--uno: content-empty bg-primary;
}
&::after {
--uno: content-empty bg-primary/60;
}
.mode-container {
--uno: bg-primary/20 border-width-1.5 border-dashed border-primary;
&::before {
--uno: content-empty absolute w-full h-full;
}
}
&-side {
&::before {
--uno: top-2 bottom-2 start-2 w-2 rounded-ss-1 rounded-es-1;
}
&::after {
--uno: top-2 bottom-2 start-4.5 w-3;
}
.mode-container {
--uno: inset-t-2 inset-e-2 inset-b-2 inset-s-8 rounded-se-1 rounded-ee-1;
}
}
&-head {
&::before {
--uno: top-2 start-2 end-2 h-2 rounded-ss-1 rounded-se-1;
}
&::after {
--uno: top-4.5 start-2 bottom-2 w-3 rounded-es-1;
}
.mode-container {
--uno: inset-t-4.5 inset-e-2 inset-b-2 inset-s-5.5 rounded-ee-1;
}
}
&-single {
&::after {
--uno: top-2 start-2 bottom-2 w-3 rounded-ss-1 rounded-es-1;
}
.mode-container {
--uno: inset-t-2 inset-e-2 inset-b-2 inset-s-5.5 rounded-se-1 rounded-ee-1;
}
}
}
}
.setting-item {
--uno: flex items-center justify-between gap-4;
.label {
--uno: flex items-center flex-shrink-0 gap-2 text-sm;
}
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<template>
<div class="text-sm flex items-center">
<slot />
</div>
</template>
<style scoped>
:deep(.breadcrumb-item) {
&:first-child {
.separator {
display: none;
}
}
&:last-child {
.text {
opacity: 1;
}
}
}
</style>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router'
const props = withDefaults(
defineProps<{
to?: RouteLocationRaw
replace?: boolean
separator?: string
}>(),
{
separator: '/',
},
)
const router = useRouter()
function onClick() {
if (props.to) {
props.replace ? router.replace(props.to) : router.push(props.to)
}
}
</script>
<template>
<div class="breadcrumb-item text-foreground flex items-center">
<span class="separator mx-2">
{{ separator }}
</span>
<span
class="text opacity-60 flex items-center"
:class="{
'is-link cursor-pointer transition-opacity hover-opacity-100': !!props.to,
}" @click="onClick"
>
<slot />
</span>
</div>
</template>
+152
View File
@@ -0,0 +1,152 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import Logo from '../Logo/index.vue'
defineOptions({
name: 'LayoutHeader',
})
defineProps<{
enable: boolean
}>()
const appSettingsStore = useAppSettingsStore()
const appMenuStore = useAppMenuStore()
const { generateTitle, switchTo } = useAppMenu()
</script>
<template>
<Transition name="header">
<header v-if="enable">
<div class="header-container">
<Component :is="useSlots('header-start')" />
<Logo class="title" />
<Component :is="useSlots('header-after-logo')" />
<FaScrollArea :scrollbar="false" mask horizontal class="menu-container overscroll-contain flex-1 h-full">
<!-- 顶部模式 -->
<div
v-if="appSettingsStore.settings.menu.mode === 'head'" class="menu flex h-full transition-all of-hidden"
>
<template v-for="(item, index) in appMenuStore.allMenus" :key="index">
<div
class="menu-item mx-1 py-2 transition-all relative" :class="{
active: index === appMenuStore.actived,
}"
>
<FaTooltip v-if="item.children && item.children.length !== 0" :disabled="!generateTitle(item.meta?.title)" :text="generateTitle(item.meta?.title)" side="bottom">
<div
class="group menu-item-container text-[var(--g-header-menu-color)] px-3 rounded-lg flex gap-1 h-full w-full cursor-pointer transition-colors items-center justify-between relative hover-(text-[var(--g-header-menu-hover-color)] bg-[var(--g-header-menu-hover-bg)])" :class="{
'text-[var(--g-header-menu-active-color)]! bg-[var(--g-header-menu-active-bg)]!': index === appMenuStore.actived,
}" @click="switchTo(index)"
>
<div class="inline-flex flex-1 gap-1 items-center justify-center">
<FaIcon v-if="item.meta?.icon" :name="item.meta.icon" class="menu-item-container-icon transition-transform group-hover-scale-120" />
<span class="text-sm flex-1 w-full truncate transition-height transition-opacity transition-width empty:hidden">
{{ generateTitle(item.meta?.title) }}
</span>
</div>
</div>
</FaTooltip>
</div>
</template>
</div>
</FaScrollArea>
<Component :is="useSlots('header-after-menu')" />
<div class="flex-center">
<AppAccountButton only-avatar dropdown-side="bottom" class="p-2 size-12" />
</div>
<Component :is="useSlots('header-end')" />
</div>
</header>
</Transition>
</template>
<style scoped>
header {
position: fixed;
top: var(--g-slots-layout-top-height);
right: var(--scrollbar-width, 0);
left: 0;
z-index: 1020;
display: flex;
align-items: center;
width: calc(100% - var(--scrollbar-width, 0px));
height: var(--g-header-height);
margin: 0 auto;
color: var(--g-header-color);
background-color: var(--g-header-bg);
box-shadow: inset 0 -1px 0 0 oklch(var(--border));
transition: width 0.3s, background-color 0.15s, box-shadow 0.15s;
.header-container {
display: flex;
gap: 30px;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: var(--g-header-width);
height: 100%;
padding: 0 12px;
margin: 0 auto;
:deep(a.title) {
position: relative;
flex: 0;
width: inherit;
height: inherit;
padding: 0;
background-color: inherit;
.logo {
width: initial;
max-width: initial;
height: min(70%, 50px);
}
span {
font-size: 20px;
color: var(--g-header-color);
letter-spacing: 1px;
}
}
.menu-container {
.menu {
display: inline-flex;
:deep(.menu-item) {
.menu-item-container {
color: var(--g-header-menu-color);
&:hover {
color: var(--g-header-menu-hover-color);
background-color: var(--g-header-menu-hover-bg);
}
.menu-item-container-icon {
font-size: 20px !important;
}
}
&.active .menu-item-container {
color: var(--g-header-menu-active-color) !important;
background-color: var(--g-header-menu-active-bg) !important;
}
}
}
}
}
}
/* 头部动画 */
.header-enter-active,
.header-leave-active {
transition: transform 0.3s;
}
.header-enter-from,
.header-leave-to {
transform: translateY(calc(var(--g-header-height) * -1));
}
</style>
@@ -0,0 +1,106 @@
<script setup lang="ts">
import { globalHotkeyBindings } from '@/hotkeys/registry'
import eventBus from '@/utils/eventBus'
defineOptions({
name: 'HotkeysIntro',
})
const appSettingsStore = useAppSettingsStore()
const hotkeyContext = {
settings: appSettingsStore.settings,
}
const groupTitleMap = {
global: '全局',
nav: '主导航',
tabbar: '标签栏',
} as const
const itemTitleMap: Record<string, string> = {
'global.system': '查看系统信息',
'global.search': '唤起导航搜索',
'nav.next': '激活下一个主导航',
'nav.prev': '激活上一个主导航',
'tabbar.prev': '切换到上一个标签页',
'tabbar.next': '切换到下一个标签页',
'tabbar.close': '关闭当前标签页',
'tabbar.n': '切换到第 n 个标签页',
'tabbar.last': '切换到最后一个标签页',
} as const
const isShow = ref(false)
const helpGroups = computed(() => {
const groups = [
{ id: 'global', title: groupTitleMap.global },
{ id: 'nav', title: groupTitleMap.nav },
{ id: 'tabbar', title: groupTitleMap.tabbar },
] as const
return groups.map((group) => {
const items = globalHotkeyBindings
.filter(binding => binding.help?.group === group.id)
.filter((binding) => {
if (!binding.help) {
return false
}
if (binding.help.visible) {
return binding.help.visible(hotkeyContext)
}
return true
})
.sort((a, b) => (a.help?.order ?? 0) - (b.help?.order ?? 0))
.map((binding) => {
const displayKeys = appSettingsStore.os === 'mac' && binding.help?.displayKeys.mac
? binding.help.displayKeys.mac
: binding.help?.displayKeys.default ?? []
return {
id: binding.id,
title: binding.help?.titleKey ? itemTitleMap[binding.help.titleKey] : '',
displayKeys,
}
})
return {
...group,
items,
}
}).filter(group => group.items.length > 0)
})
onMounted(() => {
eventBus.on('global-hotkeys-intro-toggle', () => {
isShow.value = !isShow.value
})
})
onUnmounted(() => {
eventBus.off('global-hotkeys-intro-toggle')
})
</script>
<template>
<FaModal v-model="isShow" title="快捷键" :footer="false">
<div class="px-4">
<div class="gap-4 grid sm-grid-cols-2">
<div v-for="group in helpGroups" :key="group.id">
<h2 class="text-lg font-bold m-0">
{{ group.title }}
</h2>
<ul class="text-sm ps-2 pt-2 list-none">
<li v-for="item in group.items" :key="item.id" class="py-1 flex-baseline gap-2">
<FaKbdGroup>
<FaKbd v-for="key in item.displayKeys" :key="key">
{{ key }}
</FaKbd>
</FaKbdGroup>
{{ item.title }}
</li>
</ul>
</div>
</div>
</div>
</FaModal>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import imgLogo from '@/assets/images/logo.svg'
defineOptions({
name: 'Logo',
})
withDefaults(
defineProps<{
showLogo?: boolean
showTitle?: boolean
}>(),
{
showLogo: true,
showTitle: true,
},
)
const appSettingsStore = useAppSettingsStore()
const title = ref(import.meta.env.VITE_APP_TITLE)
const logo = ref(imgLogo)
const to = computed(() => appSettingsStore.settings.app.home.enable ? appSettingsStore.settings.app.home.fullPath : '')
</script>
<template>
<RouterLink :to class="text-primary px-3 no-underline flex-center gap-2 h-[var(--g-sidebar-logo-height)] w-inherit" :class="{ 'cursor-default': !appSettingsStore.settings.app.home.enable }" :title="title">
<img v-if="showLogo" :src="logo" class="logo h-[30px] w-[30px] object-contain">
<span v-if="showTitle" class="font-bold block truncate">{{ title }}</span>
</RouterLink>
</template>
+125
View File
@@ -0,0 +1,125 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useSlots } from '@/slots'
import Logo from '../Logo/index.vue'
defineOptions({
name: 'MainSidebar',
})
defineProps<{
enable: boolean
}>()
const appSettingsStore = useAppSettingsStore()
const appMenuStore = useAppMenuStore()
const { generateTitle, switchTo } = useAppMenu()
useHotkeyBindings({
'menu.next': () => {
switchTo(appMenuStore.actived + 1 < appMenuStore.allMenus.length ? appMenuStore.actived + 1 : 0)
},
'menu.prev': () => {
switchTo(appMenuStore.actived - 1 >= 0 ? appMenuStore.actived - 1 : appMenuStore.allMenus.length - 1)
},
})
</script>
<template>
<Transition name="main-sidebar">
<div v-if="enable" class="main-sidebar-container">
<Component :is="useSlots('main-sidebar-top')" />
<Logo :show-title="false" class="sidebar-logo" />
<Component :is="useSlots('main-sidebar-after-logo')" />
<FaScrollArea :scrollbar="false" mask gradient-color="var(--g-main-sidebar-bg)" class="menu overscroll-contain flex-1">
<!-- 侧边栏模式(含主导航) -->
<div
v-if="appSettingsStore.settings.menu.mode === 'side' || (appSettingsStore.mode === 'mobile' && appSettingsStore.settings.menu.mode !== 'single')" class="py-1 flex flex-col w-full transition-all of-hidden -mt-2"
>
<template v-for="(item, index) in appMenuStore.allMenus" :key="index">
<div
class="menu-item px-2 py-1 transition-all relative" :class="{
active: index === appMenuStore.actived,
}"
>
<FaTooltip v-if="item.children && item.children.length !== 0" :disabled="!generateTitle(item.meta?.title)" :text="generateTitle(item.meta?.title)" side="right">
<div
class="group menu-item-container text-[var(--g-main-sidebar-menu-color)] py-4 rounded-lg flex gap-1 h-full w-full cursor-pointer transition-colors items-center justify-between relative hover-(text-[var(--g-main-sidebar-menu-hover-color)] bg-[var(--g-main-sidebar-menu-hover-bg)]) px-2!" :class="{
'text-[var(--g-main-sidebar-menu-active-color)]! bg-[var(--g-main-sidebar-menu-active-bg)]!': index === appMenuStore.actived,
}" @click="switchTo(index)"
>
<div class="inline-flex flex-1 flex-col gap-1 w-full items-center justify-center">
<FaIcon v-if="item.meta?.icon" :name="item.meta.icon" class="menu-item-container-icon transition-transform group-hover-scale-120" />
<span class="text-sm text-center flex-1 w-full truncate transition-height transition-opacity transition-width empty:hidden">
{{ generateTitle(item.meta?.title) }}
</span>
</div>
</div>
</FaTooltip>
</div>
</template>
</div>
</FaScrollArea>
<Component :is="useSlots('main-sidebar-after-menu')" />
<div class="px-4 py-3 flex-center">
<AppAccountButton only-avatar :button-variant="appSettingsStore.settings.menu.mode === 'side' ? 'secondary' : 'ghost'" class="p-2 size-12" />
</div>
<Component :is="useSlots('main-sidebar-bottom')" />
</div>
</Transition>
</template>
<style scoped>
.main-sidebar-container {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
width: var(--g-main-sidebar-width);
color: var(--g-main-sidebar-menu-color);
background-color: var(--g-main-sidebar-bg);
box-shadow: 1px 0 0 0 oklch(var(--border));
transition: color 0.15s, background-color 0.15s, box-shadow 0.15s;
.sidebar-logo {
background-color: var(--g-main-sidebar-bg);
transition: background-color 0.15s;
}
.menu {
:deep(.menu-item) {
.menu-item-container {
aspect-ratio: 1 / 1;
padding-block: 8px;
color: var(--g-main-sidebar-menu-color);
&:hover {
color: var(--g-main-sidebar-menu-hover-color);
background-color: var(--g-main-sidebar-menu-hover-bg);
}
.menu-item-container-icon {
font-size: 20px !important;
}
}
&.active .menu-item-container {
color: var(--g-main-sidebar-menu-active-color) !important;
background-color: var(--g-main-sidebar-menu-active-bg) !important;
}
}
}
}
/* 主侧边栏动画 */
.main-sidebar-enter-active,
.main-sidebar-leave-active {
transition: 0.3s;
}
.main-sidebar-enter-from,
.main-sidebar-leave-to {
transform: translateX(calc(var(--g-main-sidebar-width) * -1));
}
</style>
+188
View File
@@ -0,0 +1,188 @@
<script setup lang="ts">
import type { MenuInjection, MenuProps } from './types'
import { cn } from '@/utils'
import Item from './item.vue'
import SubMenu from './sub.vue'
import { rootMenuInjectionKey } from './types'
defineOptions({
name: 'MainMenu',
})
const props = withDefaults(
defineProps<MenuProps>(),
{
accordion: true,
defaultExpandPaths: () => [],
mode: 'vertical',
collapse: false,
},
)
// 用于缓存对象到 ID 的映射
const idMap = new WeakMap<object, string>()
let idCounter = 0
// 获取对象的唯一 ID,如果已存在则返回缓存的 ID,否则生成新的
function getUseId(obj: object): string {
if (!idMap.has(obj)) {
idMap.set(obj, `menu-item-${++idCounter}`)
}
return idMap.get(obj)!
}
const activeIndex = ref<MenuInjection['activeIndex']>(props.value)
const items = ref<MenuInjection['items']>({})
const subMenus = ref<MenuInjection['subMenus']>({})
const expandMenus = ref<MenuInjection['expandMenus']>(props.defaultExpandPaths.slice(0))
const mouseInMenu = ref<MenuInjection['mouseInMenu']>([])
const isMenuPopup = computed<MenuInjection['isMenuPopup']>(() => {
return props.mode === 'horizontal' || (props.mode === 'vertical' && props.collapse)
})
// 解析传入的 menu 数据,并保存到 items 和 subMenus 对象中
function initItems(menu: MenuProps['menu'], parentPaths: string[] = []) {
menu.forEach((item) => {
const index = item.path ?? getUseId(item)
if (item.children?.some(item => item.meta?.menu !== false)) {
const indexPath = [...parentPaths, index]
subMenus.value[index] = {
index,
indexPath,
active: false,
}
initItems(item.children, indexPath)
}
else {
items.value[index] = {
index,
indexPath: parentPaths,
}
}
})
}
const openMenu: MenuInjection['openMenu'] = (index, indexPath) => {
if (expandMenus.value.includes(index)) {
return
}
if (props.accordion) {
expandMenus.value = expandMenus.value.filter(key => indexPath.includes(key))
}
expandMenus.value.push(index)
}
const closeMenu: MenuInjection['closeMenu'] = (index) => {
if (Array.isArray(index)) {
nextTick(() => {
closeMenu(index.at(-1)!)
if (index.length > 1) {
closeMenu(index.slice(0, -1))
}
})
return
}
expandMenus.value = expandMenus.value.filter(item => item !== index)
}
function setSubMenusActive(index: string) {
for (const key in subMenus.value) {
subMenus.value[key].active = false
}
subMenus.value[index]?.indexPath.forEach((idx) => {
subMenus.value[idx].active = true
})
items.value[index]?.indexPath.forEach((idx) => {
subMenus.value[idx].active = true
})
}
const handleMenuItemClick: MenuInjection['handleMenuItemClick'] = (index) => {
if (props.mode === 'horizontal' || props.collapse) {
expandMenus.value = []
}
setSubMenusActive(index)
}
const handleSubMenuClick: MenuInjection['handleSubMenuClick'] = (index, indexPath) => {
if (expandMenus.value.includes(index)) {
closeMenu(index)
}
else {
openMenu(index, indexPath)
}
}
function initMenu() {
const activeItem = activeIndex.value && items.value[activeIndex.value]
setSubMenusActive(activeIndex.value)
if (!activeItem || isMenuPopup.value || props.collapse) {
return
}
// 展开该菜单项的路径上所有子菜单
activeItem.indexPath.forEach((index) => {
const subMenu = subMenus.value[index]
subMenu && openMenu(index, subMenu.indexPath)
})
}
watch(() => props.menu, (val) => {
initItems(val)
initMenu()
}, {
deep: true,
immediate: true,
})
watch(() => props.value, (currentValue) => {
if (!items.value[currentValue]) {
activeIndex.value = ''
}
const item = items.value[currentValue] || (activeIndex.value && items.value[activeIndex.value]) || items.value[props.value]
if (item) {
activeIndex.value = item.index
}
else {
activeIndex.value = currentValue
}
initMenu()
})
watch(() => props.collapse, (value) => {
if (value) {
expandMenus.value = []
}
else {
expandMenus.value = props.defaultExpandPaths.slice(0)
}
initMenu()
})
provide(rootMenuInjectionKey, reactive({
props,
getUseId,
items,
subMenus,
activeIndex,
expandMenus,
mouseInMenu,
isMenuPopup,
openMenu,
closeMenu,
handleMenuItemClick,
handleSubMenuClick,
}))
</script>
<template>
<div
:class="cn('h-full w-full flex flex-col of-hidden', {
'flex-row w-auto': isMenuPopup && props.mode === 'horizontal',
'py-1': props.mode === 'vertical',
})"
>
<template v-for="item in menu" :key="item.path ?? getUseId(item)">
<template v-if="item.meta?.menu !== false">
<SubMenu v-if="item.children?.length" :menu="item" :unique-key="[item.path ?? (item.children.every(item => item.meta?.menu === false) ? item.children[0].path! : getUseId(item))]" />
<Item v-else :item="item" :unique-key="[item.path ?? (item.children?.every(item => item.meta?.menu === false) ? item.children[0].path! : getUseId(item))]" @click="handleMenuItemClick(item.path ?? (item.children?.every(item => item.meta?.menu === false) ? item.children[0].path! : getUseId(item)))" />
</template>
</template>
</div>
</template>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { SubMenuItemProps } from './types'
import { cn } from '@/utils'
import { rootMenuInjectionKey } from './types'
defineOptions({
name: 'SubMenuItem',
})
const props = withDefaults(
defineProps<SubMenuItemProps>(),
{
level: 0,
subMenu: false,
expand: false,
},
)
const { generateTitle } = useAppMenu()
const rootMenu = inject(rootMenuInjectionKey)!
const itemRef = ref<HTMLElement>()
const isActived = computed(() => {
return props.subMenu
? rootMenu.subMenus[props.uniqueKey.at(-1)!].active
: rootMenu.activeIndex === props.uniqueKey.at(-1)!
})
const isItemActive = computed(() => {
return isActived.value && (!props.subMenu || rootMenu.isMenuPopup)
})
const icon = computed(() => {
return props.item.meta?.icon
})
defineExpose({
ref: itemRef,
})
</script>
<template>
<div
ref="itemRef" :class="cn('menu-item relative', {
'active': isItemActive,
'py-1 px-2': (rootMenu.isMenuPopup && rootMenu.props.mode === 'vertical') || (rootMenu.isMenuPopup && level !== 0 && rootMenu.props.mode === 'horizontal') || !rootMenu.isMenuPopup,
'px-1 py-2': rootMenu.isMenuPopup && level === 0 && rootMenu.props.mode === 'horizontal',
})"
>
<router-link v-slot="{ href, navigate }" custom :to="uniqueKey.at(-1) ?? ''">
<FaTooltip :disabled="!generateTitle(item.meta?.title) || !rootMenu.isMenuPopup || level !== 0 || subMenu" :text="generateTitle(item.meta?.title)" :side="rootMenu.props.mode === 'vertical' ? 'right' : 'bottom'" class="h-full w-full">
<Component
:is="subMenu ? 'div' : 'a'" v-bind="{
...(!subMenu && {
href: item.meta?.link ? item.meta.link : href,
target: item.meta?.link ? '_blank' : '_self',
class: 'no-underline',
}),
}" :class="cn('group menu-item-container relative h-full w-full min-w-0 flex items-center justify-between gap-1 rounded-lg', {
'px-4 py-3': true,
'cursor-pointer text-[var(--g-sub-sidebar-menu-color)] transition-colors hover-(bg-[var(--g-sub-sidebar-menu-hover-bg)] text-[var(--g-sub-sidebar-menu-hover-color)])': true,
'text-[var(--g-sub-sidebar-menu-active-color)]! bg-[var(--g-sub-sidebar-menu-active-bg)]!': isItemActive,
'px-3': rootMenu.isMenuPopup && level === 0,
'py-3': rootMenu.isMenuPopup && level !== 0,
})" :title="generateTitle(item.meta?.title)" v-on="{
...(!subMenu && {
click: navigate,
}),
}"
>
<div
:class="cn('inline-flex min-w-0 flex-1 items-center justify-start', {
'gap-[12px]': true,
'ps-[calc(var(--indent-level)*20px)]': true,
'flex-col': rootMenu.isMenuPopup && level === 0 && rootMenu.props.mode === 'vertical',
})" :style="{
'--indent-level': !rootMenu.isMenuPopup ? props.level ?? 0 : 0,
}"
>
<FaIcon
v-if="icon" :name="icon" :class="cn('menu-item-container-icon', {
'size-5': true,
'transition-transform group-hover-scale-120': true,
})"
/>
<span
v-if="!(rootMenu.isMenuPopup && level === 0)" :class="cn('block min-w-0 flex-1 truncate text-sm transition-height transition-opacity transition-width', {
'empty:hidden': !generateTitle(item.meta?.title),
})"
>
{{ generateTitle(item.meta?.title) }}
</span>
</div>
<i
v-if="
subMenu
&& (!rootMenu.isMenuPopup || level !== 0) // 非收起模式,非一级的子菜单
" :class="cn('relative ms-1 w-[10px] after:absolute before:absolute after:h-[1.5px] after:w-[6px] before:h-[1.5px] before:w-[6px] after:bg-current before:bg-current after:transition-transform-200 before:transition-transform-200 after:content-empty before:content-empty after:-translate-y-[1px] before:-translate-y-[1px]', {
[expand ? 'before:-rotate-45 before:-translate-x-[2px] after:rotate-45 after:translate-x-[2px]' : 'before:rotate-45 before:-translate-x-[2px] after:-rotate-45 after:translate-x-[2px]']: true,
'opacity-0': rootMenu.isMenuPopup && level === 0,
'-rotate-90 -top-[1.5px]': rootMenu.isMenuPopup && level !== 0,
})"
/>
</component>
</FaTooltip>
</router-link>
</div>
</template>
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
import type { SubMenuProps } from './types'
import { useTimeoutFn } from '@vueuse/core'
import { cn } from '@/utils'
import Item from './item.vue'
import { rootMenuInjectionKey } from './types'
defineOptions({
name: 'SubMenu',
})
const props = withDefaults(
defineProps<SubMenuProps>(),
{
level: 0,
},
)
const itemRef = useTemplateRef('itemRef')
const subMenuRef = useTemplateRef('subMenuRef')
const rootMenu = inject(rootMenuInjectionKey)!
const index = props.menu.path ?? rootMenu.getUseId(props.menu)
const expand = computed(() => rootMenu.expandMenus.includes(props.uniqueKey.at(-1)!))
const transitionEvent = computed(() => {
if (rootMenu.isMenuPopup) {
return {
enter(el: HTMLElement) {
if (el.offsetHeight > window.innerHeight) {
el.style.height = `${window.innerHeight}px`
}
},
afterEnter: () => {},
beforeLeave: (el: HTMLElement) => {
el.style.maxHeight = `${el.offsetHeight}px`
el.style.overflow = 'hidden'
},
leave: (el: HTMLElement) => {
el.style.maxHeight = '0'
},
afterLeave(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
}
}
if (CSS.supports('height', 'calc-size(auto, size)')) {
return {}
}
return {
enter(el: HTMLElement) {
el.dataset.height = el.offsetHeight.toString()
el.style.maxHeight = '0'
el.style.overflow = 'hidden'
void el.offsetHeight // 强制重排,确保浏览器应用初始状态
requestAnimationFrame(() => {
el.style.maxHeight = `${el.dataset.height}px`
})
},
afterEnter(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
enterCancelled(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
beforeLeave(el: HTMLElement) {
el.style.maxHeight = `${el.offsetHeight}px`
el.style.overflow = 'hidden'
void el.offsetHeight // 强制重排,确保浏览器应用初始状态
},
leave(el: HTMLElement) {
requestAnimationFrame(() => {
el.style.maxHeight = '0'
})
},
afterLeave(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
leaveCancelled(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
}
})
const transitionClass = computed(() => {
if (rootMenu.isMenuPopup) {
return {
enterActiveClass: 'ease-in-out duration-300',
enterFromClass: 'opacity-0 translate-x-4',
enterToClass: 'opacity-100',
leaveActiveClass: 'ease-in-out duration-300',
leaveFromClass: 'opacity-100',
leaveToClass: 'opacity-0',
}
}
return {
enterActiveClass: 'ease-in-out duration-200',
enterFromClass: cn('opacity-0 translate-y-2', CSS.supports('height', 'calc-size(auto, size)') && 'h-0'),
enterToClass: 'opacity-100 translate-y-0',
leaveActiveClass: 'ease-in-out duration-200',
leaveFromClass: 'opacity-100 translate-y-0',
leaveToClass: cn('opacity-0 translate-y-2', CSS.supports('height', 'calc-size(auto, size)') && 'h-0'),
}
})
const hasChildren = computed(() => props.menu.children?.some((item: any) => item.meta?.menu !== false) ?? false)
function handleClick() {
if (
(rootMenu.isMenuPopup && hasChildren.value)
|| props.menu.meta?.link
) {
return
}
if (hasChildren.value) {
rootMenu.handleSubMenuClick(index, props.uniqueKey)
}
else {
rootMenu.handleMenuItemClick(index)
}
}
let timeout: (() => void) | undefined
function handleMouseenter() {
if (!rootMenu.isMenuPopup) {
return
}
rootMenu.mouseInMenu = props.uniqueKey
timeout?.()
;({ stop: timeout } = useTimeoutFn(() => {
if (hasChildren.value) {
rootMenu.openMenu(index, props.uniqueKey)
nextTick(() => {
requestAnimationFrame(() => {
const el = itemRef.value?.ref
const subMenuEl = subMenuRef.value?.$el
if (!el || !subMenuEl) {
return
}
const rect = el.getBoundingClientRect()
const { top, left, width, height } = rect
let menuTop = 0
let menuLeft = 0
if (rootMenu.props.mode === 'vertical' || props.level !== 0) {
menuTop = top + el.scrollTop - 5 // 4px padding + 1px border
menuLeft = left + width + 4
// 处理边界情况
if (menuTop + subMenuEl.offsetHeight > window.innerHeight) {
menuTop = Math.max(0, window.innerHeight - subMenuEl.offsetHeight)
}
}
else {
menuTop = top + height + 3
menuLeft = left
menuLeft -= 5 // 4px padding + 1px border
// 处理边界情况
if (menuTop + subMenuEl.offsetHeight > window.innerHeight) {
subMenuEl.style.height = `${window.innerHeight - menuTop}px`
}
}
// 处理边界情况
if (menuLeft + subMenuEl.offsetWidth > document.documentElement.clientWidth) {
menuLeft = left - width
}
// 设置样式
Object.assign(subMenuEl.style, {
top: `${menuTop}px`,
insetInlineStart: `${menuLeft}px`,
willChange: 'transform',
transform: 'translateZ(0)',
})
})
})
}
else {
const path = props.menu.children ? rootMenu.subMenus[index].indexPath.at(-1)! : rootMenu.items[index].indexPath.at(-1)!
rootMenu.openMenu(path, rootMenu.subMenus[path].indexPath)
}
}, 300))
}
function handleMouseleave() {
if (!rootMenu.isMenuPopup) {
return
}
rootMenu.mouseInMenu = []
timeout?.()
;({ stop: timeout } = useTimeoutFn(() => {
requestAnimationFrame(() => {
if (rootMenu.mouseInMenu.length === 0) {
rootMenu.closeMenu(props.uniqueKey)
}
else {
if (hasChildren.value) {
!rootMenu.mouseInMenu.includes(props.uniqueKey.at(-1)!) && rootMenu.closeMenu(props.uniqueKey.at(-1)!)
}
}
})
}, 300))
}
</script>
<template>
<Item ref="itemRef" :unique-key="uniqueKey" :item="menu" :level="level" :sub-menu="hasChildren" :expand @click="handleClick" @mouseenter="handleMouseenter" @mouseleave="handleMouseleave" />
<template v-if="hasChildren">
<Teleport to="body" :disabled="!rootMenu.isMenuPopup">
<Transition v-bind="transitionClass" v-on="transitionEvent">
<FaScrollArea
v-if="expand" ref="subMenuRef" :scrollbar="false" :mask="rootMenu.isMenuPopup"
:class="cn('sub-menu static h-[calc-size(auto,size)] rounded-lg', {
'bg-[var(--g-sub-sidebar-bg)]': rootMenu.isMenuPopup,
'border shadow-xl fixed z-3000 w-[200px]': rootMenu.isMenuPopup,
'py-1 overscroll-contain': rootMenu.isMenuPopup,
})"
>
<template v-for="item in menu.children" :key="item.path ?? rootMenu.getUseId(item)">
<SubMenu v-if="item.meta?.menu !== false" :unique-key="[...uniqueKey, item.path ?? rootMenu.getUseId(item)]" :menu="item" :level="level + 1" />
</template>
</FaScrollArea>
</Transition>
</Teleport>
</template>
</template>
+47
View File
@@ -0,0 +1,47 @@
import type { MenuRecordRaw } from '@fantastic-admin/types'
export interface MenuItem {
index: string
indexPath: string[]
active?: boolean
}
export interface MenuProps {
menu: MenuRecordRaw[]
value: string
accordion?: boolean
defaultExpandPaths?: string[]
mode?: 'horizontal' | 'vertical'
collapse?: boolean
}
export interface MenuInjection {
props: MenuProps
getUseId: (obj: object) => string
items: Record<string, MenuItem>
subMenus: Record<string, MenuItem>
activeIndex: MenuProps['value']
expandMenus: string[]
mouseInMenu: string[]
isMenuPopup: boolean
openMenu: (index: string, indexPath: string[]) => void
closeMenu: (index: string | string[]) => void
handleMenuItemClick: (index: string) => void
handleSubMenuClick: (index: string, indexPath: string[]) => void
}
export const rootMenuInjectionKey = Symbol('rootMenu') as InjectionKey<MenuInjection>
export interface SubMenuProps {
uniqueKey: string[]
menu: MenuRecordRaw
level?: number
}
export interface SubMenuItemProps {
uniqueKey: string[]
item: MenuRecordRaw
level?: number
subMenu?: boolean
expand?: boolean
}
+169
View File
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import Logo from '../Logo/index.vue'
import Menu from '../Menu/index.vue'
defineOptions({
name: 'SubSidebar',
})
defineProps<{
enable: boolean
}>()
const route = useRoute()
const appSettingsStore = useAppSettingsStore()
const appMenuStore = useAppMenuStore()
const isCollapse = computed(() => appSettingsStore.settings.menu.subMenuCollapse)
const transitionName = ref('')
watch(() => appMenuStore.actived, (val, oldVal) => {
if (appSettingsStore.mode === 'mobile' || appSettingsStore.settings.menu.mode === 'side') {
if (val > oldVal) {
transitionName.value = 'sub-sidebar-y-start'
}
else {
transitionName.value = 'sub-sidebar-y-end'
}
}
else if (appSettingsStore.settings.menu.mode === 'head') {
if (val > oldVal) {
transitionName.value = 'sub-sidebar-x-start'
}
else {
transitionName.value = 'sub-sidebar-x-end'
}
}
})
</script>
<template>
<Transition name="sub-sidebar">
<div
v-if="enable" class="sub-sidebar-container" :class="{
'is-collapse': isCollapse,
}"
>
<Component :is="useSlots('sub-sidebar-top')" />
<Logo
v-if="['side', 'single'].includes(appSettingsStore.settings.menu.mode) || appSettingsStore.mode === 'mobile'" :show-logo="appSettingsStore.settings.menu.mode === 'single'" class="sidebar-logo" :class="{
single: appSettingsStore.settings.menu.mode === 'single',
}"
/>
<Component :is="useSlots('sub-sidebar-after-logo')" />
<FaScrollArea :scrollbar="false" mask class="overscroll-contain flex-1">
<TransitionGroup :name="transitionName">
<template v-for="(mainItem, mainIndex) in appMenuStore.allMenus" :key="mainIndex">
<div v-show="mainIndex === appMenuStore.actived">
<Menu
:menu="mainItem.children" :value="route.meta.activeMenu || route.path" :default-expand-paths="appMenuStore.defaultExpandPaths" :accordion="appSettingsStore.settings.menu.subMenuUniqueExpand" :collapse="isCollapse" class="menu" :class="{
'-mt-2': !(isCollapse || ['head', 'single'].includes(appSettingsStore.settings.menu.mode)),
}"
/>
</div>
</template>
</TransitionGroup>
</FaScrollArea>
<div
v-if="appSettingsStore.mode === 'pc' && appSettingsStore.settings.menu.subMenuCollapseButton" class="px-4 py-3 flex items-center relative" :class="{
'justify-center': isCollapse,
'justify-end': !isCollapse,
}"
>
<FaButton variant="secondary" size="icon-sm" @click="appSettingsStore.toggleSidebarCollapse()">
<FaIcon name="app-toolbar-collapse" class="size-4 transition" :class="{ 'rotate-z--180': appSettingsStore.settings.menu.subMenuCollapse }" />
</FaButton>
</div>
<Component :is="useSlots('sub-sidebar-after-menu')" />
<div v-if="appSettingsStore.settings.menu.mode === 'single'" class="px-4 pb-3 flex-center">
<AppAccountButton :only-avatar="isCollapse" dropdown-align="center" :dropdown-side="isCollapse ? 'right' : 'top'" button-variant="secondary" :class="{ 'w-full': !isCollapse }" />
</div>
<Component :is="useSlots('sub-sidebar-bottom')" />
</div>
</Transition>
</template>
<style scoped>
.sub-sidebar-container {
position: absolute;
inset-inline-start: 0;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
width: var(--g-sub-sidebar-width);
background-color: var(--g-sub-sidebar-bg);
box-shadow: 1px 0 0 0 oklch(var(--border));
transition: inset-inline-start 0.3s, width 0.3s, background-color 0.15s, box-shadow 0.15s;
&.is-collapse {
width: var(--g-sub-sidebar-collapse-width);
.sidebar-logo {
&:not(.single) {
display: none;
}
:deep(span) {
display: none;
}
}
}
.menu {
width: 100%;
}
}
/* 次侧边栏动画 */
.sub-sidebar-x-start-enter-active,
.sub-sidebar-x-end-enter-active,
.sub-sidebar-y-start-enter-active,
.sub-sidebar-y-end-enter-active {
transition: 0.2s;
}
.sub-sidebar-x-start-enter-from,
.sub-sidebar-x-start-leave-active {
opacity: 0;
transform: translateX(30px);
}
.sub-sidebar-x-end-enter-from,
.sub-sidebar-x-end-leave-active {
opacity: 0;
transform: translateX(-30px);
}
.sub-sidebar-y-start-enter-from,
.sub-sidebar-y-start-leave-active {
opacity: 0;
transform: translateY(30px);
}
.sub-sidebar-y-end-enter-from,
.sub-sidebar-y-end-leave-active {
opacity: 0;
transform: translateY(-30px);
}
.sub-sidebar-x-start-leave-active,
.sub-sidebar-x-end-leave-active,
.sub-sidebar-y-start-leave-active,
.sub-sidebar-y-end-leave-active {
position: absolute;
}
/* 次侧边栏动画 */
.sub-sidebar-enter-active,
.sub-sidebar-leave-active {
transition: 0.3s;
}
.sub-sidebar-enter-from,
.sub-sidebar-leave-to {
transform: translateX(calc(var(--g-sub-sidebar-width) * -1));
}
</style>
+437
View File
@@ -0,0 +1,437 @@
<script setup lang="ts">
import type { TabbarRecordRaw } from '@fantastic-admin/types'
import { useMagicKeys } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useSlots } from '@/slots'
defineOptions({
name: 'Tabbar',
})
const route = useRoute()
const router = useRouter()
const appSettingsStore = useAppSettingsStore()
const appTabbarStore = useAppTabbarStore()
const tabbar = useAppTabbar()
const mainPage = useAppPage()
const keys = useMagicKeys({ reactive: true })
const { generateTitle } = useAppMenu()
const activedTabId = computed(() => tabbar.getId())
const tabsRef = useTemplateRef('tabsRef')
const tabContainerRef = useTemplateRef('tabContainerRef')
const tabRef = useTemplateRef<HTMLElement[]>('tabRef')
const isAnimating = ref(false)
const isInit = ref(true)
function onBeforeLeave(el: Element) {
isAnimating.value = true
const htmlEl = el as HTMLElement
htmlEl.style.left = `${htmlEl.offsetLeft}px`
htmlEl.style.top = `${htmlEl.offsetTop}px`
htmlEl.style.width = `${htmlEl.offsetWidth}px`
}
onMounted(() => {
nextTick(() => {
isInit.value = false
})
})
watch(() => route, (val) => {
if (appSettingsStore.settings.topbar.tabbar) {
appTabbarStore.add(val)
nextTick(() => {
const index = appTabbarStore.list.findIndex(item => item.tabId === activedTabId.value)
if (index !== -1) {
const tabEl = tabRef.value?.find(item => Number.parseInt(item.dataset.index!) === index)
const containerEl = tabsRef.value?.ref?.$el
if (tabEl && containerEl) {
const tabLeft = tabEl.offsetLeft
const tabWidth = tabEl.offsetWidth
const containerWidth = containerEl.clientWidth
// 计算滚动位置,使标签页居中
const scrollLeft = tabLeft - (containerWidth - tabWidth) / 2
// 首次加载时直接定位到指定位置
tabsRef.value?.scrollTo(scrollLeft, !isInit.value ? 'smooth' : undefined)
}
tabbarScrollTip()
}
})
}
}, {
immediate: true,
deep: true,
})
function tabbarScrollTip() {
if (tabContainerRef.value?.$el.clientWidth > (tabsRef.value?.ref?.$el.clientWidth ?? 0) && !localStorage.has('tabbarScrollTip')) {
localStorage.setItem('tabbarScrollTip', '')
const tips = faToast.info('温馨提示', {
description: '标签栏数量超过展示区域范围,可以将鼠标移到标签栏上,通过鼠标滚轮滑动浏览',
position: 'top-center',
duration: Infinity,
action: {
label: '知道了',
onClick: () => faToast.dismiss(tips),
},
})
}
}
function contextMenuItems(routeItem: TabbarRecordRaw) {
return [
[
{
label: '重新加载',
icon: 'i-ri:refresh-line',
disabled: routeItem.tabId !== activedTabId.value,
handle: () => mainPage.reload(),
},
{
label: '关闭标签页',
icon: 'i-ri:close-line',
disabled: !tabbar.checkClose(routeItem.tabId),
handle: () => tabbar.closeById(routeItem.tabId),
},
],
[
{
label: '关闭其它标签页',
icon: 'i-mdi:close',
disabled: !tabbar.checkCloseOtherSide(routeItem.tabId),
handle: () => {
tabbar.closeOtherSide(routeItem.tabId)
},
},
{
label: '关闭左侧标签页',
icon: 'i-mdi:arrow-expand-left',
disabled: !tabbar.checkCloseLeftSide(routeItem.tabId),
handle: () => {
tabbar.closeLeftSide(routeItem.tabId)
},
},
{
label: '关闭右侧标签页',
icon: 'i-mdi:arrow-expand-right',
disabled: !tabbar.checkCloseRightSide(routeItem.tabId),
handle: () => {
tabbar.closeRightSide(routeItem.tabId)
},
},
],
]
}
function iconName(icon: TabbarRecordRaw['icon']) {
return icon
}
const visibleTabIndex = ref<number[]>([])
function getVisibleTabs() {
const containerWidth = tabsRef.value?.ref?.$el.clientWidth ?? 0
const scrollLeft = tabsRef.value?.ref?.el?.viewportElement?.scrollLeft ?? 0
visibleTabIndex.value = []
if (tabRef.value) {
for (let i = 0; i < tabRef.value.length; i++) {
const tab = tabRef.value[i]
const tabLeft = tab.offsetLeft
const tabRight = tabLeft + tab.offsetWidth
// 检查标签页是否在可视区域内
if (tabLeft < scrollLeft + containerWidth && tabRight > scrollLeft) {
if (i >= 0 && i < appTabbarStore.list.length) {
visibleTabIndex.value.push(i)
}
}
}
}
}
function getVisibleTabIndex(arrayIndex: number) {
return visibleTabIndex.value.findIndex(visibleTab => visibleTab === arrayIndex) ?? -1
}
useHotkeyBindings({
'tabbar.prev': () => {
if (appTabbarStore.list[0]?.tabId !== activedTabId.value) {
const index = appTabbarStore.list.findIndex(item => item.tabId === activedTabId.value)
index > 0 && router.push(appTabbarStore.list[index - 1].fullPath)
}
},
'tabbar.next': () => {
if (appTabbarStore.list.at(-1)?.tabId !== activedTabId.value) {
const index = appTabbarStore.list.findIndex(item => item.tabId === activedTabId.value)
index >= 0 && router.push(appTabbarStore.list[index + 1].fullPath)
}
},
'tabbar.closeCurrent': () => {
tabbar.closeById(activedTabId.value)
},
'tabbar.gotoVisibleIndex': ({ hotkey }) => {
const number = Number(hotkey.split('+')[1])
if (visibleTabIndex.value[number - 1] !== undefined) {
router.push(appTabbarStore.list[visibleTabIndex.value[number - 1]].fullPath)
}
},
'tabbar.gotoLast': () => {
const last = appTabbarStore.list.at(-1)
last && router.push(last.fullPath)
},
})
watch(() => keys.alt, (val) => {
if (val) {
getVisibleTabs()
}
})
</script>
<template>
<div class="tabbar">
<Component :is="useSlots('tabbar-start')" />
<div class="tabbar-container">
<FaScrollArea
ref="tabsRef" :scrollbar="false" mask horizontal class="tabs overscroll-contain"
>
<TransitionGroup ref="tabContainerRef" :name="!isInit ? 'tabbar' : undefined" tag="div" class="tab-container" @before-leave="onBeforeLeave" @after-leave="isAnimating = false">
<div
v-for="(element, index) in appTabbarStore.list" :key="element.tabId"
ref="tabRef" :data-index="index" class="tab" :class="{
actived: element.tabId === activedTabId,
}" @click="!isAnimating && element.tabId !== activedTabId && router.push(element.fullPath)"
>
<FaContextMenu :items="contextMenuItems(element)">
<div class="size-full">
<div class="tab-dividers" />
<div class="tab-background" />
<FaTooltip :delay="1000" side="bottom">
<div class="tab-content">
<div :key="element.tabId" class="title">
<FaIcon v-if="appSettingsStore.settings.tabbar.icon && iconName(element.icon)" :name="iconName(element.icon)!" class="icon" />
{{ generateTitle(element.title) }}
</div>
<div v-if="appTabbarStore.list.length > 1" class="action-icon" @click.stop="tabbar.closeById(element.tabId)">
<FaIcon name="i-ri:close-fill" />
</div>
<div v-show="appSettingsStore.settings.tabbar.hotkeys && keys.alt && getVisibleTabIndex(index) >= 0 && getVisibleTabIndex(index) < 9" class="hotkey-number">
{{ getVisibleTabIndex(index) + 1 }}
</div>
</div>
<template #content>
<div class="text-sm">
{{ generateTitle(element.title) }}
</div>
<div class="text-accent/50">
{{ element.fullPath }}
</div>
</template>
</FaTooltip>
</div>
</FaContextMenu>
</div>
</TransitionGroup>
</FaScrollArea>
</div>
<Component :is="useSlots('tabbar-end')" />
</div>
</template>
<style scoped>
.tabbar {
position: relative;
display: flex;
align-items: center;
height: var(--g-tabbar-height);
background-color: var(--g-tabbar-bg);
box-shadow: 0 1px 0 0 oklch(var(--border)), 0 -1px 0 0 oklch(var(--border));
.tabbar-container {
display: flex;
flex: 1;
min-width: 0;
height: 100%;
.tabs {
flex: 1;
align-items: end;
white-space: nowrap;
.tab-container {
position: relative;
.tab {
&:not(.actived):hover {
z-index: 3;
&::before,
&::after {
content: none;
}
& + .tab .tab-dividers::before {
opacity: 0;
}
.tab-content {
.title,
.action-icon {
color: var(--g-tabbar-tab-hover-color);
}
}
.tab-background {
background-color: var(--g-tabbar-tab-hover-bg);
}
}
}
.tab {
position: relative;
display: inline-block;
min-width: 150px;
max-width: 150px;
height: var(--g-tabbar-height);
font-size: 14px;
line-height: calc(var(--g-tabbar-height) - 2px);
vertical-align: bottom;
pointer-events: none;
cursor: pointer;
* {
user-select: none;
}
& + .tab:hover,
& + .tab.actived {
.tab-dividers::before {
opacity: 0;
}
}
&.actived {
z-index: 5;
&::before,
&::after {
content: none;
}
& + .tab .tab-dividers::before {
opacity: 0;
}
.tab-content {
.title,
.action-icon {
color: var(--g-tabbar-tab-active-color);
}
}
.tab-background {
background-color: var(--g-tabbar-tab-active-bg);
}
}
.tab-dividers {
position: absolute;
inset-inline: -1px;
top: 50%;
z-index: 0;
height: 14px;
transform: translateY(-50%);
&::before {
position: absolute;
inset-inline-start: 1px;
top: 0;
bottom: 0;
display: block;
width: 1px;
content: "";
background-color: oklch(var(--border));
opacity: 1;
transition: opacity 0.3s, background-color 0.15s;
}
}
&:first-child .tab-dividers::before {
opacity: 0;
}
.tab-background {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
transition: background-color 0.15s;
}
.tab-content {
display: flex;
width: 100%;
height: 100%;
pointer-events: all;
.title {
display: flex;
flex: 1;
gap: 5px;
align-items: center;
height: 100%;
padding: 0 10px;
margin-inline-end: 10px;
overflow: hidden;
color: var(--g-tabbar-tab-color);
white-space: nowrap;
mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent);
transition: margin-inline-end 0.3s;
&:has(+ .action-icon) {
margin-inline-end: 28px;
}
.icon {
flex-shrink: 0;
}
}
.action-icon {
--uno: transition absolute inset-e-2 top-1/2 -translate-y-1/2 rounded-full z-10 w-5 h-5 flex-center text-xs "text-[var(--g-tabbar-tab-color)]" hover:(border bg-secondary);
}
.hotkey-number {
--uno: border bg-secondary absolute inset-e-2 top-1/2 -translate-y-1/2 rounded-full z-10 w-5 h-5 flex-center text-xs "text-[var(--g-tabbar-tab-color)]";
}
}
}
}
}
}
}
/* 标签栏动画 */
.tabs {
.tabbar-move,
.tabbar-enter-active,
.tabbar-leave-active {
transition: all 0.3s;
}
.tabbar-enter-from,
.tabbar-leave-to {
opacity: 0;
transform: translateY(30px);
}
.tabbar-leave-active {
position: absolute !important;
}
}
</style>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { compile } from 'path-to-regexp'
import Breadcrumb from '../../../Breadcrumb/index.vue'
import BreadcrumbItem from '../../../Breadcrumb/item.vue'
defineOptions({
name: 'ToolbarBreadcrumb',
})
const route = useRoute()
const appSettingsStore = useAppSettingsStore()
const { generateTitle } = useAppMenu()
interface BreadcrumbRecord {
path: string
title?: string | (() => string)
}
let breadcrumbListBackup: BreadcrumbRecord[] = []
const breadcrumbList = computed<BreadcrumbRecord[]>(() => {
if (route.name === 'reload') {
return breadcrumbListBackup
}
const list: BreadcrumbRecord[] = []
if (appSettingsStore.settings.app.home.enable) {
list.push({
path: appSettingsStore.settings.app.home.fullPath,
title: appSettingsStore.settings.app.home.title,
})
}
route.matched.forEach((item) => {
if (item.meta?.breadcrumb !== false) {
list.push({
path: item.path,
title: item.meta?.title,
})
}
})
breadcrumbListBackup = list.filter(item => generateTitle(item.title))
return breadcrumbListBackup
})
function pathCompile(path: string) {
const toPath = compile(path)
return toPath(route.params)
}
</script>
<template>
<Breadcrumb v-if="appSettingsStore.mode === 'pc'" class="breadcrumb px-2 whitespace-nowrap">
<TransitionGroup name="breadcrumb">
<BreadcrumbItem v-for="(item, index) in breadcrumbList" :key="`${index}_${item.path}_${item.title}`" :to="index < breadcrumbList.length - 1 && item.path !== '' ? pathCompile(item.path) : ''">
{{ generateTitle(item.title) }}
</BreadcrumbItem>
</TransitionGroup>
</Breadcrumb>
</template>
<style scoped>
/* 面包屑动画 */
.breadcrumb-enter-active {
transition: transform 0.3s, opacity 0.3s;
}
.breadcrumb-enter-from {
opacity: 0;
transform: translateX(30px) skewX(-50deg);
}
</style>
@@ -0,0 +1,58 @@
<script setup lang="ts">
defineOptions({
name: 'ToolbarColorScheme',
})
const appSettingsStore = useAppSettingsStore()
function toggleColorScheme(event: MouseEvent) {
if (!document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
appSettingsStore.currentColorScheme && appSettingsStore.setColorScheme(appSettingsStore.currentColorScheme === 'dark' ? 'light' : 'dark')
return
}
const target = event.target as HTMLElement
const { left, top, width, height } = target.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
)
const ratioX = (100 * x) / innerWidth
const ratioY = (100 * y) / innerHeight
const referR = Math.hypot(innerWidth, innerHeight) / Math.SQRT2
const ratioR = (100 * endRadius) / referR
const transition = document.startViewTransition(async () => {
appSettingsStore.currentColorScheme && appSettingsStore.setColorScheme(appSettingsStore.currentColorScheme === 'dark' ? 'light' : 'dark')
await nextTick()
})
transition.ready.then(() => {
const clipPath = [
`circle(0% at ${ratioX}% ${ratioY}%)`,
`circle(${ratioR}% at ${ratioX}% ${ratioY}%)`,
]
document.documentElement.animate(
{
clipPath: appSettingsStore.currentColorScheme === 'light' ? clipPath : clipPath.toReversed(),
},
{
duration: 500,
easing: 'ease-in-out',
fill: 'both',
pseudoElement: appSettingsStore.currentColorScheme === 'light' ? '::view-transition-new(root)' : '::view-transition-old(root)',
},
)
})
}
</script>
<template>
<FaButton variant="ghost" size="icon-sm" @click="toggleColorScheme">
<FaIcon
:name="{
light: 'i-ri:sun-line',
dark: 'i-ri:moon-line',
}[appSettingsStore.currentColorScheme!]" class="size-4"
/>
</FaButton>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useFullscreen } from '@vueuse/core'
defineOptions({
name: 'ToolbarFullscreen',
})
const appSettingsStore = useAppSettingsStore()
const { isFullscreen, toggle } = useFullscreen()
</script>
<template>
<FaButton v-if="appSettingsStore.mode === 'pc'" variant="ghost" size="icon-sm" @click="toggle">
<FaIcon :name="isFullscreen ? 'i-ri:fullscreen-exit-line' : 'i-ri:fullscreen-line'" class="size-4" />
</FaButton>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import Search from './search.vue'
defineOptions({
name: 'ToolbarMenuSearch',
})
const appSettingsStore = useAppSettingsStore()
const isShow = ref(false)
const useMobileStyle = computed(() => {
return appSettingsStore.mode !== 'pc' || !appSettingsStore.settings.toolbar.menuSearch.hotkeys
})
useHotkeyBindings({
'menuSearch.open': () => {
isShow.value = true
},
})
</script>
<template>
<FaButton :variant="useMobileStyle ? 'ghost' : 'outline'" :size="useMobileStyle ? 'icon-sm' : undefined" :class="{ 'mx-2 px-3 h-9': !useMobileStyle }" @click="isShow = true">
<FaIcon name="i-ri:search-line" class="size-4" />
<template v-if="!useMobileStyle">
<FaKbdGroup v-if="appSettingsStore.settings.toolbar.menuSearch.hotkeys" class="-me-1">
<FaKbd>{{ appSettingsStore.os === 'mac' ? '⌘' : 'Ctrl' }}</FaKbd>
<FaKbd>K</FaKbd>
</FaKbdGroup>
</template>
</FaButton>
<Search v-model="isShow" />
</template>
@@ -0,0 +1,256 @@
<script setup lang="ts">
import type { MenuRecordRaw } from '@fantastic-admin/types'
import { match } from 'pinyin-pro'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
import { resolveRoutePath } from '@/utils'
defineOptions({
name: 'ToolbarMenuSearchModal',
})
const isShow = defineModel<boolean>({
default: false,
})
const router = useRouter()
const appSettingsStore = useAppSettingsStore()
const appRouteStore = useAppRouteStore()
const appMenuStore = useAppMenuStore()
const { generateTitle } = useAppMenu()
interface listTypes {
path: string
icon?: string
title?: string | (() => string)
link?: string
}
const searchInput = ref('')
const sourceList = ref<listTypes[]>([])
const actived = ref(0)
const searchResultRef = useTemplateRef('searchResultRef')
const searchResultItemRef = useTemplateRef<HTMLElement[]>('searchResultItemRef')
const resultList = computed(() => {
let result = []
result = sourceList.value.filter((item) => {
let flag = false
if (searchInput.value !== '') {
if (item.path.includes(searchInput.value)) {
flag = true
}
if (
generateTitle(item.title).toString().includes(searchInput.value)
|| (match(generateTitle(item.title).toString(), searchInput.value, { continuous: true })?.length ?? 0) > 0
) {
flag = true
}
if (appRouteStore.getRouteMatchedByPath(item.path).some((b) => {
return generateTitle(b.meta?.title).toString().includes(searchInput.value)
|| (match(generateTitle(b.meta?.title).toString(), searchInput.value, { continuous: true })?.length ?? 0) > 0
})) { flag = true }
}
return flag
})
return result
})
useHotkeyBindings({
'menuSearch.moveUp': () => {
keyUp()
},
'menuSearch.moveDown': () => {
keyDown()
},
'menuSearch.confirm': () => {
keyEnter()
},
'menuSearch.close': () => {
isShow.value = false
},
}, () => isShow.value)
watch(() => isShow.value, (val) => {
if (val) {
searchInput.value = ''
actived.value = 0
}
})
watch(() => resultList.value, () => {
actived.value = 0
handleScroll()
})
watch(() => actived.value, () => {
handleScroll()
})
onMounted(() => {
initSourceList()
})
function initSourceList() {
sourceList.value = []
appMenuStore.allMenus.forEach((item) => {
getSourceList(item.children)
})
}
function hasChildren(item: MenuRecordRaw) {
let flag = true
if (item.children?.every(i => i.meta?.menu === false)) {
flag = false
}
return flag
}
function getSourceList(arr: MenuRecordRaw[], basePath?: string, icon?: string) {
arr.forEach((item) => {
if (item.meta?.menu !== false) {
if (item.children && hasChildren(item)) {
getSourceList(
item.children,
resolveRoutePath(basePath, item.path),
item.meta?.icon ?? icon,
)
}
else {
sourceList.value.push({
path: resolveRoutePath(basePath, item.path),
icon: item.meta?.icon ?? icon,
title: item.meta?.title,
link: item.meta?.link,
})
}
}
})
}
function keyUp() {
if (resultList.value.length) {
actived.value -= 1
if (actived.value < 0) {
actived.value = resultList.value.length - 1
}
}
}
function keyDown() {
if (resultList.value.length) {
actived.value += 1
if (actived.value > resultList.value.length - 1) {
actived.value = 0
}
}
}
function keyEnter() {
searchResultItemRef.value?.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.click()
}
function handleScroll() {
if (searchResultRef.value?.ref?.el?.viewportElement) {
const contentDom = searchResultRef.value.ref.el.viewportElement
let scrollTo = 0
if (resultList.value.length > 0) {
scrollTo = contentDom.scrollTop
const activedOffsetTop = searchResultItemRef.value?.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.offsetTop ?? 0
const activedClientHeight = searchResultItemRef.value?.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.clientHeight ?? 0
const searchScrollTop = contentDom.scrollTop
const searchClientHeight = contentDom.clientHeight
if (activedOffsetTop + activedClientHeight > searchScrollTop + searchClientHeight) {
scrollTo = activedOffsetTop + activedClientHeight - searchClientHeight
}
else if (activedOffsetTop <= searchScrollTop) {
scrollTo = activedOffsetTop - 16
}
}
contentDom.scrollTo({
top: scrollTo,
})
}
}
function pageJump(path: listTypes['path'], link: listTypes['link']) {
if (link) {
window.open(link, '_blank')
}
else {
router.push(path)
}
isShow.value = false
}
</script>
<template>
<FaModal v-model="isShow" :footer="appSettingsStore.mode === 'pc'" :closable="false" open-auto-focus border class="w-full lg-max-w-2xl" content-class="flex flex-col p-0 min-h-auto" header-class="p-0" footer-class="p-0">
<template #header>
<div class="flex flex-shrink-0 h-12 items-center">
<div class="flex-center h-full w-14">
<FaIcon name="i-ri:search-line" class="text-foreground/30 size-4" />
</div>
<input v-model="searchInput" placeholder="支持标题、拼音(首字母)、URL模糊查询" class="text-base text-foreground border-0 rounded-md bg-transparent h-full w-full focus-outline-none placeholder-foreground/30" @keydown.esc.prevent="isShow = false" @keydown.up.prevent="keyUp" @keydown.down.prevent="keyDown" @keydown.enter.prevent="keyEnter">
<div v-if="appSettingsStore.mode === 'mobile'" class="border-s flex-center h-full w-14">
<FaIcon name="i-carbon:close" class="size-4" @click="isShow = false" />
</div>
</div>
</template>
<template #footer>
<div class="px-4 py-3 flex w-full justify-between">
<div class="flex gap-8">
<div class="text-xs inline-flex gap-1 items-center">
<FaKbd>⏎</FaKbd>
<span>访问</span>
</div>
<div class="text-xs inline-flex gap-1 items-center">
<FaKbd>
<FaIcon name="i-ant-design:caret-up-filled" />
</FaKbd>
<FaKbd>
<FaIcon name="i-ant-design:caret-down-filled" />
</FaKbd>
<span>切换</span>
</div>
</div>
<div v-if="appSettingsStore.settings.toolbar.menuSearch.hotkeys" class="text-xs inline-flex gap-1 items-center">
<FaKbd>Esc</FaKbd>
<span>退出</span>
</div>
</div>
</template>
<FaScrollArea ref="searchResultRef">
<template v-if="resultList.length > 0">
<div v-for="(item, index) in resultList" ref="searchResultItemRef" :key="item.path" class="p-4" :data-index="index" @click="pageJump(item.path, item.link)" @mouseover="actived = index">
<a class="border rounded-lg flex cursor-pointer items-center" :class="{ '-mt-4': index !== 0, 'bg-accent border-primary shadow-[0_0_0_1px_oklch(var(--primary))]': index === actived, 'op-50': index !== actived }">
<div class="flex-center basis-16 -me-4">
<FaIcon v-if="item.icon" :name="item.icon" class="size-5 transition" :class="{ 'scale-120 text-primary': index === actived }" />
</div>
<div class="px-4 py-3 flex flex-1 flex-col gap-1 truncate">
<div class="text-base font-bold text-start truncate">{{ generateTitle(item.title) }}</div>
<Breadcrumb v-if="appRouteStore.getRouteMatchedByPath(item.path).length" class="truncate">
<BreadcrumbItem v-for="(bc, bcIndex) in appRouteStore.getRouteMatchedByPath(item.path)" :key="bcIndex" class="text-xs">
{{ generateTitle(bc.meta?.title) }}
</BreadcrumbItem>
</Breadcrumb>
</div>
</a>
</div>
</template>
<template v-else-if="searchInput === ''">
<div class="text-secondary-foreground/50 py-6 flex-col-center h-full">
<FaIcon name="i-tabler:mood-smile" class="size-10" />
<p class="text-base m-2">
输入你要搜索的导航
</p>
</div>
</template>
<template v-else>
<div class="text-secondary-foreground/50 py-6 flex-col-center h-full">
<FaIcon name="i-tabler:mood-empty" class="size-10" />
<p class="text-base m-2">
没有找到你想要的
</p>
</div>
</template>
</FaScrollArea>
</FaModal>
</template>
@@ -0,0 +1,51 @@
<script setup lang="ts">
defineOptions({
name: 'ToolbarPageReload',
})
const appSettingsStore = useAppSettingsStore()
const mainPage = useAppPage()
const isAnimating = ref(false)
function handleClick() {
isAnimating.value = true
mainPage.reload()
}
function handleCtrlClick() {
location.reload()
}
</script>
<template>
<FaTooltip side="bottom" :disabled="appSettingsStore.os === 'mac'">
<template #content>
<div class="flex-col-center gap-2">
<p class="flex-center gap-1">
按住 <FaKbd>Ctrl</FaKbd> 键并点击
</p>
<p>可切换为浏览器原生刷新</p>
</div>
</template>
<FaButton variant="ghost" size="icon-sm" @click.exact="handleClick" @click.ctrl.exact="handleCtrlClick" @animationend="isAnimating = false">
<FaIcon name="i-iconoir:refresh-double" class="size-4" :class="{ animation: isAnimating }" />
</FaButton>
</FaTooltip>
</template>
<style scoped>
.animation {
animation: animation 1s;
}
@keyframes animation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import Tools from './tools.vue'
defineOptions({
name: 'ToolbarRightSide',
})
</script>
<template>
<div class="flex items-center">
<Tools mode="right-side" />
<Component :is="useSlots('toolbar-end')" />
</div>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import EndSide from './endSide.vue'
import StartSide from './startSide.vue'
defineOptions({
name: 'Toolbar',
})
</script>
<template>
<div class="bg-[var(--g-toolbar-bg)] flex h-[var(--g-toolbar-height)] items-center justify-between">
<div class="start-side pe-16 ps-2 flex h-full items-center of-hidden">
<StartSide />
</div>
<div class="px-2 flex flex-shrink-0 h-full items-center justify-end">
<EndSide />
</div>
</div>
</template>
<style scoped>
.start-side {
mask-image: linear-gradient(to right, #000 0%, #000 calc(100% - 50px), transparent);
}
</style>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import Tools from './tools.vue'
defineOptions({
name: 'ToolbarLeftSide',
})
const appSettingsStore = useAppSettingsStore()
</script>
<template>
<div class="flex items-center">
<FaButton v-if="appSettingsStore.mode === 'mobile'" variant="ghost" size="icon" class="h-9 w-9 -rotate-z-180" @click="appSettingsStore.toggleSidebarCollapse()">
<FaIcon name="app-toolbar-collapse" class="size-4" />
</FaButton>
<Component :is="useSlots('toolbar-start')" />
<Tools mode="left-side" />
</div>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { ToolbarSettings } from '@fantastic-admin/settings'
import { pascalCase } from 'scule'
defineOptions({
name: 'ToolbarTools',
})
const props = defineProps<{
mode: 'left-side' | 'right-side'
}>()
const modules = import.meta.glob('./*/index.vue', { import: 'default', eager: true })
const appSettingsStore = useAppSettingsStore()
const toolbarTools = {
'left-side': ['breadcrumb'],
'right-side': ['menuSearch', 'fullscreen', 'pageReload', 'colorScheme'],
} satisfies Record<typeof props.mode, (keyof ToolbarSettings)[]>
const tools = computed(() => toolbarTools[props.mode])
function checkVisible(item: boolean | { enable: boolean }) {
if (typeof item === 'boolean') {
return item
}
else if (typeof item === 'object' && item !== null) {
return item.enable ?? false
}
return false
}
</script>
<template>
<template v-for="item in tools" :key="item">
<Component :is="modules[`./${pascalCase(item)}/index.vue`]" v-if="checkVisible(appSettingsStore.settings.toolbar[item])" />
</template>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import eventBus from '@/utils/eventBus'
import Tabbar from './Tabbar/index.vue'
import Toolbar from './Toolbar/index.vue'
defineOptions({
name: 'Topbar',
})
defineProps<{
enable: boolean
enableTabbar: boolean
enableToolbar: boolean
}>()
const appSettingsStore = useAppSettingsStore()
const { height: topbarHeight } = useElementSize(useTemplateRef('topbarRef'))
const { y } = useScroll(window)
const scrollOnHide = ref(false)
watch(y, (val, oldVal) => {
scrollOnHide.value = appSettingsStore.settings.topbar.mode === 'sticky' && val > (oldVal ?? 0) && val > topbarHeight.value
})
watch(scrollOnHide, (val) => {
eventBus.emit('topbar-scroll-visible-or-hidden', val)
}, {
immediate: true,
})
</script>
<template>
<Transition name="topbar">
<div
v-if="enable" ref="topbarRef" class="flex flex-col w-full shadow-[0_1px_0_0_oklch(var(--border))] transition-transform-300"
>
<Tabbar v-if="enableTabbar" class="z-2" />
<Toolbar v-if="enableToolbar" class="z-1" />
</div>
</Transition>
</template>
<style scoped>
/* 顶栏动画 */
.topbar-enter-active,
.topbar-leave-active {
transition: transform 0.3s;
}
.topbar-enter-from,
.topbar-leave-to {
transform: translateY(calc(var(--g-topbar-height) * -1));
}
</style>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
defineOptions({
name: 'LinkView',
})
const route = useRoute()
const { copy, copied } = useClipboard()
watch(copied, (val) => {
val && faToast.success('复制成功')
})
function open() {
window.open(route.meta.link, '_blank')
}
</script>
<template>
<div class="flex flex-col h-full w-full absolute">
<Transition name="fade" mode="out-in" appear>
<FaPageMain :key="route.meta.link" class="flex flex-1 flex-col justify-center">
<div class="flex flex-col items-center">
<FaIcon name="i-icon-park-twotone:planet" class="text-primary/80 size-30" />
<div class="text-xl text-dark my-2 dark-text-white">
是否访问此链接
</div>
<div class="text-[14px] text-secondary-foreground/50 my-2 text-center max-w-[300px] cursor-pointer" @click="route.meta.link && copy(route.meta.link)">
<FaTooltip text="复制链接">
<div class="line-clamp-3">
{{ route.meta.link }}
</div>
</FaTooltip>
</div>
<FaButton class="my-4" @click="open">
<FaIcon name="i-ri:external-link-fill" />
立即访问
</FaButton>
</div>
</FaPageMain>
</Transition>
</div>
</template>
<style scoped>
.fade-enter-active {
transition: 0.2s;
}
.fade-leave-active {
transition: 0.15s;
}
.fade-enter-from {
opacity: 0;
}
.fade-leave-to {
opacity: 0;
}
</style>
+323
View File
@@ -0,0 +1,323 @@
<script setup lang="ts">
import { useElementSize, useScroll } from '@vueuse/core'
import { useHotkeyBindings } from '@/hotkeys/useHotkeys'
import { useSlots } from '@/slots'
import { cn } from '@/utils'
import eventBus from '@/utils/eventBus'
import AppSetting from './components/AppSetting/index.vue'
import Header from './components/Header/index.vue'
import Hotkeys from './components/Hotkeys/index.vue'
import MainSidebar from './components/MainSidebar/index.vue'
import SubSidebar from './components/SubSidebar/index.vue'
import Topbar from './components/Topbar/index.vue'
import LinkView from './components/views/link.vue'
defineOptions({
name: 'Layout',
})
const routeInfo = useRoute()
const appSettingsStore = useAppSettingsStore()
const appKeepAliveStore = useAppKeepAliveStore()
const appMenuStore = useAppMenuStore()
const mainPage = useAppPage()
useHotkeyBindings({
'system.info.open': () => {
eventBus.emit('global-system-info-open')
},
'page.reload': () => {
mainPage.reload()
},
})
const layoutTopRef = useTemplateRef('layoutTopRef')
const { height: layoutTopHeight } = useElementSize(layoutTopRef)
const layoutBottomRef = useTemplateRef('layoutBottomRef')
const { height: layoutBottomHeight } = useElementSize(layoutBottomRef)
// 头部是否显示
const isHeaderEnable = computed(() => {
return appSettingsStore.mode === 'pc'
&& appSettingsStore.settings.menu.mode === 'head'
})
// 侧边栏主导航是否显示
const isMainSidebarEnable = computed(() => {
return appSettingsStore.settings.menu.mode === 'side'
|| (appSettingsStore.mode === 'mobile' && appSettingsStore.settings.menu.mode !== 'single')
})
// 侧边栏次导航是否显示
const isSubSidebarEnable = computed(() => {
return appSettingsStore.mode === 'mobile'
|| (
['side', 'head'].includes(appSettingsStore.settings.menu.mode)
&& appMenuStore.sidebarMenus.length !== 0
&& (
(
appSettingsStore.settings.menu.mainMenuClickMode === 'switch'
&& !appMenuStore.sidebarMenus.every(item => item.meta?.menu === false)
)
|| (
appSettingsStore.settings.menu.mainMenuClickMode !== 'switch'
&& appMenuStore.sidebarMenus.filter(item => item.meta?.menu !== false).length > 1
)
)
)
|| (
['single'].includes(appSettingsStore.settings.menu.mode)
&& appMenuStore.sidebarMenus.length !== 0
&& !appMenuStore.sidebarMenus.every(item => item.meta?.menu === false)
)
})
// 顶栏是否显示
const isTopbarEnable = computed(() => true)
// 标签栏是否显示
const isTabbarEnable = computed(() => {
return appSettingsStore.settings.topbar.tabbar
})
// 工具栏是否显示
const isToolbarEnable = computed(() => {
const { toolbar } = appSettingsStore.settings
return appSettingsStore.settings.topbar.toolbar && [
toolbar.breadcrumb,
toolbar.menuSearch,
toolbar.fullscreen,
toolbar.pageReload,
toolbar.colorScheme,
].some((item) => {
if (typeof item === 'boolean') {
return item
}
return item?.enable ?? false
})
})
const isLink = computed(() => !!routeInfo.meta.link)
watch(() => appSettingsStore.settings.menu.subMenuCollapse, (val) => {
if (appSettingsStore.mode === 'mobile') {
if (!val) {
document.body.classList.add('overflow-hidden')
}
else {
document.body.classList.remove('overflow-hidden')
}
}
})
watch(() => routeInfo.path, () => {
if (appSettingsStore.mode === 'mobile') {
appSettingsStore.$patch((state) => {
state.settings.menu.subMenuCollapse = true
})
}
})
const { y } = useScroll(window)
const { height: fixedContentBeforeAreaHeight } = useElementSize(useTemplateRef('fixedContentBeforeAreaRef'))
const { height: fixedContentAfterAreaHeight } = useElementSize(useTemplateRef('fixedContentAfterAreaRef'))
const topbarScrollVisibleOrHidden = ref(false)
eventBus.on('topbar-scroll-visible-or-hidden', (val) => {
topbarScrollVisibleOrHidden.value = val
})
const enableAppSetting = import.meta.env.VITE_APP_SETTING
</script>
<template>
<div
class="layout h-full" :style="{
'--g-slots-layout-top-height': `${layoutTopHeight}px`,
'--g-slots-layout-bottom-height': `${layoutBottomHeight}px`,
'--g-header-actual-height': isHeaderEnable ? 'var(--g-header-height)' : '0px',
'--g-main-sidebar-actual-width': isMainSidebarEnable ? 'var(--g-main-sidebar-width)' : '0px',
'--g-sub-sidebar-actual-width': isSubSidebarEnable ? (appSettingsStore.settings.menu.subMenuCollapse && appSettingsStore.mode !== 'mobile' ? 'var(--g-sub-sidebar-collapse-width)' : 'var(--g-sub-sidebar-width)') : '0px',
'--g-topbar-height': 'calc(var(--g-tabbar-actual-height) + var(--g-toolbar-actual-height))',
'--g-topbar-actual-height': isTopbarEnable ? 'calc(var(--g-tabbar-actual-height) + var(--g-toolbar-actual-height))' : '0px',
'--g-tabbar-actual-height': isTabbarEnable ? 'var(--g-tabbar-height)' : '0px',
'--g-toolbar-actual-height': isToolbarEnable ? 'var(--g-toolbar-height)' : '0px',
'--g-main-container-padding-top': `${fixedContentBeforeAreaHeight}px`,
'--g-main-container-padding-bottom': `${fixedContentAfterAreaHeight}px`,
}"
>
<div ref="layoutTopRef" class="slots-layout-top mx-auto bg-background w-full shadow-[-1px_0_0_0_oklch(var(--border)),1px_0_0_0_oklch(var(--border)),0_-1px_0_0_oklch(var(--border)),0_1px_0_0_oklch(var(--border))] transition-width-300 inset-t-0 fixed z-1030 empty:hidden">
<Component :is="useSlots('layout-top')" />
</div>
<Header :enable="isHeaderEnable" />
<div class="wrapper">
<div class="sidebar-container" :class="{ show: appSettingsStore.mode === 'mobile' && !appSettingsStore.settings.menu.subMenuCollapse }">
<MainSidebar :enable="isMainSidebarEnable" />
<SubSidebar :enable="isSubSidebarEnable" />
</div>
<!-- 移动端下,展开侧边栏时的遮罩层 -->
<div :class="cn('invisible fixed inset-0 z-1009 bg-black/50 op-0 backdrop-blur-sm transition-opacity', { 'op-100 visible': appSettingsStore.mode === 'mobile' && !appSettingsStore.settings.menu.subMenuCollapse })" @click="appSettingsStore.toggleSidebarCollapse()" />
<div class="main-container pb-[calc(var(--g-slots-layout-bottom-height)+var(--g-main-container-padding-bottom,0px))] pt-[calc(var(--g-slots-layout-top-height)+var(--g-header-actual-height)+var(--g-topbar-actual-height))]">
<div
class="fixed-content-around-area w-full inset-t-[calc(var(--g-slots-layout-top-height)+var(--g-header-actual-height))] inset-inline-1/2 fixed z-1005" :style="{
...(appSettingsStore.settings.topbar.mode === 'static' && { marginTop: `calc(min(var(--g-topbar-actual-height), ${y}px) * -1)` }),
...(topbarScrollVisibleOrHidden && { translate: '0 calc(var(--g-topbar-actual-height) * -1)' }),
}"
>
<Topbar :enable="isTopbarEnable" :enable-tabbar="isTabbarEnable" :enable-toolbar="isToolbarEnable" />
<div id="fixed-content-before-area" ref="fixedContentBeforeAreaRef" class="shadow-[0_1px_0_0_oklch(var(--border)),0_-1px_0_0_oklch(var(--border))] relative z-1 empty:hidden" />
</div>
<div id="app-content" class="main">
<RouterView v-slot="{ Component, route }">
<Transition :name="!appSettingsStore.isReloading ? 'fade' : ''" mode="out-in">
<KeepAlive :include="appKeepAliveStore.list">
<Component :is="Component" v-show="!isLink" :key="route.fullPath" />
</KeepAlive>
</Transition>
</RouterView>
<LinkView v-if="isLink" />
</div>
<div class="copyright">
<AppCopyright />
</div>
<div class="fixed-content-around-area w-full inset-b-0 inset-inline-1/2 fixed z-1005">
<div id="fixed-content-after-area" ref="fixedContentAfterAreaRef" class="shadow-[0_1px_0_0_oklch(var(--border)),0_-1px_0_0_oklch(var(--border))] relative z-1 empty:hidden" />
</div>
</div>
</div>
<div ref="layoutBottomRef" class="slots-layout-bottom mx-auto bg-background w-full shadow-[-1px_0_0_0_oklch(var(--border)),1px_0_0_0_oklch(var(--border)),0_-1px_0_0_oklch(var(--border)),0_1px_0_0_oklch(var(--border))] transition-width-300 inset-b-0 fixed z-1030 empty:hidden">
<Component :is="useSlots('layout-bottom')" />
</div>
<Hotkeys />
<template v-if="enableAppSetting">
<div class="text-primary-foreground rounded-l-md bg-primary flex-center size-12 cursor-pointer inset-e-0 inset-t-[calc(50%+250px)] fixed z-10" @click="eventBus.emit('global-app-setting-toggle')">
<FaIcon name="i-uiw:setting-o" class="size-6 animate-spin animate-duration-3000" />
</div>
<AppSetting />
</template>
<Component :is="useSlots('free-position')" />
</div>
</template>
<style scoped>
[data-mode="mobile"] {
.sidebar-container {
transform: translateX(calc((var(--g-main-sidebar-width) + var(--g-sub-sidebar-width)) * -1));
&.show {
transform: translateX(0);
}
}
.main-container {
padding-inline-start: 0 !important;
}
&[data-menu-mode="single"] {
.sidebar-container {
transform: translateX(calc(var(--g-sub-sidebar-width) * -1));
&.show {
transform: translateX(0);
}
}
}
}
.layout {
width: 100%;
height: 100%;
margin: 0 auto;
transition: width 0.3s;
}
.wrapper {
position: relative;
width: 100%;
height: 100%;
.sidebar-container {
position: fixed;
top: calc(var(--g-slots-layout-top-height) + var(--g-header-actual-height));
bottom: var(--g-slots-layout-bottom-height);
z-index: 1010;
display: flex;
width: calc(var(--g-main-sidebar-actual-width) + var(--g-sub-sidebar-actual-width));
transition: width 0.3s, transform 0.3s, top 0.3s, box-shadow 0.15s;
&:has(> .main-sidebar-container.main-sidebar-enter-active),
&:has(> .main-sidebar-container.main-sidebar-leave-active),
&:has(> .sub-sidebar-container.sub-sidebar-enter-active),
&:has(> .sub-sidebar-container.sub-sidebar-leave-active) {
overflow: hidden;
}
}
.main-sidebar-container:not(.main-sidebar-leave-active) + .sub-sidebar-container {
inset-inline-start: var(--g-main-sidebar-width);
}
.main-container {
display: flex;
flex-direction: column;
min-height: 100%;
padding-inline-start: calc(var(--g-main-sidebar-actual-width) + var(--g-sub-sidebar-actual-width));
overflow: auto;
background-color: var(--g-main-area-bg);
box-shadow: -1px 0 0 0 oklch(var(--border)), 1px 0 0 0 oklch(var(--border));
transition: padding-inline-start 0.3s, padding 0.3s, background-color 0.15s, box-shadow 0.15s;
.fixed-content-around-area {
width: calc(100% - var(--g-main-sidebar-actual-width) - var(--g-sub-sidebar-actual-width));
padding-right: var(--scrollbar-width, 0);
transform: translateX(-50%) translateX(calc(var(--g-main-sidebar-actual-width) / 2)) translateX(calc(var(--g-sub-sidebar-actual-width) / 2));
transform-origin: left;
transition: width 0.3s, transform 0.3s, translate 0.3s, top 0.3s;
[data-mode="mobile"] & {
width: 100% !important;
transform: translateX(-50%) !important;
}
}
.main {
position: relative;
flex: auto;
width: 100%;
height: 100%;
padding-top: var(--g-main-container-padding-top, 0);
margin: 0 auto;
overflow: hidden;
background-color: var(--g-main-area-bg);
box-shadow: -1px 0 0 0 oklch(var(--border)), 1px 0 0 0 oklch(var(--border));
transition: padding-top 0.3s, width 0.3s, background-color 0.15s, box-shadow 0.15s;
}
.copyright {
position: relative;
width: 100%;
margin-inline: auto;
background-color: oklch(var(--background));
box-shadow: -1px 0 0 0 oklch(var(--border)), 1px 0 0 0 oklch(var(--border)), 0 -1px 0 0 oklch(var(--border));
}
}
}
/* 主内容区动画 */
.fade-enter-active {
transition: 0.2s;
}
.fade-leave-active {
transition: 0.15s;
}
.fade-enter-from {
opacity: 0;
}
.fade-leave-to {
opacity: 0;
}
</style>
+38
View File
@@ -0,0 +1,38 @@
import VxeUI from 'vxe-pc-ui'
import VxeUITable from 'vxe-table'
// 加载 iconify 图标
import { downloadAndInstall } from '@/iconify'
import icons from '@/iconify/index.json'
// 自定义指令
import directive from '@/utils/directive'
import App from './App.vue'
import router from './router'
import pinia from './store'
import uiProvider from './ui/provider'
import '@/utils/storage'
import '@/utils/baidu'
import 'vxe-table/es/style.css'
import 'vxe-pc-ui/es/style.css'
// UnoCSS
import 'virtual:uno.css'
// 全局样式
import '@/assets/styles/globals.css'
const app = createApp(App)
app.use(VxeUI).use(VxeUITable)
app.use(pinia)
app.use(router)
app.use(uiProvider)
directive(app)
if (icons.isOfflineUse) {
for (const info of icons.collections) {
downloadAndInstall(info)
}
}
app.mount('#app')
+136
View File
@@ -0,0 +1,136 @@
import type { NavigationFailure, RouteLocationRaw, Router } from 'vue-router'
import pinia from '@/store'
function getId(router: Router) {
return router.currentRoute.value.fullPath
}
function extendPush(router: Router) {
const originalPush = router.push
router.push = function (to: RouteLocationRaw) {
const appSettingsStore = useAppSettingsStore(pinia)
if (appSettingsStore.settings.topbar.tabbar) {
const appTabbarStore = useAppTabbarStore(pinia)
const index = appTabbarStore.list.findIndex(item => item.tabId === getId(router))
appTabbarStore.$patch({
leaveIndex: index,
})
}
return originalPush(to)
}
}
function extendReplace(router: Router) {
const originalReplace = router.replace
router.replace = function (to: RouteLocationRaw) {
const appSettingsStore = useAppSettingsStore(pinia)
if (appSettingsStore.settings.topbar.tabbar) {
const tabId = getId(router)
const appTabbarStore = useAppTabbarStore(pinia)
return originalReplace(to).then(() => {
appTabbarStore.remove(tabId)
})
}
else {
return originalReplace(to)
}
}
}
function extendGo(router: Router) {
const originalGo = router.go
router.go = function (delta: number) {
const appSettingsStore = useAppSettingsStore(pinia)
if (appSettingsStore.settings.topbar.tabbar) {
const tabId = getId(router)
const appTabbarStore = useAppTabbarStore(pinia)
originalGo(delta)
if (delta < 0) {
appTabbarStore.remove(tabId)
}
}
else {
originalGo(delta)
}
}
}
function shouldUseHistoryBack(router: Router, to?: RouteLocationRaw) {
const historyBack = window.history.state?.back
if (typeof historyBack !== 'string' || !historyBack) {
return false
}
if (!to) {
return true
}
const backRoute = router.resolve(historyBack)
const targetRoute = router.resolve(to)
if (backRoute.name && targetRoute.name) {
return backRoute.name === targetRoute.name
}
return (backRoute.matched.at(-1)?.path ?? backRoute.path) === (targetRoute.matched.at(-1)?.path ?? targetRoute.path)
}
function extendBack(router: Router) {
const originalBack = router.back
router.back = function (to?: RouteLocationRaw) {
const appSettingsStore = useAppSettingsStore(pinia)
const tabId = getId(router)
const fallbackTo = to ?? appSettingsStore.settings.app.home.fullPath
if (shouldUseHistoryBack(router, to)) {
originalBack()
if (appSettingsStore.settings.topbar.tabbar) {
const appTabbarStore = useAppTabbarStore(pinia)
appTabbarStore.remove(tabId)
}
return Promise.resolve()
}
else {
return router.replace(fallbackTo).then(() => {
if (appSettingsStore.settings.topbar.tabbar) {
const appTabbarStore = useAppTabbarStore(pinia)
appTabbarStore.remove(tabId)
}
})
}
}
}
function extendClose(router: Router) {
router.close = function (to: RouteLocationRaw) {
const tabId = getId(router)
return router.push(to).then(() => {
const appSettingsStore = useAppSettingsStore(pinia)
if (appSettingsStore.settings.topbar.tabbar) {
const appTabbarStore = useAppTabbarStore(pinia)
appTabbarStore.remove(tabId)
}
})
}
}
declare module 'vue-router' {
interface Router {
/**
* 本方法为框架扩展语法,会同时关闭当前标签页
*
* 如果上一条历史记录与传入目标页一致,则调用原生 `back` 方法
* 如果没有历史记录,或者上一条历史记录不是目标页,则使用 `replace` 方法跳转到指定路径或首页
*
* @param to 可选的目标路由,当无法安全回退时使用此参数作为跳转目标
*/
back: (to?: RouteLocationRaw) => Promise<NavigationFailure | void | undefined>
/**
* 本方法为框架扩展语法,等同于 `push` 方法,并且同时会关闭当前标签页
*/
close: (to: RouteLocationRaw) => Promise<NavigationFailure | void | undefined>
}
}
export default function setupExtensions(router: Router) {
extendPush(router)
extendReplace(router)
extendGo(router)
extendBack(router)
extendClose(router)
}
+182
View File
@@ -0,0 +1,182 @@
import type { Router } from 'vue-router'
import { useNProgress } from '@vueuse/integrations/useNProgress'
import { warnKeepAliveComponentNameMissing } from 'virtual:fantastic-admin/turbo-console'
import { asyncRoutes } from './routes'
import '@/assets/styles/nprogress.css'
function setupRoutes(router: Router) {
router.beforeEach(async (to) => {
const appSettingsStore = useAppSettingsStore()
const appAccountStore = useAppAccountStore()
const appRouteStore = useAppRouteStore()
const appMenuStore = useAppMenuStore()
// 是否已登录
if (appAccountStore.isLogin) {
// 是否已根据权限动态生成并注册路由
if (appRouteStore.isGenerate) {
// 导航菜单如果不是 single 模式,则需要根据 path 定位主导航菜单的选中状态
appSettingsStore.settings.menu.mode !== 'single' && appMenuStore.setActived(to.path)
// 如果已登录状态下,进入登录页会强制跳转到主页
if (to.name === 'login') {
return {
path: appSettingsStore.settings.app.home.fullPath,
replace: true,
}
}
// 如果未开启主页,但进入的是主页,则会进入侧边栏导航第一个模块
else if (!appSettingsStore.settings.app.home.enable && to.fullPath === appSettingsStore.settings.app.home.fullPath && appMenuStore.sidebarMenus.length > 0) {
return {
path: appMenuStore.sidebarMenusFirstDeepestPath,
replace: true,
}
}
}
else {
try {
// 获取用户权限
appSettingsStore.settings.app.account.auth && await appAccountStore.getPermissions()
// 生成动态路由
switch (appSettingsStore.settings.app.routeBaseOn) {
case 'frontend':
appRouteStore.generateRoutesAtFront(asyncRoutes)
break
case 'backend':
await appRouteStore.generateRoutesAtBack()
break
}
// 注册并记录路由数据
// 记录的数据会在登出时会使用到,不使用 router.removeRoute 是考虑配置的路由可能不一定有设置 name ,则通过调用 router.addRoute() 返回的回调进行删除
const removeRoutes: (() => void)[] = []
appRouteStore.routes.forEach((route) => {
if (!/^(?:https?:|mailto:|tel:)/.test(route.path)) {
removeRoutes.push(router.addRoute(route))
}
})
appRouteStore.systemRoutes.forEach((route) => {
removeRoutes.push(router.addRoute(route))
})
appRouteStore.setCurrentRemoveRoutes(removeRoutes)
}
catch {}
// 动态路由生成并注册后,重新进入当前路由
return {
path: to.path,
query: to.query,
replace: true,
}
}
}
else {
if (to.name !== 'login') {
return {
name: 'login',
query: {
redirect: to.fullPath !== appSettingsStore.settings.app.home.fullPath ? to.fullPath : undefined,
},
}
}
}
})
}
// 当父级路由未配置重定向时,自动重定向到有访问权限的子路由
function setupRedirectAuthChildrenRoute(router: Router) {
router.beforeEach((to) => {
const { auth } = useAppAuth()
const currentRoute = router.getRoutes().find(route => route.path === (to.matched.at(-1)?.path ?? ''))
if (!currentRoute?.redirect) {
const findAuthRoute = currentRoute?.children?.find(route => route.meta?.menu !== false && auth(route.meta?.auth ?? ''))
if (findAuthRoute) {
return findAuthRoute
}
}
})
}
// 进度条
function setupProgress(router: Router) {
const { isLoading } = useNProgress()
router.beforeEach(() => {
const appSettingsStore = useAppSettingsStore()
if (appSettingsStore.settings.page.progress) {
isLoading.value = true
}
})
router.afterEach(() => {
const appSettingsStore = useAppSettingsStore()
if (appSettingsStore.settings.page.progress) {
isLoading.value = false
}
})
}
// 标题
function setupTitle(router: Router) {
router.afterEach((to) => {
const appSettingsStore = useAppSettingsStore()
appSettingsStore.setTitle(to.matched?.at(-1)?.meta?.title ?? to.meta.title)
})
}
// 页面保活
function setupKeepAlive(router: Router) {
router.afterEach(async (to, from) => {
const appKeepAliveStore = useAppKeepAliveStore()
if (to.meta.keepAlive) {
const componentName = to.matched.at(-1)?.components?.default.name
if (componentName) {
// 保活当前页面前,先判断是否需要清除保活,判断依据:
// 1. 如果 to.meta.keepAlive 为 boolean 类型,并且不为 true,则需要清除保活
// 2. 如果 to.meta.keepAlive 为 string 类型,并且与 from.name 不一致,则需要清除保活
// 3. 如果 to.meta.keepAlive 为 array 类型,并且不包含 from.name,则需要清除保活
// 4. 如果 to.meta.noKeepAlive 为 string 类型,并且与 from.name 一致,则需要清除保活
// 5. 如果 to.meta.noKeepAlive 为 array 类型,并且包含 from.name,则需要清除保活
// 6. 如果是刷新页面,则需要清除保活
let shouldClear = false
if (typeof to.meta.keepAlive === 'boolean') {
shouldClear = !to.meta.keepAlive
}
else if (typeof to.meta.keepAlive === 'string') {
shouldClear = to.meta.keepAlive !== from.name
}
else if (Array.isArray(to.meta.keepAlive)) {
shouldClear = !to.meta.keepAlive.includes(from.name as string)
}
if (to.meta.noKeepAlive) {
if (typeof to.meta.noKeepAlive === 'string') {
shouldClear = to.meta.noKeepAlive === from.name
}
else if (Array.isArray(to.meta.noKeepAlive)) {
shouldClear = to.meta.noKeepAlive.includes(from.name as string)
}
}
if (from.name === 'reload') {
shouldClear = true
}
if (shouldClear) {
appKeepAliveStore.remove(componentName)
await nextTick()
}
appKeepAliveStore.add(componentName)
}
else if (import.meta.env.DEV) {
warnKeepAliveComponentNameMissing((to.matched.at(-1)?.components?.default as any).__file)
}
}
})
}
// 其他
function setupOther(router: Router) {
router.afterEach(() => {
document.documentElement.scrollTop = 0
})
}
export default function setupGuards(router: Router) {
setupRoutes(router)
setupRedirectAuthChildrenRoute(router)
setupProgress(router)
setupTitle(router)
setupKeepAlive(router)
setupOther(router)
}
+22
View File
@@ -0,0 +1,22 @@
import { loadingFadeOut } from 'virtual:app-loading'
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import pinia from '@/store'
import setupExtensions from './extensions'
import setupGuards from './guards'
// 路由相关数据
import { constantRoutes } from './routes'
const router = createRouter({
history: useAppSettingsStore(pinia).settings.app.routeMode === 'hash' ? createWebHashHistory() : createWebHistory(),
routes: constantRoutes,
strict: true,
})
setupGuards(router)
setupExtensions(router)
router.isReady().then(() => {
loadingFadeOut()
})
export default router
+42
View File
@@ -0,0 +1,42 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/auth_example',
component: Layout,
name: 'authExample',
meta: {
title: '权限验证',
icon: 'i-ri:shield-keyhole-line',
},
children: [
{
path: '',
name: 'authExampleIndex',
component: () => import('@/views/auth_example/index.vue'),
meta: {
title: '权限验证',
menu: false,
breadcrumb: false,
activeMenu: '/auth_example',
},
},
{
path: 'test',
name: 'authExampleTest',
component: () => import('@/views/auth_example/test.vue'),
meta: {
title: '测试页面',
auth: ['pages.general:browse'],
menu: false,
breadcrumb: false,
activeMenu: '/auth_example',
},
},
],
}
export default routes
+65
View File
@@ -0,0 +1,65 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/breadcrumb_example',
component: Layout,
name: 'breadcrumbExample',
meta: {
title: '面包屑导航',
icon: 'i-ic:twotone-double-arrow',
},
children: [
{
path: 'list1',
name: 'breadcrumbExampleList1',
component: () => import('@/views/breadcrumb_example/list1.vue'),
meta: {
title: '列表1(平级模式)',
},
},
{
path: 'detail1',
name: 'breadcrumbExampleDetail1',
component: () => import('@/views/breadcrumb_example/detail1.vue'),
meta: {
title: '详情1',
menu: false,
activeMenu: '/breadcrumb_example/list1',
},
},
{
path: 'list2',
meta: {
title: '列表2(层级模式)',
},
children: [
{
path: '',
name: 'breadcrumbExampleList2',
component: () => import('@/views/breadcrumb_example/list2.vue'),
meta: {
title: '列表2(层级模式)',
menu: false,
breadcrumb: false,
},
},
{
path: 'detail2',
name: 'breadcrumbExampleDetail2',
component: () => import('@/views/breadcrumb_example/detail2.vue'),
meta: {
title: '详情2',
menu: false,
activeMenu: '/breadcrumb_example/list2',
},
},
],
},
],
}
export default routes
+300
View File
@@ -0,0 +1,300 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/component_example',
component: Layout,
name: 'componentExample',
meta: {
title: '组件',
icon: 'i-bx:bxs-component',
},
children: [
{
path: 'avatar',
name: 'componentExampleAvatar',
component: () => import('@/views/component_example/avatar/index.vue'),
meta: {
title: '头像',
},
},
{
path: 'button',
name: 'componentExampleButton',
component: () => import('@/views/component_example/button/index.vue'),
meta: {
title: '按钮',
},
},
{
path: 'button_group',
name: 'componentExampleButtonGroup',
component: () => import('@/views/component_example/button_group/index.vue'),
meta: {
title: '按钮组',
},
},
{
path: 'card',
name: 'componentExampleCard',
component: () => import('@/views/component_example/card/index.vue'),
meta: {
title: '卡片',
},
},
{
path: 'checkbox',
name: 'componentExampleCheckbox',
component: () => import('@/views/component_example/checkbox/index.vue'),
meta: {
title: '复选框',
},
},
{
path: 'collapsible',
name: 'componentExampleCollapsible',
component: () => import('@/views/component_example/collapsible/index.vue'),
meta: {
title: '折叠面板',
},
},
{
path: 'context_menu',
name: 'componentExampleContextMenu',
component: () => import('@/views/component_example/context_menu/index.vue'),
meta: {
title: '右键菜单',
},
},
{
path: 'divider',
name: 'componentExampleDivider',
component: () => import('@/views/component_example/divider/index.vue'),
meta: {
title: '分割线',
},
},
{
path: 'drawer',
name: 'componentExampleDrawer',
component: () => import('@/views/component_example/drawer/index.vue'),
meta: {
title: '抽屉',
},
},
{
path: 'dropdown',
name: 'componentExampleDropdown',
component: () => import('@/views/component_example/dropdown/index.vue'),
meta: {
title: '下拉菜单',
},
},
{
path: 'file_upload',
name: 'componentExampleFileUpload',
component: () => import('@/views/component_example/file_upload/index.vue'),
meta: {
title: '文件上传',
},
},
{
path: 'fixed_bar',
name: 'componentExampleFixedactionbar',
component: () => import('@/views/component_example/fixed_bar/index.vue'),
meta: {
title: '固定栏',
copyright: false,
},
},
{
path: 'hover_card',
name: 'componentExampleHoverCard',
component: () => import('@/views/component_example/hover_card/index.vue'),
meta: {
title: '悬浮卡片',
},
},
{
path: 'image_preview',
name: 'componentExampleImagePreview',
component: () => import('@/views/component_example/image_preview/index.vue'),
meta: {
title: '图片预览',
},
},
{
path: 'image_upload',
name: 'componentExampleImageUpload',
component: () => import('@/views/component_example/image_upload/index.vue'),
meta: {
title: '图片上传',
},
},
{
path: 'input',
name: 'componentExampleInput',
component: () => import('@/views/component_example/input/index.vue'),
meta: {
title: '输入框',
},
},
{
path: 'input_otp',
name: 'componentExampleInputOtp',
component: () => import('@/views/component_example/input_otp/index.vue'),
meta: {
title: '一次性密码输入框',
},
},
{
path: 'kbd',
name: 'componentExampleKbd',
component: () => import('@/views/component_example/kbd/index.vue'),
meta: {
title: '键盘',
},
},
{
path: 'modal',
name: 'componentExampleModal',
component: () => import('@/views/component_example/modal/index.vue'),
meta: {
title: '弹窗',
},
},
{
path: 'number_field',
name: 'componentExampleNumberField',
component: () => import('@/views/component_example/number_field/index.vue'),
meta: {
title: '数字输入框',
},
},
{
path: 'page_header',
name: 'componentExamplePageheader',
component: () => import('@/views/component_example/page_header/index.vue'),
meta: {
title: '页头',
},
},
{
path: 'page_main',
name: 'componentExamplePagemain',
component: () => import('@/views/component_example/page_main/index.vue'),
meta: {
title: '内容块',
},
},
{
path: 'password_strength',
name: 'componentExamplePasswordStrength',
component: () => import('@/views/component_example/password_strength/index.vue'),
meta: {
title: '密码强度',
},
},
{
path: 'popover',
name: 'componentExamplePopover',
component: () => import('@/views/component_example/popover/index.vue'),
meta: {
title: '浮动面板',
},
},
{
path: 'progress',
name: 'componentExampleProgress',
component: () => import('@/views/component_example/progress/index.vue'),
meta: {
title: '进度条',
},
},
{
path: 'scroll_area',
name: 'componentExampleScrollArea',
component: () => import('@/views/component_example/scroll_area/index.vue'),
meta: {
title: '滚动区域',
},
},
{
path: 'search_bar',
name: 'componentExampleSearchBar',
component: () => import('@/views/component_example/search_bar/index.vue'),
meta: {
title: '搜索面板',
},
},
{
path: 'select',
name: 'componentExampleSelect',
component: () => import('@/views/component_example/select/index.vue'),
meta: {
title: '选择器',
},
},
{
path: 'slider',
name: 'componentExampleSlider',
component: () => import('@/views/component_example/slider/index.vue'),
meta: {
title: '滑块',
},
},
{
path: 'switch',
name: 'componentExampleSwitch',
component: () => import('@/views/component_example/switch/index.vue'),
meta: {
title: '开关',
},
},
{
path: 'tabs',
name: 'componentExampleTabs',
component: () => import('@/views/component_example/tabs/index.vue'),
meta: {
title: '标签页',
},
},
{
path: 'textarea',
name: 'componentExampleTextarea',
component: () => import('@/views/component_example/textarea/index.vue'),
meta: {
title: '文本域',
},
},
{
path: 'toast',
name: 'componentExampleToast',
component: () => import('@/views/component_example/toast/index.vue'),
meta: {
title: '轻提示',
},
},
{
path: 'tooltip',
name: 'componentExampleTooltip',
component: () => import('@/views/component_example/tooltip/index.vue'),
meta: {
title: '文字提示',
},
},
{
path: 'trend',
name: 'componentExampleTrend',
component: () => import('@/views/component_example/trend/index.vue'),
meta: {
title: '趋势标记',
},
},
],
}
export default routes
+102
View File
@@ -0,0 +1,102 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw[] = [
{
path: '/official',
component: Layout,
meta: {
title: '官方周边',
icon: 'i-ion:apps',
expand: true,
},
children: [
{
path: 'fantastic-startkit',
redirect: '',
name: 'officialFantasticStartkit',
meta: {
title: 'Fantastic-startkit 项目启动套件',
icon: 'https://cn.vuejs.org/logo.svg',
link: 'https://hooray.github.io/fantastic-startkit/',
},
},
{
path: 'one-step-admin',
redirect: '',
name: 'officialOneStepAdmin',
meta: {
title: 'One-step-admin 干啥都快人一步的中后台框架',
icon: 'https://one-step-admin.hurui.me/logo.png',
link: 'https://one-step-admin.hurui.me',
},
},
{
path: 'fantastic-mobile',
redirect: '',
name: 'officialFantasticMobile',
meta: {
title: 'Fantastic-mobile 自成一派的 H5 框架',
icon: 'https://fantastic-mobile.hurui.me/logo.png',
link: 'https://fantastic-mobile.hurui.me',
},
},
],
},
{
path: '/recommand',
component: Layout,
meta: {
title: '友情推荐',
icon: 'i-ic:outline-handshake',
expand: true,
},
children: [
{
path: 'vform',
redirect: '',
name: 'recommandVform',
meta: {
title: 'VForm 低代码表单',
icon: 'https://www.vform666.com/mini-logo.png',
link: 'https://www.vform666.com',
},
},
{
path: 'form-create',
redirect: '',
name: 'recommandFormcreate',
meta: {
title: 'FormCreate 可视化表单设计器',
icon: 'https://form-create.com/logo.png',
link: 'https://form-create.com',
},
},
{
path: 'vexip',
redirect: '',
name: 'recommandVexip',
meta: {
title: 'Vexip UI 组件库',
icon: 'https://www.vexipui.com/vexip-ui.png',
link: 'https://www.vexipui.com/zh-CN',
},
},
{
path: 'mineadmin',
redirect: '',
name: 'recommandMineadmin',
meta: {
title: 'MineAdmin 开箱即用后台系统',
icon: 'https://www.mineadmin.com/favicon.ico',
link: 'https://github.com/mineadmin/mineadmin',
},
},
],
},
]
export default routes
+29
View File
@@ -0,0 +1,29 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/element_plus_example',
component: Layout,
meta: {
title: 'Element Plus',
icon: 'i-ep:element-plus',
menu: false,
},
children: [
{
path: '',
name: 'elementPlusExample',
component: () => import('@/views/element_plus_example/index.vue'),
meta: {
title: 'Element Plus',
menu: false,
breadcrumb: false,
},
},
],
}
export default routes
+37
View File
@@ -0,0 +1,37 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/link',
component: Layout,
name: 'externalLinkExample',
meta: {
title: '外链',
icon: 'i-ri:external-link-fill',
},
children: [
{
path: 'github',
redirect: '',
name: 'linkExampleWindowGithub',
meta: {
title: 'Github',
link: 'https://github.com/fantastic-admin/basic',
},
},
{
path: 'gitee',
redirect: '',
name: 'linkExampleWindowGitee',
meta: {
title: 'Gitee',
link: 'https://gitee.com/fantastic-admin/basic',
},
},
],
}
export default routes
+28
View File
@@ -0,0 +1,28 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/fake_example',
component: Layout,
meta: {
title: '假数据',
icon: 'i-ri:database-2-line',
},
children: [
{
path: '',
name: 'fakeExample',
component: () => import('@/views/fake_example/index.vue'),
meta: {
title: '假数据',
menu: false,
breadcrumb: false,
},
},
],
}
export default routes
+84
View File
@@ -0,0 +1,84 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/feature_example',
component: Layout,
name: 'featureExample',
meta: {
title: '功能',
icon: 'i-ic:twotone-auto-awesome',
},
children: [
{
path: 'vueuse',
name: 'featureExampleVueuse',
component: () => import('@/views/feature_example/vueuse.vue'),
meta: {
title: 'VueUse',
icon: 'i-logos:vueuse',
},
},
{
path: 'rules',
name: 'featureExampleRules',
component: () => import('@/views/feature_example/rules.vue'),
meta: {
title: '常用正则',
icon: 'i-mdi:regex',
},
},
{
path: 'tableautoheight',
name: 'featureExampleTableautoheight',
component: () => import('@/views/feature_example/table.autoheight.vue'),
meta: {
title: '表格高度自适应',
icon: 'i-tabler:arrow-autofit-height',
},
},
{
path: 'reload',
name: 'featureExampleReload',
component: () => import('@/views/feature_example/reload.vue'),
meta: {
title: '页面刷新',
icon: 'i-iconoir:refresh-double',
keepAlive: true,
},
},
{
path: 'menuswitch',
name: 'featureExampleMenuswitch',
component: () => import('@/views/feature_example/menuswitch.vue'),
meta: {
title: '导航切换',
icon: 'i-charm:menu-hamburger',
},
},
{
path: 'leavetips',
name: 'featureExampleLeavetips',
component: () => import('@/views/feature_example/leavetips.vue'),
meta: {
title: '页面离开提醒',
icon: 'i-pepicons:leave',
},
},
{
path: 'scroll',
name: 'featureExampleScroll',
component: () => import('@/views/feature_example/scroll.vue'),
meta: {
title: '记录滚动位置',
icon: 'i-carbon:auto-scroll',
keepAlive: true,
},
},
],
}
export default routes
+28
View File
@@ -0,0 +1,28 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/icon_example',
component: Layout,
meta: {
title: '图标',
icon: 'i-ri:remixicon-line',
},
children: [
{
path: '',
name: 'iconExample',
component: () => import('@/views/icon_example/index.vue'),
meta: {
title: '图标',
menu: false,
breadcrumb: false,
},
},
],
}
export default routes
+28
View File
@@ -0,0 +1,28 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/jsx_example',
component: Layout,
meta: {
title: 'JSX',
icon: 'i-file-icons:jsx',
},
children: [
{
path: '',
name: 'jsxExample',
component: () => import('@/views/jsx_example/index.vue'),
meta: {
title: 'JSX',
menu: false,
breadcrumb: false,
},
},
],
}
export default routes
+29
View File
@@ -0,0 +1,29 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/keep_alive_example',
component: Layout,
meta: {
title: '页面保活',
icon: 'i-cil:window-restore',
},
children: [
{
path: '',
name: 'keepAliveExample',
component: () => import('@/views/keep_alive_example/index.vue'),
meta: {
title: '页面保活',
menu: false,
breadcrumb: false,
keepAlive: true,
},
},
],
}
export default routes
+69
View File
@@ -0,0 +1,69 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/multilevel_menu_example',
component: Layout,
name: 'multilevelMenuExample',
meta: {
title: '多级导航',
icon: 'i-heroicons-solid:menu-alt-3',
},
children: [
{
path: 'page',
name: 'multilevelMenuExample1',
component: () => import('@/views/multilevel_menu_example/page.vue'),
meta: {
title: '导航1',
},
},
{
path: 'level2',
name: 'multilevelMenuExample2',
meta: {
title: '导航2',
},
children: [
{
path: 'page',
name: 'multilevelMenuExample2-1',
component: () => import('@/views/multilevel_menu_example/level2/page.vue'),
meta: {
title: '导航2-1',
},
},
{
path: 'level3',
name: 'multilevelMenuExample2-2',
meta: {
title: '导航3',
},
children: [
{
path: 'page1',
name: 'multilevelMenuExample2-2-1',
component: () => import('@/views/multilevel_menu_example/level2/level3/page1.vue'),
meta: {
title: '导航2-2-1',
},
},
{
path: 'page2',
name: 'multilevelMenuExample2-2-2',
component: () => import('@/views/multilevel_menu_example/level2/level3/page2.vue'),
meta: {
title: '导航2-2-2',
},
},
],
},
],
},
],
}
export default routes
+160
View File
@@ -0,0 +1,160 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/plugin_example',
component: Layout,
name: 'pluginExample',
meta: {
title: '插件',
icon: 'i-clarity:plugin-outline-alerted',
},
children: [
{
path: 'hooksplus',
name: 'pluginExampleHooksPlus',
component: () => import('@/views/plugin_example/hooks.plus.vue'),
meta: {
title: 'VueHooks Plus',
icon: 'https://inhiblabcore.github.io/docs/hooks/logo.svg',
},
},
{
path: 'print',
name: 'pluginExamplePrint',
component: () => import('@/views/plugin_example/print.vue'),
meta: {
title: 'Print',
icon: 'i-ri:printer-line',
},
},
{
path: 'esign',
name: 'pluginExampleEsign',
component: () => import('@/views/plugin_example/esign.vue'),
meta: {
title: 'E-sign',
icon: 'i-mdi:draw',
},
},
{
path: 'animation',
name: 'pluginExampleAnimation',
component: () => import('@/views/plugin_example/animation.vue'),
meta: {
title: 'Animate.css',
icon: 'i-clarity:animation-line',
},
},
{
path: 'swiper',
name: 'pluginExampleSwiper',
component: () => import('@/views/plugin_example/swiper.vue'),
meta: {
title: 'Swiper',
icon: 'i-carbon:carousel-horizontal',
},
},
{
path: 'chart',
name: 'pluginExampleChart',
meta: {
title: '图表',
icon: 'i-ri:bar-chart-2-line',
},
children: [
{
path: 'echarts',
name: 'pluginExampleChartEcharts',
component: () => import('@/views/plugin_example/echarts.vue'),
meta: {
title: 'Echarts',
},
},
{
path: 'vchart',
name: 'pluginExampleChartVchart',
component: () => import('@/views/plugin_example/vchart.vue'),
meta: {
title: 'VChart',
},
},
{
path: 'g2plot',
name: 'pluginExampleChartG2plot',
component: () => import('@/views/plugin_example/g2plot.vue'),
meta: {
title: 'G2plot',
},
},
{
path: 'vue-data-ui',
name: 'pluginExampleChartVueDataUi',
component: () => import('@/views/plugin_example/vue-data-ui.vue'),
meta: {
title: 'VueDataUI',
},
},
],
},
{
path: 'tinymce',
name: 'pluginExampleTinymce',
component: () => import('@/views/plugin_example/tinymce.vue'),
meta: {
title: 'TinyMCE',
icon: 'i-file-icons:tinymce',
},
},
{
path: 'markdown',
name: 'pluginExampleMarkdown',
component: () => import('@/views/plugin_example/markdown.vue'),
meta: {
title: 'Bytemd',
icon: 'i-teenyicons:markdown-outline',
},
},
{
path: 'currencyinput',
name: 'pluginExampleCurrencyInput',
component: () => import('@/views/plugin_example/currency.input.vue'),
meta: {
title: 'Currency Input',
icon: 'i-ph:currency-cny-bold',
},
},
{
path: 'splitpanes',
name: 'pluginExampleSplitpanes',
component: () => import('@/views/plugin_example/splitpanes.vue'),
meta: {
title: 'Splitpanes',
icon: 'i-icon-park-outline:split-cells',
},
},
{
path: 'vxe-table',
name: 'pluginExampleVxeTable',
component: () => import('@/views/plugin_example/vxe-table.vue'),
meta: {
title: 'Vxe Table',
icon: 'i-mdi:table-large',
},
},
{
path: 'cropper',
name: 'pluginExampleCropper',
component: () => import('@/views/plugin_example/cropper.vue'),
meta: {
title: 'Cropper',
icon: 'i-mdi:crop',
},
},
],
}
export default routes
+27
View File
@@ -0,0 +1,27 @@
import type { RouteRecordRaw } from 'vue-router'
function Layout() {
return import('@/layouts/index.vue')
}
const routes: RouteRecordRaw = {
path: '/tabbar_example',
component: Layout,
name: 'tabbarExample',
meta: {
title: '标签栏',
icon: 'i-ic:round-tab',
},
children: [
{
path: 'api',
name: 'tabbarExampleApi',
component: () => import('@/views/tabbar_example/api.vue'),
meta: {
title: '常用API',
},
},
],
}
export default routes
+116
View File
@@ -0,0 +1,116 @@
import type { RouteRecordMainRaw } from '@fantastic-admin/types'
import type { RouteRecordRaw } from 'vue-router'
import pinia from '@/store'
import AuthExample from './modules/auth.example'
import BreadcrumbExample from './modules/breadcrumb.example'
import ComponentExample from './modules/component.example'
import EcologyExample from './modules/ecology.example'
import ElementPlusExample from './modules/element.plus.example'
import ExternalLinkExample from './modules/external.link.example'
import FakeExample from './modules/fake.example'
import FeatureExample from './modules/feature.example'
import IconExample from './modules/icon.example'
import JsxExample from './modules/jsx.example'
import KeepAliveExample from './modules/keep.alive.example'
import MultilevelMenuExample from './modules/multilevel.menu.example'
import PluginExample from './modules/plugin.example'
import TabbarExample from './modules/tabbar.example'
// 固定路由(默认路由)
const constantRoutes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('@/views/login.vue'),
meta: {
title: '登录',
},
},
{
path: '/:all(.*)*',
name: 'notFound',
component: () => import('@/views/[...all].vue'),
meta: {
title: '找不到页面',
},
},
]
// 系统路由
const systemRoutes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('@/layouts/index.vue'),
meta: {
breadcrumb: false,
},
children: [
{
path: '',
component: () => import('@/views/index.vue'),
meta: {
title: useAppSettingsStore(pinia).settings.app.home.title,
icon: 'i-ant-design:home-twotone',
breadcrumb: false,
},
},
{
path: 'reload',
name: 'reload',
component: () => import('@/views/reload.vue'),
meta: {
title: '重新加载中...',
breadcrumb: false,
},
},
],
},
]
// 动态路由(异步路由、导航菜单路由)
const asyncRoutes: RouteRecordMainRaw[] = [
{
meta: {
title: '演示',
icon: 'i-ri:function-ai-line',
},
children: [
MultilevelMenuExample,
BreadcrumbExample,
KeepAliveExample,
TabbarExample,
ComponentExample,
IconExample,
FeatureExample,
PluginExample,
AuthExample,
FakeExample,
JsxExample,
ExternalLinkExample,
],
},
{
meta: {
title: 'UI',
icon: 'i-whh:jqueryui',
},
children: [
ElementPlusExample,
],
},
{
meta: {
title: '生态',
icon: 'i-icon-park-outline:circular-connection',
},
children: [
...EcologyExample,
],
},
]
export {
asyncRoutes,
constantRoutes,
systemRoutes,
}
+35
View File
@@ -0,0 +1,35 @@
import { setSettings } from '@fantastic-admin/settings'
export default setSettings({
app: {
account: {
auth: true,
},
dynamicTitle: true,
copyright: {
enable: true,
dates: '2020-present',
company: 'Fantastic-admin',
website: 'https://fantastic-admin.hurui.me',
},
},
menu: {
mainMenuClickMode: 'smart',
subMenuCollapseButton: true,
hotkeys: true,
},
topbar: {
tabbar: true,
toolbar: true,
mode: 'fixed',
},
tabbar: {
icon: true,
hotkeys: true,
},
toolbar: {
fullscreen: true,
pageReload: true,
colorScheme: true,
},
})
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import isBetween from 'dayjs/plugin/isBetween'
import dayjs from '@/utils/dayjs'
const isShow = ref(false)
onMounted(() => {
dayjs.extend(isBetween)
if (dayjs().isBetween('2025-09-17', '2025-10-17')) {
isShow.value = true
}
})
function handleOpen() {
window.open('https://fantastic-admin.hurui.me/buy-5yr.html', '_blank')
}
</script>
<template>
<div v-if="isShow" class="text-sm text-gray-100 font-medium flex-center gap-3 h-12 relative from-slate-800 to-gray-900 bg-gradient-to-r">
<span class="text-lg font-bold">✨ 五周年庆,全年最低价 ✨</span>
<button class="text-xs text-white font-semibold px-4 py-1 rounded-full bg-blue-600 transition-colors duration-200 hover:bg-blue-500" @click="handleOpen">
查看详情 →
</button>
<button class="text-gray-400 p-1 bg-transparent flex-center transition-colors duration-200 right-3 top-1/2 absolute hover:text-gray-200 -translate-y-1/2" @click="isShow = false">
<FaIcon name="i-ep:close" />
</button>
</div>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
function upgrade() {
window.open('https://fantastic-admin.hurui.me/buy.html', '_blank')
}
</script>
<template>
<div class="flex-center">
<FaPopover align="end" side="right" class="p-0 min-w-auto">
<FaButton size="icon" variant="ghost" class="size-12">
<FaIcon name="i-noto:crown" class="text-8 filter-grayscale" />
</FaButton>
<template #panel>
<FaCard title="升级到专业版" description="解锁全部功能,享受极致体验" class="border-none w-60">
<FaButton size="sm" class="w-full" @click="upgrade">
升级
</FaButton>
</FaCard>
</template>
</FaPopover>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
import { pascalCase } from 'scule'
type Slots
= 'layout-top' | 'layout-bottom'
| 'header-start' | 'header-after-logo' | 'header-after-menu' | 'header-end'
| 'main-sidebar-top' | 'main-sidebar-after-logo' | 'main-sidebar-after-menu' | 'main-sidebar-bottom'
| 'sub-sidebar-top' | 'sub-sidebar-after-logo' | 'sub-sidebar-after-menu' | 'sub-sidebar-bottom'
| 'tabbar-start' | 'tabbar-end'
| 'toolbar-start' | 'toolbar-end'
| 'free-position'
function tryLoadComponent(name: Slots) {
const componentMap = import.meta.glob('./*/index.vue', { eager: true })
const path = `./${pascalCase(name as unknown as string)}/index.vue`
const component = componentMap[path as keyof typeof componentMap]
if (!component) {
return {
default: defineComponent({
name: 'SlotsInvalidComponent',
render: () => null,
}),
}
}
return component
}
export function useSlots(name: Slots) {
const component = tryLoadComponent(name)
return defineComponent((component as any).default)
}
+6
View File
@@ -0,0 +1,6 @@
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export default pinia
+121
View File
@@ -0,0 +1,121 @@
import apiApp from '@/api/modules/app'
import router from '@/router'
export const useAppAccountStore = defineStore('appAccount', () => {
const appSettingsStore = useAppSettingsStore()
const appTabbarStore = useAppTabbarStore()
const appRouteStore = useAppRouteStore()
const appMenuStore = useAppMenuStore()
// 账号信息
const token = ref(localStorage.getItem('token') ?? '')
const account = ref(localStorage.getItem('account') ?? '')
const avatar = ref(localStorage.getItem('avatar') ?? '')
// 权限信息
const permissions = ref<string[]>([])
// 登录状态
const isLogin = computed(() => {
if (token.value) {
return true
}
return false
})
// 登录
async function login(data: {
account: string
password: string
}) {
const res = await apiApp.login(data)
localStorage.setItem('account', res.data.account)
localStorage.setItem('token', res.data.token)
localStorage.setItem('avatar', res.data.avatar)
account.value = res.data.account
token.value = res.data.token
avatar.value = res.data.avatar
}
// 手动登出
function logout(redirect = router.currentRoute.value.fullPath) {
localStorage.removeItem('token')
token.value = ''
router.push({
name: 'login',
query: {
...(redirect !== appSettingsStore.settings.app.home.fullPath && router.currentRoute.value.name !== 'login' && { redirect }),
},
}).then(logoutCleanStatus)
}
// 请求登出
function requestLogout() {
localStorage.removeItem('token')
token.value = ''
router.push({
name: 'login',
query: {
...(
router.currentRoute.value.fullPath !== appSettingsStore.settings.app.home.fullPath
&& router.currentRoute.value.name !== 'login'
&& {
redirect: router.currentRoute.value.fullPath,
}
),
},
}).then(logoutCleanStatus)
}
// 登出后清除状态
function logoutCleanStatus() {
localStorage.removeItem('account')
localStorage.removeItem('avatar')
account.value = ''
avatar.value = ''
permissions.value = []
appSettingsStore.updateSettings({}, true)
appTabbarStore.clean()
appRouteStore.removeRoutes()
appMenuStore.setActived(0)
}
// 获取权限
async function getPermissions() {
const res = await apiApp.permission()
permissions.value = res.data.permissions
}
// 修改密码
async function editPassword(data: {
password: string
newPassword: string
}) {
await apiApp.passwordEdit(data)
}
// 锁屏
function lock() {
localStorage.removeItem('token')
}
// 解锁
function unlock() {
localStorage.setItem('token', token.value)
}
return {
token,
account,
avatar,
permissions,
isLogin,
login,
logout,
requestLogout,
getPermissions,
editPassword,
lock,
unlock,
}
})
+39
View File
@@ -0,0 +1,39 @@
export const useAppKeepAliveStore = defineStore(
'appKeepAlive',
() => {
const list = ref<string[]>([])
function add(name: string | string[]) {
if (typeof name === 'string') {
!list.value.includes(name) && list.value.push(name)
}
else {
name.forEach((v) => {
v && !list.value.includes(v) && list.value.push(v)
})
}
}
function remove(name: string | string[]) {
if (typeof name === 'string') {
list.value = list.value.filter((v) => {
return v !== name
})
}
else {
list.value = list.value.filter((v) => {
return !name.includes(v)
})
}
}
function clean() {
list.value = []
}
return {
list,
add,
remove,
clean,
}
},
)
+211
View File
@@ -0,0 +1,211 @@
import type { MenuRecordMainRaw, MenuRecordRaw, RouteRecordMainRaw } from '@fantastic-admin/types'
import type { RouteRecordRaw } from 'vue-router'
import { cloneDeep } from 'es-toolkit'
import { resolveRoutePath } from '@/utils'
export const useAppMenuStore = defineStore(
'appMenu',
() => {
const appSettingsStore = useAppSettingsStore()
const appRouteStore = useAppRouteStore()
// 将原始路由转换成导航菜单
function convertRouteToMenu(routes: RouteRecordMainRaw[]): MenuRecordMainRaw[] {
const returnMenus: MenuRecordMainRaw[] = []
routes.forEach((item) => {
if (item.children.length > 0) {
if (appSettingsStore.settings.menu.mode === 'single') {
returnMenus.length === 0 && returnMenus.push({
meta: {},
children: [],
})
returnMenus[0].children.push(...convertRouteToMenuRecursive(item.children))
}
else {
const menuItem: MenuRecordMainRaw = {
meta: {
title: item?.meta?.title,
icon: item?.meta?.icon,
auth: item?.meta?.auth,
},
children: [],
}
menuItem.children = convertRouteToMenuRecursive(item.children)
returnMenus.push(menuItem)
}
}
})
return returnMenus
}
function convertRouteToMenuRecursive(routes: RouteRecordRaw[], basePath = ''): MenuRecordRaw[] {
const returnMenus: MenuRecordRaw[] = []
routes.forEach((item) => {
const menuItem: MenuRecordRaw = {
path: resolveRoutePath(basePath, item.path),
meta: {
auth: item?.meta?.auth,
title: item?.meta?.title,
icon: item?.meta?.icon,
menu: item?.meta?.menu,
expand: item?.meta?.expand,
link: item?.meta?.link,
},
}
if (item.children) {
menuItem.children = convertRouteToMenuRecursive(item.children, menuItem.path)
}
returnMenus.push(menuItem)
})
return returnMenus
}
// 完整导航数据
const allMenus = computed(() => filterAsyncMenus(convertRouteToMenu(appRouteStore.routesRaw)))
function normalizeActivedIndex(index: number) {
if (allMenus.value.length === 0) {
return 0
}
if (Number.isNaN(index) || index < 0) {
return 0
}
if (index >= allMenus.value.length) {
return allMenus.value.length - 1
}
return index
}
// 原始记录值:保存最近一次被设置的主导航索引
// 当 allMenus 变化后,它可能暂时变成过期值,因此不应该直接在外部使用
const activedOriginal = ref(0)
// 对外可用值:始终基于 allMenus 当前长度做安全修正
// 外部读取 actived 时,拿到的一定是当前有效索引
const actived = computed({
get: () => normalizeActivedIndex(activedOriginal.value),
set: value => activedOriginal.value = normalizeActivedIndex(value),
})
// 次导航数据
const sidebarMenus = computed<MenuRecordMainRaw['children']>(() => {
return allMenus.value.length > 0
? allMenus.value[normalizeActivedIndex(actived.value)].children
: []
})
// 次导航第一层最深路径
const sidebarMenusFirstDeepestPath = computed(() => {
return sidebarMenus.value.length > 0
? getDeepestPath(sidebarMenus.value[0])
: appSettingsStore.settings.app.home.fullPath
})
function getDeepestPath(menu: MenuRecordRaw, rootPath = '') {
let retnPath = ''
if (menu.children?.some(item => item.meta?.menu !== false)) {
const item = menu.children.find(item => item.meta?.menu !== false)
if (item) {
retnPath = getDeepestPath(item, resolveRoutePath(rootPath, menu.path))
}
else {
retnPath = getDeepestPath(menu.children[0], resolveRoutePath(rootPath, menu.path))
}
}
else {
retnPath = resolveRoutePath(rootPath, menu.path)
}
return retnPath
}
// 次导航是否有且只有一个可访问的菜单
const sidebarMenusHasOnlyMenu = computed(() => {
return isSidebarMenusHasOnlyMenu(sidebarMenus.value)
})
function isSidebarMenusHasOnlyMenu(menus: MenuRecordRaw[]) {
let count = 0
let isOnly = true
menus.forEach((menu) => {
if (menu.meta?.menu !== false) {
count++
}
if (menu.children) {
isOnly = isSidebarMenusHasOnlyMenu(menu.children)
}
})
return count <= 1 && isOnly
}
function getExpandPaths(menus: MenuRecordRaw[], rootPath = '') {
const expandPaths: string[] = []
menus.forEach((item) => {
if (item.children) {
if (item.meta?.expand) {
expandPaths.push(resolveRoutePath(rootPath, item.path))
}
const childrenExpandPaths = getExpandPaths(item.children, resolveRoutePath(rootPath, item.path))
if (childrenExpandPaths.length > 0) {
expandPaths.push(...childrenExpandPaths)
}
}
})
return expandPaths
}
// 默认展开的导航路径
const defaultExpandPaths = computed(() => {
const defaultExpandPaths: string[] = []
allMenus.value.forEach((item) => {
defaultExpandPaths.push(...getExpandPaths(item.children))
})
return defaultExpandPaths
})
const auth = useAppAuth()
// 根据权限过滤导航
function filterAsyncMenus<T extends MenuRecordMainRaw[] | MenuRecordRaw[]>(menus: T): T {
const res: any = []
menus.forEach((menu) => {
if (auth.auth(menu.meta?.auth ?? '')) {
const tmpMenu = cloneDeep(menu)
if (tmpMenu.children && tmpMenu.children.length > 0) {
tmpMenu.children = filterAsyncMenus(tmpMenu.children) as MenuRecordRaw[]
tmpMenu.children.length > 0 && res.push(tmpMenu)
}
else {
delete tmpMenu.children
res.push(tmpMenu)
}
}
})
return res
}
// 设置主导航
function isPathInMenus(menus: MenuRecordRaw[], path: string) {
let flag = false
flag = menus.some((item) => {
if (item.children) {
return isPathInMenus(item.children, path)
}
return path.indexOf(`${item.path}/`) === 0 || path === item.path
})
return flag
}
function setActived(indexOrPath: number | string) {
if (typeof indexOrPath === 'number') {
// 如果是 number 类型,则认为是主导航的索引
actived.value = indexOrPath
}
else {
// 如果是 string 类型,则认为是路由,需要查找对应的主导航索引
const findIndex = allMenus.value.findIndex(item => isPathInMenus(item.children, indexOrPath))
if (findIndex >= 0) {
actived.value = findIndex
}
}
}
return {
actived,
allMenus,
sidebarMenus,
sidebarMenusFirstDeepestPath,
sidebarMenusHasOnlyMenu,
defaultExpandPaths,
setActived,
}
},
)
+162
View File
@@ -0,0 +1,162 @@
import type { RouteRecordMainRaw } from '@fantastic-admin/types'
import type { RouteRecordRaw, RouterMatcher } from 'vue-router'
import { cloneDeep } from 'es-toolkit'
import { createRouterMatcher } from 'vue-router'
import apiApp from '@/api/modules/app'
import { systemRoutes as systemRoutesRaw } from '@/router/routes'
export const useAppRouteStore = defineStore(
'appRoute',
() => {
const isGenerate = ref(false)
// 原始路由
const routesRaw = ref<RouteRecordMainRaw[]>([])
// 已注册的路由,用于登出时删除路由
const currentRemoveRoutes = ref<(() => void)[]>([])
// 实际路由
const routes = computed(() => {
const returnRoutes: RouteRecordRaw[] = []
if (routesRaw.value) {
routesRaw.value.forEach((item) => {
const tmpRoutes = cloneDeep(item.children) as RouteRecordRaw[]
tmpRoutes.map((v) => {
if (!v.meta) {
v.meta = {}
}
v.meta.auth = item.meta?.auth ?? v.meta?.auth
return v
})
returnRoutes.push(...tmpRoutes)
})
returnRoutes.forEach((item) => {
if (item.children) {
item.children = deleteMiddleRouteComponent(item.children)
}
return item
})
}
return returnRoutes
})
// 系统路由
const systemRoutes = computed(() => {
const routes = [...systemRoutesRaw]
routes.forEach((item) => {
if (item.children) {
item.children = deleteMiddleRouteComponent(item.children)
}
})
return routes
})
// 删除路由中间层级对应的组件
function deleteMiddleRouteComponent(routes: RouteRecordRaw[]) {
const res: RouteRecordRaw[] = []
routes.forEach((route) => {
if (route.children?.length) {
delete route.component
route.children = deleteMiddleRouteComponent(route.children)
}
else {
delete route.children
}
res.push(route)
})
return res
}
// 路由匹配器
const routesMatcher = ref<RouterMatcher>()
// 根据路径获取匹配的路由
function getRouteMatchedByPath(path: string) {
return routesMatcher.value?.resolve({ path }, undefined!)?.matched ?? []
}
// 路由排序,sort 越大越靠前
function sortAsyncRoutes<T extends RouteRecordMainRaw[] | RouteRecordRaw[]>(routes: T): T {
routes.sort((a, b) => (b.meta?.sort ?? 0) - (a.meta?.sort ?? 0))
routes.forEach((route) => {
if (route.children) {
route.children = sortAsyncRoutes(route.children)
}
})
return routes
}
// 生成路由(前端生成)
function generateRoutesAtFront(asyncRoutes: RouteRecordMainRaw[]) {
// 设置 routes 数据
routesRaw.value = sortAsyncRoutes(cloneDeep(asyncRoutes) as any)
// 创建路由匹配器
const routes: RouteRecordRaw[] = []
routesRaw.value.forEach((route) => {
if (route.children) {
routes.push(...route.children)
}
})
routesMatcher.value = createRouterMatcher(routes, {})
isGenerate.value = true
}
// 格式化后端路由数据
function formatBackRoutes(routes: any, views = import.meta.glob('../../views/**/*.vue')): RouteRecordMainRaw[] {
return routes.map((route: any) => {
switch (route.component) {
case 'Layout':
route.component = () => import('@/layouts/index.vue')
break
default:
if (route.component) {
route.component = views[`../../views/${route.component}`]
}
else {
delete route.component
}
}
if (route.children) {
route.children = formatBackRoutes(route.children, views)
}
return route
})
}
// 生成路由(后端获取)
async function generateRoutesAtBack() {
await apiApp.routeList().then((res) => {
// 设置 routes 数据
routesRaw.value = sortAsyncRoutes(formatBackRoutes(res.data) as any)
// 创建路由匹配器
const routes: RouteRecordRaw[] = []
routesRaw.value.forEach((route) => {
if (route.children) {
routes.push(...route.children)
}
})
routesMatcher.value = createRouterMatcher(routes, {})
isGenerate.value = true
})
}
function setCurrentRemoveRoutes(routes: (() => void)[]) {
currentRemoveRoutes.value = routes
}
// 清空动态路由
function removeRoutes() {
isGenerate.value = false
routesRaw.value = []
currentRemoveRoutes.value.forEach((removeRoute) => {
removeRoute()
})
currentRemoveRoutes.value = []
}
return {
isGenerate,
routesRaw,
currentRemoveRoutes,
routes,
systemRoutes,
getRouteMatchedByPath,
generateRoutesAtFront,
generateRoutesAtBack,
setCurrentRemoveRoutes,
removeRoutes,
}
},
)
+176
View File
@@ -0,0 +1,176 @@
import type { SettingsOptions, ThemeSettings } from '@fantastic-admin/settings'
import type { RouteMeta } from 'vue-router'
import { merge } from '@fantastic-admin/settings'
import { cloneDeep } from 'es-toolkit'
import settingsDefault from '@/settings'
export const useAppSettingsStore = defineStore(
'appSettings',
() => {
const settings = ref(cloneDeep(settingsDefault))
const prefersColorScheme = window.matchMedia('(prefers-color-scheme: dark)')
watch(() => settings.value.theme.colorScheme, (val) => {
document.documentElement.classList.add('disable-color-scheme-transition-duration')
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.documentElement.classList.remove('disable-color-scheme-transition-duration')
})
})
if (val === '') {
prefersColorScheme.addEventListener('change', updateTheme)
}
else {
prefersColorScheme.removeEventListener('change', updateTheme)
}
}, {
immediate: true,
})
const currentColorScheme = ref<Exclude<NonNullable<ThemeSettings['colorScheme']>, ''>>()
watch(() => settings.value.theme.colorScheme, updateTheme, {
immediate: true,
})
function updateTheme() {
let colorScheme = settings.value.theme.colorScheme
if (colorScheme === '') {
colorScheme = prefersColorScheme.matches ? 'dark' : 'light'
}
currentColorScheme.value = colorScheme
switch (colorScheme) {
case 'light':
document.documentElement.classList.remove('dark')
document.documentElement.setAttribute('color-scheme', '')
break
case 'dark':
document.documentElement.classList.add('dark')
document.documentElement.setAttribute('color-scheme', 'dark')
break
}
}
watch(() => settings.value.theme.radius, (val) => {
document.documentElement.style.removeProperty('--radius')
document.documentElement.style.setProperty('--radius', `${val}rem`)
}, {
immediate: true,
})
watch([
() => settings.value.app.rip,
() => settings.value.theme.colorAmblyopia,
], (val) => {
document.documentElement.style.removeProperty('filter')
if (val[0] && val[1]) {
document.documentElement.style.setProperty('filter', 'grayscale(100%) invert(80%)')
}
else if (val[0]) {
document.documentElement.style.setProperty('filter', 'grayscale(100%)')
}
else if (val[1]) {
document.documentElement.style.setProperty('filter', 'invert(80%)')
}
}, {
immediate: true,
})
watch(() => settings.value.menu.mode, (val) => {
document.body.setAttribute('data-menu-mode', val)
}, {
immediate: true,
})
// 操作系统
const os = ref<'mac' | 'windows' | 'linux' | 'other'>('other')
const agent = navigator.userAgent.toLowerCase()
switch (true) {
case agent.includes('mac os'):
os.value = 'mac'
break
case agent.includes('windows'):
os.value = 'windows'
break
case agent.includes('linux'):
os.value = 'linux'
break
}
// 页面是否刷新
const isReloading = ref(false)
// 切换当前页面是否刷新
function setIsReloading(value?: boolean) {
isReloading.value = value ?? !isReloading.value
}
// 页面标题
const title = ref<RouteMeta['title']>()
// 记录页面标题
function setTitle(_title: RouteMeta['title']) {
title.value = _title
}
// 显示模式
const mode = ref<'pc' | 'mobile'>('pc')
// 设置显示模式
function setMode(width: number) {
// 先判断 UA 是否为移动端设备(手机&平板)
if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
mode.value = 'mobile'
}
else {
// 如果是桌面设备,则根据页面宽度判断是否需要切换为移动端展示
mode.value = width < 1024 ? 'mobile' : 'pc'
}
}
// 切换侧边栏导航展开/收起
function toggleSidebarCollapse() {
settings.value.menu.subMenuCollapse = !settings.value.menu.subMenuCollapse
}
// 次导航是否收起(用于记录 pc 模式下最后的状态)
const subMenuCollapseLastStatus = ref(settingsDefault.menu.subMenuCollapse)
watch(() => settings.value.menu.subMenuCollapse, (val) => {
if (mode.value === 'pc') {
subMenuCollapseLastStatus.value = val
}
})
watch(mode, (val) => {
switch (val) {
case 'pc':
settings.value.menu.subMenuCollapse = subMenuCollapseLastStatus.value
break
case 'mobile':
settings.value.menu.subMenuCollapse = true
break
}
document.body.setAttribute('data-mode', val)
}, {
immediate: true,
})
// 设置主题颜色模式
function setColorScheme(color: NonNullable<ThemeSettings['colorScheme']>) {
settings.value.theme.colorScheme = color
}
// 更新应用配置
function updateSettings(data: SettingsOptions, fromBase = false) {
const mergedSettings = merge(data, fromBase ? cloneDeep(settingsDefault) : settings.value) as typeof settings.value
settings.value = mergedSettings
}
return {
settings,
currentColorScheme,
os,
isReloading,
setIsReloading,
title,
setTitle,
mode,
setMode,
subMenuCollapseLastStatus,
toggleSidebarCollapse,
setColorScheme,
updateSettings,
}
},
)

Some files were not shown because too many files have changed in this diff Show More