Compare commits

...
76 Commits
Author SHA1 Message Date
Hooray 35af83a82a chore: release v5.5.0 2025-06-29 18:22:54 +08:00
Hooray 128ac5ee46 chore: 更新依赖 2025-06-29 18:18:40 +08:00
Hooray fa537b7969 feat: vite 升级到 7.0 2025-06-26 17:38:57 +08:00
Hooray e2cd48469c feat: 标签页右键菜单补充图标 2025-06-25 18:33:53 +08:00
Hooray bfe6607f04 feat: 个人设置弹窗适配移动端 2025-06-24 10:11:19 +08:00
Hooray 59e683c2e3 refactor!: 扩展路由 close 函数,并移除 useTabbar 中 close 函数 2025-06-21 15:19:37 +08:00
Hooray 8d59733973 refactor!: pinia store 改为自动导入 2025-06-17 12:39:04 +08:00
Hooray 0ac47384aa refactor!: 移除 useViewTransition 函数封装 2025-06-16 11:37:59 +08:00
Hooray 1a0eb3989c feat!: 新增 FaImagePreview 组件,并移除 ImagePreview 组件 2025-06-15 23:52:20 +08:00
Hooray 6fc89426c9 feat: FaDrawer 和 FaModal 组件新增 destroyOnClose 属性 2025-06-13 16:07:19 +08:00
Hooray 28cc84ff0e fix: 修复账户按钮移动端次导航下不实现用户名 2025-06-12 11:30:31 +08:00
Hooray 094ee89c6c refactor: 优化主题样式和全局样式引入 2025-06-12 02:08:40 +08:00
Hooray 7ee93b0ccb perf: 优化导航菜单动效执行性能 2025-06-11 18:27:20 +08:00
Hooray e3aa58e892 feat: 为 FaTabs 组件添加 listClass 和 contentClass 属性,以支持自定义样式 2025-06-11 10:08:35 +08:00
Hooray 6febfea146 feat: FaModal 组件增加 beforeClose 回调函数,优化关闭逻辑并支持异步处理 2025-06-10 14:37:25 +08:00
Hooray 3859c26f42 fix: 修复权限未开启时菜单未过滤的问题,并简化菜单过滤逻辑 2025-06-09 23:49:35 +08:00
Hooray 245eda34cb refactor: FaModal 组件 provide/inject 改为组件传参方式 2025-06-09 11:43:40 +08:00
Hooray 2bf683d75d chore: 更新依赖 2025-06-09 01:17:55 +08:00
Hooray e553fefffb feat: 新增 FaButtonGroup 组件 2025-06-07 16:54:28 +08:00
Hooray 5160da3085 refactor: 使用 useId 优化菜单逻辑 2025-06-07 10:20:56 +08:00
Hooray 4ef21b7c7f fix: 更新viewport设置以改善移动设备上的显示效果 2025-06-06 16:33:24 +08:00
Hooray 202fc12e99 refactor: 首屏loading适配暗黑模式 2025-06-06 15:05:07 +08:00
Hooray 098d39b6bf refactor: 优化生成icon脚本 2025-06-04 14:09:27 +08:00
Hooray 53f7485754 chore: 更新依赖 2025-06-03 10:47:30 +08:00
Hooray b5981dd6ac chore: 更新 Node.js 版本要求 2025-06-03 10:06:50 +08:00
Hooray ce810aa633 chore: release v5.4.0 2025-05-30 14:52:21 +08:00
Hooray eabb43ecab chore: 更新依赖 2025-05-30 14:49:31 +08:00
Hooray 8bfa0f0c27 fix: 修复激活标签页定位可能不居中的问题 2025-05-27 18:19:59 +08:00
Hooray 05261b37bb fix: 修复次导航收起时页面展示错误 2025-05-26 14:35:24 +08:00
Hooray b974139eeb feat: 将 FaDropdown 组件插槽名从 label 修改为 header,并新增 footer 插槽 2025-05-21 12:01:08 +08:00
Hooray 867852e501 refactor: 优化布局相关 CSS 变量处理逻辑 2025-05-15 16:04:03 +08:00
Hooray 2cadef4ad1 chore: 更新依赖 2025-05-15 10:50:47 +08:00
Hooray 3b889f611f feat: 增加请求重试机制 2025-05-14 17:30:42 +08:00
Hooray 9f37438669 fix: 优化菜单点击处理逻辑,增加对链接的判断 2025-05-14 14:14:00 +08:00
Hooray b7c627c556 feat: 新增 FaProgress 组件 2025-05-14 10:43:27 +08:00
Hooray 7d2279f38d chore: typo 2025-05-06 09:43:44 +08:00
Hooray eb306b18bc feat: 新增 FaHoverCard 组件 2025-05-06 03:01:16 +08:00
Hooray 8d4e226c17 feat: 对未设置 redirect 的父路由,访问时将自动重定向到允许访问的子路由 2025-05-05 00:59:11 +08:00
Hooray 1edf486160 refactor: 优化 FaScrollArea 滚动体验 2025-05-02 23:19:32 +08:00
Hooray e2d058abdd refactor: 调整标签页关闭逻辑,优先跳转至前一个标签页 2025-05-01 20:09:23 +08:00
Hooray 9b5003fad8 feat: FaScrollArea 组件新增 onScroll 事件 2025-05-01 17:07:21 +08:00
Hooray cbef1bb52e refacttor: toast 代替 ElMessage 2025-04-30 15:09:54 +08:00
Hooray 623da2b411 chore: release v5.3.0 2025-04-30 14:16:20 +08:00
Hooray e24a722545 chore: 更新依赖 2025-04-30 14:16:08 +08:00
Hooray f8794e2a5e feat: 新增工具栏全局开关设置 2025-04-29 17:23:39 +08:00
Hooray d7808da1ca feat: 新增预设插槽 header-after-menu、main-sidebar-after-menu 和 sub-sidebar-after-menu 2025-04-28 18:21:01 +08:00
Hooray a1d326810e feat: 将账号信息按钮封装为 AccountButton 组件,并优化工具栏和导航栏的显示逻辑 2025-04-28 18:12:28 +08:00
Hooray 3be15b2446 fix: 修复导航logo在禁用主页时依旧显示可点击状态 2025-04-24 15:56:17 +08:00
Hooray 97757e33c3 fix: 修复 FaDrawer 和 FaModal 组件中遗漏的 props 2025-04-12 16:36:01 +08:00
Hooray 4378226ae6 chore: 多个内建组件添加 defineOptions 以设置组件名称 2025-04-07 16:21:43 +08:00
Hooray 5956008dbf feat: FileUpload 组件增加 onRemove 事件回调 2025-04-07 16:18:39 +08:00
Hooray cf16db603a chore: release v5.2.0 2025-04-04 18:53:05 +08:00
Hooray e68d67d606 chore: 更新依赖 2025-04-04 18:52:49 +08:00
Hooray 1bce49cce6 revert: 回滚移除 <Suspense> 2025-04-03 17:52:13 +08:00
Hooray 52c1bb4b58 refactor: 优化icons生成脚本 2025-04-03 14:47:31 +08:00
Hooray 1ea3345556 chore: 更新依赖 2025-03-30 20:34:53 +08:00
Hooray 35d31a078e style: 样式优化 2025-03-29 13:20:27 +08:00
Hooray 2b18c3f138 refactor!: 重写路由 push/go/replace 函数,移除 useTabbar 中 open/go/replace 函数 2025-03-29 01:53:13 +08:00
Hooray 10c8401a88 perf: 优化子菜单过渡效果,支持两套实现方案 2025-03-26 22:38:37 +08:00
Hooray b0ac06172e feat: 增加 cursor 配置 2025-03-26 15:45:16 +08:00
Hooray 2a902473a8 refactor: 修改圆角系数类型为 number 2025-03-24 18:38:16 +08:00
Hooray f02298bf1b feat: 新增 FaSlider 组件 2025-03-24 18:23:27 +08:00
Hooray 5fcd3fe5ed fix: 在路由守卫中捕获异常时,添加登出逻辑 2025-03-24 14:02:38 +08:00
Hooray ee47369579 refactor: 移除 unocss-preset-scrollbar 依赖 2025-03-24 00:04:38 +08:00
Hooray ce7477b10d feat: layouts 布局支持 <Suspense> 2025-03-13 15:54:39 +08:00
Hooray 609e77c159 fix: 浏览器不支持 color-mix 时,框架主题不会同步至 element-plus 主题 2025-03-13 15:52:58 +08:00
Hooray db4aa406eb refactor: FaCopyright 组件改用 unocss 实现样式 2025-03-12 12:41:33 +08:00
Hooray 5bfb3c8fb0 fix: 修复 FaDrawer 组件 contentClass 设置无效 2025-03-12 01:22:15 +08:00
Hooray 5e59d12d73 perf: 优化导航菜单 2025-03-10 21:49:49 +08:00
Hooray 097e700464 refactor: 根据 reka-ui 调整用法 2025-03-10 17:31:06 +08:00
Hooray 501e82dd44 chore: 更新依赖 2025-03-10 16:37:13 +08:00
Hooray dcad2c6ecf refactor!: radix-vue 迁移到 reka-ui 2025-03-10 16:07:52 +08:00
Hooray 2b08e82c5e revert: 面包屑导航回滚回 computed 模式 2025-03-05 10:30:06 +08:00
Hooray b533c5a040 feat: FaDivider 增加 class props 2025-03-03 23:03:37 +08:00
Hooray 33db1040be refactor: FaPageMain group 作用域 2025-03-03 23:01:25 +08:00
Hooray ebf61b0738 fix: FaContextMenu z-index 被遮挡 2025-03-03 14:50:51 +08:00
220 changed files with 6154 additions and 5768 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"recommendations": [
"EditorConfig.EditorConfig",
"mikestead.dotenv",
"dbaeumer.vscode-eslint",
"stylelint.vscode-stylelint",
"Vue.volar",
"antfu.unocss"
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"eslint.useFlatConfig": true,
"prettier.enable": false,
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.stylelint": "explicit",
"source.organizeImports": "never"
},
"stylelint.validate": [
"css",
"postcss",
"scss",
"vue"
],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml"
],
"typescript.tsdk": "node_modules/typescript/lib"
}
+2 -2
View File
@@ -8,9 +8,9 @@
"baseColor": "neutral",
"cssVariables": true
},
"framework": "vite",
"aliases": {
"components": "@/ui/shadcn",
"utils": "@/utils"
"utils": "@/utils",
"lib": "@/utils"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/browser_upgrade/index.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover"/>
<meta http-equiv="Expires" content="0">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Cache-control" content="no-cache">
+1 -1
View File
@@ -101,7 +101,7 @@
height: 100%;
color: var(--rainbow-color);
user-select: none;
background-color: snow;
background-color: hsl(var(--background));
}
.loading-container *::before,
+63 -64
View File
@@ -1,8 +1,8 @@
{
"type": "module",
"version": "5.1.0",
"version": "5.5.0",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
@@ -12,7 +12,7 @@
"serve:test": "http-server ./dist-test -o",
"svgo": "svgo -f src/assets/icons",
"new": "plop",
"generate:icons": "esno ./scripts/generate.icons.ts",
"generate:icons": "tsx ./scripts/generate.icons.ts",
"lint": "npm-run-all -s lint:tsc lint:eslint lint:stylelint",
"lint:tsc": "vue-tsc -b",
"lint:eslint": "eslint . --cache --fix",
@@ -24,96 +24,95 @@
"release": "bumpp"
},
"dependencies": {
"@vee-validate/zod": "^4.15.0",
"@vueuse/components": "^12.7.0",
"@vueuse/core": "^12.7.0",
"@vueuse/integrations": "^12.7.0",
"axios": "^1.8.1",
"@vee-validate/zod": "^4.15.1",
"@vueuse/components": "^13.4.0",
"@vueuse/core": "^13.4.0",
"@vueuse/integrations": "^13.4.0",
"axios": "^1.10.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.13",
"defu": "^6.1.4",
"disable-devtool": "^0.3.8",
"element-plus": "^2.9.5",
"eruda": "^3.4.1",
"es-toolkit": "^1.32.0",
"hotkeys-js": "^3.13.9",
"lucide-vue-next": "^0.477.0",
"element-plus": "^2.10.2",
"eruda": "^3.4.3",
"es-toolkit": "^1.39.5",
"hotkeys-js": "^3.13.14",
"lucide-vue-next": "^0.525.0",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"path-browserify": "^1.0.1",
"path-to-regexp": "^8.2.0",
"pinia": "^3.0.1",
"pinia": "^3.0.3",
"qs": "^6.14.0",
"radix-vue": "^1.9.17",
"reka-ui": "^2.3.1",
"scule": "^1.3.0",
"tailwind-merge": "^3.0.2",
"ua-parser-js": "^2.0.2",
"tailwind-merge": "^3.3.1",
"ua-parser-js": "^2.0.4",
"vconsole": "^3.15.1",
"vee-validate": "^4.15.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"vue-sonner": "^1.3.0",
"zod": "^3.24.2"
"vee-validate": "^4.15.1",
"vue": "^3.5.17",
"vue-router": "^4.5.1",
"vue-sonner": "^2.0.1",
"zod": "^3.25.67"
},
"devDependencies": {
"@antfu/eslint-config": "4.3.0",
"@faker-js/faker": "^9.5.1",
"@iconify/json": "^2.2.312",
"@iconify/vue": "^4.3.0",
"@antfu/eslint-config": "^4.16.1",
"@clack/prompts": "^0.11.0",
"@faker-js/faker": "^9.8.0",
"@iconify/json": "^2.2.353",
"@iconify/vue": "^5.0.0",
"@stylistic/stylelint-config": "^2.0.0",
"@types/nprogress": "^0.2.3",
"@types/path-browserify": "^1.0.3",
"@types/qs": "^6.9.18",
"@unocss/eslint-plugin": "^65.5.0",
"@unocss/preset-legacy-compat": "^65.5.0",
"@vitejs/plugin-legacy": "^6.0.2",
"@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^4.1.1",
"@types/qs": "^6.14.0",
"@unocss/eslint-plugin": "^66.3.2",
"@unocss/preset-legacy-compat": "^66.3.2",
"@vitejs/plugin-legacy": "^7.0.0",
"@vitejs/plugin-vue": "^6.0.0",
"@vitejs/plugin-vue-jsx": "^5.0.0",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.20",
"autoprefixer": "^10.4.21",
"boxen": "^8.0.1",
"bumpp": "^10.0.3",
"cz-git": "^1.11.0",
"eslint": "^9.21.0",
"esno": "^4.8.0",
"bumpp": "^10.2.0",
"cz-git": "^1.11.2",
"eslint": "^9.30.0",
"fs-extra": "^11.3.0",
"http-server": "^14.1.1",
"inquirer": "^12.4.2",
"lint-staged": "^15.4.3",
"npm-run-all2": "^7.0.2",
"lint-staged": "^16.1.2",
"npm-run-all2": "^8.0.4",
"picocolors": "^1.1.1",
"plop": "^4.0.1",
"postcss": "^8.5.3",
"postcss": "^8.5.6",
"postcss-nested": "^7.0.2",
"sass-embedded": "^1.85.1",
"simple-git-hooks": "^2.11.1",
"stylelint": "^16.15.0",
"stylelint-config-recess-order": "^6.0.0",
"stylelint-config-standard-scss": "^14.0.0",
"sass-embedded": "^1.89.2",
"simple-git-hooks": "^2.13.0",
"stylelint": "^16.21.0",
"stylelint-config-recess-order": "^7.1.0",
"stylelint-config-standard-scss": "^15.0.1",
"stylelint-config-standard-vue": "^1.0.0",
"stylelint-scss": "^6.11.1",
"svgo": "^3.3.2",
"taze": "^18.6.0",
"typescript": "^5.8.2",
"unocss": "^65.5.0",
"unocss-preset-animations": "^1.1.1",
"unocss-preset-scrollbar": "^3.2.0",
"unplugin-auto-import": "^19.1.1",
"unplugin-turbo-console": "^1.11.3",
"unplugin-vue-components": "^28.4.1",
"vite": "^6.2.0",
"vite-plugin-app-loading": "^0.3.1",
"vite-plugin-archiver": "^0.1.2",
"vite-plugin-banner": "^0.8.0",
"vite-plugin-compression2": "^1.3.3",
"stylelint-scss": "^6.12.1",
"svgo": "^4.0.0",
"taze": "^19.1.0",
"tsx": "^4.20.3",
"typescript": "^5.8.3",
"unocss": "^66.3.2",
"unocss-preset-animations": "^1.2.1",
"unplugin-auto-import": "^19.3.0",
"unplugin-turbo-console": "^2.1.3",
"unplugin-vue-components": "^28.8.0",
"vite": "^7.0.0",
"vite-plugin-app-loading": "^0.4.0",
"vite-plugin-archiver": "^0.2.0",
"vite-plugin-banner": "^0.8.1",
"vite-plugin-compression2": "^2.2.0",
"vite-plugin-env-parse": "^1.0.15",
"vite-plugin-fake-server": "^2.2.0",
"vite-plugin-pages": "^0.32.5",
"vite-plugin-pages": "^0.33.1",
"vite-plugin-svg-icons": "^2.0.1",
"vite-plugin-vue-devtools": "^7.7.2",
"vite-plugin-vue-devtools": "^7.7.7",
"vite-plugin-vue-meta-layouts": "^0.5.1",
"vue-tsc": "^2.2.8"
"vue-tsc": "^2.2.10"
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged",
+1 -3
View File
@@ -1,4 +1,4 @@
const use{{ properCase name }}Store = defineStore(
export const use{{ properCase name }}Store = defineStore(
// 唯一ID
'{{ camelCase name }}',
() => {
@@ -9,5 +9,3 @@ const use{{ properCase name }}Store = defineStore(
}
},
)
export default use{{ properCase name }}Store
+1 -1
View File
@@ -19,7 +19,7 @@ export default {
const actions = [
{
type: 'add',
path: 'src/store/modules/{{camelCase name}}.ts',
path: 'src/store/{{camelCase name}}.ts',
templateFile: 'plop-templates/store/index.hbs',
},
]
Generated Executable → Regular
+4002 -4595
View File
File diff suppressed because it is too large Load Diff
+89 -67
View File
@@ -1,83 +1,105 @@
import { exec } from 'node:child_process'
import { spawn } from 'node:child_process'
import path from 'node:path'
import process from 'node:process'
import * as p from '@clack/prompts'
import { lookupCollection, lookupCollections } from '@iconify/json'
import fs from 'fs-extra'
import inquirer from 'inquirer'
async function generateIcons() {
// 拿到全部图标集的原始数据
const raw = await lookupCollections()
// 拿到全部图标集的原始数据
const raw = await lookupCollections()
let lastChoose = fs.readFileSync(path.resolve(process.cwd(), 'src/iconify/index.json'), 'utf-8')
lastChoose = JSON.parse(lastChoose)
let lastChoose = fs.readFileSync(path.resolve(process.cwd(), 'src/iconify/index.json'), 'utf-8')
lastChoose = JSON.parse(lastChoose)
// 取出可使用的图标集数据用于 inquirer 选择,并按名称排序
const collections = Object.entries(raw).map(([id, item]) => ({
...item,
id,
})).sort((a, b) => a.name.localeCompare(b.name))
// 取出可使用的图标集数据用于选择,并按名称排序
const collections = Object.entries(raw).map(([id, item]) => ({
...item,
id,
})).sort((a, b) => a.name.localeCompare(b.name))
/**
* 分别会在对应目录下生成以下文件,其中(1)(3)用于离线下载并安装图标,(2)用于图标选择器使用
* (1) src/iconify/index.json 记录用户 inquirer 的交互信息
* (2) src/iconify/data.json 包含多个图标集数据,仅记录图标名
* (3) public/icons/*-raw.json 多个图标集的原始数据,独立存放,用于离线使用
*/
inquirer.prompt([
{
type: 'checkbox',
message: '请选择需要生成的图标集',
name: 'collections',
choices: collections.map(item => ({
name: `${item.name} (${item.total} icons)`,
value: item.id,
})),
default: lastChoose.collections,
p.intro('图标集生成工具')
/**
* 分别会在对应目录下生成以下文件,其中(1)(3)用于离线下载并安装图标,(2)用于图标选择器使用
* (1) src/iconify/index.json 记录用户交互信息
* (2) src/iconify/data.json 包含多个图标集数据,仅记录图标名
* (3) public/icons/*-raw.json 多个图标集的原始数据,独立存放,用于离线使用
*/
const answers = await p.group(
{
collections: () =>
p.multiselect({
message: '请选择需要生成的图标集',
options: collections.map(item => ({
label: item.name,
value: item.id,
hint: `${item.total} 个图标`,
})),
initialValues: lastChoose.collections,
}),
isOfflineUse: () =>
p.confirm({
message: '是否需要离线使用',
initialValue: false,
}),
},
{
onCancel: () => {
p.cancel('操作已取消')
process.exit(0)
},
{
type: 'confirm',
name: 'isOfflineUse',
message: '是否需要离线使用',
default: false,
},
]).then(async (answers) => {
await fs.writeJSON(
path.resolve(process.cwd(), 'src/iconify/index.json'),
{
collections: answers.collections,
isOfflineUse: answers.isOfflineUse,
},
)
},
)
const outputDir = path.resolve(process.cwd(), 'public/icons')
await fs.ensureDir(outputDir)
await fs.emptyDir(outputDir)
await fs.writeJSON(
path.resolve(process.cwd(), 'src/iconify/index.json'),
{
collections: answers.collections,
isOfflineUse: answers.isOfflineUse,
},
)
const collectionsMeta: object[] = []
for (const info of answers.collections) {
const setData = await lookupCollection(info)
const outputDir = path.resolve(process.cwd(), 'public/icons')
await fs.ensureDir(outputDir)
await fs.emptyDir(outputDir)
collectionsMeta.push({
prefix: setData.prefix,
info: setData.info,
icons: Object.keys(setData.icons),
})
const collectionsMeta: object[] = []
for (const info of answers.collections) {
const setData = await lookupCollection(info)
const offlineFilePath = path.join(outputDir, `${info}-raw.json`)
if (answers.isOfflineUse) {
await fs.writeJSON(offlineFilePath, setData)
}
}
await fs.writeJSON(
path.resolve(process.cwd(), 'src/iconify/data.json'),
collectionsMeta,
)
exec('eslint src/iconify/data.json src/iconify/index.json --cache --fix')
collectionsMeta.push({
prefix: setData.prefix,
info: setData.info,
icons: Object.keys(setData.icons),
})
const offlineFilePath = path.join(outputDir, `${info}-raw.json`)
if (answers.isOfflineUse) {
await fs.writeJSON(offlineFilePath, setData)
}
}
generateIcons()
await fs.writeJSON(
path.resolve(process.cwd(), 'src/iconify/data.json'),
collectionsMeta,
)
// 使用 spawn 运行 eslint
const eslint = spawn('eslint', ['src/iconify/data.json', 'src/iconify/index.json', '--cache', '--fix'], {
stdio: 'inherit',
shell: true,
})
eslint.on('error', (err) => {
p.log.error(`ESLint 执行失败: ${err.message}`)
process.exit(1)
})
eslint.on('close', (code) => {
if (code !== 0) {
p.log.error(`ESLint 执行失败,退出码: ${code}`)
process.exit(code)
}
p.outro('图标集生成完成!')
})
-1
View File
@@ -1,5 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
import { ua } from '@/utils/ua'
import Provider from './ui/provider/index.vue'
+57 -18
View File
@@ -1,8 +1,19 @@
import useUserStore from '@/store/modules/user'
import axios from 'axios'
// import qs from 'qs'
import { toast } from 'vue-sonner'
// 请求重试配置
const MAX_RETRY_COUNT = 3 // 最大重试次数
const RETRY_DELAY = 1000 // 重试延迟时间(毫秒)
// 扩展 AxiosRequestConfig 类型
declare module 'axios' {
export interface AxiosRequestConfig {
retry?: boolean
retryCount?: number
}
}
const api = axios.create({
baseURL: (import.meta.env.DEV && import.meta.env.VITE_OPEN_PROXY) ? '/proxy/' : import.meta.env.VITE_APP_API_BASEURL,
timeout: 1000 * 60,
@@ -29,6 +40,28 @@ api.interceptors.request.use(
},
)
// 处理错误信息的函数
function handleError(error: any) {
if (error.status === 401) {
useUserStore().requestLogout()
throw error
}
let message = error.message
if (message === 'Network Error') {
message = '后端网络故障'
}
else if (message.includes('timeout')) {
message = '接口请求超时'
}
else if (message.includes('Request failed with status code')) {
message = `接口${message.substr(message.length - 3)}异常`
}
toast.error('Error', {
description: message,
})
return Promise.reject(error)
}
api.interceptors.response.use(
(response) => {
/**
@@ -50,25 +83,31 @@ api.interceptors.response.use(
}
return Promise.resolve(response.data)
},
(error) => {
if (error.status === 401) {
useUserStore().requestLogout()
throw error
async (error) => {
// 获取请求配置
const config = error.config
// 如果配置不存在或未启用重试,则直接处理错误
if (!config || !config.retry) {
return handleError(error)
}
let message = error.message
if (message === 'Network Error') {
message = '后端网络故障'
// 设置重试次数
config.retryCount = config.retryCount || 0
// 判断是否超过重试次数
if (config.retryCount >= MAX_RETRY_COUNT) {
return handleError(error)
}
else if (message.includes('timeout')) {
message = '接口请求超时'
}
else if (message.includes('Request failed with status code')) {
message = `接口${message.substr(message.length - 3)}异常`
}
toast.error('Error', {
description: message,
})
return Promise.reject(error)
// 重试次数自增
config.retryCount += 1
// 延迟重试
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY))
// 重新发起请求
return api(config)
},
)
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

+1 -1
View File
@@ -1 +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.667zm-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.666zm0 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.666H516.267zm4.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.667zm-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.266z"/></svg>
<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>

Before

Width:  |  Height:  |  Size: 998 B

After

Width:  |  Height:  |  Size: 986 B

+26 -26
View File
@@ -1,5 +1,7 @@
/* 页面布局 CSS 变量 */
:root {
color-scheme: light;
/* 头部高度 */
--g-header-height: 60px;
@@ -16,44 +18,45 @@
/* 工具栏高度 */
--g-toolbar-height: 50px;
/* 滚动条颜色 */
--scrollbar-color: 240 5.9% 90%;
&.dark {
color-scheme: dark;
--scrollbar-color: 240 3.7% 15.9%;
}
}
/* 明暗模式 CSS 变量 */
/* stylelint-disable-next-line no-duplicate-selectors */
:root {
color-scheme: light;
::view-transition-old(root),
::view-transition-new(root) {
mix-blend-mode: normal;
animation: none;
}
&::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: 9999;
}
&.dark {
color-scheme: dark;
&::view-transition-old(root) {
z-index: 9999;
}
&::view-transition-new(root) {
z-index: 1;
}
z-index: 0;
}
}
* {
scrollbar-color: hsl(var(--border)) transparent;
scrollbar-color: hsl(var(--scrollbar-color)) transparent;
scrollbar-width: thin;
border-color: hsl(var(--border));
}
html,
@@ -64,9 +67,6 @@ body {
body {
box-sizing: border-box;
margin: 0;
font-family: var(--font-family);
color: hsl(var(--foreground));
background: hsl(var(--background));
-webkit-tap-highlight-color: transparent;
}
@@ -1,9 +1,21 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
import useUserStore from '@/store/modules/user'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import eventBus from '@/utils/eventBus'
import Profile from './profile.vue'
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 settingsStore = useSettingsStore()
@@ -14,7 +26,7 @@ const isProfileShow = ref(false)
<template>
<FaDropdown
align="end" :items="[
:align="dropdownAlign" :side="dropdownSide" :items="[
[
{ label: settingsStore.settings.home.title, icon: 'i-mdi:home', handle: () => router.push({ path: settingsStore.settings.home.fullPath }), hide: !settingsStore.settings.home.enable },
{ label: '个人设置', icon: 'i-mdi:account', handle: () => isProfileShow = true },
@@ -27,7 +39,7 @@ const isProfileShow = ref(false)
],
]" class="flex-center"
>
<template #label>
<template #header>
<div class="space-y-2">
<div class="text-xs text-secondary-foreground/50 font-light">
当前登录账号
@@ -39,23 +51,29 @@ const isProfileShow = ref(false)
{{ userStore.account }}
</div>
<div class="text-xs text-secondary-foreground/50 font-normal">
这里可以显示邮箱
[ 这里可以显示邮箱 ]
</div>
</div>
</div>
</div>
</template>
<FaButton variant="ghost" class="h-9 flex-center gap-1 px-2">
<FaAvatar :src="userStore.avatar" class="size-6">
<FaIcon name="i-carbon:user-avatar-filled-alt" class="size-6 text-secondary-foreground/50" />
<FaButton
:variant="buttonVariant" :class="cn('flex-center gap-1 p-2', {
'size-8 p-1': onlyAvatar,
}, props.class)"
>
<FaAvatar :src="userStore.avatar" :class="cn('size-6', { 'size-full': onlyAvatar })">
<FaIcon name="i-carbon:user-avatar-filled" class="size-6 text-secondary-foreground/50" />
</FaAvatar>
<template v-if="settingsStore.mode === 'pc'">
{{ userStore.account }}
<FaIcon name="i-ep:caret-bottom" />
</template>
<div v-if="!onlyAvatar" class="min-w-0 flex-center-between flex-1 gap-2">
<div class="flex-1 truncate text-start">
{{ userStore.account }}
</div>
<FaIcon name="i-material-symbols:expand-all-rounded" />
</div>
</FaButton>
</FaDropdown>
<FaModal v-model="isProfileShow" align-center :header="false" :footer="false" :close-on-click-overlay="false" :close-on-press-escape="false" class="h-500px min-w-600px overflow-hidden" content-class="min-h-full p-0 flex">
<FaModal v-model="isProfileShow" align-center :header="false" :footer="false" :close-on-click-overlay="false" :close-on-press-escape="false" class="h-500px max-w-xl overflow-hidden" content-class="min-h-full p-0 flex">
<Profile />
</FaModal>
</template>
@@ -16,8 +16,8 @@ const tabs = ref([
<template>
<div class="min-h-full w-full">
<div class="fixed inset-s-0 bottom-0 top-0 h-full w-40 border-e">
<div v-for="(tab, index) in tabs" :key="index" class="cursor-pointer px-4 py-3 transition-background-color space-y-2 hover-bg-accent/50" :class="{ 'bg-accent hover-bg-accent!': active === index }" @click="active = index">
<div class="fixed right-0 top-0 z-1 flex flex-row overflow-auto border-b border-e bg-background md:(inset-s-0 bottom-0 h-full w-40 flex-col)">
<div v-for="(tab, index) in tabs" :key="index" class="flex-shrink-0 cursor-pointer px-4 py-3 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>
@@ -26,7 +26,7 @@ const tabs = ref([
</div>
</div>
</div>
<div class="ms-40 min-h-full flex-col-center p-10">
<div class="min-h-full flex-col-center p-10 pt-20 md:(ms-40 pt-10)">
<div v-if="active === 0">
请开发者自行扩展
</div>
@@ -1,10 +1,9 @@
<script setup lang="ts">
import useUserStore from '@/store/modules/user'
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import * as z from 'zod'
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
defineOptions({
name: 'EditPasswordForm',
+1 -2
View File
@@ -1,9 +1,8 @@
<script setup lang="ts">
import useUserStore from '@/store/modules/user'
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
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',
+1 -1
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
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: 'RegisterForm',
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { FormControl, FormField, FormItem, FormMessage } from '@/ui/shadcn/ui/form'
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',
+13 -4
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { UploadProps, UploadUserFile } from 'element-plus'
import { ElMessage } from 'element-plus'
import { toast } from 'vue-sonner'
defineOptions({
name: 'FileUpload',
@@ -35,6 +35,10 @@ const emits = defineEmits<{
file: UploadUserFile,
fileList: UploadUserFile[],
]
onRemove: [
file: UploadUserFile,
fileList: UploadUserFile[],
]
}>()
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
@@ -43,22 +47,26 @@ const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isTypeOk = props.ext.includes(fileExt)
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk) {
ElMessage.error(`上传文件只支持 ${props.ext.join(' / ')} 格式!`)
toast.error(`上传文件只支持 ${props.ext.join(' / ')} 格式!`)
}
if (!isSizeOk) {
ElMessage.error(`上传文件大小不能超过 ${props.size}MB!`)
toast.error(`上传文件大小不能超过 ${props.size}MB!`)
}
return isTypeOk && isSizeOk
}
const onExceed: UploadProps['onExceed'] = () => {
ElMessage.warning('文件上传超过限制')
toast.warning('文件上传超过限制')
}
const onSuccess: UploadProps['onSuccess'] = (res, file, fileList) => {
emits('onSuccess', res, file, fileList)
}
const onRemove: UploadProps['onRemove'] = (file, fileList) => {
emits('onRemove', file, fileList)
}
const onPreview: UploadProps['onPreview'] = (e) => {
const getBlob = (url: string) => new Promise((resolve) => {
const xhr = new XMLHttpRequest()
@@ -92,6 +100,7 @@ const onPreview: UploadProps['onPreview'] = (e) => {
:before-upload="beforeUpload"
:on-exceed="onExceed"
:on-success="onSuccess"
:on-remove="onRemove"
:on-preview="onPreview"
:http-request="httpRequest"
:file-list="files"
-63
View File
@@ -1,63 +0,0 @@
<script setup lang="ts">
defineOptions({
name: 'ImagePreview',
})
const props = withDefaults(
defineProps<{
src: string
width?: number | string
height?: number | string
}>(),
{
width: 200,
height: 200,
},
)
const realWidth = computed(() => {
return typeof props.width === 'string' ? props.width : `${props.width}px`
})
const realHeight = computed(() => {
return typeof props.height === 'string' ? props.height : `${props.height}px`
})
</script>
<template>
<ElImage :src="src" fit="cover" :style="`width:${realWidth};height:${realHeight};`" :preview-src-list="[src]" preview-teleported>
<template #error>
<div class="image-slot">
<FaIcon name="i-ph:image-broken-duotone" />
</div>
</template>
</ElImage>
</template>
<style scoped>
.el-image {
background-color: var(--el-fill-color);
border-radius: 5px;
box-shadow: var(--el-box-shadow-light);
transition: background-color 0.3s, var(--el-transition-box-shadow);
:deep(.el-image__inner) {
cursor: pointer;
transition: all 0.3s;
&:hover {
transform: scale(1.2);
}
}
:deep(.image-slot) {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 30px;
color: #909399;
}
}
</style>
+3 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { UploadProps } from 'element-plus'
import { ElMessage } from 'element-plus'
import { toast } from 'vue-sonner'
defineOptions({
name: 'ImageUpload',
@@ -67,10 +67,10 @@ const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isTypeOk = props.ext.includes(fileExt)
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk) {
ElMessage.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`)
toast.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`)
}
if (!isSizeOk) {
ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`)
toast.error(`上传图片大小不能超过 ${props.size}MB!`)
}
if (isTypeOk && isSizeOk) {
uploadData.value.progress.preview = URL.createObjectURL(file)
+3 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { UploadProps } from 'element-plus'
import { ElMessage } from 'element-plus'
import { toast } from 'vue-sonner'
defineOptions({
name: 'ImagesUpload',
@@ -81,10 +81,10 @@ const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isTypeOk = props.ext.includes(fileExt)
const isSizeOk = file.size / 1024 / 1024 < props.size
if (!isTypeOk) {
ElMessage.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`)
toast.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`)
}
if (!isSizeOk) {
ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`)
toast.error(`上传图片大小不能超过 ${props.size}MB!`)
}
if (isTypeOk && isSizeOk) {
uploadData.value.progress.preview = URL.createObjectURL(file)
File diff suppressed because one or more lines are too long
+26 -25
View File
@@ -1,11 +1,9 @@
<script setup lang="ts">
import settingsDefault from '@/settings.default'
import useMenuStore from '@/store/modules/menu'
import useSettingsStore from '@/store/modules/settings'
import eventBus from '@/utils/eventBus'
import { diffTwoObj } from '@/utils/object'
import { useClipboard } from '@vueuse/core'
import { toast } from 'vue-sonner'
import settingsDefault from '@/settings.default'
import eventBus from '@/utils/eventBus'
import { diffTwoObj } from '@/utils/object'
defineOptions({
name: 'AppSetting',
@@ -18,6 +16,15 @@ const menuStore = useMenuStore()
const isShow = ref(false)
const appRadius = computed<number[]>({
get() {
return [settingsStore.settings.app.radius]
},
set(value) {
settingsStore.settings.app.radius = value[0]
},
})
watch(() => settingsStore.settings.menu.mode, (value) => {
if (value === 'single') {
menuStore.setActived(0)
@@ -49,7 +56,7 @@ function handleCopy() {
</script>
<template>
<FaDrawer v-model="isShow" title="应用配置" description="在生产环境中应关闭该模块" :footer="isSupported">
<FaDrawer v-model="isShow" title="应用配置" description="在生产环境中应关闭该模块" :footer="isSupported" :destroy-on-close="false" content-class="sm:min-w-md">
<div class="rounded-2 bg-rose/20 px-4 py-2 text-sm/6 c-rose">
应用配置可实时预览效果,但只是临时生效,要想真正应用于项目,可以点击下方的「复制配置」按钮,并将配置粘贴到 src/settings.ts 文件中。
</div>
@@ -70,19 +77,7 @@ function handleCopy() {
<div class="label">
圆角系数
</div>
<div class="flex-center-start gap-1">
<FaButton
v-for="(item, index) in [
{ label: 0, value: 0 },
{ label: 0.25, value: 0.25 },
{ label: 0.5, value: 0.5 },
{ label: 0.75, value: 0.75 },
{ label: 1, value: 1 },
]" :key="index" :variant="settingsStore.settings.app.radius === item.value ? 'default' : 'outline'" size="sm" class="w-12" @click="settingsStore.settings.app.radius = (item.value as any)"
>
{{ item.label }}
</FaButton>
</div>
<FaSlider v-model="appRadius" :min="0" :max="1" :step="0.25" class="w-1/2" />
</div>
</div>
<div v-if="settingsStore.mode === 'pc'">
@@ -196,11 +191,17 @@ function handleCopy() {
</div>
<div>
<FaDivider>工具栏</FaDivider>
<div class="setting-item">
<div class="label">
是否启用
</div>
<FaSwitch v-model="settingsStore.settings.toolbar.enable" />
</div>
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
<div class="label">
面包屑导航
</div>
<FaSwitch v-model="settingsStore.settings.toolbar.breadcrumb" />
<FaSwitch v-model="settingsStore.settings.toolbar.breadcrumb" :disabled="!settingsStore.settings.toolbar.enable" />
</div>
<div class="setting-item">
<div class="label">
@@ -209,13 +210,13 @@ function handleCopy() {
<FaIcon name="i-ri:question-line" />
</FaTooltip>
</div>
<FaSwitch v-model="settingsStore.settings.toolbar.navSearch" />
<FaSwitch v-model="settingsStore.settings.toolbar.navSearch" :disabled="!settingsStore.settings.toolbar.enable" />
</div>
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
<div class="label">
全屏
</div>
<FaSwitch v-model="settingsStore.settings.toolbar.fullscreen" />
<FaSwitch v-model="settingsStore.settings.toolbar.fullscreen" :disabled="!settingsStore.settings.toolbar.enable" />
</div>
<div class="setting-item">
<div class="label">
@@ -224,7 +225,7 @@ function handleCopy() {
<FaIcon name="i-ri:question-line" />
</FaTooltip>
</div>
<FaSwitch v-model="settingsStore.settings.toolbar.pageReload" />
<FaSwitch v-model="settingsStore.settings.toolbar.pageReload" :disabled="!settingsStore.settings.toolbar.enable" />
</div>
<div class="setting-item">
<div class="label">
@@ -233,7 +234,7 @@ function handleCopy() {
<FaIcon name="i-ri:question-line" />
</FaTooltip>
</div>
<FaSwitch v-model="settingsStore.settings.toolbar.colorScheme" />
<FaSwitch v-model="settingsStore.settings.toolbar.colorScheme" :disabled="!settingsStore.settings.toolbar.enable" />
</div>
</div>
<div>
@@ -242,7 +243,7 @@ function handleCopy() {
<div class="label">
是否启用快捷键
</div>
<FaSwitch v-model="settingsStore.settings.mainPage.enableHotkeys" />
<FaSwitch v-model="settingsStore.settings.mainPage.enableHotkeys" :disabled="!settingsStore.settings.toolbar.enable" />
</div>
</div>
<div>
+7 -7
View File
@@ -1,9 +1,6 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import useMenuStore from '@/store/modules/menu'
import useSettingsStore from '@/store/modules/settings'
import Logo from '../Logo/index.vue'
import ToolbarRightSide from '../Topbar/Toolbar/rightSide.vue'
defineOptions({
name: 'LayoutHeader',
@@ -18,8 +15,8 @@ const { switchTo } = useMenu()
<template>
<Transition name="header">
<header v-if="settingsStore.mode === 'pc' && settingsStore.settings.menu.mode === 'head'">
<component :is="useSlots('header-start')" />
<div class="header-container">
<component :is="useSlots('header-start')" />
<Logo class="title" />
<component :is="useSlots('header-after-logo')" />
<FaScrollArea :scrollbar="false" mask horizontal gradient-color="var(--g-header-bg)" class="menu-container h-full flex-1">
@@ -47,9 +44,12 @@ const { switchTo } = useMenu()
</template>
</div>
</FaScrollArea>
<ToolbarRightSide />
<component :is="useSlots('header-after-menu')" />
<div class="flex-center">
<AccountButton only-avatar dropdown-side="bottom" class="size-12 p-2" />
</div>
<component :is="useSlots('header-end')" />
</div>
<component :is="useSlots('header-end')" />
</header>
</Transition>
</template>
@@ -78,7 +78,7 @@ header {
justify-content: space-between;
width: 100%;
height: 100%;
padding: 0 12px 0 20px;
padding: 0 12px;
margin: 0 auto;
:deep(a.title) {
@@ -1,5 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
import eventBus from '@/utils/eventBus'
defineOptions({
+1 -2
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import imgLogo from '@/assets/images/logo.svg'
import useSettingsStore from '@/store/modules/settings'
defineOptions({
name: 'Logo',
@@ -26,7 +25,7 @@ const to = computed(() => settingsStore.settings.home.enable ? settingsStore.set
</script>
<template>
<RouterLink :to="to" class="h-[var(--g-sidebar-logo-height)] w-inherit flex-center gap-2 px-3 text-inherit no-underline" :class="{ 'cursor-pointer': settingsStore.settings.home.enable }" :title="title">
<RouterLink :to class="h-[var(--g-sidebar-logo-height)] w-inherit flex-center gap-2 px-3 text-inherit no-underline" :class="{ 'cursor-default': !settingsStore.settings.home.enable }" :title="title">
<img v-if="showLogo" :src="logo" class="logo h-[30px] w-[30px] object-contain">
<span v-if="showTitle" class="block truncate font-bold">{{ title }}</span>
</RouterLink>
+6 -6
View File
@@ -1,8 +1,6 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import useMenuStore from '@/store/modules/menu'
import useSettingsStore from '@/store/modules/settings'
import hotkeys from 'hotkeys-js'
import { useSlots } from '@/slots'
import Logo from '../Logo/index.vue'
defineOptions({
@@ -40,7 +38,7 @@ onUnmounted(() => {
<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">
<FaScrollArea :scrollbar="false" mask gradient-color="var(--g-main-sidebar-bg)" class="menu flex-1">
<!-- 侧边栏模式(含主导航) -->
<div class="w-full flex flex-col of-hidden py-1 transition-all -mt-2">
<template v-for="(item, index) in menuStore.allMenus" :key="index">
@@ -65,6 +63,10 @@ onUnmounted(() => {
</template>
</div>
</FaScrollArea>
<component :is="useSlots('main-sidebar-after-menu')" />
<div class="flex-center px-4 py-3">
<AccountButton only-avatar :button-variant="settingsStore.settings.menu.mode === 'side' ? 'secondary' : 'ghost'" class="size-12 p-2" />
</div>
<component :is="useSlots('main-sidebar-bottom')" />
</div>
</Transition>
@@ -88,8 +90,6 @@ onUnmounted(() => {
}
.menu {
flex: 1;
:deep(.menu-item) {
.menu-item-container {
padding-block: 8px;
+19 -7
View File
@@ -1,5 +1,6 @@
<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'
@@ -19,6 +20,16 @@ const props = withDefaults(
},
)
// 用于缓存对象到 ID 的映射
const idMap = new WeakMap<object, string>()
// 获取对象的唯一 ID,如果已存在则返回缓存的 ID,否则生成新的
function getUseId(obj: object): string {
if (!idMap.has(obj)) {
idMap.set(obj, useId())
}
return idMap.get(obj)!
}
const activeIndex = ref<MenuInjection['activeIndex']>(props.value)
const items = ref<MenuInjection['items']>({})
const subMenus = ref<MenuInjection['subMenus']>({})
@@ -31,7 +42,7 @@ const isMenuPopup = computed<MenuInjection['isMenuPopup']>(() => {
// 解析传入的 menu 数据,并保存到 items 和 subMenus 对象中
function initItems(menu: MenuProps['menu'], parentPaths: string[] = []) {
menu.forEach((item) => {
const index = item.path ?? JSON.stringify(item)
const index = item.path ?? getUseId(item)
if (item.children) {
const indexPath = [...parentPaths, index]
subMenus.value[index] = {
@@ -143,6 +154,7 @@ watch(() => props.collapse, (value) => {
provide(rootMenuInjectionKey, reactive({
props,
getUseId,
items,
subMenus,
activeIndex,
@@ -158,15 +170,15 @@ provide(rootMenuInjectionKey, reactive({
<template>
<div
class="h-full w-full flex flex-col of-hidden transition-all-300" :class="{
'flex-row! w-auto!': isMenuPopup && props.mode === 'horizontal',
:class="cn('h-full w-full flex flex-col of-hidden transition-all-300', {
'flex-row w-auto': isMenuPopup && props.mode === 'horizontal',
'py-1': props.mode === 'vertical',
}"
})"
>
<template v-for="item in menu" :key="item.path ?? JSON.stringify(item)">
<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 ?? JSON.stringify(item)]" />
<Item v-else :item="item" :unique-key="[item.path ?? JSON.stringify(item)]" @click="handleMenuItemClick(item.path ?? JSON.stringify(item))" />
<SubMenu v-if="item.children?.length" :menu="item" :unique-key="[item.path ?? getUseId(item)]" />
<Item v-else :item="item" :unique-key="[item.path ?? getUseId(item)]" @click="handleMenuItemClick(item.path ?? getUseId(item))" />
</template>
</template>
</div>
+18 -23
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { SubMenuItemProps } from './types'
import { cn } from '@/utils'
import { rootMenuInjectionKey } from './types'
defineOptions({
@@ -29,13 +30,6 @@ const isItemActive = computed(() => {
return isActived.value && (!props.subMenu || rootMenu.isMenuPopup)
})
// 缩进样式
const indentStyle = computed(() => {
return !rootMenu.isMenuPopup
? `padding-left: ${20 * (props.level ?? 0)}px`
: ''
})
defineExpose({
ref: itemRef,
})
@@ -43,11 +37,11 @@ defineExpose({
<template>
<div
ref="itemRef" class="menu-item relative transition-all" :class="{
ref="itemRef" :class="cn('menu-item relative transition-all', {
'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="!rootMenu.isMenuPopup || level !== 0 || subMenu" :text="typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title" :side="rootMenu.props.mode === 'vertical' ? 'right' : 'bottom'" class="h-full w-full">
@@ -58,39 +52,40 @@ defineExpose({
target: item.meta?.link ? '_blank' : '_self',
class: 'no-underline',
}),
}" class="group menu-item-container relative h-full w-full flex cursor-pointer items-center justify-between gap-1 rounded-lg px-4 py-3 text-[var(--g-sub-sidebar-menu-color)] transition-all hover-(bg-[var(--g-sub-sidebar-menu-hover-bg)] text-[var(--g-sub-sidebar-menu-hover-color)])" :class="{
}" :class="cn('group menu-item-container relative h-full w-full flex cursor-pointer items-center justify-between gap-1 rounded-lg px-4 py-3 text-[var(--g-sub-sidebar-menu-color)] transition-all hover-(bg-[var(--g-sub-sidebar-menu-hover-bg)] text-[var(--g-sub-sidebar-menu-hover-color)])', {
'text-[var(--g-sub-sidebar-menu-active-color)]! bg-[var(--g-sub-sidebar-menu-active-bg)]!': isItemActive,
'px-3!': rootMenu.isMenuPopup && level === 0,
}" :title="typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title" v-on="{
'px-3': rootMenu.isMenuPopup && level === 0,
})" :title="typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title" v-on="{
...(!subMenu && {
click: navigate,
}),
}"
>
<div
class="inline-flex flex-1 items-center justify-center gap-[12px]" :class="{
:class="cn('inline-flex flex-1 items-center justify-center gap-[12px] pl-[calc(var(--indent-level)*20px)]', {
'flex-col': rootMenu.isMenuPopup && level === 0 && rootMenu.props.mode === 'vertical',
'gap-1!': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName,
'gap-1': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName,
'w-full': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName && rootMenu.props.mode === 'vertical',
}" :style="indentStyle"
})" :style="{
'--indent-level': !rootMenu.isMenuPopup ? props.level ?? 0 : 0,
}"
>
<FaIcon v-if="props.item.meta?.icon" :name="props.item.meta.icon" class="menu-item-container-icon size-5 transition-transform group-hover-scale-120" />
<span
v-if="!(rootMenu.isMenuPopup && level === 0 && !rootMenu.props.showCollapseName)" class="w-0 flex-1 truncate text-sm transition-height transition-opacity transition-width"
:class="{
v-if="!(rootMenu.isMenuPopup && level === 0 && !rootMenu.props.showCollapseName)" :class="cn('w-0 flex-1 truncate text-sm transition-height transition-opacity transition-width', {
'opacity-0 w-0 h-0': rootMenu.isMenuPopup && level === 0 && !rootMenu.props.showCollapseName,
'w-full text-center': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName,
}"
})"
>
{{ typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title }}
</span>
</div>
<i
v-if="subMenu && !(rootMenu.isMenuPopup && level === 0)" class="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]" :class="[
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]',
rootMenu.isMenuPopup && level === 0 && 'opacity-0',
rootMenu.isMenuPopup && level !== 0 && '-rotate-90 -top-[1.5px]',
]"
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>
+141 -128
View File
@@ -1,6 +1,7 @@
<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'
@@ -15,115 +16,113 @@ const props = withDefaults(
},
)
const index = props.menu.path ?? JSON.stringify(props.menu)
const itemRef = useTemplateRef('itemRef')
const subMenuRef = useTemplateRef('subMenuRef')
const rootMenu = inject(rootMenuInjectionKey)!
const opened = computed(() => {
return rootMenu.openedMenus.includes(props.uniqueKey.at(-1)!)
})
const index = props.menu.path ?? rootMenu.getUseId(props.menu)
const opened = computed(() => rootMenu.openedMenus.includes(props.uniqueKey.at(-1)!))
const transitionEvent = computed(() => {
return rootMenu.isMenuPopup
? {
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 = ''
},
}
: {
enter(el: HTMLElement) {
requestAnimationFrame(() => {
el.dataset.height = el.offsetHeight.toString()
el.style.maxHeight = '0'
void el.offsetHeight
el.style.maxHeight = `${el.dataset.height}px`
el.style.overflow = 'hidden'
})
},
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'
},
leave(el: HTMLElement) {
el.style.maxHeight = '0'
},
afterLeave(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
leaveCancelled(el: HTMLElement) {
el.style.maxHeight = ''
el.style.overflow = ''
},
}
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) {
requestAnimationFrame(() => {
el.dataset.height = el.offsetHeight.toString()
el.style.maxHeight = '0'
void el.offsetHeight
el.style.maxHeight = `${el.dataset.height}px`
el.style.overflow = 'hidden'
})
},
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'
},
leave(el: HTMLElement) {
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(() => {
return rootMenu.isMenuPopup
? {
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',
}
: {
enterActiveClass: 'ease-in-out duration-300',
enterFromClass: 'opacity-0 translate-y-4 scale-95 blur-4',
enterToClass: 'opacity-100 translate-y-0 scale-100 blur-0',
leaveActiveClass: 'ease-in-out duration-300',
leaveFromClass: 'opacity-100 translate-y-0 scale-100 blur-0',
leaveToClass: 'opacity-0 translate-y-4 scale-95 blur-4',
}
})
const hasChildren = computed(() => {
let flag = true
if (props.menu.children) {
if (props.menu.children.every((item: any) => item.meta?.menu === false)) {
flag = false
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',
}
}
else {
flag = false
return {
enterActiveClass: 'ease-in-out duration-300',
enterFromClass: cn('opacity-0 translate-y-4 scale-95 blur-4', CSS.supports('height', 'calc-size(auto, size)') && 'h-0'),
enterToClass: 'opacity-100 translate-y-0 scale-100 blur-0',
leaveActiveClass: 'ease-in-out duration-300',
leaveFromClass: 'opacity-100 translate-y-0 scale-100 blur-0',
leaveToClass: cn('opacity-0 translate-y-4 scale-95 blur-4', CSS.supports('height', 'calc-size(auto, size)') && 'h-0'),
}
return flag
})
const hasChildren = computed(() => props.menu.children?.some((item: any) => item.meta?.menu !== false) ?? false)
function handleClick() {
if (rootMenu.isMenuPopup && hasChildren.value) {
if (
(rootMenu.isMenuPopup && hasChildren.value)
|| props.menu.meta?.link
) {
return
}
if (hasChildren.value) {
rootMenu.handleSubMenuClick(index, props.uniqueKey)
}
else {
rootMenu.handleMenuItemClick(index)
}
requestAnimationFrame(() => {
if (hasChildren.value) {
rootMenu.handleSubMenuClick(index, props.uniqueKey)
}
else {
rootMenu.handleMenuItemClick(index)
}
})
}
let timeout: (() => void) | undefined
@@ -138,32 +137,44 @@ function handleMouseenter() {
if (hasChildren.value) {
rootMenu.openMenu(index, props.uniqueKey)
nextTick(() => {
const el = itemRef.value?.ref
const subMenuEl = subMenuRef.value?.$el
if (!el || !subMenuEl) {
return
}
let top = 0
let left = 0
if (rootMenu.props.mode === 'vertical' || props.level !== 0) {
top = el.getBoundingClientRect().top + el.scrollTop
left = el.getBoundingClientRect().left + el.getBoundingClientRect().width
if (top + subMenuEl.offsetHeight > window.innerHeight) {
top = window.innerHeight - subMenuEl.offsetHeight
requestAnimationFrame(() => {
const el = itemRef.value?.ref
const subMenuEl = subMenuRef.value?.$el
if (!el || !subMenuEl) {
return
}
}
else {
top = el.getBoundingClientRect().top + el.getBoundingClientRect().height
left = el.getBoundingClientRect().left
if (top + subMenuEl.offsetHeight > window.innerHeight) {
subMenuEl.style.height = `${window.innerHeight - top}px`
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
menuLeft = left + width
// 处理边界情况
if (menuTop + subMenuEl.offsetHeight > window.innerHeight) {
menuTop = Math.max(0, window.innerHeight - subMenuEl.offsetHeight)
}
}
}
if (left + subMenuEl.offsetWidth > document.documentElement.clientWidth) {
left = el.getBoundingClientRect().left - el.getBoundingClientRect().width
}
subMenuEl.style.top = `${top}px`
subMenuEl.style.insetInlineStart = `${left}px`
else {
menuTop = top + height
menuLeft = left
// 处理边界情况
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 {
@@ -180,14 +191,16 @@ function handleMouseleave() {
rootMenu.mouseInMenu = []
timeout?.()
;({ stop: timeout } = useTimeoutFn(() => {
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)!)
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>
@@ -197,15 +210,15 @@ function handleMouseleave() {
<Teleport v-if="hasChildren" to="body" :disabled="!rootMenu.isMenuPopup">
<Transition v-bind="transitionClass" v-on="transitionEvent">
<FaScrollArea
v-if="opened" ref="subMenuRef" :scrollbar="false" :mask="rootMenu.isMenuPopup" class="sub-menu static rounded-lg" :class="{
v-if="opened" ref="subMenuRef" :scrollbar="false" :mask="rootMenu.isMenuPopup" :class="cn('sub-menu static h-[calc-size(auto,size)] rounded-lg will-change-transform', {
'bg-[var(--g-sub-sidebar-bg)]': rootMenu.isMenuPopup,
'border shadow-xl fixed! z-3000 w-[200px]': rootMenu.isMenuPopup,
'border shadow-xl fixed z-3000 w-[200px]': rootMenu.isMenuPopup,
'mx-1': rootMenu.isMenuPopup && (rootMenu.props.mode === 'vertical' || level !== 0),
'py-1': rootMenu.isMenuPopup,
}"
})"
>
<template v-for="item in menu.children" :key="item.path ?? JSON.stringify(item)">
<SubMenu v-if="item.meta?.menu !== false" :unique-key="[...uniqueKey, item.path ?? JSON.stringify(item)]" :menu="item" :level="level + 1" />
<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>
+1
View File
@@ -18,6 +18,7 @@ export interface MenuProps {
export interface MenuInjection {
props: MenuProps
getUseId: (obj: object) => string
items: Record<string, MenuItem>
subMenus: Record<string, MenuItem>
activeIndex: MenuProps['value']
+5 -3
View File
@@ -1,7 +1,5 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import useMenuStore from '@/store/modules/menu'
import useSettingsStore from '@/store/modules/settings'
import Logo from '../Logo/index.vue'
import Menu from '../Menu/index.vue'
@@ -62,7 +60,7 @@ watch(() => menuStore.actived, (val, oldVal) => {
<div v-show="mainIndex === menuStore.actived">
<Menu
:menu="mainItem.children" :value="route.meta.activeMenu || route.path" :default-openeds="menuStore.defaultOpenedPaths" :accordion="settingsStore.settings.menu.subMenuUniqueOpened" :collapse="settingsStore.mode === 'pc' && settingsStore.settings.menu.subMenuCollapse" class="menu" :class="{
'-mt-2': !['head', 'single'].includes(settingsStore.settings.menu.mode),
'-mt-2': !((settingsStore.mode === 'pc' && settingsStore.settings.menu.subMenuCollapse) || ['head', 'single'].includes(settingsStore.settings.menu.mode)),
}"
/>
</div>
@@ -74,6 +72,10 @@ watch(() => menuStore.actived, (val, oldVal) => {
<FaIcon name="toolbar-collapse" class="size-4" />
</FaButton>
</div>
<component :is="useSlots('sub-sidebar-after-menu')" />
<div v-if="settingsStore.settings.menu.mode === 'single'" class="flex-center px-4 pb-3">
<AccountButton :only-avatar="settingsStore.settings.menu.subMenuCollapse" dropdown-align="center" :dropdown-side="settingsStore.settings.menu.subMenuCollapse ? 'right' : 'top'" button-variant="secondary" :class="{ 'w-full': !settingsStore.settings.menu.subMenuCollapse }" />
</div>
<component :is="useSlots('sub-sidebar-bottom')" />
</div>
</Transition>
+17 -6
View File
@@ -1,11 +1,9 @@
<script setup lang="ts">
import type { Tabbar } from '#/global'
import { useSlots } from '@/slots'
import useSettingsStore from '@/store/modules/settings'
import useTabbarStore from '@/store/modules/tabbar'
import { useMagicKeys } from '@vueuse/core'
import hotkeys from 'hotkeys-js'
import { toast } from 'vue-sonner'
import { useSlots } from '@/slots'
defineOptions({
name: 'Tabbar',
@@ -30,10 +28,20 @@ const tabRef = useTemplateRef<HTMLElement[]>('tabRef')
watch(() => route, (val) => {
if (settingsStore.settings.tabbar.enable) {
tabbarStore.add(val).then(() => {
tabbarStore.add(val)
nextTick(() => {
const index = tabbarStore.list.findIndex(item => item.tabId === activedTabId.value)
if (index !== -1) {
tabRef.value && tabsRef.value?.scrollTo(tabRef.value[index].offsetLeft - tabsRef.value.ref?.$el.clientWidth * 0.4)
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)
}
tabbarScrollTip()
}
})
@@ -75,16 +83,19 @@ function contextMenuItems(routeItem: Tabbar.recordRaw) {
[
{
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),
},
@@ -147,7 +158,7 @@ onUnmounted(() => {
<div class="tabbar">
<component :is="useSlots('tabbar-start')" />
<div class="tabbar-container">
<FaScrollArea ref="tabsRef" horizontal :scrollbar="false" mask gradient-color="var(--g-tabbar-bg)" class="tabs">
<FaScrollArea ref="tabsRef" :scrollbar="false" mask horizontal gradient-color="var(--g-tabbar-bg)" class="tabs">
<TransitionGroup ref="tabContainerRef" name="tabbar" tag="div" class="tab-container">
<div
v-for="(element, index) in tabbarStore.list" :key="element.tabId"
@@ -1,5 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
import { compile } from 'path-to-regexp'
import Breadcrumb from '../../../Breadcrumb/index.vue'
import BreadcrumbItem from '../../../Breadcrumb/item.vue'
@@ -8,10 +7,11 @@ const route = useRoute()
const settingsStore = useSettingsStore()
const breadcrumbList = ref<{ path: string, title: string }[]>([])
watch(() => route.matched, () => {
// 面包屑备份
let breadcrumbListBackup: any = []
const breadcrumbList = computed(() => {
if (route.name === 'reload') {
return
return breadcrumbListBackup
}
const list = []
if (settingsStore.settings.home.enable) {
@@ -28,9 +28,8 @@ watch(() => route.matched, () => {
})
}
})
breadcrumbList.value = list
}, {
immediate: true,
breadcrumbListBackup = list
return list
})
function pathCompile(path: string) {
@@ -1,6 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
defineOptions({
name: 'ColorScheme',
})
@@ -8,28 +6,33 @@ defineOptions({
const settingsStore = useSettingsStore()
function toggleColorScheme(event: MouseEvent) {
const { startViewTransition } = useViewTransition(() => {
if (!document.startViewTransition || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
settingsStore.currentColorScheme && settingsStore.setColorScheme(settingsStore.currentColorScheme === 'dark' ? 'light' : 'dark')
return
}
const x = event.clientX
const y = event.clientY
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
)
const transition = document.startViewTransition(async () => {
settingsStore.currentColorScheme && settingsStore.setColorScheme(settingsStore.currentColorScheme === 'dark' ? 'light' : 'dark')
await nextTick()
})
startViewTransition()?.ready.then(() => {
const x = event.clientX
const y = event.clientY
const endRadius = Math.hypot(
Math.max(x, innerWidth - x),
Math.max(y, innerHeight - y),
)
transition.ready.then(() => {
const clipPath = [
`circle(0px at ${x}px ${y}px)`,
`circle(${endRadius}px at ${x}px ${y}px)`,
]
document.documentElement.animate(
{
clipPath: settingsStore.settings.app.colorScheme !== 'dark' ? clipPath : clipPath.reverse(),
clipPath: settingsStore.currentColorScheme === 'light' ? clipPath : clipPath.reverse(),
},
{
duration: 300,
easing: 'ease-out',
pseudoElement: settingsStore.settings.app.colorScheme !== 'dark' ? '::view-transition-new(root)' : '::view-transition-old(root)',
duration: 500,
easing: 'ease-in-out',
pseudoElement: settingsStore.currentColorScheme === 'light' ? '::view-transition-new(root)' : '::view-transition-old(root)',
},
)
})
@@ -37,7 +40,7 @@ function toggleColorScheme(event: MouseEvent) {
</script>
<template>
<FaButton variant="ghost" size="icon" @click="toggleColorScheme">
<FaButton variant="ghost" size="icon" class="size-9" @click="toggleColorScheme">
<FaIcon
:name="{
light: 'i-ri:sun-line',
@@ -1,5 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
import { useFullscreen } from '@vueuse/core'
defineOptions({
@@ -12,7 +11,7 @@ const { isFullscreen, toggle } = useFullscreen()
</script>
<template>
<FaButton v-if="settingsStore.mode === 'pc'" variant="ghost" size="icon" @click="toggle">
<FaButton v-if="settingsStore.mode === 'pc'" variant="ghost" size="icon" class="size-9" @click="toggle">
<FaIcon :name="isFullscreen ? 'i-ri:fullscreen-exit-line' : 'i-ri:fullscreen-line'" class="size-4" />
</FaButton>
</template>
@@ -1,5 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
import Search from './search.vue'
defineOptions({
@@ -12,7 +11,7 @@ const isShow = ref(false)
</script>
<template>
<FaButton :variant="settingsStore.mode === 'pc' ? 'outline' : 'ghost'" :size="settingsStore.mode === 'pc' ? undefined : 'icon'" :class="{ 'mx-2 px-3': settingsStore.mode === 'pc' }" @click="isShow = true">
<FaButton :variant="settingsStore.mode === 'pc' ? 'outline' : 'ghost'" :size="settingsStore.mode === 'pc' ? undefined : 'icon'" :class="{ 'mx-2 px-3 h-9': settingsStore.mode === 'pc', 'size-9': settingsStore.mode !== 'pc' }" @click="isShow = true">
<FaIcon name="i-ri:search-line" class="size-4" />
<template v-if="settingsStore.mode === 'pc'">
<span class="text-sm text-muted-foreground/60 transition group-hover-text-muted-foreground">搜索</span>
@@ -1,16 +1,13 @@
<script setup lang="ts">
import type { Menu } from '@/types/global'
import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
import useMenuStore from '@/store/modules/menu'
import useRouteStore from '@/store/modules/route'
import useSettingsStore from '@/store/modules/settings'
import { resolveRoutePath } from '@/utils'
import { useFocus } from '@vueuse/core'
import hotkeys from 'hotkeys-js'
import Breadcrumb from '@/layouts/components/Breadcrumb/index.vue'
import BreadcrumbItem from '@/layouts/components/Breadcrumb/item.vue'
import { resolveRoutePath } from '@/utils'
defineOptions({
name: 'Search',
name: 'NavSearchModal',
})
const isShow = defineModel<boolean>({
@@ -1,5 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
import hotkeys from 'hotkeys-js'
defineOptions({
@@ -41,7 +40,7 @@ function handleCtrlClick() {
<p>可切换为浏览器原生刷新</p>
</div>
</template>
<FaButton variant="ghost" size="icon" @click.exact="handleClick" @click.ctrl.exact="handleCtrlClick" @animationend="isAnimating = false">
<FaButton variant="ghost" size="icon" class="size-9" @click.exact="handleClick" @click.ctrl.exact="handleCtrlClick" @animationend="isAnimating = false">
<FaIcon name="i-iconoir:refresh-double" class="size-4" :class="{ animation: isAnimating }" />
</FaButton>
</FaTooltip>
@@ -1,13 +1,10 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
import LeftSide from './leftSide.vue'
import RightSide from './rightSide.vue'
defineOptions({
name: 'Toolbar',
})
const settingsStore = useSettingsStore()
</script>
<template>
@@ -15,7 +12,7 @@ const settingsStore = useSettingsStore()
<div class="left-side h-full flex items-center of-hidden pe-16 ps-2">
<LeftSide />
</div>
<div v-if="['side', 'single'].includes(settingsStore.settings.menu.mode) || settingsStore.mode === 'mobile'" class="h-full flex flex-shrink-0 items-center justify-end px-2">
<div class="h-full flex flex-shrink-0 items-center justify-end px-2">
<RightSide />
</div>
</div>
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import useSettingsStore from '@/store/modules/settings'
import Breadcrumb from './Breadcrumb/index.vue'
defineOptions({
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import useSettingsStore from '@/store/modules/settings'
import ColorScheme from './ColorScheme/index.vue'
import Fullscreen from './Fullscreen/index.vue'
import NavSearch from './NavSearch/index.vue'
+12 -12
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
import { useElementSize } from '@vueuse/core'
import Tabbar from './Tabbar/index.vue'
import Toolbar from './Toolbar/index.vue'
@@ -10,20 +10,20 @@ defineOptions({
const settingsStore = useSettingsStore()
const enableToolbar = computed(() => {
return !(
settingsStore.settings.menu.mode === 'head' && (
!settingsStore.settings.toolbar.breadcrumb || settingsStore.settings.app.routeBaseOn === 'filesystem'
)
)
return settingsStore.settings.toolbar.enable && Object.keys(settingsStore.settings.toolbar).some((key) => {
if (settingsStore.settings.app.routeBaseOn === 'filesystem' && key === 'breadcrumb') {
return false
}
return settingsStore.settings.toolbar[key as keyof typeof settingsStore.settings.toolbar]
})
})
const scrollTop = ref(0)
const scrollOnHide = ref(false)
const topbarHeight = computed(() => {
const tabbarHeight = settingsStore.settings.tabbar.enable ? Number.parseInt(getComputedStyle(document.documentElement || document.body).getPropertyValue('--g-tabbar-height')) : 0
const toolbarHeight = enableToolbar.value ? Number.parseInt(getComputedStyle(document.documentElement || document.body).getPropertyValue('--g-toolbar-height')) : 0
return tabbarHeight + toolbarHeight
})
const topbarRef = useTemplateRef('topbarRef')
const { height: topbarHeight } = useElementSize(topbarRef)
onMounted(() => {
window.addEventListener('scroll', onScroll)
})
@@ -41,7 +41,7 @@ watch(scrollTop, (val, oldVal) => {
<template>
<FaSmartFixedBlock position="top" :class="{ 'absolute!': settingsStore.settings.topbar.mode === 'static' }">
<div
class="topbar-container transition-[transform,box-shadow]-300" :class="{
ref="topbarRef" class="topbar-container transition-[transform,box-shadow]-300" :class="{
[`topbar-${settingsStore.settings.topbar.mode}`]: true,
mask: scrollTop,
hide: scrollOnHide,
+30 -42
View File
@@ -1,8 +1,5 @@
<script setup lang="ts">
import { useSlots } from '@/slots'
import useKeepAliveStore from '@/store/modules/keepAlive'
import useMenuStore from '@/store/modules/menu'
import useSettingsStore from '@/store/modules/settings'
import eventBus from '@/utils/eventBus'
import AppSetting from './components/AppSetting/index.vue'
import Header from './components/Header/index.vue'
@@ -22,46 +19,36 @@ const settingsStore = useSettingsStore()
const keepAliveStore = useKeepAliveStore()
const menuStore = useMenuStore()
// 头部当前实际高度
const headerActualHeight = computed(() => {
let actualHeight = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-header-height'))
if (['single', 'side'].includes(settingsStore.settings.menu.mode) || settingsStore.mode === 'mobile') {
actualHeight = 0
}
return actualHeight
// 头部是否隐藏
const isHeaderHide = computed(() => {
return ['single', 'side'].includes(settingsStore.settings.menu.mode) || settingsStore.mode === 'mobile'
})
// 侧边栏主导航当前实际宽度
const mainSidebarActualWidth = computed(() => {
let actualWidth = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-main-sidebar-width'))
if (settingsStore.settings.menu.mode === 'single' || (settingsStore.settings.menu.mode === 'head' && settingsStore.mode !== 'mobile')) {
actualWidth = 0
}
return actualWidth
// 侧边栏主导航是否隐藏
const isMainSidebarHide = computed(() => {
return settingsStore.settings.menu.mode === 'single'
|| (settingsStore.settings.menu.mode === 'head' && settingsStore.mode !== 'mobile')
})
// 侧边栏次导航当前实际宽度
const subSidebarActualWidth = computed(() => {
let actualWidth = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-sub-sidebar-width'))
if (settingsStore.settings.menu.subMenuCollapse && settingsStore.mode !== 'mobile') {
actualWidth = Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-sub-sidebar-collapse-width'))
}
if (menuStore.sidebarMenus.every(item => item.meta?.menu === false)) {
actualWidth = 0
}
return actualWidth
// 侧边栏次导航是否隐藏
const isSubSidebarHide = computed(() => {
return menuStore.sidebarMenus.every(item => item.meta?.menu === false)
})
// 顶栏当前实际高度
const topbarActualHeight = computed(() => {
let actualHeight = 0
if (settingsStore.settings.tabbar.enable) {
actualHeight += Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-tabbar-height'))
}
if (!['head'].includes(settingsStore.settings.menu.mode) || settingsStore.settings.toolbar.breadcrumb) {
actualHeight += Number.parseInt(getComputedStyle(document.documentElement).getPropertyValue('--g-toolbar-height'))
}
return actualHeight
// 标签栏是否隐藏
const isTabbarHide = computed(() => {
return !settingsStore.settings.tabbar.enable
})
// 工具栏是否隐藏
const isToolbarHide = computed(() => {
return !settingsStore.settings.toolbar.enable
|| !Object.keys(settingsStore.settings.toolbar).some((key) => {
if (settingsStore.settings.app.routeBaseOn === 'filesystem' && key === 'breadcrumb') {
return false
}
return settingsStore.settings.toolbar[key as keyof typeof settingsStore.settings.toolbar]
})
})
const isLink = computed(() => !!routeInfo.meta.link)
@@ -91,10 +78,11 @@ const enableAppSetting = import.meta.env.VITE_APP_SETTING
<template>
<div
class="layout" :style="{
'--g-header-actual-height': `${headerActualHeight}px`,
'--g-main-sidebar-actual-width': `${mainSidebarActualWidth}px`,
'--g-sub-sidebar-actual-width': `${subSidebarActualWidth}px`,
'--g-topbar-actual-height': `${topbarActualHeight}px`,
'--g-header-actual-height': isHeaderHide ? '0px' : 'var(--g-header-height)',
'--g-main-sidebar-actual-width': isMainSidebarHide ? '0px' : 'var(--g-main-sidebar-width)',
'--g-sub-sidebar-actual-width': isSubSidebarHide ? '0px' : (settingsStore.settings.menu.subMenuCollapse && settingsStore.mode !== 'mobile' ? 'var(--g-sub-sidebar-collapse-width)' : 'var(--g-sub-sidebar-width)'),
'--g-tabbar-actual-height': isTabbarHide ? '0px' : 'var(--g-tabbar-height)',
'--g-toolbar-actual-height': isToolbarHide ? '0px' : 'var(--g-toolbar-height)',
}"
>
<div id="app-main">
@@ -209,7 +197,7 @@ const enableAppSetting = import.meta.env.VITE_APP_SETTING
position: relative;
flex: auto;
height: 100%;
margin: var(--g-topbar-actual-height) 0 0;
margin: calc(var(--g-tabbar-actual-height) + var(--g-toolbar-actual-height)) 0 0;
overflow: hidden;
}
}
+2 -4
View File
@@ -1,22 +1,20 @@
// 加载 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/systemCopyright'
// 加载 svg 图标
import 'virtual:svg-icons-register'
import 'virtual:uno.css'
// UnoCSS
import '@unocss/reset/tailwind-compat.css'
import 'virtual:uno.css'
// 全局样式
import '@/assets/styles/globals.css'
-3
View File
@@ -18,7 +18,6 @@ export default defineFakeRoute([
{
path: '/multilevel_menu_example',
component: 'Layout',
redirect: '/multilevel_menu_example/page',
name: 'multilevelMenuExample',
meta: {
title: '多级导航',
@@ -36,7 +35,6 @@ export default defineFakeRoute([
{
path: 'level2',
name: 'multilevelMenuExample2',
redirect: '/multilevel_menu_example/level2/page',
meta: {
title: '导航2',
},
@@ -52,7 +50,6 @@ export default defineFakeRoute([
{
path: 'level3',
name: 'multilevelMenuExample2-2',
redirect: '/multilevel_menu_example/level2/level3/page1',
meta: {
title: '导航2-2',
},
+84
View File
@@ -0,0 +1,84 @@
import type { 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 settingsStore = useSettingsStore(pinia)
if (settingsStore.settings.tabbar.enable) {
const tabbarStore = useTabbarStore(pinia)
const index = tabbarStore.list.findIndex(item => item.tabId === getId(router))
tabbarStore.$patch({
leaveIndex: index,
})
}
return originalPush(to)
}
}
function extendGo(router: Router) {
const originalGo = router.go
router.go = function (delta: number) {
const settingsStore = useSettingsStore(pinia)
if (settingsStore.settings.tabbar.enable) {
const tabId = getId(router)
const tabbarStore = useTabbarStore(pinia)
originalGo(delta)
if (delta < 0) {
tabbarStore.remove(tabId)
}
}
else {
originalGo(delta)
}
}
}
function extendReplace(router: Router) {
const originalReplace = router.replace
router.replace = function (to: RouteLocationRaw) {
const settingsStore = useSettingsStore(pinia)
if (settingsStore.settings.tabbar.enable) {
const tabId = getId(router)
const tabbarStore = useTabbarStore(pinia)
return originalReplace(to).then(() => {
tabbarStore.remove(tabId)
})
}
else {
return originalReplace(to)
}
}
}
declare module 'vue-router' {
interface Router {
/**
* 本方法为框架扩展语法,等同于 `push` 方法,并且同时会关闭当前标签页
*/
close: (to: RouteLocationRaw) => Promise<NavigationFailure | void | undefined>
}
}
function extendClose(router: Router) {
router.close = function (to: RouteLocationRaw) {
const settingsStore = useSettingsStore(pinia)
if (settingsStore.settings.tabbar.enable) {
const tabId = getId(router)
const tabbarStore = useTabbarStore(pinia)
tabbarStore.remove(tabId)
}
return router.push(to)
}
}
export default function setupExtensions(router: Router) {
extendPush(router)
extendGo(router)
extendReplace(router)
extendClose(router)
}
+25 -7
View File
@@ -1,9 +1,4 @@
import type { Router, RouteRecordRaw } from 'vue-router'
import useKeepAliveStore from '@/store/modules/keepAlive'
import useMenuStore from '@/store/modules/menu'
import useRouteStore from '@/store/modules/route'
import useSettingsStore from '@/store/modules/settings'
import useUserStore from '@/store/modules/user'
import { useNProgress } from '@vueuse/integrations/useNProgress'
import { asyncRoutes, asyncRoutesByFilesystem } from './routes'
import '@/assets/styles/nprogress.css'
@@ -47,7 +42,7 @@ function setupRoutes(router: Router) {
}
else {
try {
// 获取用户权限
// 获取用户权限
settingsStore.settings.app.enablePermission && await userStore.getPermissions()
// 生成动态路由
switch (settingsStore.settings.app.routeBaseOn) {
@@ -85,7 +80,9 @@ function setupRoutes(router: Router) {
}
routeStore.setCurrentRemoveRoutes(removeRoutes)
}
catch {}
catch {
userStore.logout()
}
// 动态路由生成并注册后,重新进入当前路由
next({
path: to.path,
@@ -110,6 +107,26 @@ function setupRoutes(router: Router) {
})
}
// 当父级路由未配置重定向时,自动重定向到有访问权限的子路由
function setupRedirectAuthChildrenRoute(router: Router) {
router.beforeEach((to, _from, next) => {
const { auth } = useAuth()
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) {
next(findAuthRoute)
}
else {
next()
}
}
else {
next()
}
})
}
// 进度条
function setupProgress(router: Router) {
const { isLoading } = useNProgress()
@@ -206,6 +223,7 @@ function setupOther(router: Router) {
export default function setupGuards(router: Router) {
setupRoutes(router)
setupRedirectAuthChildrenRoute(router)
setupProgress(router)
setupTitle(router)
setupKeepAlive(router)
+3 -3
View File
@@ -1,8 +1,7 @@
import pinia from '@/store'
import useSettingsStore from '@/store/modules/settings'
import { loadingFadeOut } from 'virtual:app-loading'
import { createRouter, createWebHashHistory } from 'vue-router'
import pinia from '@/store'
import setupExtensions from './extensions'
import setupGuards from './guards'
// 路由相关数据
import { constantRoutes, constantRoutesByFilesystem } from './routes'
@@ -13,6 +12,7 @@ const router = createRouter({
})
setupGuards(router)
setupExtensions(router)
router.isReady().then(() => {
loadingFadeOut()
@@ -7,7 +7,6 @@ function Layout() {
const routes: RouteRecordRaw = {
path: '/multilevel_menu_example',
component: Layout,
redirect: '/multilevel_menu_example/page',
name: 'multilevelMenuExample',
meta: {
title: '多级导航',
@@ -25,7 +24,6 @@ const routes: RouteRecordRaw = {
{
path: 'level2',
name: 'multilevelMenuExample2',
redirect: '/multilevel_menu_example/level2/page',
meta: {
title: '导航2',
},
@@ -41,7 +39,6 @@ const routes: RouteRecordRaw = {
{
path: 'level3',
name: 'multilevelMenuExample2-2',
redirect: '/multilevel_menu_example/level2/level3/page1',
meta: {
title: '导航2-2',
},
-1
View File
@@ -1,6 +1,5 @@
import type { Route } from '#/global'
import type { RouteRecordRaw } from 'vue-router'
import useSettingsStore from '@/store/modules/settings'
import generatedRoutes from 'virtual:generated-pages'
import { setupLayouts } from 'virtual:meta-layouts'
import MultilevelMenuExample from './modules/multilevel.menu.example'
+1
View File
@@ -39,6 +39,7 @@ const globalSettingsDefault: RecursiveRequired<Settings.all> = {
enableHotkeys: false,
},
toolbar: {
enable: true,
breadcrumb: true,
navSearch: true,
fullscreen: false,
+1 -1
View File
@@ -1,7 +1,7 @@
import type { RecursiveRequired, Settings } from '#/global'
import { cloneDeep } from 'es-toolkit'
import settingsDefault from '@/settings.default'
import { merge } from '@/utils/object'
import { cloneDeep } from 'es-toolkit'
const globalSettings: Settings.all = {
// 请在此处编写或粘贴配置代码
+7 -7
View File
@@ -1,12 +1,12 @@
import { pascalCase } from 'scule'
type Slots =
'header-start' | 'header-end' | 'header-after-logo' |
'main-sidebar-top' | 'main-sidebar-bottom' | 'main-sidebar-after-logo' |
'sub-sidebar-top' | 'sub-sidebar-bottom' | 'sub-sidebar-after-logo' |
'tabbar-start' | 'tabbar-end' |
'toolbar-start' | 'toolbar-end' |
'free-position'
type Slots
= '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 })
Executable → Regular
View File
+1 -3
View File
@@ -1,4 +1,4 @@
const useKeepAliveStore = defineStore(
export const useKeepAliveStore = defineStore(
// 唯一ID
'keepAlive',
() => {
@@ -38,5 +38,3 @@ const useKeepAliveStore = defineStore(
}
},
)
export default useKeepAliveStore
+7 -36
View File
@@ -1,19 +1,15 @@
import type { Menu, Route } from '#/global'
import type { RouteRecordRaw } from 'vue-router'
import { cloneDeep } from 'es-toolkit'
import apiApp from '@/api/modules/app'
import menu from '@/menu'
import { resolveRoutePath } from '@/utils'
import { cloneDeep } from 'es-toolkit'
import useRouteStore from './route'
import useSettingsStore from './settings'
import useUserStore from './user'
const useMenuStore = defineStore(
export const useMenuStore = defineStore(
// 唯一ID
'menu',
() => {
const settingsStore = useSettingsStore()
const userStore = useUserStore()
const routeStore = useRouteStore()
const filesystemMenusRaw = ref<Menu.recordMainRaw[]>([])
@@ -78,10 +74,7 @@ const useMenuStore = defineStore(
else {
returnMenus = filesystemMenusRaw.value
}
// 如果权限功能开启,则需要对导航数据进行筛选过滤
if (settingsStore.settings.app.enablePermission) {
returnMenus = filterAsyncMenus(returnMenus, userStore.permissions)
}
returnMenus = filterAsyncMenus(returnMenus)
return returnMenus
})
// 次导航数据
@@ -155,35 +148,15 @@ const useMenuStore = defineStore(
return defaultOpenedPaths
}
// 判断是否有权限
function hasPermission(permissions: string[], menu: Menu.recordMainRaw | Menu.recordRaw) {
let isAuth = false
if (menu.meta?.auth) {
isAuth = permissions.some((auth) => {
if (typeof menu.meta?.auth === 'string') {
return menu.meta.auth !== '' ? menu.meta.auth === auth : true
}
else if (typeof menu.meta?.auth === 'object') {
return menu.meta.auth.length > 0 ? menu.meta.auth.includes(auth) : true
}
else {
return false
}
})
}
else {
isAuth = true
}
return isAuth
}
const auth = useAuth()
// 根据权限过滤导航
function filterAsyncMenus<T extends Menu.recordMainRaw[] | Menu.recordRaw[]>(menus: T, permissions: string[]): T {
function filterAsyncMenus<T extends Menu.recordMainRaw[] | Menu.recordRaw[]>(menus: T): T {
const res: any = []
menus.forEach((menu) => {
if (hasPermission(permissions, menu)) {
if (auth.auth(menu.meta?.auth ?? '')) {
const tmpMenu = cloneDeep(menu)
if (tmpMenu.children && tmpMenu.children.length > 0) {
tmpMenu.children = filterAsyncMenus(tmpMenu.children, permissions) as Menu.recordRaw[]
tmpMenu.children = filterAsyncMenus(tmpMenu.children) as Menu.recordRaw[]
tmpMenu.children.length > 0 && res.push(tmpMenu)
}
else {
@@ -242,5 +215,3 @@ const useMenuStore = defineStore(
}
},
)
export default useMenuStore
+4 -7
View File
@@ -1,12 +1,11 @@
import type { Route } from '#/global'
import type { RouteRecordRaw, RouterMatcher } from 'vue-router'
import apiApp from '@/api/modules/app'
import { systemRoutes as systemRoutesRaw } from '@/router/routes'
import { cloneDeep } from 'es-toolkit'
import { createRouterMatcher } from 'vue-router'
import useSettingsStore from './settings'
import apiApp from '@/api/modules/app'
import { systemRoutes as systemRoutesRaw } from '@/router/routes'
const useRouteStore = defineStore(
export const useRouteStore = defineStore(
// 唯一ID
'route',
() => {
@@ -131,7 +130,7 @@ const useRouteStore = defineStore(
})
routesMatcher.value = createRouterMatcher(routes, {})
isGenerate.value = true
}).catch(() => {})
})
}
// 生成路由(文件系统生成)
function generateRoutesAtFilesystem(asyncRoutes: RouteRecordRaw[]) {
@@ -169,5 +168,3 @@ const useRouteStore = defineStore(
}
},
)
export default useRouteStore
+2 -4
View File
@@ -1,10 +1,10 @@
import type { Settings } from '#/global'
import type { RouteMeta } from 'vue-router'
import { cloneDeep } from 'es-toolkit'
import settingsDefault from '@/settings'
import { merge } from '@/utils/object'
import { cloneDeep } from 'es-toolkit'
const useSettingsStore = defineStore(
export const useSettingsStore = defineStore(
// 唯一ID
'settings',
() => {
@@ -172,5 +172,3 @@ const useSettingsStore = defineStore(
}
},
)
export default useSettingsStore
+2 -5
View File
@@ -1,8 +1,7 @@
import type { Tabbar } from '#/global'
import type { RouteLocationNormalized } from 'vue-router'
import useKeepAliveStore from './keepAlive'
const useTabbarStore = defineStore(
export const useTabbarStore = defineStore(
// 唯一ID
'tabbar',
() => {
@@ -12,7 +11,7 @@ const useTabbarStore = defineStore(
const leaveIndex = ref(-1)
// 添加标签页
async function add(route: RouteLocationNormalized) {
function add(route: RouteLocationNormalized) {
const names: string[] = []
route.matched.forEach((v, i) => {
if (i > 0) {
@@ -159,5 +158,3 @@ const useTabbarStore = defineStore(
}
},
)
export default useTabbarStore
+1 -7
View File
@@ -1,11 +1,7 @@
import apiUser from '@/api/modules/user'
import router from '@/router'
import useMenuStore from './menu'
import useRouteStore from './route'
import useSettingsStore from './settings'
import useTabbarStore from './tabbar'
const useUserStore = defineStore(
export const useUserStore = defineStore(
// 唯一ID
'user',
() => {
@@ -109,5 +105,3 @@ const useUserStore = defineStore(
}
},
)
export default useUserStore
Vendored Executable → Regular
+7 -2
View File
@@ -71,16 +71,21 @@ declare global {
const useCssVars: typeof import('vue')['useCssVars']
const useGlobalProperties: typeof import('../utils/composables/useGlobalProperties')['default']
const useId: typeof import('vue')['useId']
const useKeepAliveStore: typeof import('../store/modules/keepAlive')['useKeepAliveStore']
const useLink: typeof import('vue-router')['useLink']
const useMainPage: typeof import('../utils/composables/useMainPage')['default']
const useMenu: typeof import('../utils/composables/useMenu')['default']
const useMenuStore: typeof import('../store/modules/menu')['useMenuStore']
const useModel: typeof import('vue')['useModel']
const useRoute: typeof import('vue-router')['useRoute']
const useRouteStore: typeof import('../store/modules/route')['useRouteStore']
const useRouter: typeof import('vue-router')['useRouter']
const useSettingsStore: typeof import('../store/modules/settings')['useSettingsStore']
const useSlots: typeof import('vue')['useSlots']
const useTabbar: typeof import('../utils/composables/useTabbar')['default']
const useTabbarStore: typeof import('../store/modules/tabbar')['useTabbarStore']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useViewTransition: typeof import('../utils/composables/useViewTransition')['default']
const useUserStore: typeof import('../store/modules/user')['useUserStore']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
@@ -89,6 +94,6 @@ declare global {
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+6 -1
View File
@@ -8,10 +8,12 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AccountButton: typeof import('./../components/AccountButton/index.vue')['default']
FaAuth: typeof import('./../ui/components/FaAuth/index.vue')['default']
FaAvatar: typeof import('./../ui/components/FaAvatar/index.vue')['default']
FaBackToTop: typeof import('./../ui/components/FaBackToTop/index.vue')['default']
FaButton: typeof import('./../ui/components/FaButton/index.vue')['default']
FaButtonGroup: typeof import('./../ui/components/FaButtonGroup/index.vue')['default']
FaCard: typeof import('./../ui/components/FaCard/index.vue')['default']
FaCheckbox: typeof import('./../ui/components/FaCheckbox/index.vue')['default']
FaContextMenu: typeof import('./../ui/components/FaContextMenu/index.vue')['default']
@@ -20,7 +22,9 @@ declare module 'vue' {
FaDrawer: typeof import('./../ui/components/FaDrawer/index.vue')['default']
FaDropdown: typeof import('./../ui/components/FaDropdown/index.vue')['default']
FaFixedActionBar: typeof import('./../ui/components/FaFixedActionBar/index.vue')['default']
FaHoverCard: typeof import('./../ui/components/FaHoverCard/index.vue')['default']
FaIcon: typeof import('./../ui/components/FaIcon/index.vue')['default']
FaImagePreview: typeof import('./../ui/components/FaImagePreview/index.vue')['default']
FaInput: typeof import('./../ui/components/FaInput/index.vue')['default']
FaKbd: typeof import('./../ui/components/FaKbd/index.vue')['default']
FaModal: typeof import('./../ui/components/FaModal/index.vue')['default']
@@ -31,9 +35,11 @@ declare module 'vue' {
FaPasswordStrength: typeof import('./../ui/components/FaPasswordStrength/index.vue')['default']
FaPinInput: typeof import('./../ui/components/FaPinInput/index.vue')['default']
FaPopover: typeof import('./../ui/components/FaPopover/index.vue')['default']
FaProgress: typeof import('./../ui/components/FaProgress/index.vue')['default']
FaScrollArea: typeof import('./../ui/components/FaScrollArea/index.vue')['default']
FaSearchBar: typeof import('./../ui/components/FaSearchBar/index.vue')['default']
FaSelect: typeof import('./../ui/components/FaSelect/index.vue')['default']
FaSlider: typeof import('./../ui/components/FaSlider/index.vue')['default']
FaSmartFixedBlock: typeof import('./../ui/components/FaSmartFixedBlock/index.vue')['default']
FaSwitch: typeof import('./../ui/components/FaSwitch/index.vue')['default']
FaSystemInfo: typeof import('./../ui/components/FaSystemInfo/index.vue')['default']
@@ -41,7 +47,6 @@ declare module 'vue' {
FaToast: typeof import('./../ui/components/FaToast/index.vue')['default']
FaTooltip: typeof import('./../ui/components/FaTooltip/index.vue')['default']
FileUpload: typeof import('./../components/FileUpload/index.vue')['default']
ImagePreview: typeof import('./../components/ImagePreview/index.vue')['default']
ImagesUpload: typeof import('./../components/ImagesUpload/index.vue')['default']
ImageUpload: typeof import('./../components/ImageUpload/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
+7 -2
View File
@@ -19,9 +19,9 @@ declare namespace Settings {
/**
* 圆角系数
* @默认值 `0.5`
* @可选值 `0` / `0.25` / `0.5` / `0.75` / `1`
* @可选值 `0到1区间的任意值`
*/
radius?: 0 | 0.25 | 0.5 | 0.75 | 1
radius?: number
/**
* 是否开启哀悼模式
* @默认值 `false`
@@ -148,6 +148,11 @@ declare namespace Settings {
enableHotkeys?: boolean
}
interface toolbar {
/**
* 是否开启工具栏
* @默认值 `true`
*/
enable?: boolean
/**
* 是否开启面包屑导航
* @默认值 `true`
+1 -1
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { AvatarVariants } from '.'
import { AvatarRoot } from 'reka-ui'
import { cn } from '@/utils'
import { AvatarRoot } from 'radix-vue'
import { avatarVariant } from '.'
const props = withDefaults(defineProps<{
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from 'radix-vue'
import { AvatarFallback } from 'radix-vue'
import type { AvatarFallbackProps } from 'reka-ui'
import { AvatarFallback } from 'reka-ui'
const props = defineProps<AvatarFallbackProps>()
</script>
@@ -1,10 +1,12 @@
<script setup lang="ts">
import type { AvatarImageProps } from 'radix-vue'
import { AvatarImage } from 'radix-vue'
import type { AvatarImageProps } from 'reka-ui'
import { AvatarImage } from 'reka-ui'
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage v-bind="props" class="h-full w-full object-cover" />
<AvatarImage v-bind="props" class="h-full w-full object-cover">
<slot />
</AvatarImage>
</template>
+4
View File
@@ -3,6 +3,10 @@ import type { HTMLAttributes } from 'vue'
import type { AvatarVariants } from './avatar'
import { Avatar, AvatarFallback, AvatarImage } from './avatar'
defineOptions({
name: 'FaAvatar',
})
const props = defineProps<{
src: string
fallback?: string
+2 -2
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'radix-vue'
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { ButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@/utils'
import { Primitive } from 'radix-vue'
import { buttonVariants } from '.'
interface Props extends PrimitiveProps {
+8 -9
View File
@@ -8,22 +8,21 @@ export const buttonVariants = cva(
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
+60
View File
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { cn } from '@/utils'
defineOptions({
name: 'FaButtonGroup',
})
const props = defineProps<{
vertical?: boolean
}>()
</script>
<template>
<div
:class="cn('inline-flex gap-[1px] items-stretch', {
'horizontal-group flex-row': !props.vertical,
'vertical-group flex-col': props.vertical,
})"
>
<slot />
</div>
</template>
<style scoped>
.horizontal-group {
:deep(> button) {
&:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
&:not(:first-child, :last-child) {
border-radius: 0;
}
}
}
.vertical-group {
:deep(> button) {
&:first-child {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
&:last-child {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
&:not(:first-child, :last-child) {
border-radius: 0;
}
}
}
</style>
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from 'radix-vue'
import type { CheckboxRootEmits, CheckboxRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import { Check } from 'lucide-vue-next'
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'radix-vue'
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CheckboxRootEmits>()
+1 -1
View File
@@ -16,7 +16,7 @@ const id = useId()
<template>
<div class="flex-center-start gap-2">
<Checkbox :id v-model:checked="value" :disabled />
<Checkbox :id v-model="value" :disabled />
<label :for="id" class="cursor-pointer text-sm">
<slot />
</label>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'radix-vue'
import { ContextMenuRoot, useForwardPropsEmits } from 'radix-vue'
import type { ContextMenuRootEmits, ContextMenuRootProps } from 'reka-ui'
import { ContextMenuRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<ContextMenuRootProps>()
const emits = defineEmits<ContextMenuRootEmits>()
@@ -1,15 +1,14 @@
<script setup lang="ts">
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from 'radix-vue'
import type { ContextMenuCheckboxItemEmits, ContextMenuCheckboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import { Check } from 'lucide-vue-next'
import {
ContextMenuCheckboxItem,
ContextMenuItemIndicator,
useForwardPropsEmits,
} from 'radix-vue'
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuCheckboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ContextMenuCheckboxItemEmits>()
@@ -1,14 +1,13 @@
<script setup lang="ts">
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'radix-vue'
import type { ContextMenuContentEmits, ContextMenuContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import {
ContextMenuContent,
ContextMenuPortal,
useForwardPropsEmits,
} from 'radix-vue'
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ContextMenuContentEmits>()
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ContextMenuGroupProps } from 'radix-vue'
import { ContextMenuGroup } from 'radix-vue'
import type { ContextMenuGroupProps } from 'reka-ui'
import { ContextMenuGroup } from 'reka-ui'
const props = defineProps<ContextMenuGroupProps>()
</script>
@@ -1,13 +1,12 @@
<script setup lang="ts">
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'radix-vue'
import type { ContextMenuItemEmits, ContextMenuItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import {
ContextMenuItem,
useForwardPropsEmits,
} from 'radix-vue'
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuItemProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
const emits = defineEmits<ContextMenuItemEmits>()
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { ContextMenuLabelProps } from 'radix-vue'
import type { ContextMenuLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import { ContextMenuLabel } from 'radix-vue'
import { ContextMenuLabel } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuLabelProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
@@ -17,10 +17,7 @@ const delegatedProps = computed(() => {
<template>
<ContextMenuLabel
v-bind="delegatedProps"
:class="
cn('px-2 py-1.5 text-sm font-semibold text-foreground',
inset && 'pl-8', props.class,
)"
:class="cn('px-2 py-1.5 text-sm font-semibold text-foreground', inset && 'pl-8', props.class)"
>
<slot />
</ContextMenuLabel>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ContextMenuPortalProps } from 'radix-vue'
import { ContextMenuPortal } from 'radix-vue'
import type { ContextMenuPortalProps } from 'reka-ui'
import { ContextMenuPortal } from 'reka-ui'
const props = defineProps<ContextMenuPortalProps>()
</script>
@@ -1,10 +1,9 @@
<script setup lang="ts">
import type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from 'radix-vue'
import type { ContextMenuRadioGroupEmits, ContextMenuRadioGroupProps } from 'reka-ui'
import {
ContextMenuRadioGroup,
useForwardPropsEmits,
} from 'radix-vue'
} from 'reka-ui'
const props = defineProps<ContextMenuRadioGroupProps>()
const emits = defineEmits<ContextMenuRadioGroupEmits>()
@@ -1,15 +1,14 @@
<script setup lang="ts">
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from 'radix-vue'
import type { ContextMenuRadioItemEmits, ContextMenuRadioItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import { Circle } from 'lucide-vue-next'
import {
ContextMenuItemIndicator,
ContextMenuRadioItem,
useForwardPropsEmits,
} from 'radix-vue'
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuRadioItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ContextMenuRadioItemEmits>()
@@ -1,12 +1,11 @@
<script setup lang="ts">
import type { ContextMenuSeparatorProps } from 'radix-vue'
import type { ContextMenuSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import {
ContextMenuSeparator,
} from 'radix-vue'
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuSeparatorProps & { class?: HTMLAttributes['class'] }>()
@@ -1,10 +1,9 @@
<script setup lang="ts">
import type { ContextMenuSubEmits, ContextMenuSubProps } from 'radix-vue'
import type { ContextMenuSubEmits, ContextMenuSubProps } from 'reka-ui'
import {
ContextMenuSub,
useForwardPropsEmits,
} from 'radix-vue'
} from 'reka-ui'
const props = defineProps<ContextMenuSubProps>()
const emits = defineEmits<ContextMenuSubEmits>()
@@ -1,13 +1,12 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'radix-vue'
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import {
ContextMenuSubContent,
useForwardPropsEmits,
} from 'radix-vue'
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
@@ -1,14 +1,13 @@
<script setup lang="ts">
import type { ContextMenuSubTriggerProps } from 'radix-vue'
import type { ContextMenuSubTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import { ChevronRight } from 'lucide-vue-next'
import {
ContextMenuSubTrigger,
useForwardProps,
} from 'radix-vue'
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<ContextMenuSubTriggerProps & { class?: HTMLAttributes['class'], inset?: boolean }>()
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { ContextMenuTriggerProps } from 'radix-vue'
import { ContextMenuTrigger, useForwardProps } from 'radix-vue'
import type { ContextMenuTriggerProps } from 'reka-ui'
import { ContextMenuTrigger, useForwardProps } from 'reka-ui'
const props = defineProps<ContextMenuTriggerProps>()
+1 -1
View File
@@ -48,7 +48,7 @@ function handleItemClick(item: { handle?: () => void }) {
<ContextMenuTrigger as-child>
<slot />
</ContextMenuTrigger>
<ContextMenuContent class="z-1050">
<ContextMenuContent class="z-2050">
<template v-if="!!slots.label">
<ContextMenuLabel>
<slot name="label" />
+6 -23
View File
@@ -1,6 +1,4 @@
<script setup lang="ts">
import useSettingsStore from '@/store/modules/settings'
defineOptions({
name: 'FaCopyright',
})
@@ -9,29 +7,14 @@ const settingsStore = useSettingsStore()
</script>
<template>
<footer v-if="settingsStore.settings.copyright.enable" class="copyright">
<span>Copyright</span>
<footer v-if="settingsStore.settings.copyright.enable" class="my-4 flex flex-wrap items-center justify-center px-4 text-sm text-secondary-foreground/50">
<span class="px-1">Copyright</span>
<FaIcon name="i-ri:copyright-line" class="size-5" />
<span v-if="settingsStore.settings.copyright.dates">{{ settingsStore.settings.copyright.dates }}</span>
<span v-if="settingsStore.settings.copyright.dates" class="px-1">{{ settingsStore.settings.copyright.dates }}</span>
<template v-if="settingsStore.settings.copyright.company">
<a v-if="settingsStore.settings.copyright.website" :href="settingsStore.settings.copyright.website" target="_blank" rel="noopener">{{ settingsStore.settings.copyright.company }}</a>
<span v-else>{{ settingsStore.settings.copyright.company }}</span>
<a v-if="settingsStore.settings.copyright.website" :href="settingsStore.settings.copyright.website" target="_blank" rel="noopener" class="px-1 text-center no-underline transition hover-text-secondary-foreground">{{ settingsStore.settings.copyright.company }}</a>
<span v-else class="px-1">{{ settingsStore.settings.copyright.company }}</span>
</template>
<a v-if="settingsStore.settings.copyright.beian" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">{{ settingsStore.settings.copyright.beian }}</a>
<a v-if="settingsStore.settings.copyright.beian" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener" class="px-1 text-center no-underline transition hover-text-secondary-foreground">{{ settingsStore.settings.copyright.beian }}</a>
</footer>
</template>
<style scoped>
.copyright {
--uno: flex items-center justify-center flex-wrap my-4 px-4 text-sm text-secondary-foreground/50;
span,
a {
--uno: px-1;
}
a {
--uno: text-center no-underline hover-text-secondary-foreground transition;
}
}
</style>
+4 -2
View File
@@ -1,12 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
defineOptions({
name: 'FaDivider',
})
defineProps<{
const props = defineProps<{
position?: 'start' | 'end'
class?: HTMLAttributes['class']
}>()
const slots = defineSlots<{
@@ -20,7 +22,7 @@ const slots = defineSlots<{
'before:(flex-basis-0)': position === 'start',
'after:(flex-basis-0)': position === 'end',
'gap-4': !!slots.default,
})"
}, props.class)"
>
<slot />
</div>
+24 -5
View File
@@ -36,9 +36,9 @@ const props = withDefaults(
footer?: boolean
closeOnClickOverlay?: boolean
closeOnPressEscape?: boolean
class?: HTMLAttributes['class']
headerClass?: HTMLAttributes['class']
destroyOnClose?: boolean
contentClass?: HTMLAttributes['class']
headerClass?: HTMLAttributes['class']
footerClass?: HTMLAttributes['class']
}>(),
{
@@ -60,6 +60,7 @@ const props = withDefaults(
footer: true,
closeOnClickOverlay: true,
closeOnPressEscape: true,
destroyOnClose: true,
},
)
@@ -82,6 +83,20 @@ watch(() => props.modelValue, (newValue) => {
isOpen.value = newValue
})
const hasOpened = ref(false)
const isClosed = ref(true)
watch(() => isOpen.value, (value) => {
isClosed.value = false
if (value && !hasOpened.value) {
hasOpened.value = true
}
}, {
immediate: true,
})
const forceMount = computed(() => !props.destroyOnClose && hasOpened.value)
function updateOpen(value: boolean) {
isOpen.value = value
emits('update:modelValue', value)
@@ -128,6 +143,7 @@ function handleAnimationEnd() {
}
else {
emits('closed')
isClosed.value = true
}
}
</script>
@@ -139,8 +155,11 @@ function handleAnimationEnd() {
:open="isOpen"
:overlay="props.overlay"
:overlay-blur="props.overlayBlur"
class="w-full flex flex-col gap-0 p-0"
:class="cn('w-full flex flex-col gap-0 p-0', props.contentClass, {
hidden: isClosed,
})"
:side="props.side"
:force-mount="forceMount"
@open-auto-focus="handleFocusOutside"
@close-auto-focus="handleFocusOutside"
@focus-outside="handleFocusOutside"
@@ -180,10 +199,10 @@ function handleAnimationEnd() {
})"
>
<slot name="footer">
<FaButton variant="outline" @click="onCancel">
<FaButton v-if="showCancelButton" variant="outline" @click="onCancel">
{{ cancelButtonText }}
</FaButton>
<FaButton @click="onConfirm">
<FaButton v-if="showConfirmButton" :disabled="confirmButtonDisabled" :loading="confirmButtonLoading" @click="onConfirm">
{{ confirmButtonText }}
</FaButton>
</slot>
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'radix-vue'
import { DialogRoot, useForwardPropsEmits } from 'radix-vue'
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'radix-vue'
import { DialogClose } from 'radix-vue'
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
@@ -1,8 +1,7 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'radix-vue'
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { SheetVariants } from '.'
import { cn } from '@/utils'
import { useScrollLock } from '@vueuse/core'
import { X } from 'lucide-vue-next'
import {
@@ -10,8 +9,9 @@ import {
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from 'radix-vue'
} from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
import { sheetVariants } from '.'
interface SheetContentProps extends DialogContentProps {
@@ -1,9 +1,9 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'radix-vue'
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils'
import { DialogDescription } from 'radix-vue'
import { DialogDescription } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()

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