refactor: 移除用户应用相关代码和资源文件
清理未使用的用户应用代码、资源文件和配置 更新管理后台的路由和国际化配置
@ -229,6 +229,10 @@ const local: App.I18n.Schema = {
|
|||||||
'500': 'Server Error',
|
'500': 'Server Error',
|
||||||
'iframe-page': 'Iframe',
|
'iframe-page': 'Iframe',
|
||||||
'home': 'Home',
|
'home': 'Home',
|
||||||
|
'competition': 'Competition',
|
||||||
|
'competition_competition-add': 'Competition Add',
|
||||||
|
'competition_competition-detail': 'Competition Detail',
|
||||||
|
'competition_competition-list': 'Competition List',
|
||||||
},
|
},
|
||||||
page: {
|
page: {
|
||||||
login: {
|
login: {
|
||||||
|
|||||||
@ -225,6 +225,10 @@ const local: App.I18n.Schema = {
|
|||||||
'500': '服务器错误',
|
'500': '服务器错误',
|
||||||
'iframe-page': '外链页面',
|
'iframe-page': '外链页面',
|
||||||
'home': '首页',
|
'home': '首页',
|
||||||
|
'competition': '比赛',
|
||||||
|
'competition_competition-add': '比赛添加',
|
||||||
|
'competition_competition-detail': '比赛详情',
|
||||||
|
'competition_competition-list': '比赛列表',
|
||||||
},
|
},
|
||||||
page: {
|
page: {
|
||||||
login: {
|
login: {
|
||||||
|
|||||||
@ -20,6 +20,8 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
|||||||
500: () => import("@/views/_builtin/500/index.vue"),
|
500: () => import("@/views/_builtin/500/index.vue"),
|
||||||
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
"iframe-page": () => import("@/views/_builtin/iframe-page/[url].vue"),
|
||||||
login: () => import("@/views/_builtin/login/index.vue"),
|
login: () => import("@/views/_builtin/login/index.vue"),
|
||||||
dashboard: () => import("@/views/dashboard/index.vue"),
|
"competition_competition-add": () => import("@/views/competition/competition-add/index.vue"),
|
||||||
|
"competition_competition-detail": () => import("@/views/competition/competition-detail/index.vue"),
|
||||||
|
"competition_competition-list": () => import("@/views/competition/competition-list/index.vue"),
|
||||||
home: () => import("@/views/home/index.vue"),
|
home: () => import("@/views/home/index.vue"),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -40,13 +40,46 @@ export const generatedRoutes: GeneratedRoute[] = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'dashboard',
|
name: 'competition',
|
||||||
path: '/dashboard',
|
path: '/competition',
|
||||||
component: 'layout.base$view.dashboard',
|
component: 'layout.base',
|
||||||
meta: {
|
meta: {
|
||||||
title: 'dashboard',
|
title: 'competition',
|
||||||
i18nKey: 'route.dashboard'
|
i18nKey: 'route.competition'
|
||||||
}
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'competition_competition-add',
|
||||||
|
path: '/competition/competition-add',
|
||||||
|
component: 'view.competition_competition-add',
|
||||||
|
meta: {
|
||||||
|
title: 'competition_competition-add',
|
||||||
|
i18nKey: 'route.competition_competition-add',
|
||||||
|
multiTab: true,// 新增选项卡
|
||||||
|
hideInMenu: true,// 隐藏在菜单中
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'competition_competition-detail',
|
||||||
|
path: '/competition/competition-detail',
|
||||||
|
component: 'view.competition_competition-detail',
|
||||||
|
meta: {
|
||||||
|
title: 'competition_competition-detail',
|
||||||
|
i18nKey: 'route.competition_competition-detail',
|
||||||
|
multiTab: true,// 新增选项卡
|
||||||
|
hideInMenu: true,// 隐藏在菜单中
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'competition_competition-list',
|
||||||
|
path: '/competition/competition-list',
|
||||||
|
component: 'view.competition_competition-list',
|
||||||
|
meta: {
|
||||||
|
title: 'competition_competition-list',
|
||||||
|
i18nKey: 'route.competition_competition-list'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'home',
|
name: 'home',
|
||||||
|
|||||||
@ -166,7 +166,10 @@ const routeMap: RouteMap = {
|
|||||||
"403": "/403",
|
"403": "/403",
|
||||||
"404": "/404",
|
"404": "/404",
|
||||||
"500": "/500",
|
"500": "/500",
|
||||||
"dashboard": "/dashboard",
|
"competition": "/competition",
|
||||||
|
"competition_competition-add": "/competition/competition-add",
|
||||||
|
"competition_competition-detail": "/competition/competition-detail",
|
||||||
|
"competition_competition-list": "/competition/competition-list",
|
||||||
"home": "/home",
|
"home": "/home",
|
||||||
"iframe-page": "/iframe-page/:url",
|
"iframe-page": "/iframe-page/:url",
|
||||||
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?"
|
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?"
|
||||||
|
|||||||
11
apps/admin/src/typings/elegant-router.d.ts
vendored
@ -20,7 +20,10 @@ declare module "@elegant-router/types" {
|
|||||||
"403": "/403";
|
"403": "/403";
|
||||||
"404": "/404";
|
"404": "/404";
|
||||||
"500": "/500";
|
"500": "/500";
|
||||||
"dashboard": "/dashboard";
|
"competition": "/competition";
|
||||||
|
"competition_competition-add": "/competition/competition-add";
|
||||||
|
"competition_competition-detail": "/competition/competition-detail";
|
||||||
|
"competition_competition-list": "/competition/competition-list";
|
||||||
"home": "/home";
|
"home": "/home";
|
||||||
"iframe-page": "/iframe-page/:url";
|
"iframe-page": "/iframe-page/:url";
|
||||||
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
|
"login": "/login/:module(pwd-login|code-login|register|reset-pwd|bind-wechat)?";
|
||||||
@ -58,7 +61,7 @@ declare module "@elegant-router/types" {
|
|||||||
| "403"
|
| "403"
|
||||||
| "404"
|
| "404"
|
||||||
| "500"
|
| "500"
|
||||||
| "dashboard"
|
| "competition"
|
||||||
| "home"
|
| "home"
|
||||||
| "iframe-page"
|
| "iframe-page"
|
||||||
| "login"
|
| "login"
|
||||||
@ -83,7 +86,9 @@ declare module "@elegant-router/types" {
|
|||||||
| "500"
|
| "500"
|
||||||
| "iframe-page"
|
| "iframe-page"
|
||||||
| "login"
|
| "login"
|
||||||
| "dashboard"
|
| "competition_competition-add"
|
||||||
|
| "competition_competition-detail"
|
||||||
|
| "competition_competition-list"
|
||||||
| "home"
|
| "home"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|||||||
19
apps/admin/src/views/competition/competition-add/index.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NButton, NSpace } from 'naive-ui'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="competition-add">
|
||||||
|
<NSpace vertical>
|
||||||
|
<NButton type="primary">
|
||||||
|
Add Competition
|
||||||
|
</NButton>
|
||||||
|
</NSpace>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.competition-add {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>dashboard_detail</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,2 +0,0 @@
|
|||||||
export * from './proxy'
|
|
||||||
export * from './time'
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import type { ProxyOptions } from 'vite'
|
|
||||||
import { consola } from 'consola'
|
|
||||||
import { bgRed, bgYellow, green, lightBlue } from 'kolorist'
|
|
||||||
import { createServiceConfig } from '../../src/utils/service'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set http proxy
|
|
||||||
*
|
|
||||||
* @param env - The current env
|
|
||||||
* @param enable - If enable http proxy
|
|
||||||
*/
|
|
||||||
export function createViteProxy(env: Env.ImportMeta, enable: boolean) {
|
|
||||||
const isEnableHttpProxy = enable && env.VITE_HTTP_PROXY === 'Y'
|
|
||||||
|
|
||||||
if (!isEnableHttpProxy)
|
|
||||||
return undefined
|
|
||||||
|
|
||||||
const isEnableProxyLog = env.VITE_PROXY_LOG === 'Y'
|
|
||||||
|
|
||||||
const { baseURL, proxyPattern, other } = createServiceConfig(env)
|
|
||||||
|
|
||||||
const proxy: Record<string, ProxyOptions> = createProxyItem({ baseURL, proxyPattern }, isEnableProxyLog)
|
|
||||||
|
|
||||||
other.forEach((item) => {
|
|
||||||
Object.assign(proxy, createProxyItem(item, isEnableProxyLog))
|
|
||||||
})
|
|
||||||
|
|
||||||
return proxy
|
|
||||||
}
|
|
||||||
|
|
||||||
function createProxyItem(item: App.Service.ServiceConfigItem, enableLog: boolean) {
|
|
||||||
const proxy: Record<string, ProxyOptions> = {}
|
|
||||||
|
|
||||||
proxy[item.proxyPattern] = {
|
|
||||||
target: item.baseURL,
|
|
||||||
changeOrigin: true,
|
|
||||||
configure: (_proxy, options) => {
|
|
||||||
_proxy.on('proxyReq', (_proxyReq, req, _res) => {
|
|
||||||
if (!enableLog)
|
|
||||||
return
|
|
||||||
|
|
||||||
const requestUrl = `${lightBlue('[proxy url]')}: ${bgYellow(` ${req.method} `)} ${green(`${item.proxyPattern}${req.url}`)}`
|
|
||||||
|
|
||||||
const proxyUrl = `${lightBlue('[real request url]')}: ${green(`${options.target}${req.url}`)}`
|
|
||||||
|
|
||||||
consola.log(`${requestUrl}\n${proxyUrl}`)
|
|
||||||
})
|
|
||||||
_proxy.on('error', (_err, req, _res) => {
|
|
||||||
if (!enableLog)
|
|
||||||
return
|
|
||||||
consola.log(bgRed(`Error: ${req.method} `), green(`${options.target}${req.url}`))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
rewrite: path => path.replace(new RegExp(`^${item.proxyPattern}`), ''),
|
|
||||||
}
|
|
||||||
|
|
||||||
return proxy
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import dayjs from 'dayjs'
|
|
||||||
import timezone from 'dayjs/plugin/timezone'
|
|
||||||
import utc from 'dayjs/plugin/utc'
|
|
||||||
|
|
||||||
export function getBuildTime() {
|
|
||||||
dayjs.extend(utc)
|
|
||||||
dayjs.extend(timezone)
|
|
||||||
|
|
||||||
const buildTime = dayjs.tz(Date.now(), 'Asia/Shanghai').format('YYYY-MM-DD HH:mm:ss')
|
|
||||||
|
|
||||||
return buildTime
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import VueDevtools from 'vite-plugin-vue-devtools'
|
|
||||||
|
|
||||||
export function setupDevtoolsPlugin(viteEnv: Env.ImportMeta) {
|
|
||||||
const { VITE_DEVTOOLS_LAUNCH_EDITOR } = viteEnv
|
|
||||||
|
|
||||||
return VueDevtools({
|
|
||||||
launchEditor: VITE_DEVTOOLS_LAUNCH_EDITOR,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import type { Plugin } from 'vite'
|
|
||||||
|
|
||||||
export function setupHtmlPlugin(buildTime: string) {
|
|
||||||
const plugin: Plugin = {
|
|
||||||
name: 'html-plugin',
|
|
||||||
apply: 'build',
|
|
||||||
transformIndexHtml(html) {
|
|
||||||
return html.replace('<head>', `<head>\n <meta name="buildTime" content="${buildTime}">`)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return plugin
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import type { PluginOption } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
|
||||||
import progress from 'vite-plugin-progress'
|
|
||||||
import { setupDevtoolsPlugin } from './devtools'
|
|
||||||
import { setupHtmlPlugin } from './html'
|
|
||||||
import { setupElegantRouter } from './router'
|
|
||||||
import { setupUnocss } from './unocss'
|
|
||||||
import { setupUnplugin } from './unplugin'
|
|
||||||
|
|
||||||
export function setupVitePlugins(viteEnv: Env.ImportMeta, buildTime: string) {
|
|
||||||
const plugins: PluginOption = [
|
|
||||||
vue(),
|
|
||||||
vueJsx(),
|
|
||||||
setupDevtoolsPlugin(viteEnv),
|
|
||||||
setupElegantRouter(),
|
|
||||||
setupUnocss(viteEnv),
|
|
||||||
...setupUnplugin(viteEnv),
|
|
||||||
progress(),
|
|
||||||
setupHtmlPlugin(buildTime),
|
|
||||||
]
|
|
||||||
|
|
||||||
return plugins
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import type { RouteKey } from '@elegant-router/types'
|
|
||||||
import type { RouteMeta } from 'vue-router'
|
|
||||||
import ElegantVueRouter from '@elegant-router/vue/vite'
|
|
||||||
|
|
||||||
export function setupElegantRouter() {
|
|
||||||
return ElegantVueRouter({
|
|
||||||
layouts: {
|
|
||||||
base: 'src/layouts/base-layout/index.vue',
|
|
||||||
blank: 'src/layouts/blank-layout/index.vue',
|
|
||||||
},
|
|
||||||
routePathTransformer(routeName, routePath) {
|
|
||||||
const key = routeName as RouteKey
|
|
||||||
|
|
||||||
if (key === 'login') {
|
|
||||||
const modules: UnionKey.LoginModule[] = ['pwd-login', 'code-login', 'register', 'reset-pwd', 'bind-wechat']
|
|
||||||
|
|
||||||
const moduleReg = modules.join('|')
|
|
||||||
|
|
||||||
return `/login/:module(${moduleReg})?`
|
|
||||||
}
|
|
||||||
|
|
||||||
return routePath
|
|
||||||
},
|
|
||||||
onRouteMetaGen(routeName) {
|
|
||||||
const key = routeName as RouteKey
|
|
||||||
|
|
||||||
const constantRoutes: RouteKey[] = ['login', '403', '404', '500']
|
|
||||||
|
|
||||||
const meta: Partial<RouteMeta> = {
|
|
||||||
title: key,
|
|
||||||
i18nKey: `route.${key}` as App.I18n.I18nKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (constantRoutes.includes(key)) {
|
|
||||||
meta.constant = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return meta
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import path from 'node:path'
|
|
||||||
import process from 'node:process'
|
|
||||||
import { FileSystemIconLoader } from '@iconify/utils/lib/loader/node-loaders'
|
|
||||||
import presetIcons from '@unocss/preset-icons'
|
|
||||||
import unocss from '@unocss/vite'
|
|
||||||
|
|
||||||
export function setupUnocss(viteEnv: Env.ImportMeta) {
|
|
||||||
const { VITE_ICON_PREFIX, VITE_ICON_LOCAL_PREFIX } = viteEnv
|
|
||||||
|
|
||||||
const localIconPath = path.join(process.cwd(), 'src/assets/svg-icon')
|
|
||||||
|
|
||||||
/** The name of the local icon collection */
|
|
||||||
const collectionName = VITE_ICON_LOCAL_PREFIX.replace(`${VITE_ICON_PREFIX}-`, '')
|
|
||||||
|
|
||||||
return unocss({
|
|
||||||
presets: [
|
|
||||||
presetIcons({
|
|
||||||
prefix: `${VITE_ICON_PREFIX}-`,
|
|
||||||
scale: 1,
|
|
||||||
extraProperties: {
|
|
||||||
display: 'inline-block',
|
|
||||||
},
|
|
||||||
collections: {
|
|
||||||
[collectionName]: FileSystemIconLoader(localIconPath, svg =>
|
|
||||||
svg.replace(/^<svg\s/, '<svg width="1em" height="1em" ')),
|
|
||||||
},
|
|
||||||
warn: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import type { PluginOption } from 'vite'
|
|
||||||
import path from 'node:path'
|
|
||||||
import process from 'node:process'
|
|
||||||
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
|
||||||
import IconsResolver from 'unplugin-icons/resolver'
|
|
||||||
import Icons from 'unplugin-icons/vite'
|
|
||||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
|
||||||
import Components from 'unplugin-vue-components/vite'
|
|
||||||
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
|
||||||
|
|
||||||
export function setupUnplugin(viteEnv: Env.ImportMeta) {
|
|
||||||
const { VITE_ICON_PREFIX, VITE_ICON_LOCAL_PREFIX } = viteEnv
|
|
||||||
|
|
||||||
const localIconPath = path.join(process.cwd(), 'src/assets/svg-icon')
|
|
||||||
|
|
||||||
/** The name of the local icon collection */
|
|
||||||
const collectionName = VITE_ICON_LOCAL_PREFIX.replace(`${VITE_ICON_PREFIX}-`, '')
|
|
||||||
|
|
||||||
const plugins: PluginOption[] = [
|
|
||||||
Icons({
|
|
||||||
compiler: 'vue3',
|
|
||||||
customCollections: {
|
|
||||||
[collectionName]: FileSystemIconLoader(localIconPath, svg =>
|
|
||||||
svg.replace(/^<svg\s/, '<svg width="1em" height="1em" ')),
|
|
||||||
},
|
|
||||||
scale: 1,
|
|
||||||
defaultClass: 'inline-block',
|
|
||||||
}),
|
|
||||||
Components({
|
|
||||||
dts: 'src/typings/components.d.ts',
|
|
||||||
types: [{ from: 'vue-router', names: ['RouterLink', 'RouterView'] }],
|
|
||||||
resolvers: [
|
|
||||||
NaiveUiResolver(),
|
|
||||||
IconsResolver({ customCollections: [collectionName], componentPrefix: VITE_ICON_PREFIX }),
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
createSvgIconsPlugin({
|
|
||||||
iconDirs: [localIconPath],
|
|
||||||
symbolId: `${VITE_ICON_LOCAL_PREFIX}-[dir]-[name]`,
|
|
||||||
inject: 'body-last',
|
|
||||||
customDomId: '__SVG_ICON_LOCAL__',
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
return plugins
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-cmn-Hans">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="color-scheme" content="light dark" />
|
|
||||||
<title>%VITE_APP_TITLE%</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@reader-star/user",
|
|
||||||
"type": "module",
|
|
||||||
"version": "2.0.2",
|
|
||||||
"description": "A fresh and elegant admin template, based on Vue3、Vite7、TypeScript、NaiveUI and UnoCSS. 一个基于Vue3、Vite7、TypeScript、NaiveUI and UnoCSS的清新优雅的中后台模版。",
|
|
||||||
"author": {
|
|
||||||
"name": "reader-star",
|
|
||||||
"email": "reader-star@outlook.com",
|
|
||||||
"url": "https://github.com/reader-star"
|
|
||||||
},
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://github.com/reader-star/reader-star-admin",
|
|
||||||
"repository": {
|
|
||||||
"url": "https://github.com/reader-star/reader-star-admin.git"
|
|
||||||
},
|
|
||||||
"bugs": {
|
|
||||||
"url": "https://github.com/reader-star/reader-star-admin/issues"
|
|
||||||
},
|
|
||||||
"keywords": [
|
|
||||||
"Vue3 admin ",
|
|
||||||
"vue-admin-template",
|
|
||||||
"Vite7",
|
|
||||||
"TypeScript",
|
|
||||||
"naive-ui",
|
|
||||||
"naive-ui-admin",
|
|
||||||
"ant-design-vue v4",
|
|
||||||
"UnoCSS"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.19.0",
|
|
||||||
"pnpm": ">=10.5.0"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"build": "vite build --mode prod",
|
|
||||||
"build:test": "vite build --mode test",
|
|
||||||
"cleanup": "sa cleanup",
|
|
||||||
"commit": "sa git-commit",
|
|
||||||
"commit:zh": "sa git-commit -l=zh-cn",
|
|
||||||
"dev": "vite --mode test",
|
|
||||||
"dev:prod": "vite --mode prod",
|
|
||||||
"gen-route": "sa gen-route",
|
|
||||||
"lint": "eslint . --fix",
|
|
||||||
"prepare": "simple-git-hooks",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"release": "sa release",
|
|
||||||
"typecheck": "vue-tsc --noEmit --skipLibCheck",
|
|
||||||
"update-pkg": "sa update-pkg"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@better-scroll/core": "2.5.1",
|
|
||||||
"@iconify/vue": "5.0.0",
|
|
||||||
"@sa/axios": "workspace:*",
|
|
||||||
"@sa/color": "workspace:*",
|
|
||||||
"@sa/hooks": "workspace:*",
|
|
||||||
"@sa/materials": "workspace:*",
|
|
||||||
"@sa/utils": "workspace:*",
|
|
||||||
"@vueuse/core": "14.1.0",
|
|
||||||
"clipboard": "2.0.11",
|
|
||||||
"dayjs": "1.11.19",
|
|
||||||
"defu": "6.1.4",
|
|
||||||
"echarts": "6.0.0",
|
|
||||||
"json5": "2.2.3",
|
|
||||||
"naive-ui": "2.43.2",
|
|
||||||
"nprogress": "0.2.0",
|
|
||||||
"pinia": "3.0.4",
|
|
||||||
"tailwind-merge": "3.4.0",
|
|
||||||
"vue": "3.5.26",
|
|
||||||
"vue-draggable-plus": "0.6.0",
|
|
||||||
"vue-i18n": "11.2.7",
|
|
||||||
"vue-router": "4.6.4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@elegant-router/vue": "0.3.8",
|
|
||||||
"@iconify/json": "2.2.417",
|
|
||||||
"@sa/scripts": "workspace:*",
|
|
||||||
"@sa/uno-preset": "workspace:*",
|
|
||||||
"@types/node": "25.0.3",
|
|
||||||
"@types/nprogress": "0.2.3",
|
|
||||||
"@unocss/eslint-config": "66.5.10",
|
|
||||||
"@unocss/preset-icons": "66.5.10",
|
|
||||||
"@unocss/preset-uno": "66.5.10",
|
|
||||||
"@unocss/transformer-directives": "66.5.10",
|
|
||||||
"@unocss/transformer-variant-group": "66.5.10",
|
|
||||||
"@unocss/vite": "66.5.10",
|
|
||||||
"@vitejs/plugin-vue": "6.0.3",
|
|
||||||
"@vitejs/plugin-vue-jsx": "5.1.3",
|
|
||||||
"consola": "3.4.2",
|
|
||||||
"eslint": "9.39.2",
|
|
||||||
"eslint-plugin-vue": "10.6.2",
|
|
||||||
"kolorist": "1.8.0",
|
|
||||||
"sass": "1.97.1",
|
|
||||||
"simple-git-hooks": "2.13.1",
|
|
||||||
"tsx": "4.21.0",
|
|
||||||
"typescript": "5.9.3",
|
|
||||||
"unplugin-icons": "22.5.0",
|
|
||||||
"unplugin-vue-components": "30.0.0",
|
|
||||||
"vite": "7.3.0",
|
|
||||||
"vite-plugin-progress": "0.0.7",
|
|
||||||
"vite-plugin-svg-icons": "2.0.1",
|
|
||||||
"vite-plugin-vue-devtools": "8.0.5",
|
|
||||||
"vue-eslint-parser": "10.2.0",
|
|
||||||
"vue-tsc": "3.2.1"
|
|
||||||
},
|
|
||||||
"simple-git-hooks": {
|
|
||||||
"commit-msg": "pnpm sa git-commit-verify",
|
|
||||||
"pre-commit": "pnpm typecheck && pnpm lint && git diff --exit-code"
|
|
||||||
},
|
|
||||||
"website": "https://admin.reader-star.cn"
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
<svg width="100%" height="100%" version="1.1" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<g>
|
|
||||||
<path
|
|
||||||
d="M 200,866 C 100,866 50,779.4 100,692.8 L 200,519.6 C 220,485 240,490 265,499.6 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
|
|
||||||
fill="url(#LinearGradient)" fill-rule="nonzero" opacity="1" stroke="none" />
|
|
||||||
<path
|
|
||||||
d="M 420,866 C 455,861 478,846 500,827 C 614,696 615,597 500,517 C 394,444 333,374 380,207.82 L 260,415.67 C 240.22,450 254.37,465.1 275.28,481.79 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
|
|
||||||
fill="url(#LinearGradient_2)" fill-rule="nonzero" opacity="1" stroke="none" />
|
|
||||||
<path d="M 500,517 C 394,444 333,374 380,207.82 L 400,173.2 C 367,295 421,350 603,428 C 572,440 524,474 500,517"
|
|
||||||
fill="url(#LinearGradient_3)" fill-rule="nonzero" opacity="1" stroke="none" />
|
|
||||||
<path d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 572,440 524,474 500,517 C 615,597 614,696 500,827"
|
|
||||||
fill="url(#LinearGradient_4)" fill-rule="nonzero" opacity="1" stroke="none" />
|
|
||||||
<path d="M 400,173.2 C 367,295 421,350 603,428 C 690,389, 750,445 788,500 L 600,173.2 C 550,86.6 450,86.6 400,173.2"
|
|
||||||
fill="url(#LinearGradient_5)" fill-rule="nonzero" opacity="1" stroke="none" />
|
|
||||||
<path
|
|
||||||
d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 690,389, 750,445 788,500 C 816,554 797,606 750,640 L 500,827"
|
|
||||||
fill="url(#LinearGradient_6)" fill-rule="nonzero" opacity="1" stroke="none" />
|
|
||||||
<path
|
|
||||||
d="M 788,500 C 816,554 797,606 750,640 L 500,827 C 497,851 513,862 540,866 L 800,866 C 900,866 950,779.4 900,692.8 L 788,500"
|
|
||||||
fill="url(#LinearGradient_7)" fill-rule="nonzero" opacity="1" stroke="none" />
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient gradientTransform="matrix(104.391 -73.3432 73.3432 104.391 277.441 710.122)"
|
|
||||||
gradientUnits="userSpaceOnUse" id="LinearGradient" x1="0" x2="1" y1="0" y2="0">
|
|
||||||
<stop offset="0" stop-color="#373ebf" />
|
|
||||||
<stop offset="1" stop-color="#5058e6" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient gradientTransform="matrix(-173.747 557.324 -557.324 -173.747 508.829 258.172)"
|
|
||||||
gradientUnits="userSpaceOnUse" id="LinearGradient_2" x1="0" x2="1" y1="0" y2="0">
|
|
||||||
<stop offset="0" stop-color="#c2d6ff" />
|
|
||||||
<stop offset="1" stop-color="#646cff" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient gradientTransform="matrix(157.951 295.666 -295.666 157.951 382.944 193.642)"
|
|
||||||
gradientUnits="userSpaceOnUse" id="LinearGradient_3" x1="0" x2="1" y1="0" y2="0">
|
|
||||||
<stop offset="0" stop-color="#5058e6" />
|
|
||||||
<stop offset="1" stop-color="#373ebf" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient gradientTransform="matrix(-44.3023 219.578 -219.578 -44.3023 619.69 469.652)"
|
|
||||||
gradientUnits="userSpaceOnUse" id="LinearGradient_4" x1="0" x2="1" y1="0" y2="0">
|
|
||||||
<stop offset="0" stop-color="#91a7ff" />
|
|
||||||
<stop offset="1" stop-color="#5058e6" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient gradientTransform="matrix(125.52 334.256 -334.256 125.52 539.723 235.139)"
|
|
||||||
gradientUnits="userSpaceOnUse" id="LinearGradient_5" x1="0" x2="1" y1="0" y2="0">
|
|
||||||
<stop offset="0" stop-color="#646cff" />
|
|
||||||
<stop offset="1" stop-color="#c2d6ff" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient gradientTransform="matrix(-241.23 357.206 -357.206 -241.23 754.054 449.312)"
|
|
||||||
gradientUnits="userSpaceOnUse" id="LinearGradient_6" x1="0" x2="1" y1="0" y2="0">
|
|
||||||
<stop offset="0" stop-color="#c2d6ff" />
|
|
||||||
<stop offset="1" stop-color="#646cff" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient gradientTransform="matrix(125.978 210.065 -210.065 125.978 596.433 613.665)"
|
|
||||||
gradientUnits="userSpaceOnUse" id="LinearGradient_7" x1="0" x2="1" y1="0" y2="0">
|
|
||||||
<stop offset="0" stop-color="#373ebf" />
|
|
||||||
<stop offset="1" stop-color="#5058e6" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB |
@ -1,58 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { WatermarkProps } from 'naive-ui'
|
|
||||||
import { darkTheme, NConfigProvider } from 'naive-ui'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { naiveDateLocales, naiveLocales } from './locales/naive'
|
|
||||||
import { useAppStore } from './store/modules/app'
|
|
||||||
import { useThemeStore } from './store/modules/theme'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'App',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
|
|
||||||
const naiveDarkTheme = computed(() => (themeStore.darkMode ? darkTheme : undefined))
|
|
||||||
|
|
||||||
const naiveLocale = computed(() => {
|
|
||||||
return naiveLocales[appStore.locale]
|
|
||||||
})
|
|
||||||
|
|
||||||
const naiveDateLocale = computed(() => {
|
|
||||||
return naiveDateLocales[appStore.locale]
|
|
||||||
})
|
|
||||||
|
|
||||||
const watermarkProps = computed<WatermarkProps>(() => {
|
|
||||||
return {
|
|
||||||
content: themeStore.watermarkContent,
|
|
||||||
cross: true,
|
|
||||||
fullscreen: true,
|
|
||||||
fontSize: 16,
|
|
||||||
lineHeight: 16,
|
|
||||||
width: 384,
|
|
||||||
height: 384,
|
|
||||||
xOffset: 12,
|
|
||||||
yOffset: 60,
|
|
||||||
rotate: -15,
|
|
||||||
zIndex: 9999,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NConfigProvider
|
|
||||||
:theme="naiveDarkTheme"
|
|
||||||
:theme-overrides="themeStore.naiveTheme"
|
|
||||||
:locale="naiveLocale"
|
|
||||||
:date-locale="naiveDateLocale"
|
|
||||||
class="h-full"
|
|
||||||
>
|
|
||||||
<AppProvider>
|
|
||||||
<RouterView class="bg-layout" />
|
|
||||||
<NWatermark v-if="themeStore.watermark.visible" v-bind="watermarkProps" />
|
|
||||||
</AppProvider>
|
|
||||||
</NConfigProvider>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
Before Width: | Height: | Size: 112 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 202 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="prefix__prefix__feather prefix__prefix__feather-at-sign"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 006 0v-1a10 10 0 10-3.92 7.94"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 315 B |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 19 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="prefix__prefix__feather prefix__prefix__feather-cast"><path d="M2 16.1A5 5 0 015.9 20M2 12.05A9 9 0 019.95 20M2 8V6a2 2 0 012-2h16a2 2 0 012 2v12a2 2 0 01-2 2h-6M2 20h.01"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 345 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4"/><path d="M21.17 8H12M3.95 6.06L8.54 14m2.34 7.94L15.46 14"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 288 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 283 B |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="0 0 24 24"><path fill="currentColor" d="M19 10c0 1.38-2.12 2.5-3.5 2.5s-2.75-1.12-2.75-2.5h-1.5c0 1.38-1.37 2.5-2.75 2.5S5 11.38 5 10h-.75c-.16.64-.25 1.31-.25 2a8 8 0 008 8 8 8 0 008-8c0-.69-.09-1.36-.25-2H19m-7-6C9.04 4 6.45 5.61 5.07 8h13.86C17.55 5.61 14.96 4 12 4m10 8a10 10 0 01-10 10A10 10 0 012 12 10 10 0 0112 2a10 10 0 0110 10m-10 5.23c-1.75 0-3.29-.73-4.19-1.81L9.23 14c.45.72 1.52 1.23 2.77 1.23s2.32-.51 2.77-1.23l1.42 1.42c-.9 1.08-2.44 1.81-4.19 1.81z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 544 B |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 70 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 309 B |
@ -1 +0,0 @@
|
|||||||
<svg viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg"><path d="M81.28 55.9c-.1-11.67-2.93-22.55-9.37-32.38-1-1.5-2.14-2.86-2.5-4.71a8.1 8.1 0 014-8.61 7.89 7.89 0 019.3 1.23 35.999 35.999 0 015.9 8.83 75.18 75.18 0 018.44 28.58 83.211 83.211 0 01-5.23 36.74 102.983 102.983 0 01-3 7.28 1.2 1.2 0 000 1.41c9.58 13.3 21.76 23 37.85 27.24a54.37 54.37 0 0019.68 1.57 7.72 7.72 0 018.36 6.9 7.903 7.903 0 01-6.7 9 64.744 64.744 0 01-23-1.33 77.68 77.68 0 01-36.93-19.88 93.628 93.628 0 01-11.91-13.71 2.18 2.18 0 00-2.3-1.06 72.744 72.744 0 00-27.38 7.55c-11.6 6-20.67 14.58-26.4 26.45a10.134 10.134 0 01-3.7 4.7 8 8 0 01-9.19-.7 7.86 7.86 0 01-2.36-9.28 60.324 60.324 0 018.72-14.52c12.2-15.43 28.21-24.59 47.32-28.57A85.085 85.085 0 0173.07 87c.524.015 1-.307 1.18-.8a76.06 76.06 0 006.53-22.3c.351-2.652.518-5.325.5-8z" fill="currentColor"/><path d="M136.26 108.34a44.742 44.742 0 01-11.13-2.87 46.108 46.108 0 01-19.66-13.76 8 8 0 015.72-13.22 7.93 7.93 0 016.54 2.93 33.27 33.27 0 0018.87 10.75c1.546.155 3.058.553 4.48 1.18a8.08 8.08 0 013.84 9.21c-.92 3.52-4.13 5.81-8.66 5.78zm-80.6-75.02a7.61 7.61 0 016.64 5 49.139 49.139 0 013.64 17 46.33 46.33 0 01-2.46 17.28c-2 5.77-8.24 7.79-12.89 4.15a8.1 8.1 0 01-2.39-9 31.679 31.679 0 001.68-12.36 35.77 35.77 0 00-2.43-11c-2.1-5.45 1.75-11.07 8.21-11.07zm22.26 93.25a8 8 0 01-6.68 7.86 32.88 32.88 0 00-19.7 12.19 8.13 8.13 0 01-11.21 1.62 8 8 0 01-1.41-11.58A51.043 51.043 0 0154 123.81a45.842 45.842 0 0114-5.1c5.35-1.04 9.91 2.56 9.92 7.86z" fill="currentColor"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 74 KiB |
@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="feather feather-wind"><path d="M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 327 B |
@ -1,45 +0,0 @@
|
|||||||
<script setup lang="ts" generic="T extends Record<string, unknown>, K = never">
|
|
||||||
import { VueDraggable } from 'vue-draggable-plus'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'TableColumnSetting',
|
|
||||||
})
|
|
||||||
|
|
||||||
const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
|
|
||||||
required: true,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NPopover placement="bottom-end" trigger="click">
|
|
||||||
<template #trigger>
|
|
||||||
<NButton size="small">
|
|
||||||
<template #icon>
|
|
||||||
<icon-ant-design-setting-outlined class="text-icon" />
|
|
||||||
</template>
|
|
||||||
{{ $t('common.columnSetting') }}
|
|
||||||
</NButton>
|
|
||||||
</template>
|
|
||||||
<VueDraggable v-model="columns" :animation="150" filter=".none_draggable">
|
|
||||||
<div
|
|
||||||
v-for="item in columns"
|
|
||||||
:key="item.key"
|
|
||||||
class="h-36px flex-y-center rd-4px hover:(bg-primary bg-opacity-20)"
|
|
||||||
:class="{ hidden: !item.visible }"
|
|
||||||
>
|
|
||||||
<icon-mdi-drag class="mr-8px h-full cursor-move text-icon" />
|
|
||||||
<NCheckbox v-model:checked="item.checked" class="none_draggable flex-1">
|
|
||||||
<template v-if="typeof item.title === 'function'">
|
|
||||||
<component :is="item.title" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
{{ item.title }}
|
|
||||||
</template>
|
|
||||||
</NCheckbox>
|
|
||||||
</div>
|
|
||||||
</VueDraggable>
|
|
||||||
</NPopover>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'TableHeaderOperation',
|
|
||||||
})
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
itemAlign?: NaiveUI.Align
|
|
||||||
disabledDelete?: boolean
|
|
||||||
loading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'add'): void
|
|
||||||
(e: 'delete'): void
|
|
||||||
(e: 'refresh'): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = defineModel<NaiveUI.TableColumnCheck[]>('columns', {
|
|
||||||
default: () => [],
|
|
||||||
})
|
|
||||||
|
|
||||||
function add() {
|
|
||||||
emit('add')
|
|
||||||
}
|
|
||||||
|
|
||||||
function batchDelete() {
|
|
||||||
emit('delete')
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
emit('refresh')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NSpace :align="itemAlign" wrap justify="end" class="lt-sm:w-200px">
|
|
||||||
<slot name="prefix" />
|
|
||||||
<slot name="default">
|
|
||||||
<NButton size="small" ghost type="primary" @click="add">
|
|
||||||
<template #icon>
|
|
||||||
<icon-ic-round-plus class="text-icon" />
|
|
||||||
</template>
|
|
||||||
{{ $t('common.add') }}
|
|
||||||
</NButton>
|
|
||||||
<NPopconfirm @positive-click="batchDelete">
|
|
||||||
<template #trigger>
|
|
||||||
<NButton size="small" ghost type="error" :disabled="disabledDelete">
|
|
||||||
<template #icon>
|
|
||||||
<icon-ic-round-delete class="text-icon" />
|
|
||||||
</template>
|
|
||||||
{{ $t('common.batchDelete') }}
|
|
||||||
</NButton>
|
|
||||||
</template>
|
|
||||||
{{ $t('common.confirmDelete') }}
|
|
||||||
</NPopconfirm>
|
|
||||||
</slot>
|
|
||||||
<NButton size="small" @click="refresh">
|
|
||||||
<template #icon>
|
|
||||||
<icon-mdi-refresh class="text-icon" :class="{ 'animate-spin': loading }" />
|
|
||||||
</template>
|
|
||||||
{{ $t('common.refresh') }}
|
|
||||||
</NButton>
|
|
||||||
<TableColumnSetting v-model:columns="columns" />
|
|
||||||
<slot name="suffix" />
|
|
||||||
</NSpace>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useDialog, useLoadingBar, useMessage, useNotification } from 'naive-ui'
|
|
||||||
import { createTextVNode, defineComponent } from 'vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'AppProvider',
|
|
||||||
})
|
|
||||||
|
|
||||||
const ContextHolder = defineComponent({
|
|
||||||
name: 'ContextHolder',
|
|
||||||
setup() {
|
|
||||||
function register() {
|
|
||||||
window.$loadingBar = useLoadingBar()
|
|
||||||
window.$dialog = useDialog()
|
|
||||||
window.$message = useMessage()
|
|
||||||
window.$notification = useNotification()
|
|
||||||
}
|
|
||||||
|
|
||||||
register()
|
|
||||||
|
|
||||||
return () => createTextVNode()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NLoadingBarProvider>
|
|
||||||
<NDialogProvider>
|
|
||||||
<NNotificationProvider>
|
|
||||||
<NMessageProvider>
|
|
||||||
<ContextHolder />
|
|
||||||
<slot />
|
|
||||||
</NMessageProvider>
|
|
||||||
</NNotificationProvider>
|
|
||||||
</NDialogProvider>
|
|
||||||
</NLoadingBarProvider>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineOptions({ name: 'DarkModeContainer' })
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
inverted?: boolean
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="bg-container text-base-text transition-300" :class="{ 'bg-inverted text-#1f1f1f': inverted }">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({ name: 'ExceptionBase' })
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
type ExceptionType = '403' | '404' | '500'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Exception type
|
|
||||||
*
|
|
||||||
* - 403: no permission
|
|
||||||
* - 404: not found
|
|
||||||
* - 500: service error
|
|
||||||
*/
|
|
||||||
type: ExceptionType
|
|
||||||
}
|
|
||||||
|
|
||||||
const { routerPushByKey } = useRouterPush()
|
|
||||||
|
|
||||||
const iconMap: Record<ExceptionType, string> = {
|
|
||||||
403: 'no-permission',
|
|
||||||
404: 'not-found',
|
|
||||||
500: 'service-error',
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = computed(() => iconMap[props.type])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="size-full min-h-520px flex-col-center gap-24px overflow-hidden">
|
|
||||||
<div class="flex text-400px text-primary">
|
|
||||||
<SvgIcon :local-icon="icon" />
|
|
||||||
</div>
|
|
||||||
<NButton type="primary" @click="routerPushByKey('root')">
|
|
||||||
{{ $t('common.backToHome') }}
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'FullScreen',
|
|
||||||
})
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
full?: boolean
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ButtonIcon :key="String(full)" :tooltip-content="full ? $t('icon.fullscreenExit') : $t('icon.fullscreen')">
|
|
||||||
<icon-gridicons-fullscreen-exit v-if="full" />
|
|
||||||
<icon-gridicons-fullscreen v-else />
|
|
||||||
</ButtonIcon>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import type { PopoverPlacement } from 'naive-ui'
|
|
||||||
import { computed, useSlots } from 'vue'
|
|
||||||
|
|
||||||
defineOptions({ name: 'IconTooltip' })
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
icon: 'mdi-help-circle',
|
|
||||||
localIcon: '',
|
|
||||||
desc: '',
|
|
||||||
placement: 'top',
|
|
||||||
})
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
icon?: string
|
|
||||||
localIcon?: string
|
|
||||||
desc?: string
|
|
||||||
placement?: PopoverPlacement
|
|
||||||
}
|
|
||||||
|
|
||||||
const slots = useSlots()
|
|
||||||
const hasCustomTrigger = computed(() => Boolean(slots.trigger))
|
|
||||||
|
|
||||||
if (!hasCustomTrigger.value && !props.icon && !props.localIcon) {
|
|
||||||
throw new Error('icon or localIcon is required when no custom trigger slot is provided')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NTooltip :placement="placement">
|
|
||||||
<template #trigger>
|
|
||||||
<slot name="trigger">
|
|
||||||
<div class="cursor-pointer">
|
|
||||||
<SvgIcon :icon="icon" :local-icon="localIcon" />
|
|
||||||
</div>
|
|
||||||
</slot>
|
|
||||||
</template>
|
|
||||||
<slot>
|
|
||||||
<span>{{ desc }}</span>
|
|
||||||
</slot>
|
|
||||||
</NTooltip>
|
|
||||||
</template>
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'LangSwitch',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
showTooltip: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Current language */
|
|
||||||
lang: App.I18n.LangType
|
|
||||||
/** Language options */
|
|
||||||
langOptions: App.I18n.LangOption[]
|
|
||||||
/** Show tooltip */
|
|
||||||
showTooltip?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'changeLang', lang: App.I18n.LangType): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const tooltipContent = computed(() => {
|
|
||||||
if (!props.showTooltip)
|
|
||||||
return ''
|
|
||||||
|
|
||||||
return $t('icon.lang')
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Add bottom margin to all options except the last one for proper visual separation */
|
|
||||||
const dropdownOptions = computed(() => {
|
|
||||||
const lastIndex = props.langOptions.length - 1
|
|
||||||
|
|
||||||
return props.langOptions.map((option, index) => ({
|
|
||||||
...option,
|
|
||||||
props: {
|
|
||||||
class: index < lastIndex ? 'mb-1' : undefined,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
function changeLang(lang: App.I18n.LangType) {
|
|
||||||
emit('changeLang', lang)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NDropdown :value="lang" :options="dropdownOptions" trigger="hover" @select="changeLang">
|
|
||||||
<div>
|
|
||||||
<ButtonIcon :tooltip-content="tooltipContent" tooltip-placement="left">
|
|
||||||
<SvgIcon icon="heroicons:language" />
|
|
||||||
</ButtonIcon>
|
|
||||||
</div>
|
|
||||||
</NDropdown>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({ name: 'MenuToggler' })
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
arrowIcon: false,
|
|
||||||
zIndex: 98,
|
|
||||||
})
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Show collapsed icon */
|
|
||||||
collapsed?: boolean
|
|
||||||
/** Arrow style icon */
|
|
||||||
arrowIcon?: boolean
|
|
||||||
zIndex?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type NumberBool = 0 | 1
|
|
||||||
|
|
||||||
const icon = computed(() => {
|
|
||||||
const icons: Record<NumberBool, Record<NumberBool, string>> = {
|
|
||||||
0: {
|
|
||||||
0: 'line-md:menu-fold-left',
|
|
||||||
1: 'line-md:menu-fold-right',
|
|
||||||
},
|
|
||||||
1: {
|
|
||||||
0: 'ph-caret-double-left-bold',
|
|
||||||
1: 'ph-caret-double-right-bold',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrowIcon = Number(props.arrowIcon || false) as NumberBool
|
|
||||||
|
|
||||||
const collapsed = Number(props.collapsed || false) as NumberBool
|
|
||||||
|
|
||||||
return icons[arrowIcon][collapsed]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ButtonIcon
|
|
||||||
:key="String(collapsed)"
|
|
||||||
:tooltip-content="collapsed ? $t('icon.expand') : $t('icon.collapse')"
|
|
||||||
tooltip-placement="bottom-start"
|
|
||||||
:z-index="zIndex"
|
|
||||||
>
|
|
||||||
<SvgIcon :icon="icon" />
|
|
||||||
</ButtonIcon>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({ name: 'PinToggler' })
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
pin?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = computed(() => (props.pin ? 'mdi-pin-off' : 'mdi-pin'))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ButtonIcon
|
|
||||||
:tooltip-content="pin ? $t('icon.unpin') : $t('icon.pin')"
|
|
||||||
tooltip-placement="bottom-start"
|
|
||||||
:z-index="100"
|
|
||||||
>
|
|
||||||
<SvgIcon :icon="icon" />
|
|
||||||
</ButtonIcon>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ReloadButton',
|
|
||||||
})
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
loading?: boolean
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ButtonIcon :tooltip-content="$t('icon.reload')">
|
|
||||||
<icon-ant-design-reload-outlined :class="{ 'animate-spin animate-duration-750': loading }" />
|
|
||||||
</ButtonIcon>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="app-logo">
|
|
||||||
<svg
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
version="1.1"
|
|
||||||
viewBox="0 0 1000 1000"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
||||||
>
|
|
||||||
<g>
|
|
||||||
<path
|
|
||||||
d="M 200,866 C 100,866 50,779.4 100,692.8 L 200,519.6 C 220,485 240,490 265,499.6 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
|
|
||||||
fill="url(#LinearGradient)"
|
|
||||||
fill-rule="nonzero"
|
|
||||||
opacity="1"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M 420,866 C 455,861 478,846 500,827 C 614,696 615,597 500,517 C 394,444 333,374 380,207.82 L 260,415.67 C 240.22,450 254.37,465.1 275.28,481.79 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866"
|
|
||||||
fill="url(#LinearGradient_2)"
|
|
||||||
fill-rule="nonzero"
|
|
||||||
opacity="1"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M 500,517 C 394,444 333,374 380,207.82 L 400,173.2 C 367,295 421,350 603,428 C 572,440 524,474 500,517"
|
|
||||||
fill="url(#LinearGradient_3)"
|
|
||||||
fill-rule="nonzero"
|
|
||||||
opacity="1"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 572,440 524,474 500,517 C 615,597 614,696 500,827"
|
|
||||||
fill="url(#LinearGradient_4)"
|
|
||||||
fill-rule="nonzero"
|
|
||||||
opacity="1"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M 400,173.2 C 367,295 421,350 603,428 C 690,389, 750,445 788,500 L 600,173.2 C 550,86.6 450,86.6 400,173.2"
|
|
||||||
fill="url(#LinearGradient_5)"
|
|
||||||
fill-rule="nonzero"
|
|
||||||
opacity="1"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 690,389, 750,445 788,500 C 816,554 797,606 750,640 L 500,827"
|
|
||||||
fill="url(#LinearGradient_6)"
|
|
||||||
fill-rule="nonzero"
|
|
||||||
opacity="1"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M 788,500 C 816,554 797,606 750,640 L 500,827 C 497,851 513,862 540,866 L 800,866 C 900,866 950,779.4 900,692.8 L 788,500"
|
|
||||||
fill="url(#LinearGradient_7)"
|
|
||||||
fill-rule="nonzero"
|
|
||||||
opacity="1"
|
|
||||||
stroke="none"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient
|
|
||||||
id="LinearGradient"
|
|
||||||
gradientTransform="matrix(104.391 -73.3432 73.3432 104.391 277.441 710.122)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
x1="0"
|
|
||||||
x2="1"
|
|
||||||
y1="0"
|
|
||||||
y2="0"
|
|
||||||
>
|
|
||||||
<stop offset="0" stop-color="var(--logo-color-700)" />
|
|
||||||
<stop offset="1" stop-color="var(--logo-color-600)" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient
|
|
||||||
id="LinearGradient_2"
|
|
||||||
gradientTransform="matrix(-173.747 557.324 -557.324 -173.747 508.829 258.172)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
x1="0"
|
|
||||||
x2="1"
|
|
||||||
y1="0"
|
|
||||||
y2="0"
|
|
||||||
>
|
|
||||||
<stop offset="0" stop-color="var(--logo-color-300)" />
|
|
||||||
<stop offset="1" stop-color="var(--logo-color-500)" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient
|
|
||||||
id="LinearGradient_3"
|
|
||||||
gradientTransform="matrix(157.951 295.666 -295.666 157.951 382.944 193.642)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
x1="0"
|
|
||||||
x2="1"
|
|
||||||
y1="0"
|
|
||||||
y2="0"
|
|
||||||
>
|
|
||||||
<stop offset="0" stop-color="var(--logo-color-600)" />
|
|
||||||
<stop offset="1" stop-color="var(--logo-color-700)" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient
|
|
||||||
id="LinearGradient_4"
|
|
||||||
gradientTransform="matrix(-44.3023 219.578 -219.578 -44.3023 619.69 469.652)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
x1="0"
|
|
||||||
x2="1"
|
|
||||||
y1="0"
|
|
||||||
y2="0"
|
|
||||||
>
|
|
||||||
<stop offset="0" stop-color="var(--logo-color-400)" />
|
|
||||||
<stop offset="1" stop-color="var(--logo-color-600)" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient
|
|
||||||
id="LinearGradient_5"
|
|
||||||
gradientTransform="matrix(125.52 334.256 -334.256 125.52 539.723 235.139)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
x1="0"
|
|
||||||
x2="1"
|
|
||||||
y1="0"
|
|
||||||
y2="0"
|
|
||||||
>
|
|
||||||
<stop offset="0" stop-color="var(--logo-color-500)" />
|
|
||||||
<stop offset="1" stop-color="var(--logo-color-300)" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient
|
|
||||||
id="LinearGradient_6"
|
|
||||||
gradientTransform="matrix(-241.23 357.206 -357.206 -241.23 754.054 449.312)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
x1="0"
|
|
||||||
x2="1"
|
|
||||||
y1="0"
|
|
||||||
y2="0"
|
|
||||||
>
|
|
||||||
<stop offset="0" stop-color="var(--logo-color-300)" />
|
|
||||||
<stop offset="1" stop-color="var(--logo-color-500)" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient
|
|
||||||
id="LinearGradient_7"
|
|
||||||
gradientTransform="matrix(125.978 210.065 -210.065 125.978 596.433 613.665)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
x1="0"
|
|
||||||
x2="1"
|
|
||||||
y1="0"
|
|
||||||
y2="0"
|
|
||||||
>
|
|
||||||
<stop offset="0" stop-color="var(--logo-color-700)" />
|
|
||||||
<stop offset="1" stop-color="var(--logo-color-600)" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.app-logo {
|
|
||||||
--logo-color-300: rgb(var(--primary-300-color));
|
|
||||||
--logo-color-400: rgb(var(--primary-400-color));
|
|
||||||
--logo-color-500: rgb(var(--primary-500-color));
|
|
||||||
--logo-color-600: rgb(var(--primary-600-color));
|
|
||||||
--logo-color-700: rgb(var(--primary-700-color));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { PopoverPlacement } from 'naive-ui'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({ name: 'ThemeSchemaSwitch' })
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
showTooltip: true,
|
|
||||||
tooltipPlacement: 'bottom',
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Theme schema */
|
|
||||||
themeSchema: UnionKey.ThemeScheme
|
|
||||||
/** Show tooltip */
|
|
||||||
showTooltip?: boolean
|
|
||||||
/** Tooltip placement */
|
|
||||||
tooltipPlacement?: PopoverPlacement
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'switch'): void
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSwitch() {
|
|
||||||
emit('switch')
|
|
||||||
}
|
|
||||||
|
|
||||||
const icons: Record<UnionKey.ThemeScheme, string> = {
|
|
||||||
light: 'material-symbols:sunny',
|
|
||||||
dark: 'material-symbols:nightlight-rounded',
|
|
||||||
auto: 'material-symbols:hdr-auto',
|
|
||||||
}
|
|
||||||
|
|
||||||
const icon = computed(() => icons[props.themeSchema])
|
|
||||||
|
|
||||||
const tooltipContent = computed(() => {
|
|
||||||
if (!props.showTooltip)
|
|
||||||
return ''
|
|
||||||
|
|
||||||
return $t('icon.themeSchema')
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ButtonIcon
|
|
||||||
:icon="icon"
|
|
||||||
:tooltip-content="tooltipContent"
|
|
||||||
:tooltip-placement="tooltipPlacement"
|
|
||||||
@click="handleSwitch"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { Options } from '@better-scroll/core'
|
|
||||||
import BScroll from '@better-scroll/core'
|
|
||||||
import { useElementSize } from '@vueuse/core'
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
defineOptions({ name: 'BetterScroll' })
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* BetterScroll options
|
|
||||||
*
|
|
||||||
* @link https://better-scroll.github.io/docs/zh-CN/guide/base-scroll-options.html
|
|
||||||
*/
|
|
||||||
options: Options
|
|
||||||
}
|
|
||||||
|
|
||||||
const bsWrapper = ref<HTMLElement>()
|
|
||||||
const bsContent = ref<HTMLElement>()
|
|
||||||
const { width: wrapWidth } = useElementSize(bsWrapper)
|
|
||||||
const { width, height } = useElementSize(bsContent)
|
|
||||||
|
|
||||||
const instance = ref<BScroll>()
|
|
||||||
const isScrollY = computed(() => Boolean(props.options.scrollY))
|
|
||||||
|
|
||||||
function initBetterScroll() {
|
|
||||||
if (!bsWrapper.value)
|
|
||||||
return
|
|
||||||
instance.value = new BScroll(bsWrapper.value, props.options)
|
|
||||||
}
|
|
||||||
|
|
||||||
// refresh BS when scroll element size changed
|
|
||||||
watch([() => wrapWidth.value, () => width.value, () => height.value], () => {
|
|
||||||
instance.value?.refresh()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
initBetterScroll()
|
|
||||||
})
|
|
||||||
|
|
||||||
defineExpose({ instance })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div ref="bsWrapper" class="h-full text-left">
|
|
||||||
<div ref="bsContent" class="inline-block" :class="{ 'h-full': !isScrollY }">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { PopoverPlacement } from 'naive-ui'
|
|
||||||
import { twMerge } from 'tailwind-merge'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ButtonIcon',
|
|
||||||
inheritAttrs: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
class: '',
|
|
||||||
icon: '',
|
|
||||||
tooltipContent: '',
|
|
||||||
tooltipPlacement: 'bottom',
|
|
||||||
zIndex: 98,
|
|
||||||
})
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Button class */
|
|
||||||
class?: string
|
|
||||||
/** Iconify icon name */
|
|
||||||
icon?: string
|
|
||||||
/** Tooltip content */
|
|
||||||
tooltipContent?: string
|
|
||||||
/** Tooltip placement */
|
|
||||||
tooltipPlacement?: PopoverPlacement
|
|
||||||
zIndex?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CLASS = 'h-[36px] text-icon'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NTooltip :placement="tooltipPlacement" :z-index="zIndex" :disabled="!tooltipContent">
|
|
||||||
<template #trigger>
|
|
||||||
<NButton quaternary :class="twMerge(DEFAULT_CLASS, props.class)" v-bind="$attrs">
|
|
||||||
<div class="flex-center gap-8px">
|
|
||||||
<slot>
|
|
||||||
<SvgIcon :icon="icon" />
|
|
||||||
</slot>
|
|
||||||
</div>
|
|
||||||
</NButton>
|
|
||||||
</template>
|
|
||||||
{{ tooltipContent }}
|
|
||||||
</NTooltip>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { TransitionPresets, useTransition } from '@vueuse/core'
|
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'CountTo',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
startValue: 0,
|
|
||||||
endValue: 2021,
|
|
||||||
duration: 1500,
|
|
||||||
autoplay: true,
|
|
||||||
decimals: 0,
|
|
||||||
prefix: '',
|
|
||||||
suffix: '',
|
|
||||||
separator: ',',
|
|
||||||
decimal: '.',
|
|
||||||
useEasing: true,
|
|
||||||
transition: 'linear',
|
|
||||||
})
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
startValue?: number
|
|
||||||
endValue?: number
|
|
||||||
duration?: number
|
|
||||||
autoplay?: boolean
|
|
||||||
decimals?: number
|
|
||||||
prefix?: string
|
|
||||||
suffix?: string
|
|
||||||
separator?: string
|
|
||||||
decimal?: string
|
|
||||||
useEasing?: boolean
|
|
||||||
transition?: keyof typeof TransitionPresets
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = ref(props.startValue)
|
|
||||||
|
|
||||||
const transition = computed(() => (props.useEasing ? TransitionPresets[props.transition] : undefined))
|
|
||||||
|
|
||||||
const outputValue = useTransition(source, {
|
|
||||||
disabled: false,
|
|
||||||
duration: props.duration,
|
|
||||||
transition: transition.value,
|
|
||||||
})
|
|
||||||
|
|
||||||
const value = computed(() => formatValue(outputValue.value))
|
|
||||||
|
|
||||||
function formatValue(num: number) {
|
|
||||||
const { decimals, decimal, separator, suffix, prefix } = props
|
|
||||||
|
|
||||||
let number = num.toFixed(decimals)
|
|
||||||
number = String(number)
|
|
||||||
|
|
||||||
const x = number.split('.')
|
|
||||||
let x1 = x[0]
|
|
||||||
const x2 = x.length > 1 ? decimal + x[1] : ''
|
|
||||||
const rgx = /(\d+)(\d{3})/
|
|
||||||
if (separator) {
|
|
||||||
while (rgx.test(x1)) {
|
|
||||||
x1 = x1.replace(rgx, `$1${separator}$2`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return prefix + x1 + x2 + suffix
|
|
||||||
}
|
|
||||||
|
|
||||||
async function start() {
|
|
||||||
await nextTick()
|
|
||||||
source.value = props.endValue
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
[() => props.startValue, () => props.endValue],
|
|
||||||
() => {
|
|
||||||
if (props.autoplay) {
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<span>{{ value }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'LookForward',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="size-full min-h-520px flex-col-center gap-24px overflow-hidden">
|
|
||||||
<div class="flex text-400px text-primary">
|
|
||||||
<SvgIcon local-icon="expectation" />
|
|
||||||
</div>
|
|
||||||
<slot>
|
|
||||||
<h3 class="text-28px text-primary font-500">
|
|
||||||
{{ $t('common.lookForward') }}
|
|
||||||
</h3>
|
|
||||||
</slot>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineOptions({
|
|
||||||
name: 'ReaderStarAvatar',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="size-72px overflow-hidden rd-1/2">
|
|
||||||
<img src="@/assets/imgs/reader-star.jpg" class="size-full">
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { Icon } from '@iconify/vue'
|
|
||||||
import { computed, useAttrs } from 'vue'
|
|
||||||
|
|
||||||
defineOptions({ name: 'SvgIcon', inheritAttrs: false })
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props
|
|
||||||
*
|
|
||||||
* - Support iconify and local svg icon
|
|
||||||
* - If icon and localIcon are passed at the same time, localIcon will be rendered first
|
|
||||||
*/
|
|
||||||
interface Props {
|
|
||||||
/** Iconify icon name */
|
|
||||||
icon?: string
|
|
||||||
/** Local svg icon name */
|
|
||||||
localIcon?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const attrs = useAttrs()
|
|
||||||
|
|
||||||
const bindAttrs = computed<{ class: string, style: string }>(() => ({
|
|
||||||
class: (attrs.class as string) || '',
|
|
||||||
style: (attrs.style as string) || '',
|
|
||||||
}))
|
|
||||||
|
|
||||||
const symbolId = computed(() => {
|
|
||||||
const { VITE_ICON_LOCAL_PREFIX: prefix } = import.meta.env
|
|
||||||
|
|
||||||
const defaultLocalIcon = 'no-icon'
|
|
||||||
|
|
||||||
const icon = props.localIcon || defaultLocalIcon
|
|
||||||
|
|
||||||
return `#${prefix}-${icon}`
|
|
||||||
})
|
|
||||||
|
|
||||||
/** If localIcon is passed, render localIcon first */
|
|
||||||
const renderLocalIcon = computed(() => props.localIcon || !props.icon)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<template v-if="renderLocalIcon">
|
|
||||||
<svg aria-hidden="true" width="1em" height="1em" v-bind="bindAttrs">
|
|
||||||
<use :xlink:href="symbolId" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" />
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { getPaletteColorByNumber } from '@sa/color'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
defineOptions({ name: 'WaveBg' })
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Theme color */
|
|
||||||
themeColor: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const lightColor = computed(() => getPaletteColorByNumber(props.themeColor, 200))
|
|
||||||
const darkColor = computed(() => getPaletteColorByNumber(props.themeColor, 500))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="absolute-lt z-1 size-full overflow-hidden">
|
|
||||||
<div class="absolute -right-300px -top-900px lt-sm:(-right-100px -top-1170px)">
|
|
||||||
<svg height="1337" width="1337">
|
|
||||||
<defs>
|
|
||||||
<path
|
|
||||||
id="path-1"
|
|
||||||
opacity="1"
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M1337,668.5 C1337,1037.455193874239 1037.455193874239,1337 668.5,1337 C523.6725684305388,1337 337,1236 370.50000000000006,1094 C434.03835568300906,824.6732385973953 6.906089672974592e-14,892.6277623047779 0,668.5000000000001 C0,299.5448061257611 299.5448061257609,1.1368683772161603e-13 668.4999999999999,0 C1037.455193874239,0 1337,299.544806125761 1337,668.5Z"
|
|
||||||
/>
|
|
||||||
<linearGradient id="linearGradient-2" x1="0.79" y1="0.62" x2="0.21" y2="0.86">
|
|
||||||
<stop offset="0" :stop-color="lightColor" stop-opacity="1" />
|
|
||||||
<stop offset="1" :stop-color="darkColor" stop-opacity="1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g opacity="1">
|
|
||||||
<use xlink:href="#path-1" fill="url(#linearGradient-2)" fill-opacity="1" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="absolute -bottom-400px -left-200px lt-sm:(-bottom-760px -left-100px)">
|
|
||||||
<svg height="896" width="967.8852157128662">
|
|
||||||
<defs>
|
|
||||||
<path
|
|
||||||
id="path-2"
|
|
||||||
opacity="1"
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M896,448 C1142.6325445712241,465.5747656464056 695.2579309733121,896 448,896 C200.74206902668806,896 5.684341886080802e-14,695.2579309733121 0,448.0000000000001 C0,200.74206902668806 200.74206902668791,5.684341886080802e-14 447.99999999999994,0 C695.2579309733121,0 475,418 896,448Z"
|
|
||||||
/>
|
|
||||||
<linearGradient id="linearGradient-3" x1="0.5" y1="0" x2="0.5" y2="1">
|
|
||||||
<stop offset="0" :stop-color="darkColor" stop-opacity="1" />
|
|
||||||
<stop offset="1" :stop-color="lightColor" stop-opacity="1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<g opacity="1">
|
|
||||||
<use xlink:href="#path-2" fill="url(#linearGradient-3)" fill-opacity="1" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import { transformRecordToOption } from '@/utils/common'
|
|
||||||
|
|
||||||
export const GLOBAL_HEADER_MENU_ID = '__GLOBAL_HEADER_MENU__'
|
|
||||||
|
|
||||||
export const GLOBAL_SIDER_MENU_ID = '__GLOBAL_SIDER_MENU__'
|
|
||||||
|
|
||||||
export const themeSchemaRecord: Record<UnionKey.ThemeScheme, App.I18n.I18nKey> = {
|
|
||||||
light: 'theme.appearance.themeSchema.light',
|
|
||||||
dark: 'theme.appearance.themeSchema.dark',
|
|
||||||
auto: 'theme.appearance.themeSchema.auto',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const themeSchemaOptions = transformRecordToOption(themeSchemaRecord)
|
|
||||||
|
|
||||||
export const loginModuleRecord: Record<UnionKey.LoginModule, App.I18n.I18nKey> = {
|
|
||||||
'pwd-login': 'page.login.pwdLogin.title',
|
|
||||||
'code-login': 'page.login.codeLogin.title',
|
|
||||||
'register': 'page.login.register.title',
|
|
||||||
'reset-pwd': 'page.login.resetPwd.title',
|
|
||||||
'bind-wechat': 'page.login.bindWeChat.title',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const themeLayoutModeRecord: Record<UnionKey.ThemeLayoutMode, App.I18n.I18nKey> = {
|
|
||||||
'vertical': 'theme.layout.layoutMode.vertical',
|
|
||||||
'vertical-mix': 'theme.layout.layoutMode.vertical-mix',
|
|
||||||
'vertical-hybrid-header-first': 'theme.layout.layoutMode.vertical-hybrid-header-first',
|
|
||||||
'horizontal': 'theme.layout.layoutMode.horizontal',
|
|
||||||
'top-hybrid-sidebar-first': 'theme.layout.layoutMode.top-hybrid-sidebar-first',
|
|
||||||
'top-hybrid-header-first': 'theme.layout.layoutMode.top-hybrid-header-first',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const themeLayoutModeOptions = transformRecordToOption(themeLayoutModeRecord)
|
|
||||||
|
|
||||||
export const themeScrollModeRecord: Record<UnionKey.ThemeScrollMode, App.I18n.I18nKey> = {
|
|
||||||
wrapper: 'theme.layout.content.scrollMode.wrapper',
|
|
||||||
content: 'theme.layout.content.scrollMode.content',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const themeScrollModeOptions = transformRecordToOption(themeScrollModeRecord)
|
|
||||||
|
|
||||||
export const themeTabModeRecord: Record<UnionKey.ThemeTabMode, App.I18n.I18nKey> = {
|
|
||||||
chrome: 'theme.layout.tab.mode.chrome',
|
|
||||||
button: 'theme.layout.tab.mode.button',
|
|
||||||
slider: 'theme.layout.tab.mode.slider',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const themeTabModeOptions = transformRecordToOption(themeTabModeRecord)
|
|
||||||
|
|
||||||
export const themePageAnimationModeRecord: Record<UnionKey.ThemePageAnimateMode, App.I18n.I18nKey> = {
|
|
||||||
'fade-slide': 'theme.layout.content.page.mode.fade-slide',
|
|
||||||
'fade': 'theme.layout.content.page.mode.fade',
|
|
||||||
'fade-bottom': 'theme.layout.content.page.mode.fade-bottom',
|
|
||||||
'fade-scale': 'theme.layout.content.page.mode.fade-scale',
|
|
||||||
'zoom-fade': 'theme.layout.content.page.mode.zoom-fade',
|
|
||||||
'zoom-out': 'theme.layout.content.page.mode.zoom-out',
|
|
||||||
'none': 'theme.layout.content.page.mode.none',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const themePageAnimationModeOptions = transformRecordToOption(themePageAnimationModeRecord)
|
|
||||||
|
|
||||||
export const DARK_CLASS = 'dark'
|
|
||||||
|
|
||||||
export const watermarkTimeFormatOptions = [
|
|
||||||
{ label: 'YYYY-MM-DD HH:mm', value: 'YYYY-MM-DD HH:mm' },
|
|
||||||
{ label: 'YYYY-MM-DD HH:mm:ss', value: 'YYYY-MM-DD HH:mm:ss' },
|
|
||||||
{ label: 'YYYY/MM/DD HH:mm', value: 'YYYY/MM/DD HH:mm' },
|
|
||||||
{ label: 'YYYY/MM/DD HH:mm:ss', value: 'YYYY/MM/DD HH:mm:ss' },
|
|
||||||
{ label: 'HH:mm', value: 'HH:mm' },
|
|
||||||
{ label: 'HH:mm:ss', value: 'HH:mm:ss' },
|
|
||||||
{ label: 'MM-DD HH:mm', value: 'MM-DD HH:mm' },
|
|
||||||
]
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import { transformRecordToOption } from '@/utils/common'
|
|
||||||
|
|
||||||
export const yesOrNoRecord: Record<CommonType.YesOrNo, App.I18n.I18nKey> = {
|
|
||||||
Y: 'common.yesOrNo.yes',
|
|
||||||
N: 'common.yesOrNo.no',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const yesOrNoOptions = transformRecordToOption(yesOrNoRecord)
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
export const REG_USER_NAME = /^[\u4E00-\u9FA5\w-]{4,16}$/
|
|
||||||
|
|
||||||
/** Phone reg */
|
|
||||||
export const REG_PHONE
|
|
||||||
= /^1((3\d)|(4[014-9])|(5[0-35-9])|(6[2567])|(7[0-8])|(8\d)|(9[0-35-9]))\d{8}$/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Password reg
|
|
||||||
*
|
|
||||||
* 6-18 characters, including letters, numbers, and underscores
|
|
||||||
*/
|
|
||||||
export const REG_PWD = /^\w{6,18}$/
|
|
||||||
|
|
||||||
/** Email reg */
|
|
||||||
export const REG_EMAIL = /^\w+([-+.]\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$/
|
|
||||||
|
|
||||||
/** Six digit code reg */
|
|
||||||
export const REG_CODE_SIX = /^\d{6}$/
|
|
||||||
|
|
||||||
/** Four digit code reg */
|
|
||||||
export const REG_CODE_FOUR = /^\d{4}$/
|
|
||||||
|
|
||||||
/** Url reg */
|
|
||||||
export const REG_URL
|
|
||||||
= /(((^https?:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-]*)?\??[-+=&;%@.\w]*(?:#\w*)?)?)$/
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
export enum SetupStoreId {
|
|
||||||
App = 'app-store',
|
|
||||||
Theme = 'theme-store',
|
|
||||||
Auth = 'auth-store',
|
|
||||||
Route = 'route-store',
|
|
||||||
Tab = 'tab-store',
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import { useAuthStore } from '@/store/modules/auth'
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
function hasAuth(codes: string | string[]) {
|
|
||||||
if (!authStore.isLogin) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof codes === 'string') {
|
|
||||||
return authStore.userInfo.buttons.includes(codes)
|
|
||||||
}
|
|
||||||
|
|
||||||
return codes.some(code => authStore.userInfo.buttons.includes(code))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasAuth,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
import { useCountDown, useLoading } from '@sa/hooks'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { REG_PHONE } from '@/constants/reg'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
export function useCaptcha() {
|
|
||||||
const { loading, startLoading, endLoading } = useLoading()
|
|
||||||
const { count, start, stop, isCounting } = useCountDown(10)
|
|
||||||
|
|
||||||
const label = computed(() => {
|
|
||||||
let text = $t('page.login.codeLogin.getCode')
|
|
||||||
|
|
||||||
const countingLabel = $t('page.login.codeLogin.reGetCode', { time: count.value })
|
|
||||||
|
|
||||||
if (loading.value) {
|
|
||||||
text = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isCounting.value) {
|
|
||||||
text = countingLabel
|
|
||||||
}
|
|
||||||
|
|
||||||
return text
|
|
||||||
})
|
|
||||||
|
|
||||||
function isPhoneValid(phone: string) {
|
|
||||||
if (phone.trim() === '') {
|
|
||||||
window.$message?.error?.($t('form.phone.required'))
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!REG_PHONE.test(phone)) {
|
|
||||||
window.$message?.error?.($t('form.phone.invalid'))
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCaptcha(phone: string) {
|
|
||||||
const valid = isPhoneValid(phone)
|
|
||||||
|
|
||||||
if (!valid || loading.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
startLoading()
|
|
||||||
|
|
||||||
// request
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, 500)
|
|
||||||
})
|
|
||||||
|
|
||||||
window.$message?.success?.($t('page.login.codeLogin.sendCodeSuccess'))
|
|
||||||
|
|
||||||
start()
|
|
||||||
|
|
||||||
endLoading()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
start,
|
|
||||||
stop,
|
|
||||||
isCounting,
|
|
||||||
loading,
|
|
||||||
getCaptcha,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,233 +0,0 @@
|
|||||||
import type {
|
|
||||||
BarSeriesOption,
|
|
||||||
GaugeSeriesOption,
|
|
||||||
LineSeriesOption,
|
|
||||||
PictorialBarSeriesOption,
|
|
||||||
PieSeriesOption,
|
|
||||||
RadarSeriesOption,
|
|
||||||
ScatterSeriesOption,
|
|
||||||
} from 'echarts/charts'
|
|
||||||
import type {
|
|
||||||
DatasetComponentOption,
|
|
||||||
GridComponentOption,
|
|
||||||
LegendComponentOption,
|
|
||||||
TitleComponentOption,
|
|
||||||
ToolboxComponentOption,
|
|
||||||
TooltipComponentOption,
|
|
||||||
} from 'echarts/components'
|
|
||||||
import { useElementSize } from '@vueuse/core'
|
|
||||||
import { BarChart, GaugeChart, LineChart, PictorialBarChart, PieChart, RadarChart, ScatterChart } from 'echarts/charts'
|
|
||||||
import {
|
|
||||||
DatasetComponent,
|
|
||||||
GridComponent,
|
|
||||||
LegendComponent,
|
|
||||||
TitleComponent,
|
|
||||||
ToolboxComponent,
|
|
||||||
TooltipComponent,
|
|
||||||
TransformComponent,
|
|
||||||
} from 'echarts/components'
|
|
||||||
import * as echarts from 'echarts/core'
|
|
||||||
import { LabelLayout, UniversalTransition } from 'echarts/features'
|
|
||||||
import { CanvasRenderer } from 'echarts/renderers'
|
|
||||||
import { computed, effectScope, nextTick, onScopeDispose, shallowRef, watch } from 'vue'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
|
|
||||||
export type ECOption = echarts.ComposeOption<
|
|
||||||
| BarSeriesOption
|
|
||||||
| LineSeriesOption
|
|
||||||
| PieSeriesOption
|
|
||||||
| ScatterSeriesOption
|
|
||||||
| PictorialBarSeriesOption
|
|
||||||
| RadarSeriesOption
|
|
||||||
| GaugeSeriesOption
|
|
||||||
| TitleComponentOption
|
|
||||||
| LegendComponentOption
|
|
||||||
| TooltipComponentOption
|
|
||||||
| GridComponentOption
|
|
||||||
| ToolboxComponentOption
|
|
||||||
| DatasetComponentOption
|
|
||||||
>
|
|
||||||
|
|
||||||
echarts.use([
|
|
||||||
TitleComponent,
|
|
||||||
LegendComponent,
|
|
||||||
TooltipComponent,
|
|
||||||
GridComponent,
|
|
||||||
DatasetComponent,
|
|
||||||
TransformComponent,
|
|
||||||
ToolboxComponent,
|
|
||||||
BarChart,
|
|
||||||
LineChart,
|
|
||||||
PieChart,
|
|
||||||
ScatterChart,
|
|
||||||
PictorialBarChart,
|
|
||||||
RadarChart,
|
|
||||||
GaugeChart,
|
|
||||||
LabelLayout,
|
|
||||||
UniversalTransition,
|
|
||||||
CanvasRenderer,
|
|
||||||
])
|
|
||||||
|
|
||||||
interface ChartHooks {
|
|
||||||
onRender?: (chart: echarts.ECharts) => void | Promise<void>
|
|
||||||
onUpdated?: (chart: echarts.ECharts) => void | Promise<void>
|
|
||||||
onDestroy?: (chart: echarts.ECharts) => void | Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* use echarts
|
|
||||||
*
|
|
||||||
* @param optionsFactory echarts options factory function
|
|
||||||
* @param darkMode dark mode
|
|
||||||
*/
|
|
||||||
export function useEcharts<T extends ECOption>(optionsFactory: () => T, hooks: ChartHooks = {}) {
|
|
||||||
const scope = effectScope()
|
|
||||||
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
const darkMode = computed(() => themeStore.darkMode)
|
|
||||||
|
|
||||||
const domRef = shallowRef<HTMLElement | null>(null)
|
|
||||||
const initialSize = { width: 0, height: 0 }
|
|
||||||
const { width, height } = useElementSize(domRef, initialSize)
|
|
||||||
|
|
||||||
const chart = shallowRef<echarts.ECharts | null>(null)
|
|
||||||
const chartOptions: T = optionsFactory()
|
|
||||||
|
|
||||||
const {
|
|
||||||
onRender = (instance) => {
|
|
||||||
const textColor = darkMode.value ? 'rgb(224, 224, 224)' : 'rgb(31, 31, 31)'
|
|
||||||
const maskColor = darkMode.value ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.8)'
|
|
||||||
|
|
||||||
instance.showLoading({
|
|
||||||
color: themeStore.themeColor,
|
|
||||||
textColor,
|
|
||||||
fontSize: 14,
|
|
||||||
maskColor,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onUpdated = (instance) => {
|
|
||||||
instance.hideLoading()
|
|
||||||
},
|
|
||||||
onDestroy,
|
|
||||||
} = hooks
|
|
||||||
|
|
||||||
/** is chart rendered */
|
|
||||||
function isRendered() {
|
|
||||||
return Boolean(domRef.value && chart.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* update chart options
|
|
||||||
*
|
|
||||||
* @param callback callback function
|
|
||||||
*/
|
|
||||||
async function updateOptions(callback: (opts: T, optsFactory: () => T) => ECOption = () => chartOptions) {
|
|
||||||
const updatedOpts = callback(chartOptions, optionsFactory)
|
|
||||||
|
|
||||||
Object.assign(chartOptions, updatedOpts)
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
if (!isRendered())
|
|
||||||
return
|
|
||||||
|
|
||||||
if (isRendered()) {
|
|
||||||
chart.value?.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
chart.value?.setOption({ ...updatedOpts, backgroundColor: 'transparent' })
|
|
||||||
|
|
||||||
await onUpdated?.(chart.value!)
|
|
||||||
}
|
|
||||||
|
|
||||||
function setOptions(options: T) {
|
|
||||||
chart.value?.setOption(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** render chart */
|
|
||||||
async function render() {
|
|
||||||
if (isRendered())
|
|
||||||
return
|
|
||||||
|
|
||||||
const chartTheme = darkMode.value ? 'dark' : 'light'
|
|
||||||
|
|
||||||
chart.value = echarts.init(domRef.value, chartTheme)
|
|
||||||
|
|
||||||
chart.value?.setOption({ ...chartOptions, backgroundColor: 'transparent' })
|
|
||||||
|
|
||||||
await onRender?.(chart.value!)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** resize chart */
|
|
||||||
function resize() {
|
|
||||||
chart.value?.resize()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** destroy chart */
|
|
||||||
async function destroy() {
|
|
||||||
if (!chart.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
await onDestroy?.(chart.value)
|
|
||||||
chart.value?.dispose()
|
|
||||||
chart.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** change chart theme */
|
|
||||||
async function changeTheme() {
|
|
||||||
await destroy()
|
|
||||||
await render()
|
|
||||||
await onUpdated?.(chart.value!)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* render chart by size
|
|
||||||
*
|
|
||||||
* @param w width
|
|
||||||
* @param h height
|
|
||||||
*/
|
|
||||||
async function renderChartBySize(w: number, h: number) {
|
|
||||||
initialSize.width = w
|
|
||||||
initialSize.height = h
|
|
||||||
|
|
||||||
// resize chart
|
|
||||||
if (isRendered()) {
|
|
||||||
resize()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// render chart
|
|
||||||
await render()
|
|
||||||
|
|
||||||
if (chart.value) {
|
|
||||||
await onUpdated?.(chart.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.run(() => {
|
|
||||||
watch(
|
|
||||||
[width, height],
|
|
||||||
([newWidth, newHeight]) => {
|
|
||||||
renderChartBySize(newWidth, newHeight)
|
|
||||||
},
|
|
||||||
{ flush: 'post' },
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(darkMode, () => {
|
|
||||||
changeTheme()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onScopeDispose(() => {
|
|
||||||
destroy()
|
|
||||||
scope.stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
domRef,
|
|
||||||
chart,
|
|
||||||
updateOptions,
|
|
||||||
setOptions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
import type { FormInst } from 'naive-ui'
|
|
||||||
import type { ComputedRef, Ref } from 'vue'
|
|
||||||
import { ref, toValue } from 'vue'
|
|
||||||
import { REG_CODE_SIX, REG_EMAIL, REG_PHONE, REG_PWD, REG_USER_NAME } from '@/constants/reg'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
export function useFormRules() {
|
|
||||||
const patternRules = {
|
|
||||||
userName: {
|
|
||||||
pattern: REG_USER_NAME,
|
|
||||||
message: $t('form.userName.invalid'),
|
|
||||||
trigger: 'change',
|
|
||||||
},
|
|
||||||
phone: {
|
|
||||||
pattern: REG_PHONE,
|
|
||||||
message: $t('form.phone.invalid'),
|
|
||||||
trigger: 'change',
|
|
||||||
},
|
|
||||||
pwd: {
|
|
||||||
pattern: REG_PWD,
|
|
||||||
message: $t('form.pwd.invalid'),
|
|
||||||
trigger: 'change',
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
pattern: REG_CODE_SIX,
|
|
||||||
message: $t('form.code.invalid'),
|
|
||||||
trigger: 'change',
|
|
||||||
},
|
|
||||||
email: {
|
|
||||||
pattern: REG_EMAIL,
|
|
||||||
message: $t('form.email.invalid'),
|
|
||||||
trigger: 'change',
|
|
||||||
},
|
|
||||||
} satisfies Record<string, App.Global.FormRule>
|
|
||||||
|
|
||||||
const formRules = {
|
|
||||||
userName: [createRequiredRule($t('form.userName.required')), patternRules.userName],
|
|
||||||
phone: [createRequiredRule($t('form.phone.required')), patternRules.phone],
|
|
||||||
pwd: [createRequiredRule($t('form.pwd.required')), patternRules.pwd],
|
|
||||||
code: [createRequiredRule($t('form.code.required')), patternRules.code],
|
|
||||||
email: [createRequiredRule($t('form.email.required')), patternRules.email],
|
|
||||||
} satisfies Record<string, App.Global.FormRule[]>
|
|
||||||
|
|
||||||
/** the default required rule */
|
|
||||||
const defaultRequiredRule = createRequiredRule($t('form.required'))
|
|
||||||
|
|
||||||
function createRequiredRule(message: string): App.Global.FormRule {
|
|
||||||
return {
|
|
||||||
required: true,
|
|
||||||
message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** create a rule for confirming the password */
|
|
||||||
function createConfirmPwdRule(pwd: string | Ref<string> | ComputedRef<string>) {
|
|
||||||
const confirmPwdRule: App.Global.FormRule[] = [
|
|
||||||
{ required: true, message: $t('form.confirmPwd.required') },
|
|
||||||
{
|
|
||||||
asyncValidator: (rule, value) => {
|
|
||||||
if (value.trim() !== '' && value !== toValue(pwd)) {
|
|
||||||
return Promise.reject(rule.message)
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
message: $t('form.confirmPwd.invalid'),
|
|
||||||
trigger: 'input',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
return confirmPwdRule
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
patternRules,
|
|
||||||
formRules,
|
|
||||||
defaultRequiredRule,
|
|
||||||
createRequiredRule,
|
|
||||||
createConfirmPwdRule,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useNaiveForm() {
|
|
||||||
const formRef = ref<FormInst | null>(null)
|
|
||||||
|
|
||||||
async function validate() {
|
|
||||||
await formRef.value?.validate()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function restoreValidation() {
|
|
||||||
formRef.value?.restoreValidation()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
formRef,
|
|
||||||
validate,
|
|
||||||
restoreValidation,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { useSvgIconRender } from '@sa/hooks'
|
|
||||||
import SvgIcon from '@/components/custom/svg-icon.vue'
|
|
||||||
|
|
||||||
export function useSvgIcon() {
|
|
||||||
const { SvgIconVNode } = useSvgIconRender(SvgIcon)
|
|
||||||
|
|
||||||
return {
|
|
||||||
SvgIconVNode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import type { RouteKey } from '@elegant-router/types'
|
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { router as globalRouter } from '@/router'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Router push
|
|
||||||
*
|
|
||||||
* Jump to the specified route, it can replace function router.push
|
|
||||||
*
|
|
||||||
* @param inSetup Whether is in vue script setup
|
|
||||||
*/
|
|
||||||
export function useRouterPush(inSetup = true) {
|
|
||||||
const router = inSetup ? useRouter() : globalRouter
|
|
||||||
const route = globalRouter.currentRoute
|
|
||||||
|
|
||||||
const routerPush = router.push
|
|
||||||
|
|
||||||
const routerBack = router.back
|
|
||||||
|
|
||||||
async function routerPushByKey(key: RouteKey, options?: App.Global.RouterPushOptions) {
|
|
||||||
const { query, params } = options || {}
|
|
||||||
|
|
||||||
const routeLocation: RouteLocationRaw = {
|
|
||||||
name: key,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(query || {}).length) {
|
|
||||||
routeLocation.query = query
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(params || {}).length) {
|
|
||||||
routeLocation.params = params
|
|
||||||
}
|
|
||||||
|
|
||||||
return routerPush(routeLocation)
|
|
||||||
}
|
|
||||||
|
|
||||||
function routerPushByKeyWithMetaQuery(key: RouteKey) {
|
|
||||||
const allRoutes = router.getRoutes()
|
|
||||||
const meta = allRoutes.find(item => item.name === key)?.meta || null
|
|
||||||
|
|
||||||
const query: Record<string, string> = {}
|
|
||||||
|
|
||||||
meta?.query?.forEach((item) => {
|
|
||||||
query[item.key] = item.value
|
|
||||||
})
|
|
||||||
|
|
||||||
return routerPushByKey(key, { query })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toHome() {
|
|
||||||
return routerPushByKey('root')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to login page
|
|
||||||
*
|
|
||||||
* @param loginModule The login module
|
|
||||||
* @param redirectUrl The redirect url, if not specified, it will be the current route fullPath
|
|
||||||
*/
|
|
||||||
async function toLogin(loginModule?: UnionKey.LoginModule, redirectUrl?: string) {
|
|
||||||
const module = loginModule || 'pwd-login'
|
|
||||||
|
|
||||||
const options: App.Global.RouterPushOptions = {
|
|
||||||
params: {
|
|
||||||
module,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const redirect = redirectUrl || route.value.fullPath
|
|
||||||
|
|
||||||
options.query = {
|
|
||||||
redirect,
|
|
||||||
}
|
|
||||||
|
|
||||||
return routerPushByKey('login', options)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle login module
|
|
||||||
*
|
|
||||||
* @param module
|
|
||||||
*/
|
|
||||||
async function toggleLoginModule(module: UnionKey.LoginModule) {
|
|
||||||
const query = route.value.query as Record<string, string>
|
|
||||||
|
|
||||||
return routerPushByKey('login', { query, params: { module } })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Redirect from login
|
|
||||||
*
|
|
||||||
* @param [needRedirect] Whether to redirect after login. Default is `true`
|
|
||||||
*/
|
|
||||||
async function redirectFromLogin(needRedirect = true) {
|
|
||||||
const redirect = route.value.query?.redirect as string
|
|
||||||
|
|
||||||
if (needRedirect && redirect) {
|
|
||||||
await routerPush(redirect)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
await toHome()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
routerPush,
|
|
||||||
routerBack,
|
|
||||||
routerPushByKey,
|
|
||||||
routerPushByKeyWithMetaQuery,
|
|
||||||
toLogin,
|
|
||||||
toggleLoginModule,
|
|
||||||
redirectFromLogin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,315 +0,0 @@
|
|||||||
import type { FlatResponseData } from '@sa/axios'
|
|
||||||
import type { PaginationData, TableColumnCheck, UseTableOptions } from '@sa/hooks'
|
|
||||||
import type { PaginationProps } from 'naive-ui'
|
|
||||||
import type { Ref } from 'vue'
|
|
||||||
import { useBoolean, useTable } from '@sa/hooks'
|
|
||||||
import { jsonClone } from '@sa/utils'
|
|
||||||
import { computed, effectScope, onScopeDispose, reactive, shallowRef, watch } from 'vue'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
|
|
||||||
export type UseNaiveTableOptions<ResponseData, ApiData, Pagination extends boolean> = Omit<
|
|
||||||
UseTableOptions<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, Pagination>,
|
|
||||||
'pagination' | 'getColumnChecks' | 'getColumns'
|
|
||||||
> & {
|
|
||||||
/**
|
|
||||||
* get column visible
|
|
||||||
*
|
|
||||||
* @param column
|
|
||||||
*
|
|
||||||
* @default true
|
|
||||||
*
|
|
||||||
* @returns true if the column is visible, false otherwise
|
|
||||||
*/
|
|
||||||
getColumnVisible?: (column: NaiveUI.TableColumn<ApiData>) => boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const SELECTION_KEY = '__selection__'
|
|
||||||
|
|
||||||
const EXPAND_KEY = '__expand__'
|
|
||||||
|
|
||||||
export function useNaiveTable<ResponseData, ApiData>(options: UseNaiveTableOptions<ResponseData, ApiData, false>) {
|
|
||||||
const scope = effectScope()
|
|
||||||
const appStore = useAppStore()
|
|
||||||
|
|
||||||
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, false>({
|
|
||||||
...options,
|
|
||||||
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
|
|
||||||
getColumns,
|
|
||||||
})
|
|
||||||
|
|
||||||
// calculate the total width of the table this is used for horizontal scrolling
|
|
||||||
const scrollX = computed(() => {
|
|
||||||
return result.columns.value.reduce((acc, column) => {
|
|
||||||
return acc + Number(column.width ?? column.minWidth ?? 120)
|
|
||||||
}, 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
scope.run(() => {
|
|
||||||
watch(
|
|
||||||
() => appStore.locale,
|
|
||||||
() => {
|
|
||||||
result.reloadColumns()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
onScopeDispose(() => {
|
|
||||||
scope.stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
scrollX,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type PaginationParams = Pick<PaginationProps, 'page' | 'pageSize'>
|
|
||||||
|
|
||||||
type UseNaivePaginatedTableOptions<ResponseData, ApiData> = UseNaiveTableOptions<ResponseData, ApiData, true> & {
|
|
||||||
paginationProps?: Omit<PaginationProps, 'page' | 'pageSize' | 'itemCount'>
|
|
||||||
/**
|
|
||||||
* whether to show the total count of the table
|
|
||||||
*
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
showTotal?: boolean
|
|
||||||
onPaginationParamsChange?: (params: PaginationParams) => void | Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useNaivePaginatedTable<ResponseData, ApiData>(
|
|
||||||
options: UseNaivePaginatedTableOptions<ResponseData, ApiData>,
|
|
||||||
) {
|
|
||||||
const scope = effectScope()
|
|
||||||
const appStore = useAppStore()
|
|
||||||
|
|
||||||
const isMobile = computed(() => appStore.isMobile)
|
|
||||||
|
|
||||||
const showTotal = computed(() => options.showTotal ?? true)
|
|
||||||
|
|
||||||
const pagination = reactive({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
itemCount: 0,
|
|
||||||
showSizePicker: true,
|
|
||||||
pageSizes: [10, 15, 20, 25, 30],
|
|
||||||
prefix: showTotal.value ? page => $t('datatable.itemCount', { total: page.itemCount }) : undefined,
|
|
||||||
onUpdatePage(page) {
|
|
||||||
pagination.page = page
|
|
||||||
},
|
|
||||||
onUpdatePageSize(pageSize) {
|
|
||||||
pagination.pageSize = pageSize
|
|
||||||
pagination.page = 1
|
|
||||||
},
|
|
||||||
...options.paginationProps,
|
|
||||||
}) as PaginationProps
|
|
||||||
|
|
||||||
// this is for mobile, if the system does not support mobile, you can use `pagination` directly
|
|
||||||
const mobilePagination = computed(() => {
|
|
||||||
const p: PaginationProps = {
|
|
||||||
...pagination,
|
|
||||||
pageSlot: isMobile.value ? 3 : 9,
|
|
||||||
prefix: !isMobile.value && showTotal.value ? pagination.prefix : undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
return p
|
|
||||||
})
|
|
||||||
|
|
||||||
const paginationParams = computed(() => {
|
|
||||||
const { page, pageSize } = pagination
|
|
||||||
|
|
||||||
return {
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = useTable<ResponseData, ApiData, NaiveUI.TableColumn<ApiData>, true>({
|
|
||||||
...options,
|
|
||||||
pagination: true,
|
|
||||||
getColumnChecks: cols => getColumnChecks(cols, options.getColumnVisible),
|
|
||||||
getColumns,
|
|
||||||
onFetched: (data) => {
|
|
||||||
pagination.itemCount = data.total
|
|
||||||
pagination.pageSize = data.pageSize
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
async function getDataByPage(page: number = 1) {
|
|
||||||
if (page !== pagination.page) {
|
|
||||||
pagination.page = page
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await result.getData()
|
|
||||||
}
|
|
||||||
|
|
||||||
scope.run(() => {
|
|
||||||
watch(
|
|
||||||
() => appStore.locale,
|
|
||||||
() => {
|
|
||||||
result.reloadColumns()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(paginationParams, async (newVal) => {
|
|
||||||
await options.onPaginationParamsChange?.(newVal)
|
|
||||||
|
|
||||||
await result.getData()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onScopeDispose(() => {
|
|
||||||
scope.stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
getDataByPage,
|
|
||||||
pagination,
|
|
||||||
mobilePagination,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTableOperate<TableData>(
|
|
||||||
data: Ref<TableData[]>,
|
|
||||||
idKey: keyof TableData,
|
|
||||||
getData: () => Promise<void>,
|
|
||||||
) {
|
|
||||||
const { bool: drawerVisible, setTrue: openDrawer, setFalse: closeDrawer } = useBoolean()
|
|
||||||
|
|
||||||
const operateType = shallowRef<NaiveUI.TableOperateType>('add')
|
|
||||||
|
|
||||||
function handleAdd() {
|
|
||||||
operateType.value = 'add'
|
|
||||||
openDrawer()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** the editing row data */
|
|
||||||
const editingData = shallowRef<TableData | null>(null)
|
|
||||||
|
|
||||||
function handleEdit(id: TableData[keyof TableData]) {
|
|
||||||
operateType.value = 'edit'
|
|
||||||
const findItem = data.value.find(item => item[idKey] === id) || null
|
|
||||||
editingData.value = jsonClone(findItem)
|
|
||||||
|
|
||||||
openDrawer()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** the checked row keys of table */
|
|
||||||
const checkedRowKeys = shallowRef<string[]>([])
|
|
||||||
|
|
||||||
/** the hook after the batch delete operation is completed */
|
|
||||||
async function onBatchDeleted() {
|
|
||||||
window.$message?.success($t('common.deleteSuccess'))
|
|
||||||
|
|
||||||
checkedRowKeys.value = []
|
|
||||||
|
|
||||||
await getData()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** the hook after the delete operation is completed */
|
|
||||||
async function onDeleted() {
|
|
||||||
window.$message?.success($t('common.deleteSuccess'))
|
|
||||||
|
|
||||||
await getData()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
drawerVisible,
|
|
||||||
openDrawer,
|
|
||||||
closeDrawer,
|
|
||||||
operateType,
|
|
||||||
handleAdd,
|
|
||||||
editingData,
|
|
||||||
handleEdit,
|
|
||||||
checkedRowKeys,
|
|
||||||
onBatchDeleted,
|
|
||||||
onDeleted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function defaultTransform<ApiData>(
|
|
||||||
response: FlatResponseData<any, Api.Common.PaginatingQueryRecord<ApiData>>,
|
|
||||||
): PaginationData<ApiData> {
|
|
||||||
const { data, error } = response
|
|
||||||
|
|
||||||
if (!error) {
|
|
||||||
const { records, current, size, total } = data
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: records,
|
|
||||||
pageNum: current,
|
|
||||||
pageSize: size,
|
|
||||||
total,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: [],
|
|
||||||
pageNum: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
total: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getColumnChecks<Column extends NaiveUI.TableColumn<any>>(
|
|
||||||
cols: Column[],
|
|
||||||
getColumnVisible?: (column: Column) => boolean,
|
|
||||||
) {
|
|
||||||
const checks: TableColumnCheck[] = []
|
|
||||||
|
|
||||||
cols.forEach((column) => {
|
|
||||||
if (isTableColumnHasKey(column)) {
|
|
||||||
checks.push({
|
|
||||||
key: column.key as string,
|
|
||||||
title: column.title!,
|
|
||||||
checked: true,
|
|
||||||
visible: getColumnVisible?.(column) ?? true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else if (column.type === 'selection') {
|
|
||||||
checks.push({
|
|
||||||
key: SELECTION_KEY,
|
|
||||||
title: $t('common.check'),
|
|
||||||
checked: true,
|
|
||||||
visible: getColumnVisible?.(column) ?? false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else if (column.type === 'expand') {
|
|
||||||
checks.push({
|
|
||||||
key: EXPAND_KEY,
|
|
||||||
title: $t('common.expandColumn'),
|
|
||||||
checked: true,
|
|
||||||
visible: getColumnVisible?.(column) ?? false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return checks
|
|
||||||
}
|
|
||||||
|
|
||||||
function getColumns<Column extends NaiveUI.TableColumn<any>>(cols: Column[], checks: TableColumnCheck[]) {
|
|
||||||
const columnMap = new Map<string, Column>()
|
|
||||||
|
|
||||||
cols.forEach((column) => {
|
|
||||||
if (isTableColumnHasKey(column)) {
|
|
||||||
columnMap.set(column.key as string, column)
|
|
||||||
}
|
|
||||||
else if (column.type === 'selection') {
|
|
||||||
columnMap.set(SELECTION_KEY, column)
|
|
||||||
}
|
|
||||||
else if (column.type === 'expand') {
|
|
||||||
columnMap.set(EXPAND_KEY, column)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredColumns = checks.filter(item => item.checked).map(check => columnMap.get(check.key) as Column)
|
|
||||||
|
|
||||||
return filteredColumns
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isTableColumnHasKey<T>(column: NaiveUI.TableColumn<T>): column is NaiveUI.TableColumnWithKey<T> {
|
|
||||||
return Boolean((column as NaiveUI.TableColumnWithKey<T>).key)
|
|
||||||
}
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { LayoutMode } from '@sa/materials'
|
|
||||||
import { AdminLayout, LAYOUT_SCROLL_EL_ID } from '@sa/materials'
|
|
||||||
import { computed, defineAsyncComponent } from 'vue'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
import GlobalContent from '../modules/global-content/index.vue'
|
|
||||||
import GlobalFooter from '../modules/global-footer/index.vue'
|
|
||||||
import GlobalHeader from '../modules/global-header/index.vue'
|
|
||||||
import { provideMixMenuContext } from '../modules/global-menu/context'
|
|
||||||
import GlobalSider from '../modules/global-sider/index.vue'
|
|
||||||
import GlobalTab from '../modules/global-tab/index.vue'
|
|
||||||
import ThemeDrawer from '../modules/theme-drawer/index.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'BaseLayout',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
const { secondLevelMenus, childLevelMenus, isActiveFirstLevelMenuHasChildren } = provideMixMenuContext()
|
|
||||||
|
|
||||||
const GlobalMenu = defineAsyncComponent(() => import('../modules/global-menu/index.vue'))
|
|
||||||
|
|
||||||
const layoutMode = computed(() => {
|
|
||||||
const vertical: LayoutMode = 'vertical'
|
|
||||||
const horizontal: LayoutMode = 'horizontal'
|
|
||||||
return themeStore.layout.mode.includes(vertical) ? vertical : horizontal
|
|
||||||
})
|
|
||||||
|
|
||||||
const headerProps = computed(() => {
|
|
||||||
const { mode } = themeStore.layout
|
|
||||||
|
|
||||||
const headerPropsConfig: Record<UnionKey.ThemeLayoutMode, App.Global.HeaderProps> = {
|
|
||||||
'vertical': {
|
|
||||||
showLogo: false,
|
|
||||||
showMenu: false,
|
|
||||||
showMenuToggler: true,
|
|
||||||
},
|
|
||||||
'vertical-mix': {
|
|
||||||
showLogo: false,
|
|
||||||
showMenu: false,
|
|
||||||
showMenuToggler: false,
|
|
||||||
},
|
|
||||||
'vertical-hybrid-header-first': {
|
|
||||||
showLogo: !isActiveFirstLevelMenuHasChildren.value,
|
|
||||||
showMenu: true,
|
|
||||||
showMenuToggler: false,
|
|
||||||
},
|
|
||||||
'horizontal': {
|
|
||||||
showLogo: true,
|
|
||||||
showMenu: true,
|
|
||||||
showMenuToggler: false,
|
|
||||||
},
|
|
||||||
'top-hybrid-sidebar-first': {
|
|
||||||
showLogo: true,
|
|
||||||
showMenu: true,
|
|
||||||
showMenuToggler: false,
|
|
||||||
},
|
|
||||||
'top-hybrid-header-first': {
|
|
||||||
showLogo: true,
|
|
||||||
showMenu: true,
|
|
||||||
showMenuToggler: isActiveFirstLevelMenuHasChildren.value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return headerPropsConfig[mode]
|
|
||||||
})
|
|
||||||
|
|
||||||
const siderVisible = computed(() => themeStore.layout.mode !== 'horizontal')
|
|
||||||
|
|
||||||
const isVerticalMix = computed(() => themeStore.layout.mode === 'vertical-mix')
|
|
||||||
|
|
||||||
const isVerticalHybridHeaderFirst = computed(() => themeStore.layout.mode === 'vertical-hybrid-header-first')
|
|
||||||
|
|
||||||
const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first')
|
|
||||||
|
|
||||||
const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first')
|
|
||||||
|
|
||||||
const siderWidth = computed(() => getSiderAndCollapsedWidth(false))
|
|
||||||
|
|
||||||
const siderCollapsedWidth = computed(() => getSiderAndCollapsedWidth(true))
|
|
||||||
|
|
||||||
function getSiderAndCollapsedWidth(isCollapsed: boolean) {
|
|
||||||
const {
|
|
||||||
mixChildMenuWidth,
|
|
||||||
collapsedWidth,
|
|
||||||
width: themeWidth,
|
|
||||||
mixCollapsedWidth,
|
|
||||||
mixWidth: themeMixWidth,
|
|
||||||
} = themeStore.sider
|
|
||||||
|
|
||||||
const width = isCollapsed ? collapsedWidth : themeWidth
|
|
||||||
const mixWidth = isCollapsed ? mixCollapsedWidth : themeMixWidth
|
|
||||||
|
|
||||||
if (isTopHybridHeaderFirst.value) {
|
|
||||||
return isActiveFirstLevelMenuHasChildren.value ? width : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVerticalHybridHeaderFirst.value && !isActiveFirstLevelMenuHasChildren.value) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMixMode = isVerticalMix.value || isTopHybridSidebarFirst.value || isVerticalHybridHeaderFirst.value
|
|
||||||
let finalWidth = isMixMode ? mixWidth : width
|
|
||||||
|
|
||||||
if (isVerticalMix.value && appStore.mixSiderFixed && secondLevelMenus.value.length) {
|
|
||||||
finalWidth += mixChildMenuWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVerticalHybridHeaderFirst.value && appStore.mixSiderFixed && childLevelMenus.value.length) {
|
|
||||||
finalWidth += mixChildMenuWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
return finalWidth
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AdminLayout
|
|
||||||
v-model:sider-collapse="appStore.siderCollapse"
|
|
||||||
:mode="layoutMode"
|
|
||||||
:scroll-el-id="LAYOUT_SCROLL_EL_ID"
|
|
||||||
:scroll-mode="themeStore.layout.scrollMode"
|
|
||||||
:is-mobile="appStore.isMobile"
|
|
||||||
:full-content="appStore.fullContent"
|
|
||||||
:fixed-top="themeStore.fixedHeaderAndTab"
|
|
||||||
:header-height="themeStore.header.height"
|
|
||||||
:tab-visible="themeStore.tab.visible"
|
|
||||||
:tab-height="themeStore.tab.height"
|
|
||||||
:content-class="appStore.contentXScrollable ? 'overflow-x-hidden' : ''"
|
|
||||||
:sider-visible="siderVisible"
|
|
||||||
:sider-width="siderWidth"
|
|
||||||
:sider-collapsed-width="siderCollapsedWidth"
|
|
||||||
:footer-visible="themeStore.footer.visible"
|
|
||||||
:footer-height="themeStore.footer.height"
|
|
||||||
:fixed-footer="themeStore.footer.fixed"
|
|
||||||
:right-footer="themeStore.footer.right"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<GlobalHeader v-bind="headerProps" />
|
|
||||||
</template>
|
|
||||||
<template #tab>
|
|
||||||
<GlobalTab />
|
|
||||||
</template>
|
|
||||||
<template #sider>
|
|
||||||
<GlobalSider />
|
|
||||||
</template>
|
|
||||||
<GlobalMenu />
|
|
||||||
<GlobalContent />
|
|
||||||
<ThemeDrawer />
|
|
||||||
<template #footer>
|
|
||||||
<GlobalFooter />
|
|
||||||
</template>
|
|
||||||
</AdminLayout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
#__SCROLL_EL_ID__ {
|
|
||||||
@include scrollbar();
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import GlobalContent from '../modules/global-content/index.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'BlankLayout',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<GlobalContent :show-padding="false" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { RouteKey } from '@elegant-router/types'
|
|
||||||
import { createReusableTemplate } from '@vueuse/core'
|
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
|
||||||
import { useRouteStore } from '@/store/modules/route'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'GlobalBreadcrumb',
|
|
||||||
})
|
|
||||||
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
const routeStore = useRouteStore()
|
|
||||||
const { routerPushByKey } = useRouterPush()
|
|
||||||
|
|
||||||
interface BreadcrumbContentProps {
|
|
||||||
breadcrumb: App.Global.Menu
|
|
||||||
}
|
|
||||||
|
|
||||||
const [DefineBreadcrumbContent, BreadcrumbContent] = createReusableTemplate<BreadcrumbContentProps>()
|
|
||||||
|
|
||||||
function handleClickMenu(key: RouteKey) {
|
|
||||||
routerPushByKey(key)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NBreadcrumb v-if="themeStore.header.breadcrumb.visible">
|
|
||||||
<!-- define component start: BreadcrumbContent -->
|
|
||||||
<DefineBreadcrumbContent v-slot="{ breadcrumb }">
|
|
||||||
<div class="i-flex-y-center align-middle">
|
|
||||||
<component :is="breadcrumb.icon" v-if="themeStore.header.breadcrumb.showIcon" class="mr-4px text-icon" />
|
|
||||||
{{ breadcrumb.label }}
|
|
||||||
</div>
|
|
||||||
</DefineBreadcrumbContent>
|
|
||||||
<!-- define component end: BreadcrumbContent -->
|
|
||||||
|
|
||||||
<NBreadcrumbItem v-for="item in routeStore.breadcrumbs" :key="item.key">
|
|
||||||
<NDropdown v-if="item.options?.length" :options="item.options" @select="handleClickMenu">
|
|
||||||
<BreadcrumbContent :breadcrumb="item" />
|
|
||||||
</NDropdown>
|
|
||||||
<BreadcrumbContent v-else :breadcrumb="item" />
|
|
||||||
</NBreadcrumbItem>
|
|
||||||
</NBreadcrumb>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { LAYOUT_SCROLL_EL_ID } from '@sa/materials'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
import { useRouteStore } from '@/store/modules/route'
|
|
||||||
import { useTabStore } from '@/store/modules/tab'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'GlobalContent',
|
|
||||||
})
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
showPadding: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Show padding for content */
|
|
||||||
showPadding?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
const routeStore = useRouteStore()
|
|
||||||
const tabStore = useTabStore()
|
|
||||||
|
|
||||||
const transitionName = computed(() => (themeStore.page.animate ? themeStore.page.animateMode : ''))
|
|
||||||
|
|
||||||
function resetScroll() {
|
|
||||||
const el = document.querySelector(`#${LAYOUT_SCROLL_EL_ID}`)
|
|
||||||
|
|
||||||
el?.scrollTo({ left: 0, top: 0 })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<RouterView v-slot="{ Component, route }">
|
|
||||||
<Transition
|
|
||||||
:name="transitionName"
|
|
||||||
mode="out-in"
|
|
||||||
@before-leave="appStore.setContentXScrollable(true)"
|
|
||||||
@after-leave="resetScroll"
|
|
||||||
@after-enter="appStore.setContentXScrollable(false)"
|
|
||||||
>
|
|
||||||
<KeepAlive :include="routeStore.cacheRoutes" :exclude="routeStore.excludeCacheRoutes">
|
|
||||||
<component
|
|
||||||
:is="Component"
|
|
||||||
v-if="appStore.reloadFlag"
|
|
||||||
:key="tabStore.getTabIdByRoute(route)"
|
|
||||||
:class="{ 'p-16px': showPadding }"
|
|
||||||
class="flex-grow bg-layout transition-300"
|
|
||||||
/>
|
|
||||||
</KeepAlive>
|
|
||||||
</Transition>
|
|
||||||
</RouterView>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style></style>
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineOptions({
|
|
||||||
name: 'GlobalFooter',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DarkModeContainer class="h-full flex-center">
|
|
||||||
<a href="https://github.com/reader-starjs/reader-star-admin/blob/main/LICENSE" target="_blank" rel="noopener noreferrer">
|
|
||||||
Copyright MIT © 2021 reader-star
|
|
||||||
</a>
|
|
||||||
</DarkModeContainer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ThemeButton',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appStore = useAppStore()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ButtonIcon
|
|
||||||
icon="majesticons:color-swatch-line"
|
|
||||||
:tooltip-content="$t('icon.themeConfig')"
|
|
||||||
@click="appStore.openThemeDrawer"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { VNode } from 'vue'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useSvgIcon } from '@/hooks/common/icon'
|
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
import { useAuthStore } from '@/store/modules/auth'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'UserAvatar',
|
|
||||||
})
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const { routerPushByKey, toLogin } = useRouterPush()
|
|
||||||
const { SvgIconVNode } = useSvgIcon()
|
|
||||||
|
|
||||||
function loginOrRegister() {
|
|
||||||
toLogin()
|
|
||||||
}
|
|
||||||
|
|
||||||
type DropdownKey = 'logout'
|
|
||||||
|
|
||||||
type DropdownOption =
|
|
||||||
| {
|
|
||||||
key: DropdownKey
|
|
||||||
label: string
|
|
||||||
icon?: () => VNode
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'divider'
|
|
||||||
key: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = computed(() => {
|
|
||||||
const opts: DropdownOption[] = [
|
|
||||||
{
|
|
||||||
label: $t('common.logout'),
|
|
||||||
key: 'logout',
|
|
||||||
icon: SvgIconVNode({ icon: 'ph:sign-out', fontSize: 18 }),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return opts
|
|
||||||
})
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
window.$dialog?.info({
|
|
||||||
title: $t('common.tip'),
|
|
||||||
content: $t('common.logoutConfirm'),
|
|
||||||
positiveText: $t('common.confirm'),
|
|
||||||
negativeText: $t('common.cancel'),
|
|
||||||
onPositiveClick: () => {
|
|
||||||
authStore.resetStore()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDropdown(key: DropdownKey) {
|
|
||||||
if (key === 'logout') {
|
|
||||||
logout()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// If your other options are jumps from other routes, they will be directly supported here
|
|
||||||
routerPushByKey(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NButton v-if="!authStore.isLogin" quaternary @click="loginOrRegister">
|
|
||||||
{{ $t('page.login.common.loginOrRegister') }}
|
|
||||||
</NButton>
|
|
||||||
<NDropdown v-else placement="bottom" trigger="click" :options="options" @select="handleDropdown">
|
|
||||||
<div>
|
|
||||||
<ButtonIcon>
|
|
||||||
<SvgIcon icon="ph:user-circle" class="text-icon-large" />
|
|
||||||
<span class="text-16px font-medium">{{ authStore.userInfo.userName }}</span>
|
|
||||||
</ButtonIcon>
|
|
||||||
</div>
|
|
||||||
</NDropdown>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useFullscreen } from '@vueuse/core'
|
|
||||||
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
import GlobalBreadcrumb from '../global-breadcrumb/index.vue'
|
|
||||||
import GlobalLogo from '../global-logo/index.vue'
|
|
||||||
import GlobalSearch from '../global-search/index.vue'
|
|
||||||
import ThemeButton from './components/theme-button.vue'
|
|
||||||
import UserAvatar from './components/user-avatar.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'GlobalHeader',
|
|
||||||
})
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Whether to show the logo */
|
|
||||||
showLogo?: App.Global.HeaderProps['showLogo']
|
|
||||||
/** Whether to show the menu toggler */
|
|
||||||
showMenuToggler?: App.Global.HeaderProps['showMenuToggler']
|
|
||||||
/** Whether to show the menu */
|
|
||||||
showMenu?: App.Global.HeaderProps['showMenu']
|
|
||||||
}
|
|
||||||
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
const { isFullscreen, toggle } = useFullscreen()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DarkModeContainer class="h-full flex-y-center px-12px shadow-header">
|
|
||||||
<GlobalLogo v-if="showLogo" class="h-full" :style="{ width: `${themeStore.sider.width}px` }" />
|
|
||||||
<MenuToggler v-if="showMenuToggler" :collapsed="appStore.siderCollapse" @click="appStore.toggleSiderCollapse" />
|
|
||||||
<div v-if="showMenu" :id="GLOBAL_HEADER_MENU_ID" class="h-full flex-y-center flex-1-hidden" />
|
|
||||||
<div v-else class="h-full flex-y-center flex-1-hidden">
|
|
||||||
<GlobalBreadcrumb v-if="!appStore.isMobile" class="ml-12px" />
|
|
||||||
</div>
|
|
||||||
<div class="h-full flex-y-center justify-end">
|
|
||||||
<GlobalSearch v-if="themeStore.header.globalSearch.visible" />
|
|
||||||
<FullScreen v-if="!appStore.isMobile" :full="isFullscreen" @click="toggle" />
|
|
||||||
<LangSwitch
|
|
||||||
v-if="themeStore.header.multilingual.visible"
|
|
||||||
:lang="appStore.locale"
|
|
||||||
:lang-options="appStore.localeOptions"
|
|
||||||
@change-lang="appStore.changeLocale"
|
|
||||||
/>
|
|
||||||
<ThemeSchemaSwitch
|
|
||||||
:theme-schema="themeStore.themeScheme"
|
|
||||||
:is-dark="themeStore.darkMode"
|
|
||||||
@switch="themeStore.toggleThemeScheme"
|
|
||||||
/>
|
|
||||||
<ThemeButton />
|
|
||||||
<UserAvatar />
|
|
||||||
</div>
|
|
||||||
</DarkModeContainer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'GlobalLogo',
|
|
||||||
})
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
showTitle: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Whether to show the title */
|
|
||||||
showTitle?: boolean
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<RouterLink to="/" class="w-full flex-center nowrap-hidden">
|
|
||||||
<SystemLogo class="size-32px" />
|
|
||||||
<h2 v-show="showTitle" class="pl-8px text-16px text-primary font-bold transition duration-300 ease-in-out">
|
|
||||||
{{ $t('system.title') }}
|
|
||||||
</h2>
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { RouteKey } from '@elegant-router/types'
|
|
||||||
import { transformColorWithOpacity } from '@sa/color'
|
|
||||||
import { SimpleScrollbar } from '@sa/materials'
|
|
||||||
import { createReusableTemplate } from '@vueuse/core'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'FirstLevelMenu',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
menus: App.Global.Menu[]
|
|
||||||
activeMenuKey?: string
|
|
||||||
inverted?: boolean
|
|
||||||
siderCollapse?: boolean
|
|
||||||
darkMode?: boolean
|
|
||||||
themeColor: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'select', menuKey: RouteKey): boolean
|
|
||||||
(e: 'toggleSiderCollapse'): void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MixMenuItemProps {
|
|
||||||
/** Menu item label */
|
|
||||||
label: App.Global.Menu['label']
|
|
||||||
/** Menu item icon */
|
|
||||||
icon: App.Global.Menu['icon']
|
|
||||||
/** Active menu item */
|
|
||||||
active: boolean
|
|
||||||
/** Mini size */
|
|
||||||
isMini?: boolean
|
|
||||||
}
|
|
||||||
const [DefineMixMenuItem, MixMenuItem] = createReusableTemplate<MixMenuItemProps>()
|
|
||||||
|
|
||||||
const selectedBgColor = computed(() => {
|
|
||||||
const { darkMode, themeColor } = props
|
|
||||||
|
|
||||||
const light = transformColorWithOpacity(themeColor, 0.1, '#ffffff')
|
|
||||||
const dark = transformColorWithOpacity(themeColor, 0.3, '#000000')
|
|
||||||
|
|
||||||
return darkMode ? dark : light
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleClickMixMenu(menuKey: RouteKey) {
|
|
||||||
emit('select', menuKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSiderCollapse() {
|
|
||||||
emit('toggleSiderCollapse')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- define component: MixMenuItem -->
|
|
||||||
<DefineMixMenuItem v-slot="{ label, icon, active, isMini }">
|
|
||||||
<div
|
|
||||||
class="mx-4px mb-6px flex-col-center cursor-pointer rounded-8px bg-transparent px-4px py-8px transition-300 hover:bg-[rgb(0,0,0,0.08)]"
|
|
||||||
:class="{
|
|
||||||
'text-primary selected-mix-menu': active,
|
|
||||||
'text-white:65 hover:text-white': inverted,
|
|
||||||
'!text-white !bg-primary': active && inverted,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<component :is="icon" :class="[isMini ? 'text-icon-small' : 'text-icon-large']" />
|
|
||||||
<p
|
|
||||||
class="w-full ellipsis-text text-center text-12px transition-height-300"
|
|
||||||
:class="[isMini ? 'h-0 pt-0' : 'h-20px pt-4px']"
|
|
||||||
>
|
|
||||||
{{ label }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</DefineMixMenuItem>
|
|
||||||
<!-- define component end: MixMenuItem -->
|
|
||||||
|
|
||||||
<div class="h-full flex-col-stretch flex-1-hidden">
|
|
||||||
<slot />
|
|
||||||
<SimpleScrollbar>
|
|
||||||
<MixMenuItem
|
|
||||||
v-for="menu in menus"
|
|
||||||
:key="menu.key"
|
|
||||||
:label="menu.label"
|
|
||||||
:icon="menu.icon"
|
|
||||||
:active="menu.key === activeMenuKey"
|
|
||||||
:is-mini="siderCollapse"
|
|
||||||
@click="handleClickMixMenu(menu.routeKey)"
|
|
||||||
/>
|
|
||||||
</SimpleScrollbar>
|
|
||||||
<MenuToggler
|
|
||||||
arrow-icon
|
|
||||||
:collapsed="siderCollapse"
|
|
||||||
:z-index="99"
|
|
||||||
:class="{ 'text-white:88 !hover:text-white': inverted }"
|
|
||||||
@click="toggleSiderCollapse"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.selected-mix-menu {
|
|
||||||
background-color: v-bind(selectedBgColor);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,185 +0,0 @@
|
|||||||
import type { RouteKey } from '@elegant-router/types'
|
|
||||||
import { useContext } from '@sa/hooks'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
|
||||||
import { useRouteStore } from '@/store/modules/route'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
|
|
||||||
export const [provideMixMenuContext, useMixMenuContext] = useContext('MixMenu', useMixMenu)
|
|
||||||
|
|
||||||
function useMixMenu() {
|
|
||||||
const route = useRoute()
|
|
||||||
const routeStore = useRouteStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
const { selectedKey } = useMenu()
|
|
||||||
const { routerPushByKeyWithMetaQuery } = useRouterPush()
|
|
||||||
|
|
||||||
const allMenus = computed<App.Global.Menu[]>(() => routeStore.menus)
|
|
||||||
|
|
||||||
const firstLevelMenus = computed<App.Global.Menu[]>(() =>
|
|
||||||
routeStore.menus.map((menu) => {
|
|
||||||
const { children: _, ...rest } = menu
|
|
||||||
|
|
||||||
return rest
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const activeFirstLevelMenuKey = ref('')
|
|
||||||
|
|
||||||
function setActiveFirstLevelMenuKey(key: string) {
|
|
||||||
activeFirstLevelMenuKey.value = key
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveFirstLevelMenuKey() {
|
|
||||||
const [firstLevelRouteName] = selectedKey.value.split('_')
|
|
||||||
|
|
||||||
setActiveFirstLevelMenuKey(firstLevelRouteName)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActiveFirstLevelMenuHasChildren = computed(() => {
|
|
||||||
if (!activeFirstLevelMenuKey.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const findItem = allMenus.value.find(item => item.key === activeFirstLevelMenuKey.value)
|
|
||||||
|
|
||||||
return Boolean(findItem?.children?.length)
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleSelectFirstLevelMenu(key: RouteKey) {
|
|
||||||
setActiveFirstLevelMenuKey(key)
|
|
||||||
|
|
||||||
if (!isActiveFirstLevelMenuHasChildren.value) {
|
|
||||||
routerPushByKeyWithMetaQuery(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const secondLevelMenus = computed<App.Global.Menu[]>(
|
|
||||||
() => allMenus.value.find(menu => menu.key === activeFirstLevelMenuKey.value)?.children || [],
|
|
||||||
)
|
|
||||||
|
|
||||||
const activeSecondLevelMenuKey = ref('')
|
|
||||||
|
|
||||||
function setActiveSecondLevelMenuKey(key: string) {
|
|
||||||
activeSecondLevelMenuKey.value = key
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActiveSecondLevelMenuKey() {
|
|
||||||
const keys = selectedKey.value.split('_')
|
|
||||||
|
|
||||||
if (keys.length < 2) {
|
|
||||||
setActiveSecondLevelMenuKey('')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const [firstLevelRouteName, level2SuffixName] = keys
|
|
||||||
|
|
||||||
const secondLevelRouteName = `${firstLevelRouteName}_${level2SuffixName}`
|
|
||||||
|
|
||||||
setActiveSecondLevelMenuKey(secondLevelRouteName)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isActiveSecondLevelMenuHasChildren = computed(() => {
|
|
||||||
if (!activeSecondLevelMenuKey.value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const findItem = secondLevelMenus.value.find(item => item.key === activeSecondLevelMenuKey.value)
|
|
||||||
|
|
||||||
return Boolean(findItem?.children?.length)
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleSelectSecondLevelMenu(key: RouteKey) {
|
|
||||||
setActiveSecondLevelMenuKey(key)
|
|
||||||
|
|
||||||
if (!isActiveSecondLevelMenuHasChildren.value) {
|
|
||||||
routerPushByKeyWithMetaQuery(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const childLevelMenus = computed<App.Global.Menu[]>(
|
|
||||||
() => secondLevelMenus.value.find(menu => menu.key === activeSecondLevelMenuKey.value)?.children || [],
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasChildLevelMenus = computed(() => childLevelMenus.value.length > 0)
|
|
||||||
|
|
||||||
function getDeepestLevelMenuKey(): RouteKey | null {
|
|
||||||
if (!secondLevelMenus.value.length || !themeStore.sider.autoSelectFirstMenu) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const secondLevelFirstMenu = secondLevelMenus.value[0]
|
|
||||||
|
|
||||||
if (!secondLevelFirstMenu) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function findDeepest(menu: App.Global.Menu): RouteKey {
|
|
||||||
if (!menu.children?.length) {
|
|
||||||
return menu.routeKey
|
|
||||||
}
|
|
||||||
|
|
||||||
return findDeepest(menu.children[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
return findDeepest(secondLevelFirstMenu)
|
|
||||||
}
|
|
||||||
|
|
||||||
function activeDeepestLevelMenuKey() {
|
|
||||||
const deepestLevelMenuKey = getDeepestLevelMenuKey()
|
|
||||||
if (!deepestLevelMenuKey)
|
|
||||||
return
|
|
||||||
|
|
||||||
// select the deepest second level menu
|
|
||||||
handleSelectSecondLevelMenu(deepestLevelMenuKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.name,
|
|
||||||
() => {
|
|
||||||
getActiveFirstLevelMenuKey()
|
|
||||||
// if there are child level menus, get the active second level menu key
|
|
||||||
if (hasChildLevelMenus.value) {
|
|
||||||
getActiveSecondLevelMenuKey()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
firstLevelMenus,
|
|
||||||
activeFirstLevelMenuKey,
|
|
||||||
setActiveFirstLevelMenuKey,
|
|
||||||
isActiveFirstLevelMenuHasChildren,
|
|
||||||
handleSelectFirstLevelMenu,
|
|
||||||
getActiveFirstLevelMenuKey,
|
|
||||||
secondLevelMenus,
|
|
||||||
activeSecondLevelMenuKey,
|
|
||||||
setActiveSecondLevelMenuKey,
|
|
||||||
isActiveSecondLevelMenuHasChildren,
|
|
||||||
handleSelectSecondLevelMenu,
|
|
||||||
getActiveSecondLevelMenuKey,
|
|
||||||
childLevelMenus,
|
|
||||||
hasChildLevelMenus,
|
|
||||||
getDeepestLevelMenuKey,
|
|
||||||
activeDeepestLevelMenuKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMenu() {
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const selectedKey = computed(() => {
|
|
||||||
const { hideInMenu, activeMenu } = route.meta
|
|
||||||
const name = route.name as string
|
|
||||||
|
|
||||||
const routeName = (hideInMenu ? activeMenu : name) || name
|
|
||||||
|
|
||||||
return routeName
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { Component } from 'vue'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
import HorizontalMenu from './modules/horizontal-menu.vue'
|
|
||||||
import TopHybridHeaderFirst from './modules/top-hybrid-header-first.vue'
|
|
||||||
import TopHybridSidebarFirst from './modules/top-hybrid-sidebar-first.vue'
|
|
||||||
import VerticalHybridHeaderFirst from './modules/vertical-hybrid-header-first.vue'
|
|
||||||
import VerticalMenu from './modules/vertical-menu.vue'
|
|
||||||
import VerticalMixMenu from './modules/vertical-mix-menu.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'GlobalMenu',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
|
|
||||||
const activeMenu = computed(() => {
|
|
||||||
const menuMap: Record<UnionKey.ThemeLayoutMode, Component> = {
|
|
||||||
'vertical': VerticalMenu,
|
|
||||||
'vertical-mix': VerticalMixMenu,
|
|
||||||
'vertical-hybrid-header-first': VerticalHybridHeaderFirst,
|
|
||||||
'horizontal': HorizontalMenu,
|
|
||||||
'top-hybrid-sidebar-first': TopHybridSidebarFirst,
|
|
||||||
'top-hybrid-header-first': TopHybridHeaderFirst,
|
|
||||||
}
|
|
||||||
|
|
||||||
return menuMap[themeStore.layout.mode]
|
|
||||||
})
|
|
||||||
|
|
||||||
const reRenderVertical = computed(() => themeStore.layout.mode === 'vertical' && appStore.isMobile)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<component :is="activeMenu" :key="reRenderVertical" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { GLOBAL_HEADER_MENU_ID } from '@/constants/app'
|
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
|
||||||
import { useRouteStore } from '@/store/modules/route'
|
|
||||||
import { useMenu } from '../context'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'HorizontalMenu',
|
|
||||||
})
|
|
||||||
|
|
||||||
const routeStore = useRouteStore()
|
|
||||||
const { routerPushByKeyWithMetaQuery } = useRouterPush()
|
|
||||||
const { selectedKey } = useMenu()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
|
||||||
<NMenu
|
|
||||||
mode="horizontal"
|
|
||||||
:value="selectedKey"
|
|
||||||
:options="routeStore.menus"
|
|
||||||
:indent="18"
|
|
||||||
responsive
|
|
||||||
@update:value="routerPushByKeyWithMetaQuery"
|
|
||||||
/>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { RouteKey } from '@elegant-router/types'
|
|
||||||
import { SimpleScrollbar } from '@sa/materials'
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'
|
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
import { useRouteStore } from '@/store/modules/route'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
import { useMenu, useMixMenuContext } from '../context'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'TopHybridHeaderFirst',
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
const routeStore = useRouteStore()
|
|
||||||
const { routerPushByKeyWithMetaQuery } = useRouterPush()
|
|
||||||
const {
|
|
||||||
firstLevelMenus,
|
|
||||||
secondLevelMenus,
|
|
||||||
activeFirstLevelMenuKey,
|
|
||||||
handleSelectFirstLevelMenu,
|
|
||||||
activeDeepestLevelMenuKey,
|
|
||||||
} = useMixMenuContext('TopHybridHeaderFirst')
|
|
||||||
const { selectedKey } = useMenu()
|
|
||||||
|
|
||||||
const expandedKeys = ref<string[]>([])
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle first level menu select
|
|
||||||
* @param key RouteKey
|
|
||||||
*/
|
|
||||||
function handleSelectMenu(key: RouteKey) {
|
|
||||||
handleSelectFirstLevelMenu(key)
|
|
||||||
|
|
||||||
// if there are second level menus, select the deepest one by default
|
|
||||||
activeDeepestLevelMenuKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateExpandedKeys() {
|
|
||||||
if (appStore.siderCollapse || !selectedKey.value) {
|
|
||||||
expandedKeys.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.name,
|
|
||||||
() => {
|
|
||||||
updateExpandedKeys()
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
|
||||||
<NMenu
|
|
||||||
mode="horizontal"
|
|
||||||
:value="activeFirstLevelMenuKey"
|
|
||||||
:options="firstLevelMenus"
|
|
||||||
:indent="18"
|
|
||||||
responsive
|
|
||||||
@update:value="handleSelectMenu"
|
|
||||||
/>
|
|
||||||
</Teleport>
|
|
||||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
|
||||||
<SimpleScrollbar>
|
|
||||||
<NMenu
|
|
||||||
v-model:expanded-keys="expandedKeys"
|
|
||||||
mode="vertical"
|
|
||||||
:value="selectedKey"
|
|
||||||
:collapsed="appStore.siderCollapse"
|
|
||||||
:collapsed-width="themeStore.sider.collapsedWidth"
|
|
||||||
:collapsed-icon-size="22"
|
|
||||||
:options="secondLevelMenus"
|
|
||||||
:indent="18"
|
|
||||||
@update:value="routerPushByKeyWithMetaQuery"
|
|
||||||
/>
|
|
||||||
</SimpleScrollbar>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { RouteKey } from '@elegant-router/types'
|
|
||||||
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'
|
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
import FirstLevelMenu from '../components/first-level-menu.vue'
|
|
||||||
import { useMenu, useMixMenuContext } from '../context'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'TopHybridSidebarFirst',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
const { routerPushByKeyWithMetaQuery } = useRouterPush()
|
|
||||||
const {
|
|
||||||
firstLevelMenus,
|
|
||||||
secondLevelMenus,
|
|
||||||
activeFirstLevelMenuKey,
|
|
||||||
handleSelectFirstLevelMenu,
|
|
||||||
activeDeepestLevelMenuKey,
|
|
||||||
} = useMixMenuContext('TopHybridSidebarFirst')
|
|
||||||
const { selectedKey } = useMenu()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle first level menu select
|
|
||||||
* @param key RouteKey
|
|
||||||
*/
|
|
||||||
function handleSelectMenu(key: RouteKey) {
|
|
||||||
handleSelectFirstLevelMenu(key)
|
|
||||||
|
|
||||||
// if there are second level menus, select the deepest one by default
|
|
||||||
activeDeepestLevelMenuKey()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
|
||||||
<NMenu
|
|
||||||
mode="horizontal"
|
|
||||||
:value="selectedKey"
|
|
||||||
:options="secondLevelMenus"
|
|
||||||
:indent="18"
|
|
||||||
responsive
|
|
||||||
@update:value="routerPushByKeyWithMetaQuery"
|
|
||||||
/>
|
|
||||||
</Teleport>
|
|
||||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
|
||||||
<div class="h-full pt-2">
|
|
||||||
<FirstLevelMenu
|
|
||||||
:menus="firstLevelMenus"
|
|
||||||
:active-menu-key="activeFirstLevelMenuKey"
|
|
||||||
:sider-collapse="appStore.siderCollapse"
|
|
||||||
:dark-mode="themeStore.darkMode"
|
|
||||||
:theme-color="themeStore.themeColor"
|
|
||||||
@select="handleSelectMenu"
|
|
||||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,175 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { RouteKey } from '@elegant-router/types'
|
|
||||||
import { useBoolean } from '@sa/hooks'
|
|
||||||
import { SimpleScrollbar } from '@sa/materials'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { GLOBAL_HEADER_MENU_ID, GLOBAL_SIDER_MENU_ID } from '@/constants/app'
|
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
import { useRouteStore } from '@/store/modules/route'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
import GlobalLogo from '../../global-logo/index.vue'
|
|
||||||
import FirstLevelMenu from '../components/first-level-menu.vue'
|
|
||||||
import { useMenu, useMixMenuContext } from '../context'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'VerticalHybridHeaderFirst',
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
const routeStore = useRouteStore()
|
|
||||||
const { routerPushByKeyWithMetaQuery } = useRouterPush()
|
|
||||||
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean()
|
|
||||||
const {
|
|
||||||
firstLevelMenus,
|
|
||||||
activeFirstLevelMenuKey,
|
|
||||||
handleSelectFirstLevelMenu,
|
|
||||||
getActiveFirstLevelMenuKey,
|
|
||||||
secondLevelMenus,
|
|
||||||
activeSecondLevelMenuKey,
|
|
||||||
isActiveSecondLevelMenuHasChildren,
|
|
||||||
handleSelectSecondLevelMenu,
|
|
||||||
getActiveSecondLevelMenuKey,
|
|
||||||
childLevelMenus,
|
|
||||||
hasChildLevelMenus,
|
|
||||||
activeDeepestLevelMenuKey,
|
|
||||||
} = useMixMenuContext('VerticalHybridHeaderFirst')
|
|
||||||
const { selectedKey } = useMenu()
|
|
||||||
|
|
||||||
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted)
|
|
||||||
|
|
||||||
const showDrawer = computed(() => hasChildLevelMenus.value && (drawerVisible.value || appStore.mixSiderFixed))
|
|
||||||
|
|
||||||
function handleSelectMixMenu(key: RouteKey) {
|
|
||||||
handleSelectSecondLevelMenu(key)
|
|
||||||
|
|
||||||
if (isActiveSecondLevelMenuHasChildren.value) {
|
|
||||||
setDrawerVisible(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle second level menu selection based on autoSelectFirstMenu setting:
|
|
||||||
* - When disabled: Activate first second-level menu for display only, expand third-level menu if exists
|
|
||||||
* - When enabled: Navigate to the deepest menu automatically
|
|
||||||
*/
|
|
||||||
function handleSelectMenu(key: RouteKey) {
|
|
||||||
handleSelectFirstLevelMenu(key)
|
|
||||||
|
|
||||||
if (secondLevelMenus.value.length === 0)
|
|
||||||
return
|
|
||||||
|
|
||||||
const secondFirstMenuKey = secondLevelMenus.value[0].routeKey
|
|
||||||
|
|
||||||
// Case 1: autoSelectFirstMenu disabled - only activate menu for display
|
|
||||||
if (!themeStore.sider.autoSelectFirstMenu) {
|
|
||||||
// Check if there are third-level menus
|
|
||||||
const hasChildren = secondLevelMenus.value.find(menu => menu.key === secondFirstMenuKey)?.children?.length
|
|
||||||
|
|
||||||
// If there are third-level menus, expand them
|
|
||||||
if (hasChildren) {
|
|
||||||
handleSelectMixMenu(secondFirstMenuKey)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: autoSelectFirstMenu enabled - navigate to deepest menu
|
|
||||||
activeDeepestLevelMenuKey()
|
|
||||||
setDrawerVisible(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleResetActiveMenu() {
|
|
||||||
setDrawerVisible(false)
|
|
||||||
|
|
||||||
if (!appStore.mixSiderFixed) {
|
|
||||||
getActiveFirstLevelMenuKey()
|
|
||||||
getActiveSecondLevelMenuKey()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const expandedKeys = ref<string[]>([])
|
|
||||||
|
|
||||||
function updateExpandedKeys() {
|
|
||||||
if (appStore.siderCollapse || !selectedKey.value) {
|
|
||||||
expandedKeys.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.name,
|
|
||||||
() => {
|
|
||||||
updateExpandedKeys()
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport :to="`#${GLOBAL_HEADER_MENU_ID}`">
|
|
||||||
<NMenu
|
|
||||||
mode="horizontal"
|
|
||||||
:value="activeFirstLevelMenuKey"
|
|
||||||
:options="firstLevelMenus"
|
|
||||||
:indent="18"
|
|
||||||
responsive
|
|
||||||
@update:value="handleSelectMenu"
|
|
||||||
/>
|
|
||||||
</Teleport>
|
|
||||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
|
||||||
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
|
|
||||||
<FirstLevelMenu
|
|
||||||
:menus="secondLevelMenus"
|
|
||||||
:active-menu-key="activeSecondLevelMenuKey"
|
|
||||||
:inverted="inverted"
|
|
||||||
:sider-collapse="appStore.siderCollapse"
|
|
||||||
:dark-mode="themeStore.darkMode"
|
|
||||||
:theme-color="themeStore.themeColor"
|
|
||||||
@select="handleSelectMixMenu"
|
|
||||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
|
||||||
>
|
|
||||||
<GlobalLogo :show-title="false" :style="{ height: `${themeStore.header.height}px` }" />
|
|
||||||
</FirstLevelMenu>
|
|
||||||
<div
|
|
||||||
class="relative h-full transition-width-300"
|
|
||||||
:style="{
|
|
||||||
width: appStore.mixSiderFixed && hasChildLevelMenus ? `${themeStore.sider.mixChildMenuWidth}px` : '0px',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<DarkModeContainer
|
|
||||||
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
|
|
||||||
:inverted="inverted"
|
|
||||||
:style="{ width: showDrawer ? `${themeStore.sider.mixChildMenuWidth}px` : '0px' }"
|
|
||||||
>
|
|
||||||
<header class="flex-y-center justify-between px-12px" :style="{ height: `${themeStore.header.height}px` }">
|
|
||||||
<h2 class="text-16px text-primary font-bold">
|
|
||||||
{{ $t('system.title') }}
|
|
||||||
</h2>
|
|
||||||
<PinToggler
|
|
||||||
:pin="appStore.mixSiderFixed"
|
|
||||||
:class="{ 'text-white:88 !hover:text-white': inverted }"
|
|
||||||
@click="appStore.toggleMixSiderFixed"
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
<SimpleScrollbar>
|
|
||||||
<NMenu
|
|
||||||
v-model:expanded-keys="expandedKeys"
|
|
||||||
mode="vertical"
|
|
||||||
:value="selectedKey"
|
|
||||||
:options="childLevelMenus"
|
|
||||||
:inverted="inverted"
|
|
||||||
:indent="18"
|
|
||||||
@update:value="routerPushByKeyWithMetaQuery"
|
|
||||||
/>
|
|
||||||
</SimpleScrollbar>
|
|
||||||
</DarkModeContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { SimpleScrollbar } from '@sa/materials'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app'
|
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
import { useRouteStore } from '@/store/modules/route'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
import { useMenu } from '../context'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'VerticalMenu',
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
const routeStore = useRouteStore()
|
|
||||||
const { routerPushByKeyWithMetaQuery } = useRouterPush()
|
|
||||||
const { selectedKey } = useMenu()
|
|
||||||
|
|
||||||
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted)
|
|
||||||
|
|
||||||
const expandedKeys = ref<string[]>([])
|
|
||||||
|
|
||||||
function updateExpandedKeys() {
|
|
||||||
if (appStore.siderCollapse || !selectedKey.value) {
|
|
||||||
expandedKeys.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.name,
|
|
||||||
() => {
|
|
||||||
updateExpandedKeys()
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
|
||||||
<SimpleScrollbar>
|
|
||||||
<NMenu
|
|
||||||
v-model:expanded-keys="expandedKeys"
|
|
||||||
mode="vertical"
|
|
||||||
:value="selectedKey"
|
|
||||||
:collapsed="appStore.siderCollapse"
|
|
||||||
:collapsed-width="themeStore.sider.collapsedWidth"
|
|
||||||
:collapsed-icon-size="22"
|
|
||||||
:options="routeStore.menus"
|
|
||||||
:inverted="inverted"
|
|
||||||
:indent="18"
|
|
||||||
@update:value="routerPushByKeyWithMetaQuery"
|
|
||||||
/>
|
|
||||||
</SimpleScrollbar>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { RouteKey } from '@elegant-router/types'
|
|
||||||
import { useBoolean } from '@sa/hooks'
|
|
||||||
import { SimpleScrollbar } from '@sa/materials'
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app'
|
|
||||||
import { useRouterPush } from '@/hooks/common/router'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
import { useRouteStore } from '@/store/modules/route'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
import GlobalLogo from '../../global-logo/index.vue'
|
|
||||||
import FirstLevelMenu from '../components/first-level-menu.vue'
|
|
||||||
import { useMenu, useMixMenuContext } from '../context'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'VerticalMixMenu',
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
const routeStore = useRouteStore()
|
|
||||||
const { routerPushByKeyWithMetaQuery } = useRouterPush()
|
|
||||||
const { bool: drawerVisible, setBool: setDrawerVisible } = useBoolean()
|
|
||||||
const {
|
|
||||||
firstLevelMenus,
|
|
||||||
secondLevelMenus,
|
|
||||||
activeFirstLevelMenuKey,
|
|
||||||
isActiveFirstLevelMenuHasChildren,
|
|
||||||
getActiveFirstLevelMenuKey,
|
|
||||||
handleSelectFirstLevelMenu,
|
|
||||||
} = useMixMenuContext('VerticalMixMenu')
|
|
||||||
const { selectedKey } = useMenu()
|
|
||||||
|
|
||||||
const inverted = computed(() => !themeStore.darkMode && themeStore.sider.inverted)
|
|
||||||
|
|
||||||
const hasChildMenus = computed(() => secondLevelMenus.value.length > 0)
|
|
||||||
|
|
||||||
const showDrawer = computed(() => hasChildMenus.value && (drawerVisible.value || appStore.mixSiderFixed))
|
|
||||||
|
|
||||||
function handleSelectMenu(key: RouteKey) {
|
|
||||||
handleSelectFirstLevelMenu(key)
|
|
||||||
|
|
||||||
if (isActiveFirstLevelMenuHasChildren.value) {
|
|
||||||
setDrawerVisible(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleResetActiveMenu() {
|
|
||||||
setDrawerVisible(false)
|
|
||||||
|
|
||||||
if (!appStore.mixSiderFixed) {
|
|
||||||
getActiveFirstLevelMenuKey()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const expandedKeys = ref<string[]>([])
|
|
||||||
|
|
||||||
function updateExpandedKeys() {
|
|
||||||
if (appStore.siderCollapse || !selectedKey.value) {
|
|
||||||
expandedKeys.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
expandedKeys.value = routeStore.getSelectedMenuKeyPath(selectedKey.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => route.name,
|
|
||||||
() => {
|
|
||||||
updateExpandedKeys()
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport :to="`#${GLOBAL_SIDER_MENU_ID}`">
|
|
||||||
<div class="h-full flex" @mouseleave="handleResetActiveMenu">
|
|
||||||
<FirstLevelMenu
|
|
||||||
:menus="firstLevelMenus"
|
|
||||||
:active-menu-key="activeFirstLevelMenuKey"
|
|
||||||
:inverted="inverted"
|
|
||||||
:sider-collapse="appStore.siderCollapse"
|
|
||||||
:dark-mode="themeStore.darkMode"
|
|
||||||
:theme-color="themeStore.themeColor"
|
|
||||||
@select="handleSelectMenu"
|
|
||||||
@toggle-sider-collapse="appStore.toggleSiderCollapse"
|
|
||||||
>
|
|
||||||
<GlobalLogo :show-title="false" :style="{ height: `${themeStore.header.height}px` }" />
|
|
||||||
</FirstLevelMenu>
|
|
||||||
<div
|
|
||||||
class="relative h-full transition-width-300"
|
|
||||||
:style="{ width: appStore.mixSiderFixed && hasChildMenus ? `${themeStore.sider.mixChildMenuWidth}px` : '0px' }"
|
|
||||||
>
|
|
||||||
<DarkModeContainer
|
|
||||||
class="absolute-lt h-full flex-col-stretch nowrap-hidden shadow-sm transition-all-300"
|
|
||||||
:inverted="inverted"
|
|
||||||
:style="{ width: showDrawer ? `${themeStore.sider.mixChildMenuWidth}px` : '0px' }"
|
|
||||||
>
|
|
||||||
<header class="flex-y-center justify-between px-12px" :style="{ height: `${themeStore.header.height}px` }">
|
|
||||||
<h2 class="text-16px text-primary font-bold">
|
|
||||||
{{ $t('system.title') }}
|
|
||||||
</h2>
|
|
||||||
<PinToggler
|
|
||||||
:pin="appStore.mixSiderFixed"
|
|
||||||
:class="{ 'text-white:88 !hover:text-white': inverted }"
|
|
||||||
@click="appStore.toggleMixSiderFixed"
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
<SimpleScrollbar>
|
|
||||||
<NMenu
|
|
||||||
v-model:expanded-keys="expandedKeys"
|
|
||||||
mode="vertical"
|
|
||||||
:value="selectedKey"
|
|
||||||
:options="secondLevelMenus"
|
|
||||||
:inverted="inverted"
|
|
||||||
:indent="18"
|
|
||||||
@update:value="routerPushByKeyWithMetaQuery"
|
|
||||||
/>
|
|
||||||
</SimpleScrollbar>
|
|
||||||
</DarkModeContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({ name: 'SearchFooter' })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="h-44px flex-y-center gap-14px px-24px">
|
|
||||||
<span class="flex-y-center">
|
|
||||||
<icon-mdi-keyboard-return class="operate-shadow operate-item" />
|
|
||||||
<span>{{ $t('common.confirm') }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="flex-y-center">
|
|
||||||
<icon-mdi-arrow-up-thin class="operate-shadow operate-item" />
|
|
||||||
<icon-mdi-arrow-down-thin class="operate-shadow operate-item" />
|
|
||||||
<span>{{ $t('common.switch') }}</span>
|
|
||||||
</span>
|
|
||||||
<span class="flex-y-center">
|
|
||||||
<icon-mdi-keyboard-esc class="operate-shadow operate-item" />
|
|
||||||
<span>{{ $t('common.close') }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.operate-shadow {
|
|
||||||
box-shadow:
|
|
||||||
inset 0 -2px #cdcde6,
|
|
||||||
inset 0 0 1px 1px #fff,
|
|
||||||
0 1px 2px 1px #1e235a66;
|
|
||||||
}
|
|
||||||
|
|
||||||
.operate-item {
|
|
||||||
--uno: mr-6px p-2px text-20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { onKeyStroke, useDebounceFn } from '@vueuse/core'
|
|
||||||
import { computed, ref, shallowRef } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
import { useRouteStore } from '@/store/modules/route'
|
|
||||||
import SearchFooter from './search-footer.vue'
|
|
||||||
import SearchResult from './search-result.vue'
|
|
||||||
|
|
||||||
defineOptions({ name: 'SearchModal' })
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const routeStore = useRouteStore()
|
|
||||||
|
|
||||||
const isMobile = computed(() => appStore.isMobile)
|
|
||||||
|
|
||||||
const keyword = ref('')
|
|
||||||
const activePath = ref('')
|
|
||||||
const resultOptions = shallowRef<App.Global.Menu[]>([])
|
|
||||||
|
|
||||||
const handleSearch = useDebounceFn(search, 300)
|
|
||||||
|
|
||||||
const visible = defineModel<boolean>('show', { required: true })
|
|
||||||
|
|
||||||
function search() {
|
|
||||||
resultOptions.value = routeStore.searchMenus.filter((menu) => {
|
|
||||||
const trimKeyword = keyword.value.toLocaleLowerCase().trim()
|
|
||||||
const title = (menu.i18nKey ? $t(menu.i18nKey) : menu.label).toLocaleLowerCase()
|
|
||||||
return trimKeyword && title.includes(trimKeyword)
|
|
||||||
})
|
|
||||||
activePath.value = resultOptions.value[0]?.routePath ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClose() {
|
|
||||||
// handle with setTimeout to prevent user from seeing some operations
|
|
||||||
setTimeout(() => {
|
|
||||||
visible.value = false
|
|
||||||
resultOptions.value = []
|
|
||||||
keyword.value = ''
|
|
||||||
}, 200)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** key up */
|
|
||||||
function handleUp() {
|
|
||||||
const { length } = resultOptions.value
|
|
||||||
if (length === 0)
|
|
||||||
return
|
|
||||||
|
|
||||||
const index = getActivePathIndex()
|
|
||||||
if (index === -1)
|
|
||||||
return
|
|
||||||
|
|
||||||
const activeIndex = index === 0 ? length - 1 : index - 1
|
|
||||||
|
|
||||||
activePath.value = resultOptions.value[activeIndex].routePath
|
|
||||||
}
|
|
||||||
|
|
||||||
/** key down */
|
|
||||||
function handleDown() {
|
|
||||||
const { length } = resultOptions.value
|
|
||||||
if (length === 0)
|
|
||||||
return
|
|
||||||
|
|
||||||
const index = getActivePathIndex()
|
|
||||||
if (index === -1)
|
|
||||||
return
|
|
||||||
|
|
||||||
const activeIndex = index === length - 1 ? 0 : index + 1
|
|
||||||
|
|
||||||
activePath.value = resultOptions.value[activeIndex].routePath
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActivePathIndex() {
|
|
||||||
return resultOptions.value.findIndex(item => item.routePath === activePath.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** key enter */
|
|
||||||
function handleEnter() {
|
|
||||||
if (resultOptions.value?.length === 0 || activePath.value === '')
|
|
||||||
return
|
|
||||||
handleClose()
|
|
||||||
router.push(activePath.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerShortcut() {
|
|
||||||
onKeyStroke('Escape', handleClose)
|
|
||||||
onKeyStroke('Enter', handleEnter)
|
|
||||||
onKeyStroke('ArrowUp', handleUp)
|
|
||||||
onKeyStroke('ArrowDown', handleDown)
|
|
||||||
}
|
|
||||||
|
|
||||||
registerShortcut()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NModal
|
|
||||||
v-model:show="visible"
|
|
||||||
:segmented="{ footer: 'soft' }"
|
|
||||||
:closable="false"
|
|
||||||
preset="card"
|
|
||||||
auto-focus
|
|
||||||
footer-style="padding: 0; margin: 0"
|
|
||||||
class="fixed left-0 right-0"
|
|
||||||
:class="[isMobile ? 'size-full top-0px rounded-0' : 'w-630px top-50px']"
|
|
||||||
@after-leave="handleClose"
|
|
||||||
>
|
|
||||||
<NInputGroup>
|
|
||||||
<NInput v-model:value="keyword" clearable :placeholder="$t('common.keywordSearch')" @input="handleSearch">
|
|
||||||
<template #prefix>
|
|
||||||
<icon-uil-search class="text-15px text-#c2c2c2" />
|
|
||||||
</template>
|
|
||||||
</NInput>
|
|
||||||
<NButton v-if="isMobile" type="primary" ghost @click="handleClose">
|
|
||||||
{{ $t('common.cancel') }}
|
|
||||||
</NButton>
|
|
||||||
</NInputGroup>
|
|
||||||
|
|
||||||
<div class="mt-20px">
|
|
||||||
<NEmpty v-if="resultOptions.length === 0" :description="$t('common.noData')" />
|
|
||||||
<SearchResult v-else v-model:path="activePath" :options="resultOptions" @enter="handleEnter" />
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<SearchFooter v-if="!isMobile" />
|
|
||||||
</template>
|
|
||||||
</NModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
|
|
||||||
defineOptions({ name: 'SearchResult' })
|
|
||||||
|
|
||||||
defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
options: App.Global.Menu[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'enter'): void
|
|
||||||
}
|
|
||||||
|
|
||||||
const theme = useThemeStore()
|
|
||||||
|
|
||||||
const active = defineModel<string>('path', { required: true })
|
|
||||||
|
|
||||||
async function handleMouseEnter(item: App.Global.Menu) {
|
|
||||||
active.value = item.routePath
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTo() {
|
|
||||||
emit('enter')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NScrollbar>
|
|
||||||
<div class="pb-12px">
|
|
||||||
<template v-for="item in options" :key="item.routePath">
|
|
||||||
<div
|
|
||||||
class="mt-8px h-56px flex-y-center cursor-pointer justify-between rounded-4px bg-#e5e7eb px-14px dark:bg-dark"
|
|
||||||
:style="{
|
|
||||||
background: item.routePath === active ? theme.themeColor : '',
|
|
||||||
color: item.routePath === active ? '#fff' : '',
|
|
||||||
}"
|
|
||||||
@click="handleTo"
|
|
||||||
@mouseenter="handleMouseEnter(item)"
|
|
||||||
>
|
|
||||||
<component :is="item.icon" />
|
|
||||||
<span class="ml-5px flex-1">
|
|
||||||
{{ (item.i18nKey && $t(item.i18nKey)) || item.label }}
|
|
||||||
</span>
|
|
||||||
<icon-ant-design-enter-outlined class="icon mr-3px p-2px text-20px" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</NScrollbar>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
import { useBoolean } from '@sa/hooks'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
import SearchModal from './components/search-modal.vue'
|
|
||||||
|
|
||||||
defineOptions({ name: 'GlobalSearch' })
|
|
||||||
|
|
||||||
const { bool: show, toggle } = useBoolean()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ButtonIcon :tooltip-content="$t('common.search')" @click="toggle">
|
|
||||||
<icon-uil-search />
|
|
||||||
</ButtonIcon>
|
|
||||||
<SearchModal v-model:show="show" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { GLOBAL_SIDER_MENU_ID } from '@/constants/app'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
import GlobalLogo from '../global-logo/index.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'GlobalSider',
|
|
||||||
})
|
|
||||||
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
|
|
||||||
const isTopHybridSidebarFirst = computed(() => themeStore.layout.mode === 'top-hybrid-sidebar-first')
|
|
||||||
const isTopHybridHeaderFirst = computed(() => themeStore.layout.mode === 'top-hybrid-header-first')
|
|
||||||
const darkMenu = computed(
|
|
||||||
() =>
|
|
||||||
!themeStore.darkMode && !isTopHybridSidebarFirst.value && !isTopHybridHeaderFirst.value && themeStore.sider.inverted,
|
|
||||||
)
|
|
||||||
const showLogo = computed(() => themeStore.layout.mode === 'vertical')
|
|
||||||
const menuWrapperClass = computed(() => (showLogo.value ? 'flex-1-hidden' : 'h-full'))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DarkModeContainer class="size-full flex-col-stretch shadow-sider" :inverted="darkMenu">
|
|
||||||
<GlobalLogo
|
|
||||||
v-if="showLogo"
|
|
||||||
:show-title="!appStore.siderCollapse"
|
|
||||||
:style="{ height: `${themeStore.header.height}px` }"
|
|
||||||
/>
|
|
||||||
<div :id="GLOBAL_SIDER_MENU_ID" :class="menuWrapperClass" />
|
|
||||||
</DarkModeContainer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { VNode } from 'vue'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useSvgIcon } from '@/hooks/common/icon'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
import { useTabStore } from '@/store/modules/tab'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ContextMenu',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
excludeKeys: () => [],
|
|
||||||
disabledKeys: () => [],
|
|
||||||
})
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** ClientX */
|
|
||||||
x: number
|
|
||||||
/** ClientY */
|
|
||||||
y: number
|
|
||||||
tabId: string
|
|
||||||
excludeKeys?: App.Global.DropdownKey[]
|
|
||||||
disabledKeys?: App.Global.DropdownKey[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const visible = defineModel<boolean>('visible')
|
|
||||||
|
|
||||||
const { removeTab, clearTabs, clearLeftTabs, clearRightTabs, fixTab, unfixTab, isTabRetain, homeTab } = useTabStore()
|
|
||||||
const { SvgIconVNode } = useSvgIcon()
|
|
||||||
|
|
||||||
interface DropdownOption {
|
|
||||||
key: App.Global.DropdownKey
|
|
||||||
label: string
|
|
||||||
icon?: () => VNode
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = computed(() => {
|
|
||||||
const opts: DropdownOption[] = [
|
|
||||||
{
|
|
||||||
key: 'closeCurrent',
|
|
||||||
label: $t('dropdown.closeCurrent'),
|
|
||||||
icon: SvgIconVNode({ icon: 'ant-design:close-outlined', fontSize: 18 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'closeOther',
|
|
||||||
label: $t('dropdown.closeOther'),
|
|
||||||
icon: SvgIconVNode({ icon: 'ant-design:column-width-outlined', fontSize: 18 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'closeLeft',
|
|
||||||
label: $t('dropdown.closeLeft'),
|
|
||||||
icon: SvgIconVNode({ icon: 'mdi:format-horizontal-align-left', fontSize: 18 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'closeRight',
|
|
||||||
label: $t('dropdown.closeRight'),
|
|
||||||
icon: SvgIconVNode({ icon: 'mdi:format-horizontal-align-right', fontSize: 18 }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'closeAll',
|
|
||||||
label: $t('dropdown.closeAll'),
|
|
||||||
icon: SvgIconVNode({ icon: 'ant-design:line-outlined', fontSize: 18 }),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (props.tabId !== homeTab?.id) {
|
|
||||||
if (isTabRetain(props.tabId)) {
|
|
||||||
opts.push({
|
|
||||||
key: 'unpin',
|
|
||||||
label: $t('dropdown.unpin'),
|
|
||||||
icon: SvgIconVNode({ icon: 'mdi:pin-off-outline', fontSize: 18 }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
opts.push({
|
|
||||||
key: 'pin',
|
|
||||||
label: $t('dropdown.pin'),
|
|
||||||
icon: SvgIconVNode({ icon: 'mdi:pin-outline', fontSize: 18 }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { excludeKeys, disabledKeys } = props
|
|
||||||
|
|
||||||
const result = opts.filter(opt => !excludeKeys.includes(opt.key))
|
|
||||||
|
|
||||||
disabledKeys.forEach((key) => {
|
|
||||||
const opt = result.find(item => item.key === key)
|
|
||||||
|
|
||||||
if (opt) {
|
|
||||||
opt.disabled = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
function hideDropdown() {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const dropdownAction: Record<App.Global.DropdownKey, () => void> = {
|
|
||||||
closeCurrent() {
|
|
||||||
removeTab(props.tabId)
|
|
||||||
},
|
|
||||||
closeOther() {
|
|
||||||
clearTabs([props.tabId])
|
|
||||||
},
|
|
||||||
closeLeft() {
|
|
||||||
clearLeftTabs(props.tabId)
|
|
||||||
},
|
|
||||||
closeRight() {
|
|
||||||
clearRightTabs(props.tabId)
|
|
||||||
},
|
|
||||||
closeAll() {
|
|
||||||
clearTabs()
|
|
||||||
},
|
|
||||||
pin() {
|
|
||||||
fixTab(props.tabId)
|
|
||||||
},
|
|
||||||
unpin() {
|
|
||||||
unfixTab(props.tabId)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDropdown(optionKey: App.Global.DropdownKey) {
|
|
||||||
dropdownAction[optionKey]?.()
|
|
||||||
hideDropdown()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NDropdown
|
|
||||||
:show="visible"
|
|
||||||
placement="bottom-start"
|
|
||||||
trigger="manual"
|
|
||||||
:x="x"
|
|
||||||
:y="y"
|
|
||||||
:options="options"
|
|
||||||
@clickoutside="hideDropdown"
|
|
||||||
@select="handleDropdown"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,237 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { PageTab } from '@sa/materials'
|
|
||||||
import { useElementBounding } from '@vueuse/core'
|
|
||||||
import { nextTick, reactive, ref, watch } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import BetterScroll from '@/components/custom/better-scroll.vue'
|
|
||||||
import { useAppStore } from '@/store/modules/app'
|
|
||||||
import { useTabStore } from '@/store/modules/tab'
|
|
||||||
import { useThemeStore } from '@/store/modules/theme'
|
|
||||||
import { isPC } from '@/utils/agent'
|
|
||||||
import ContextMenu from './context-menu.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'GlobalTab',
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const appStore = useAppStore()
|
|
||||||
const themeStore = useThemeStore()
|
|
||||||
const tabStore = useTabStore()
|
|
||||||
|
|
||||||
const bsWrapper = ref<HTMLElement>()
|
|
||||||
const { width: bsWrapperWidth, left: bsWrapperLeft } = useElementBounding(bsWrapper)
|
|
||||||
const bsScroll = ref<InstanceType<typeof BetterScroll>>()
|
|
||||||
const tabRef = ref<HTMLElement>()
|
|
||||||
const isPCFlag = isPC()
|
|
||||||
|
|
||||||
const TAB_DATA_ID = 'data-tab-id'
|
|
||||||
const MIDDLE_MOUSE_BUTTON = 1
|
|
||||||
const RIGHT_MOUSE_BUTTON = 2
|
|
||||||
|
|
||||||
type TabNamedNodeMap = NamedNodeMap & {
|
|
||||||
[TAB_DATA_ID]: Attr
|
|
||||||
}
|
|
||||||
|
|
||||||
async function scrollToActiveTab() {
|
|
||||||
await nextTick()
|
|
||||||
if (!tabRef.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
const { children } = tabRef.value
|
|
||||||
|
|
||||||
for (let i = 0; i < children.length; i += 1) {
|
|
||||||
const child = children[i]
|
|
||||||
|
|
||||||
const { value: tabId } = (child.attributes as TabNamedNodeMap)[TAB_DATA_ID]
|
|
||||||
|
|
||||||
if (tabId === tabStore.activeTabId) {
|
|
||||||
const { left, width } = child.getBoundingClientRect()
|
|
||||||
const clientX = left + width / 2
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
scrollByClientX(clientX)
|
|
||||||
}, 50)
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollByClientX(clientX: number) {
|
|
||||||
const currentX = clientX - bsWrapperLeft.value
|
|
||||||
const deltaX = currentX - bsWrapperWidth.value / 2
|
|
||||||
|
|
||||||
if (bsScroll.value?.instance) {
|
|
||||||
const { maxScrollX, x: leftX, scrollBy } = bsScroll.value.instance
|
|
||||||
|
|
||||||
const rightX = maxScrollX - leftX
|
|
||||||
const update = deltaX > 0 ? Math.max(-deltaX, rightX) : Math.min(-deltaX, -leftX)
|
|
||||||
|
|
||||||
scrollBy(update, 0, 300)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getContextMenuDisabledKeys(tabId: string) {
|
|
||||||
const disabledKeys: App.Global.DropdownKey[] = []
|
|
||||||
|
|
||||||
if (tabStore.isTabRetain(tabId)) {
|
|
||||||
const homeDisable: App.Global.DropdownKey[] = ['closeCurrent', 'closeLeft']
|
|
||||||
disabledKeys.push(...homeDisable)
|
|
||||||
}
|
|
||||||
|
|
||||||
return disabledKeys
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCloseTab(tab: App.Global.Tab) {
|
|
||||||
tabStore.removeTab(tab.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMousedown(e: MouseEvent, tab: App.Global.Tab) {
|
|
||||||
const isMiddleClick = e.button === MIDDLE_MOUSE_BUTTON
|
|
||||||
if (!isMiddleClick || !themeStore.tab.closeTabByMiddleClick) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tabStore.isTabRetain(tab.id)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
handleCloseTab(tab)
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchTab(e: MouseEvent, tab: App.Global.Tab) {
|
|
||||||
if ([MIDDLE_MOUSE_BUTTON, RIGHT_MOUSE_BUTTON].includes(e.button))
|
|
||||||
return
|
|
||||||
|
|
||||||
tabStore.switchRouteByTab(tab)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
appStore.reloadPage(500)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DropdownConfig {
|
|
||||||
visible: boolean
|
|
||||||
x: number
|
|
||||||
y: number
|
|
||||||
tabId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const dropdown: DropdownConfig = reactive({
|
|
||||||
visible: false,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
tabId: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
function setDropdown(config: Partial<DropdownConfig>) {
|
|
||||||
Object.assign(dropdown, config)
|
|
||||||
}
|
|
||||||
|
|
||||||
let isClickContextMenu = false
|
|
||||||
|
|
||||||
function handleDropdownVisible(visible: boolean | undefined) {
|
|
||||||
if (!isClickContextMenu) {
|
|
||||||
setDropdown({ visible })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleContextMenu(e: MouseEvent, tabId: string) {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
const { clientX, clientY } = e
|
|
||||||
|
|
||||||
isClickContextMenu = true
|
|
||||||
|
|
||||||
const DURATION = dropdown.visible ? 150 : 0
|
|
||||||
|
|
||||||
setDropdown({ visible: false })
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setDropdown({
|
|
||||||
visible: true,
|
|
||||||
x: clientX,
|
|
||||||
y: clientY,
|
|
||||||
tabId,
|
|
||||||
})
|
|
||||||
isClickContextMenu = false
|
|
||||||
}, DURATION)
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
tabStore.initTabStore(route)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFocus() {
|
|
||||||
(document.activeElement as HTMLElement)?.blur()
|
|
||||||
}
|
|
||||||
|
|
||||||
// watch
|
|
||||||
watch(
|
|
||||||
() => route.fullPath,
|
|
||||||
() => {
|
|
||||||
tabStore.addTab(route)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
watch(
|
|
||||||
() => tabStore.activeTabId,
|
|
||||||
() => {
|
|
||||||
scrollToActiveTab()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// init
|
|
||||||
init()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<DarkModeContainer class="size-full flex-y-center px-16px shadow-tab">
|
|
||||||
<div ref="bsWrapper" class="h-full flex-1-hidden">
|
|
||||||
<BetterScroll ref="bsScroll" :options="{ scrollX: true, scrollY: false, click: !isPCFlag }" @click="removeFocus">
|
|
||||||
<div
|
|
||||||
ref="tabRef"
|
|
||||||
class="h-full flex pr-18px"
|
|
||||||
:class="[
|
|
||||||
themeStore.tab.mode === 'chrome' || themeStore.tab.mode === 'slider' ? 'items-end' : 'items-center gap-12px',
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<PageTab
|
|
||||||
v-for="tab in tabStore.tabs"
|
|
||||||
:key="tab.id"
|
|
||||||
:[TAB_DATA_ID]="tab.id"
|
|
||||||
:mode="themeStore.tab.mode"
|
|
||||||
:dark-mode="themeStore.darkMode"
|
|
||||||
:active="tab.id === tabStore.activeTabId"
|
|
||||||
:active-color="themeStore.themeColor"
|
|
||||||
:closable="!tabStore.isTabRetain(tab.id)"
|
|
||||||
@pointerdown="switchTab($event, tab)"
|
|
||||||
@mousedown="handleMousedown($event, tab)"
|
|
||||||
@close="handleCloseTab(tab)"
|
|
||||||
@contextmenu="handleContextMenu($event, tab.id)"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<SvgIcon :icon="tab.icon" :local-icon="tab.localIcon" class="inline-block align-text-bottom text-16px" />
|
|
||||||
</template>
|
|
||||||
<div class="max-w-240px ellipsis-text">
|
|
||||||
{{ tab.label }}
|
|
||||||
</div>
|
|
||||||
</PageTab>
|
|
||||||
</div>
|
|
||||||
</BetterScroll>
|
|
||||||
</div>
|
|
||||||
<ReloadButton :loading="!appStore.reloadFlag" @click="refresh" />
|
|
||||||
<FullScreen :full="appStore.fullContent" @click="appStore.toggleFullContent" />
|
|
||||||
</DarkModeContainer>
|
|
||||||
<ContextMenu
|
|
||||||
:visible="dropdown.visible"
|
|
||||||
:tab-id="dropdown.tabId"
|
|
||||||
:disabled-keys="getContextMenuDisabledKeys(dropdown.tabId)"
|
|
||||||
:x="dropdown.x"
|
|
||||||
:y="dropdown.y"
|
|
||||||
@update:visible="handleDropdownVisible"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { PopoverPlacement } from 'naive-ui'
|
|
||||||
import { themeLayoutModeRecord } from '@/constants/app'
|
|
||||||
import { $t } from '@/locales'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'LayoutModeCard',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/** Layout mode */
|
|
||||||
mode: UnionKey.ThemeLayoutMode
|
|
||||||
/** Disabled */
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
/** Layout mode change */
|
|
||||||
(e: 'update:mode', mode: UnionKey.ThemeLayoutMode): void
|
|
||||||
}
|
|
||||||
|
|
||||||
type LayoutConfig = Record<
|
|
||||||
UnionKey.ThemeLayoutMode,
|
|
||||||
{
|
|
||||||
placement: PopoverPlacement
|
|
||||||
menuClass: string
|
|
||||||
mainClass: string
|
|
||||||
}
|
|
||||||
>
|
|
||||||
|
|
||||||
const layoutConfig: LayoutConfig = {
|
|
||||||
'vertical': {
|
|
||||||
placement: 'bottom',
|
|
||||||
menuClass: 'w-1/3 h-full',
|
|
||||||
mainClass: 'w-2/3 h-3/4',
|
|
||||||
},
|
|
||||||
'vertical-mix': {
|
|
||||||
placement: 'bottom',
|
|
||||||
menuClass: 'w-1/4 h-full',
|
|
||||||
mainClass: 'w-2/3 h-3/4',
|
|
||||||
},
|
|
||||||
'vertical-hybrid-header-first': {
|
|
||||||
placement: 'bottom',
|
|
||||||
menuClass: 'w-1/4 h-full',
|
|
||||||
mainClass: 'w-2/3 h-3/4',
|
|
||||||
},
|
|
||||||
'horizontal': {
|
|
||||||
placement: 'bottom',
|
|
||||||
menuClass: 'w-full h-1/4',
|
|
||||||
mainClass: 'w-full h-3/4',
|
|
||||||
},
|
|
||||||
'top-hybrid-sidebar-first': {
|
|
||||||
placement: 'bottom',
|
|
||||||
menuClass: 'w-full h-1/4',
|
|
||||||
mainClass: 'w-2/3 h-3/4',
|
|
||||||
},
|
|
||||||
'top-hybrid-header-first': {
|
|
||||||
placement: 'bottom',
|
|
||||||
menuClass: 'w-full h-1/4',
|
|
||||||
mainClass: 'w-2/3 h-3/4',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChangeMode(mode: UnionKey.ThemeLayoutMode) {
|
|
||||||
if (props.disabled)
|
|
||||||
return
|
|
||||||
|
|
||||||
emit('update:mode', mode)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="grid grid-cols-2 gap-x-16px gap-y-12px md:grid-cols-3">
|
|
||||||
<div
|
|
||||||
v-for="(item, key) in layoutConfig"
|
|
||||||
:key="key"
|
|
||||||
class="flex-col-center cursor-pointer"
|
|
||||||
@click="handleChangeMode(key)"
|
|
||||||
>
|
|
||||||
<IconTooltip :placement="item.placement">
|
|
||||||
<template #trigger>
|
|
||||||
<div
|
|
||||||
class="h-64px w-96px gap-6px rd-4px p-6px shadow ring-2 ring-transparent transition-all hover:ring-primary"
|
|
||||||
:class="{ '!ring-primary': mode === key }"
|
|
||||||
>
|
|
||||||
<div class="h-full w-full gap-1" :class="[key.includes('vertical') ? 'flex' : 'flex-col']">
|
|
||||||
<slot :name="key" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
{{ $t(`theme.layout.layoutMode.${key}_detail`) }}
|
|
||||||
</IconTooltip>
|
|
||||||
<p class="mt-8px text-12px">
|
|
||||||
{{ $t(themeLayoutModeRecord[key]) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||