v-4.0.0 开源可二开源码
15
src/.editorconfig
Normal file
@@ -0,0 +1,15 @@
|
||||
# Editor configuration, see http://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.md]
|
||||
max_line_length = 80
|
||||
trim_trailing_whitespace = false
|
||||
63
src/.eslintrc.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"standard-with-typescript",
|
||||
"plugin:vue/vue3-essential",
|
||||
"prettier"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"project": "./tsconfig.json"
|
||||
},
|
||||
"plugins": [
|
||||
"vue",
|
||||
"unused-imports",
|
||||
"prettier",
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"vars": "all",
|
||||
"varsIgnorePattern": "^_",
|
||||
"args": "after-used",
|
||||
"argsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-unused-expressions": "off",
|
||||
"prettier/prettier": [
|
||||
"error",
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["chat/**/*", "admin/**/*", "service/**/*"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"./chat/tsconfig.json",
|
||||
"./admin/tsconfig.json",
|
||||
"./service/tsconfig.json"
|
||||
]
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-expressions": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
7
src/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
service/.env
|
||||
test
|
||||
AIWebQuickDeploy
|
||||
service/public
|
||||
service/public
|
||||
.env
|
||||
8
src/.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true
|
||||
}
|
||||
3
src/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar", "dbaeumer.vscode-eslint"]
|
||||
}
|
||||
98
src/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"prettier.enable": true,
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"html",
|
||||
"json",
|
||||
"jsonc",
|
||||
"json5",
|
||||
"yaml",
|
||||
"yml",
|
||||
"markdown"
|
||||
],
|
||||
"cSpell.words": [
|
||||
"antfu",
|
||||
"axios",
|
||||
"Baichuan",
|
||||
"bumpp",
|
||||
"Chatbox",
|
||||
"chatglm",
|
||||
"chatgpt",
|
||||
"chenzhaoyu",
|
||||
"chevereto",
|
||||
"cogvideox",
|
||||
"commitlint",
|
||||
"cref",
|
||||
"dall",
|
||||
"dalle",
|
||||
"davinci",
|
||||
"deepseek",
|
||||
"dockerhub",
|
||||
"EMAILCODE",
|
||||
"esno",
|
||||
"GPTAPI",
|
||||
"gpts",
|
||||
"highlightjs",
|
||||
"hljs",
|
||||
"hunyuan",
|
||||
"iconify",
|
||||
"ISDEV",
|
||||
"katex",
|
||||
"katexmath",
|
||||
"langchain",
|
||||
"linkify",
|
||||
"logprobs",
|
||||
"longcontext",
|
||||
"luma",
|
||||
"mapi",
|
||||
"Markmap",
|
||||
"mdhljs",
|
||||
"micromessenger",
|
||||
"mila",
|
||||
"Mindmap",
|
||||
"MODELSMAPLIST",
|
||||
"MODELTYPELIST",
|
||||
"modelvalue",
|
||||
"newconfig",
|
||||
"niji",
|
||||
"Nmessage",
|
||||
"nodata",
|
||||
"OPENAI",
|
||||
"pinia",
|
||||
"Popconfirm",
|
||||
"PPTCREATE",
|
||||
"projectaddress",
|
||||
"qwen",
|
||||
"rushstack",
|
||||
"sdxl",
|
||||
"Sider",
|
||||
"sref",
|
||||
"suno",
|
||||
"tailwindcss",
|
||||
"traptitech",
|
||||
"tsup",
|
||||
"Typecheck",
|
||||
"typeorm",
|
||||
"unplugin",
|
||||
"usercenter",
|
||||
"vastxie",
|
||||
"VITE",
|
||||
"vueuse",
|
||||
"Zhao"
|
||||
],
|
||||
"vue.codeActions.enabled": false,
|
||||
"volar.experimental.tsconfigPaths": {
|
||||
"./chat": ["./chat/tsconfig.json"],
|
||||
"./admin": ["./admin/tsconfig.json"],
|
||||
"./service": ["./service/tsconfig.json"]
|
||||
}
|
||||
}
|
||||
21
src/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License Copyright (c) 2024 vastxie
|
||||
|
||||
Permission is hereby granted, free of
|
||||
charge, to any person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
(including the next paragraph) shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
14
src/admin/.env.development
Executable file
@@ -0,0 +1,14 @@
|
||||
# 应用配置面板
|
||||
# VITE_APP_SETTING = true
|
||||
# 页面标题
|
||||
VITE_APP_TITLE = AIWeb
|
||||
# 接口请求地址,会设置到 axios 的 baseURL 参数上
|
||||
VITE_APP_API_BASEURL = http://127.0.0.1:9520/api
|
||||
# 调试工具,可设置 eruda 或 vconsole,如果不需要开启则留空
|
||||
VITE_APP_DEBUG_TOOL =
|
||||
# VITE_BASE_PATH = /
|
||||
|
||||
# 是否开启代理
|
||||
VITE_OPEN_PROXY = false
|
||||
# 是否开启开发者工具
|
||||
VITE_OPEN_DEVTOOLS = false
|
||||
21
src/admin/.env.production
Normal file
@@ -0,0 +1,21 @@
|
||||
# 应用配置面板
|
||||
VITE_APP_SETTING = false
|
||||
# 页面标题
|
||||
VITE_APP_TITLE = AIWeb
|
||||
# 接口请求地址,会设置到 axios 的 baseURL 参数上
|
||||
VITE_APP_API_BASEURL = /api
|
||||
# 调试工具,可设置 eruda 或 vconsole,如果不需要开启则留空
|
||||
VITE_APP_DEBUG_TOOL =
|
||||
|
||||
# 是否在打包时启用 Mock
|
||||
VITE_BUILD_MOCK = false
|
||||
# 是否在打包时生成 sourcemap
|
||||
VITE_BUILD_SOURCEMAP = false
|
||||
# 是否在打包时开启压缩,支持 gzip 和 brotli
|
||||
VITE_BUILD_COMPRESS =
|
||||
# 是否在打包后生成存档,支持 zip 和 tar
|
||||
VITE_BUILD_ARCHIVE =
|
||||
# VITE_BASE_PATH = /admin/
|
||||
VITE_BASE_PATH =
|
||||
|
||||
VITE_ENABLE_TYPE_CHECK = false
|
||||
17
src/admin/.env.test
Normal file
@@ -0,0 +1,17 @@
|
||||
# 应用配置面板
|
||||
VITE_APP_SETTING = false
|
||||
# 页面标题
|
||||
VITE_APP_TITLE = 页面标题(test)
|
||||
# 接口请求地址,会设置到 axios 的 baseURL 参数上
|
||||
VITE_APP_API_BASEURL = /
|
||||
# 调试工具,可设置 eruda 或 vconsole,如果不需要开启则留空
|
||||
VITE_APP_DEBUG_TOOL =
|
||||
|
||||
# 是否在打包时启用 Mock
|
||||
VITE_BUILD_MOCK = true
|
||||
# 是否在打包时生成 sourcemap
|
||||
VITE_BUILD_SOURCEMAP = true
|
||||
# 是否在打包时开启压缩,支持 gzip 和 brotli
|
||||
VITE_BUILD_COMPRESS =
|
||||
# 是否在打包后生成存档,支持 zip 和 tar
|
||||
VITE_BUILD_ARCHIVE =
|
||||
7
src/admin/.gitignore
vendored
Executable file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist*
|
||||
dist-ssr
|
||||
*.local
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
4
src/admin/.lintstagedrc
Executable file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"*.{ts,tsx,vue}": "eslint --cache --fix",
|
||||
"*.{css,scss,vue}": "stylelint --cache --fix"
|
||||
}
|
||||
1
src/admin/.node-version
Normal file
@@ -0,0 +1 @@
|
||||
18
|
||||
3
src/admin/.npmrc
Executable file
@@ -0,0 +1,3 @@
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
engine-strict=true
|
||||
10
src/admin/.vscode/extensions.json
vendored
Executable file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"EditorConfig.EditorConfig",
|
||||
"mikestead.dotenv",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"stylelint.vscode-stylelint",
|
||||
"Vue.volar",
|
||||
"antfu.unocss"
|
||||
]
|
||||
}
|
||||
27
src/admin/.vscode/settings.json
vendored
Executable file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"eslint.experimental.useFlatConfig": true,
|
||||
"prettier.enable": false,
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.fixAll.stylelint": "explicit",
|
||||
"source.organizeImports": "never"
|
||||
},
|
||||
"stylelint.validate": [
|
||||
"css",
|
||||
"scss",
|
||||
"vue"
|
||||
],
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"vue",
|
||||
"html",
|
||||
"markdown",
|
||||
"json",
|
||||
"jsonc",
|
||||
"yaml"
|
||||
]
|
||||
}
|
||||
50
src/admin/index.html
Executable file
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/loading.css" />
|
||||
<link rel="stylesheet" href="/browser_upgrade/index.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Expires" content="0">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Cache-control" content="no-cache">
|
||||
<meta http-equiv="Cache" content="no-cache">
|
||||
<title>%VITE_APP_TITLE%</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="fantastic-admin-home">
|
||||
<div class="loading">
|
||||
<div class="square"></div>
|
||||
<div class="square"></div>
|
||||
<div class="square"></div>
|
||||
<div class="square"></div>
|
||||
</div>
|
||||
<div class="text">载入中</div>
|
||||
</div>
|
||||
<div id="browser-upgrade">
|
||||
<div class="title">为了您的体验,推荐使用以下浏览器</div>
|
||||
<div class="browsers">
|
||||
<a href="https://www.microsoft.com/edge" target="_blank" class="browser">
|
||||
<img class="browser-icon" src="/browser_upgrade/edge.png" />
|
||||
<div class="browser-name">Mircosoft Edge</div>
|
||||
</a>
|
||||
<a href="https://www.google.cn/chrome/" target="_blank" class="browser">
|
||||
<img class="browser-icon" src="/browser_upgrade/chrome.png" />
|
||||
<div class="browser-name">Google Chrome</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
if (!!window.ActiveXObject || 'ActiveXObject' in window) {
|
||||
document.getElementById('browser-upgrade').style.display = 'block'
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
102
src/admin/package.json
Executable file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"type": "module",
|
||||
"version": "4.0.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"build:test": "vue-tsc && vite build --mode test",
|
||||
"serve": "http-server ./dist -o",
|
||||
"serve:test": "http-server ./dist-test -o",
|
||||
"svgo": "svgo -f src/assets/icons",
|
||||
"new": "plop",
|
||||
"generate:icons": "esno ./scripts/generate.icons.ts",
|
||||
"lint": "npm-run-all -s lint:tsc lint:eslint lint:stylelint",
|
||||
"lint:tsc": "vue-tsc",
|
||||
"lint:eslint": "eslint . --cache --fix",
|
||||
"lint:stylelint": "stylelint \"src/**/*.{css,scss,vue}\" --cache --fix",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"commit": "git cz",
|
||||
"release": "bumpp"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@headlessui/vue": "^1.7.22",
|
||||
"@imengyu/vue3-context-menu": "^1.4.1",
|
||||
"@vueuse/core": "^10.10.0",
|
||||
"@vueuse/integrations": "^10.10.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"axios": "^1.7.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"echarts": "^5.5.0",
|
||||
"element-plus": "^2.7.4",
|
||||
"eruda": "^3.0.1",
|
||||
"floating-vue": "5.2.2",
|
||||
"hotkeys-js": "^3.13.7",
|
||||
"less": "^4.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"marked": "^13.0.0",
|
||||
"md-editor-v3": "^4.16.7",
|
||||
"mitt": "^3.0.1",
|
||||
"mockjs": "^1.1.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"overlayscrollbars": "^2.8.3",
|
||||
"overlayscrollbars-vue": "^0.5.9",
|
||||
"path-browserify": "^1.0.1",
|
||||
"path-to-regexp": "^6.2.2",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"vconsole": "^3.15.1",
|
||||
"vue": "^3.4.27",
|
||||
"vue-m-message": "^4.0.2",
|
||||
"vue-router": "^4.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify/json": "^2.2.217",
|
||||
"@iconify/vue": "^4.1.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mockjs": "^1.0.10",
|
||||
"@types/path-browserify": "^1.0.2",
|
||||
"@unocss/core": "^0.61.0",
|
||||
"@unocss/preset-mini": "^0.61.0",
|
||||
"@vitejs/plugin-legacy": "^5.4.1",
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"@vitejs/plugin-vue-jsx": "^4.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"boxen": "^7.1.1",
|
||||
"bumpp": "^9.4.1",
|
||||
"eslint": "^9.4.0",
|
||||
"esno": "^4.7.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"http-server": "^14.1.1",
|
||||
"inquirer": "^9.2.23",
|
||||
"npm-run-all2": "^6.2.0",
|
||||
"picocolors": "^1.0.1",
|
||||
"plop": "^4.0.1",
|
||||
"sass": "^1.77.4",
|
||||
"stylelint": "^16.6.1",
|
||||
"svgo": "^3.3.2",
|
||||
"typescript": "^5.4.5",
|
||||
"unocss": "^0.60.4",
|
||||
"unplugin-auto-import": "^0.17.6",
|
||||
"unplugin-turbo-console": "^1.8.6",
|
||||
"unplugin-vue-components": "^0.27.0",
|
||||
"vite": "^5.2.12",
|
||||
"vite-plugin-banner": "^0.7.1",
|
||||
"vite-plugin-checker": "^0.6.4",
|
||||
"vite-plugin-compression2": "^1.1.1",
|
||||
"vite-plugin-fake-server": "^2.1.1",
|
||||
"vite-plugin-pages": "^0.32.2",
|
||||
"vite-plugin-svg-icons": "^2.0.1",
|
||||
"vite-plugin-vue-devtools": "^7.2.1",
|
||||
"vite-plugin-vue-meta-layouts": "^0.4.3",
|
||||
"vue-tsc": "^2.0.19"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "node_modules/cz-git"
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/admin/plop-templates/component/index.hbs
Executable file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
{{#if isGlobal}}
|
||||
defineOptions({
|
||||
name: '{{ properCase name }}',
|
||||
})
|
||||
{{/if}}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 布局 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 样式
|
||||
</style>
|
||||
65
src/admin/plop-templates/component/prompt.js
Executable file
@@ -0,0 +1,65 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
function getFolder(path) {
|
||||
const components = []
|
||||
const files = fs.readdirSync(path)
|
||||
files.forEach((item) => {
|
||||
const stat = fs.lstatSync(`${path}/${item}`)
|
||||
if (stat.isDirectory() === true && item !== 'components') {
|
||||
components.push(`${path}/${item}`)
|
||||
components.push(...getFolder(`${path}/${item}`))
|
||||
}
|
||||
})
|
||||
return components
|
||||
}
|
||||
|
||||
export default {
|
||||
description: '创建组件',
|
||||
prompts: [
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'isGlobal',
|
||||
message: '是否为全局组件',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
type: 'list',
|
||||
name: 'path',
|
||||
message: '请选择组件创建目录',
|
||||
choices: getFolder('src/views'),
|
||||
when: (answers) => {
|
||||
return !answers.isGlobal
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: '请输入组件名称',
|
||||
validate: (v) => {
|
||||
if (!v || v.trim === '') {
|
||||
return '组件名称不能为空'
|
||||
}
|
||||
else {
|
||||
return true
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: (data) => {
|
||||
let path = ''
|
||||
if (data.isGlobal) {
|
||||
path = 'src/components/{{properCase name}}/index.vue'
|
||||
}
|
||||
else {
|
||||
path = `${data.path}/components/{{properCase name}}/index.vue`
|
||||
}
|
||||
const actions = [
|
||||
{
|
||||
type: 'add',
|
||||
path,
|
||||
templateFile: 'plop-templates/component/index.hbs',
|
||||
},
|
||||
]
|
||||
return actions
|
||||
},
|
||||
}
|
||||
84
src/admin/plop-templates/mock/mock.hbs
Executable file
@@ -0,0 +1,84 @@
|
||||
import Mock from 'mockjs'
|
||||
|
||||
const AllList: any[] = []
|
||||
for (let i = 0; i < 50; i++) {
|
||||
AllList.push(Mock.mock({
|
||||
id: '@id',
|
||||
title: '@ctitle(10, 20)',
|
||||
}))
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/list',
|
||||
method: 'get',
|
||||
response: (option: any) => {
|
||||
const { title, from, limit } = option.query
|
||||
const list = AllList.filter((item) => {
|
||||
return title ? item.title.includes(title) : true
|
||||
})
|
||||
const pageList = list.filter((item, index) => {
|
||||
return index >= ~~from && index < (~~from + ~~limit)
|
||||
})
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: {
|
||||
list: pageList,
|
||||
total: list.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/detail',
|
||||
method: 'get',
|
||||
response: (option: any) => {
|
||||
const info = AllList.filter(item => item.id === option.query.id)
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: info[0],
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/create',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: {
|
||||
isSuccess: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/edit',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: {
|
||||
isSuccess: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
url: '/mock/{{#if relativePath}}{{ relativePath }}/{{/if}}{{ moduleName }}/delete',
|
||||
method: 'post',
|
||||
response: () => {
|
||||
return {
|
||||
error: '',
|
||||
status: 1,
|
||||
data: {
|
||||
isSuccess: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
43
src/admin/plop-templates/mock/prompt.js
Executable file
@@ -0,0 +1,43 @@
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
function getFolder(path) {
|
||||
const components = []
|
||||
const files = fs.readdirSync(path)
|
||||
files.forEach((item) => {
|
||||
const stat = fs.lstatSync(`${path}/${item}`)
|
||||
if (stat.isDirectory() === true && item !== 'components') {
|
||||
components.push(`${path}/${item}`)
|
||||
components.push(...getFolder(`${path}/${item}`))
|
||||
}
|
||||
})
|
||||
return components
|
||||
}
|
||||
|
||||
export default {
|
||||
description: '创建标准模块 Mock',
|
||||
prompts: [
|
||||
{
|
||||
type: 'list',
|
||||
name: 'path',
|
||||
message: '请选择模块目录',
|
||||
choices: getFolder('src/views'),
|
||||
},
|
||||
],
|
||||
actions: (data) => {
|
||||
const pathArr = path.relative('src/views', data.path).split('\\')
|
||||
const moduleName = pathArr.pop()
|
||||
const relativePath = pathArr.join('/')
|
||||
const actions = []
|
||||
actions.push({
|
||||
type: 'add',
|
||||
path: pathArr.length === 0 ? 'src/mock/{{moduleName}}.ts' : `src/mock/${pathArr.join('.')}.{{moduleName}}.ts`,
|
||||
templateFile: 'plop-templates/mock/mock.hbs',
|
||||
data: {
|
||||
relativePath,
|
||||
moduleName,
|
||||
},
|
||||
})
|
||||
return actions
|
||||
},
|
||||
}
|
||||
22
src/admin/plop-templates/page/index.hbs
Executable file
@@ -0,0 +1,22 @@
|
||||
{{#if isFilesystem}}
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
title: 页面标题
|
||||
</route>
|
||||
|
||||
{{/if}}
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: '{{ properCase componentName }}',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- 布局 -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 样式
|
||||
</style>
|
||||
60
src/admin/plop-templates/page/prompt.js
Executable file
@@ -0,0 +1,60 @@
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
|
||||
function getFolder(path) {
|
||||
const components = []
|
||||
const files = fs.readdirSync(path)
|
||||
files.forEach((item) => {
|
||||
const stat = fs.lstatSync(`${path}/${item}`)
|
||||
if (stat.isDirectory() === true && item !== 'components') {
|
||||
components.push(`${path}/${item}`)
|
||||
components.push(...getFolder(`${path}/${item}`))
|
||||
}
|
||||
})
|
||||
return components
|
||||
}
|
||||
|
||||
export default {
|
||||
description: '创建页面',
|
||||
prompts: [
|
||||
{
|
||||
type: 'list',
|
||||
name: 'path',
|
||||
message: '请选择页面创建目录',
|
||||
choices: getFolder('src/views'),
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: '请输入文件名',
|
||||
validate: (v) => {
|
||||
if (!v || v.trim === '') {
|
||||
return '文件名不能为空'
|
||||
}
|
||||
else {
|
||||
return true
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'isFilesystem',
|
||||
message: '是否为基于文件系统的路由页面',
|
||||
default: false,
|
||||
},
|
||||
],
|
||||
actions: (data) => {
|
||||
const relativePath = path.relative('src/views', data.path)
|
||||
const actions = [
|
||||
{
|
||||
type: 'add',
|
||||
path: `${data.path}/{{dotCase name}}.vue`,
|
||||
templateFile: 'plop-templates/page/index.hbs',
|
||||
data: {
|
||||
componentName: `${relativePath} ${data.name}`,
|
||||
},
|
||||
},
|
||||
]
|
||||
return actions
|
||||
},
|
||||
}
|
||||
13
src/admin/plop-templates/store/index.hbs
Executable file
@@ -0,0 +1,13 @@
|
||||
const use{{ properCase name }}Store = defineStore(
|
||||
// 唯一ID
|
||||
'{{ camelCase name }}',
|
||||
() => {
|
||||
const someThing = ref(0)
|
||||
|
||||
return {
|
||||
someThing,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export default use{{ properCase name }}Store
|
||||
28
src/admin/plop-templates/store/prompt.js
Executable file
@@ -0,0 +1,28 @@
|
||||
export default {
|
||||
description: '创建全局状态',
|
||||
prompts: [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: '请输入模块名称',
|
||||
validate: (v) => {
|
||||
if (!v || v.trim === '') {
|
||||
return '模块名称不能为空'
|
||||
}
|
||||
else {
|
||||
return true
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: () => {
|
||||
const actions = [
|
||||
{
|
||||
type: 'add',
|
||||
path: 'src/store/modules/{{camelCase name}}.ts',
|
||||
templateFile: 'plop-templates/store/index.hbs',
|
||||
},
|
||||
]
|
||||
return actions
|
||||
},
|
||||
}
|
||||
13
src/admin/plopfile.js
Executable file
@@ -0,0 +1,13 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
|
||||
export default async function (plop) {
|
||||
plop.setWelcomeMessage('请选择需要创建的模式:')
|
||||
const items = await fs.readdir('./plop-templates')
|
||||
for (const item of items) {
|
||||
const stat = await fs.lstat(`./plop-templates/${item}`)
|
||||
if (stat.isDirectory()) {
|
||||
const prompt = await import(`./plop-templates/${item}/prompt.js`)
|
||||
plop.setGenerator(item, prompt.default)
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/admin/postcss.config.js
Executable file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
src/admin/public/browser_upgrade/chrome.png
Executable file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src/admin/public/browser_upgrade/edge.png
Executable file
|
After Width: | Height: | Size: 4.0 KiB |
49
src/admin/public/browser_upgrade/index.css
Executable file
@@ -0,0 +1,49 @@
|
||||
#browser-upgrade {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10001;
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #736477;
|
||||
user-select: none;
|
||||
background-color: snow;
|
||||
}
|
||||
|
||||
#browser-upgrade .title {
|
||||
margin: 40px 0;
|
||||
font-size: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#browser-upgrade .browsers {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#browser-upgrade .browsers .browser {
|
||||
display: inline-block;
|
||||
margin: 0 20px;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#browser-upgrade .browsers .browser .browser-icon {
|
||||
display: block;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#browser-upgrade .browsers .browser .browser-name {
|
||||
padding-bottom: 2px;
|
||||
margin-top: 10px;
|
||||
color: #736477;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
#browser-upgrade .browsers .browser:hover .browser-name {
|
||||
border-bottom: 1px solid #736477;
|
||||
}
|
||||
BIN
src/admin/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
92
src/admin/public/loading.css
Executable file
@@ -0,0 +1,92 @@
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fantastic-admin-home {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #736477;
|
||||
user-select: none;
|
||||
background-color: snow;
|
||||
}
|
||||
|
||||
.fantastic-admin-home .loading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.fantastic-admin-home .loading .square {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.fantastic-admin-home .loading .square::before {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
content: "";
|
||||
border: 3px solid #8c858f;
|
||||
border-radius: 15%;
|
||||
animation: square-to-dot-animation 2s linear infinite;
|
||||
}
|
||||
|
||||
.fantastic-admin-home .loading .square:nth-child(1)::before {
|
||||
animation-delay: calc(150ms * 1);
|
||||
}
|
||||
|
||||
.fantastic-admin-home .loading .square:nth-child(2)::before {
|
||||
animation-delay: calc(150ms * 2);
|
||||
}
|
||||
|
||||
.fantastic-admin-home .loading .square:nth-child(3)::before {
|
||||
animation-delay: calc(150ms * 3);
|
||||
}
|
||||
|
||||
.fantastic-admin-home .loading .square:nth-child(4)::before {
|
||||
animation-delay: calc(150ms * 4);
|
||||
}
|
||||
|
||||
@keyframes square-to-dot-animation {
|
||||
15%,
|
||||
25% {
|
||||
width: 0;
|
||||
height: 0;
|
||||
margin: 5px;
|
||||
border-width: 5px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
40% {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin: initial;
|
||||
border-width: 3px;
|
||||
border-radius: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
.fantastic-admin-home .text {
|
||||
position: relative;
|
||||
margin-top: 20px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.fantastic-admin-home .text::after {
|
||||
position: absolute;
|
||||
padding-left: 5px;
|
||||
content: "…";
|
||||
}
|
||||
83
src/admin/scripts/generate.icons.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { exec } from 'node:child_process'
|
||||
import fs from 'fs-extra'
|
||||
import inquirer from 'inquirer'
|
||||
import { lookupCollection, lookupCollections } from '@iconify/json'
|
||||
|
||||
async function generateIcons() {
|
||||
// 拿到全部图标集的原始数据
|
||||
const raw = await lookupCollections()
|
||||
|
||||
let lastChoose = fs.readFileSync(path.resolve(process.cwd(), 'src/iconify/index.json'), 'utf-8')
|
||||
lastChoose = JSON.parse(lastChoose)
|
||||
|
||||
// 取出可使用的图标集数据用于 inquirer 选择,并按名称排序
|
||||
const collections = Object.entries(raw).map(([id, item]) => ({
|
||||
...item,
|
||||
id,
|
||||
})).sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
/**
|
||||
* 分别会在对应目录下生成以下文件,其中(1)(3)用于离线下载并安装图标,(2)用于图标选择器使用
|
||||
* (1) src/iconify/index.json 记录用户 inquirer 的交互信息
|
||||
* (2) src/iconify/data.json 包含多个图标集数据,仅记录图标名
|
||||
* (3) public/icons/*-raw.json 多个图标集的原始数据,独立存放,用于离线使用
|
||||
*/
|
||||
inquirer.prompt([
|
||||
{
|
||||
type: 'checkbox',
|
||||
message: '请选择需要生成的图标集',
|
||||
name: 'collections',
|
||||
choices: collections.map(item => ({
|
||||
name: `${item.name} (${item.total} icons)`,
|
||||
value: item.id,
|
||||
})),
|
||||
default: lastChoose.collections,
|
||||
},
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'isOfflineUse',
|
||||
message: '是否需要离线使用',
|
||||
default: false,
|
||||
},
|
||||
]).then(async (answers) => {
|
||||
await fs.writeJSON(
|
||||
path.resolve(process.cwd(), 'src/iconify/index.json'),
|
||||
{
|
||||
collections: answers.collections,
|
||||
isOfflineUse: answers.isOfflineUse,
|
||||
},
|
||||
)
|
||||
|
||||
const outputDir = path.resolve(process.cwd(), 'public/icons')
|
||||
await fs.ensureDir(outputDir)
|
||||
await fs.emptyDir(outputDir)
|
||||
|
||||
const collectionsMeta: object[] = []
|
||||
for (const info of answers.collections) {
|
||||
const setData = await lookupCollection(info)
|
||||
|
||||
collectionsMeta.push({
|
||||
prefix: setData.prefix,
|
||||
info: setData.info,
|
||||
icons: Object.keys(setData.icons),
|
||||
})
|
||||
|
||||
const offlineFilePath = path.join(outputDir, `${info}-raw.json`)
|
||||
|
||||
if (answers.isOfflineUse) {
|
||||
await fs.writeJSON(offlineFilePath, setData)
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeJSON(
|
||||
path.resolve(process.cwd(), 'src/iconify/data.json'),
|
||||
collectionsMeta,
|
||||
)
|
||||
|
||||
exec('eslint src/iconify/data.json src/iconify/index.json --cache --fix')
|
||||
})
|
||||
}
|
||||
|
||||
generateIcons()
|
||||
108
src/admin/src/App.vue
Executable file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
import eruda from 'eruda';
|
||||
import hotkeys from 'hotkeys-js';
|
||||
import VConsole from 'vconsole';
|
||||
import Provider from './ui-provider/index.vue';
|
||||
import eventBus from './utils/eventBus';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const { auth } = useAuth();
|
||||
|
||||
const isAuth = computed(() => {
|
||||
return route.matched.every((item) => {
|
||||
return auth(item.meta.auth ?? '');
|
||||
});
|
||||
});
|
||||
|
||||
// 侧边栏主导航当前实际宽度
|
||||
const mainSidebarActualWidth = computed(() => {
|
||||
let actualWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
'--g-main-sidebar-width'
|
||||
)
|
||||
);
|
||||
if (
|
||||
settingsStore.settings.menu.menuMode === 'single' ||
|
||||
(settingsStore.settings.menu.menuMode === 'head' &&
|
||||
settingsStore.mode !== 'mobile')
|
||||
) {
|
||||
actualWidth = 0;
|
||||
}
|
||||
return `${actualWidth}px`;
|
||||
});
|
||||
|
||||
// 侧边栏次导航当前实际宽度
|
||||
const subSidebarActualWidth = computed(() => {
|
||||
let actualWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
'--g-sub-sidebar-width'
|
||||
)
|
||||
);
|
||||
if (
|
||||
settingsStore.settings.menu.subMenuCollapse &&
|
||||
settingsStore.mode !== 'mobile'
|
||||
) {
|
||||
actualWidth = Number.parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue(
|
||||
'--g-sub-sidebar-collapse-width'
|
||||
)
|
||||
);
|
||||
}
|
||||
return `${actualWidth}px`;
|
||||
});
|
||||
|
||||
// 设置网页 title
|
||||
watch(
|
||||
[
|
||||
() => settingsStore.settings.app.enableDynamicTitle,
|
||||
() => settingsStore.title,
|
||||
],
|
||||
() => {
|
||||
if (settingsStore.settings.app.enableDynamicTitle && settingsStore.title) {
|
||||
const title =
|
||||
typeof settingsStore.title === 'function'
|
||||
? settingsStore.title()
|
||||
: settingsStore.title;
|
||||
document.title = `${title} - ${import.meta.env.VITE_APP_TITLE}`;
|
||||
} else {
|
||||
document.title = import.meta.env.VITE_APP_TITLE;
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
settingsStore.setMode(document.documentElement.clientWidth);
|
||||
window.addEventListener('resize', () => {
|
||||
settingsStore.setMode(document.documentElement.clientWidth);
|
||||
});
|
||||
hotkeys('alt+i', () => {
|
||||
eventBus.emit('global-system-info-toggle');
|
||||
});
|
||||
});
|
||||
|
||||
import.meta.env.VITE_APP_DEBUG_TOOL === 'eruda' && eruda.init();
|
||||
import.meta.env.VITE_APP_DEBUG_TOOL === 'vconsole' && new VConsole();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Provider>
|
||||
<RouterView
|
||||
v-slot="{ Component }"
|
||||
:style="{
|
||||
'--g-main-sidebar-actual-width': mainSidebarActualWidth,
|
||||
'--g-sub-sidebar-actual-width': subSidebarActualWidth,
|
||||
}"
|
||||
>
|
||||
<component :is="Component" v-if="isAuth" />
|
||||
<NotAllowed v-else />
|
||||
</RouterView>
|
||||
<SystemInfo />
|
||||
</Provider>
|
||||
</template>
|
||||
73
src/admin/src/api/index.ts
Executable file
@@ -0,0 +1,73 @@
|
||||
import router from '@/router/index';
|
||||
import useUserStore from '@/store/modules/user';
|
||||
import axios from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL:
|
||||
import.meta.env.DEV && import.meta.env.VITE_OPEN_PROXY === 'true'
|
||||
? '/proxy/'
|
||||
: import.meta.env.VITE_APP_API_BASEURL,
|
||||
timeout: 1000 * 60,
|
||||
responseType: 'json',
|
||||
});
|
||||
|
||||
api.interceptors.request.use((request) => {
|
||||
const userStore = useUserStore();
|
||||
/**
|
||||
* 全局拦截请求发送前提交的参数
|
||||
* 以下代码为示例,在请求头里带上 token 信息
|
||||
*/
|
||||
if (userStore.isLogin && request.headers) {
|
||||
request.headers.Authorization = userStore.token
|
||||
? `Bearer ${userStore.token}`
|
||||
: '';
|
||||
}
|
||||
// 是否将 POST 请求参数进行字符串化处理
|
||||
if (request.method === 'post') {
|
||||
// request.data = qs.stringify(request.data, {
|
||||
// arrayFormat: 'brackets',
|
||||
// })
|
||||
}
|
||||
return request;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
/**
|
||||
* 全局拦截请求发送后返回的数据,如果数据有报错则在这做全局的错误提示
|
||||
* 假设返回数据格式为:{ status: 1, error: '', data: '' }
|
||||
* 规则是当 status 为 1 时表示请求成功,为 0 时表示接口需要登录或者登录状态失效,需要重新登录
|
||||
* 请求出错时 error 会返回错误信息
|
||||
*/
|
||||
return Promise.resolve(response.data);
|
||||
},
|
||||
(error) => {
|
||||
let msg = '';
|
||||
if (error?.response) {
|
||||
const { data, status } = error.response;
|
||||
if (status === 401) {
|
||||
msg = '权限验证失败,请重新登录';
|
||||
// loginout
|
||||
if (data.code === 401 && data.message.includes('请登录后继续操作')) {
|
||||
const userStore = useUserStore();
|
||||
userStore.logout().then(() => {
|
||||
router.push({ name: 'login' });
|
||||
});
|
||||
}
|
||||
}
|
||||
const { message, code } = data;
|
||||
message && (msg = message);
|
||||
} else {
|
||||
msg = '接口请求异常,请稍后再试';
|
||||
}
|
||||
|
||||
ElMessage({
|
||||
message: msg,
|
||||
type: 'error',
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
12
src/admin/src/api/modules/app.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryCats: (params: any) => api.get('app/queryAppCats', { params }),
|
||||
deleteCats: (data: { id: number }) => api.post('app/delAppCats', data),
|
||||
createCats: (data: any) => api.post('app/createAppCats', data),
|
||||
updateCats: (data: any) => api.post('app/updateAppCats', data),
|
||||
queryApp: (params: any) => api.get('app/queryApp', { params }),
|
||||
deleteApp: (data: { id: number }) => api.post('app/delApp', data),
|
||||
createApp: (data: any) => api.post('app/createApp', data),
|
||||
updateApp: (data: any) => api.post('app/updateApp', data),
|
||||
};
|
||||
21
src/admin/src/api/modules/autoReply.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import api from '../index'
|
||||
|
||||
export default {
|
||||
queryAutoReply: (params: {
|
||||
page?: number
|
||||
size?: number
|
||||
prompt?: string
|
||||
status?: number
|
||||
}) => api.get('autoreply/query', { params }),
|
||||
delAutoReply: (data: { id: number }) => api.post('autoreply/del', data),
|
||||
addAutoReply: (data: {
|
||||
prompt: string
|
||||
answer: string
|
||||
}) => api.post('autoreply/add', data),
|
||||
updateAutoReply: (data: {
|
||||
id: number
|
||||
prompt: string
|
||||
answer: string
|
||||
status: number
|
||||
}) => api.post('autoreply/update', data),
|
||||
}
|
||||
13
src/admin/src/api/modules/badWords.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import api from '../index'
|
||||
|
||||
export default {
|
||||
queryBadWords: (params = {}) => api.get('badwords/query', { params }),
|
||||
queryViolation: (params = {}) => api.get('badwords/violation', { params }),
|
||||
delBadWords: (data: { id: number }) => api.post('badwords/del', data),
|
||||
addBadWords: (data: { word: string }) => api.post('badwords/add', data),
|
||||
updateBadWords: (data: {
|
||||
id: number
|
||||
word: string
|
||||
status: number
|
||||
}) => api.post('badwords/update', data),
|
||||
}
|
||||
7
src/admin/src/api/modules/chat.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryChatAll: (params: any) => api.get('chatLog/chatAll', { params }),
|
||||
queryDrawAll: (params: any) => api.get('chatLog/drawAll', { params }),
|
||||
recDrawImg: (data: { id: number }) => api.post('chatLog/recDrawImg', data),
|
||||
};
|
||||
12
src/admin/src/api/modules/config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import api from '../index';
|
||||
|
||||
interface KeyValue {
|
||||
configKey: string;
|
||||
configVal: any;
|
||||
}
|
||||
|
||||
export default {
|
||||
queryAllConfig: () => api.get('config/queryAll'),
|
||||
queryConfig: (data: any) => api.post('config/query', data),
|
||||
setConfig: (data: { settings: KeyValue[] }) => api.post('config/set', data),
|
||||
};
|
||||
20
src/admin/src/api/modules/dashboard.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { AxiosResponse } from 'axios';
|
||||
import api from '../index';
|
||||
|
||||
const apiDashboard = {
|
||||
getBaseInfo: () => api.get('/statistic/base'),
|
||||
getChatStatistic: (params: { days: number }): Promise<AxiosResponse<any>> => {
|
||||
return api.get('/statistic/chatStatistic', {
|
||||
params: { days: params.days },
|
||||
});
|
||||
},
|
||||
getBaiduVisit: (params: any): Promise<AxiosResponse<any>> => {
|
||||
return api.get('/statistic/baiduVisit', {
|
||||
params: { days: params.days },
|
||||
});
|
||||
},
|
||||
getObserverCharts: (params: any) =>
|
||||
api.get('/statistic/observerCharts', { params }),
|
||||
};
|
||||
|
||||
export default apiDashboard;
|
||||
7
src/admin/src/api/modules/models.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryModels: (params: any) => api.get('models/query', { params }),
|
||||
setModels: (data: any) => api.post('models/setModel', data),
|
||||
delModels: (data: any) => api.post('models/delModel', data),
|
||||
};
|
||||
7
src/admin/src/api/modules/order.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryAllOrder: (params: any) => api.get('order/queryAll', { params }),
|
||||
deleteOrder: (data: any) => api.post('order/delete', data),
|
||||
deleteNotPay: () => api.post('order/deleteNotPay'),
|
||||
};
|
||||
13
src/admin/src/api/modules/package.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
queryAllPackage: (params: any) =>
|
||||
api.get('crami/queryAllPackage', { params }),
|
||||
updatePackage: (data: any) => api.post('crami/updatePackage', data),
|
||||
createPackage: (data: any) => api.post('crami/createPackage', data),
|
||||
delPackage: (data: any) => api.post('crami/delPackage', data),
|
||||
queryAllCrami: (params: any) => api.get('crami/queryAllCrami', { params }),
|
||||
delCrami: (data: any) => api.post('crami/delCrami', data),
|
||||
createCrami: (data: any) => api.post('crami/createCrami', data),
|
||||
batchDelCrami: (data: any) => api.post('crami/batchDelCrami', data),
|
||||
};
|
||||
8
src/admin/src/api/modules/plugin.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
pluginList: (params: any) => api.get('plugin/pluginList', { params }),
|
||||
delPlugin: (data: { id: number }) => api.post('plugin/delPlugin', data),
|
||||
createPlugin: (data: any) => api.post('plugin/createPlugin', data),
|
||||
updatePlugin: (data: any) => api.post('plugin/updatePlugin', data),
|
||||
};
|
||||
31
src/admin/src/api/modules/user.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import api from '../index';
|
||||
|
||||
export default {
|
||||
login: (data: { username: string; password: string }) =>
|
||||
api.post('auth/login', data),
|
||||
|
||||
permission: () => api.get('auth/getInfo'),
|
||||
|
||||
getInfo: () => api.get('auth/getInfo'),
|
||||
|
||||
queryAllUser: (params: any) => api.get('user/queryAll', { params }),
|
||||
|
||||
updateUserStatus: (data: { status: string }) =>
|
||||
api.post('user/updateStatus', data),
|
||||
|
||||
resetUserPassword: (data: { id: number }) =>
|
||||
api.post('user/resetUserPass', data),
|
||||
|
||||
sendUserCrami: (data: {
|
||||
userId: number;
|
||||
model3Count: number;
|
||||
model4Count: number;
|
||||
drawMjCount: number;
|
||||
}) => api.post('user/recharge', data),
|
||||
|
||||
passwordEdit: (data: { oldPassword: string; password: string }) =>
|
||||
api.post('auth/updatePassword', data),
|
||||
|
||||
queryUserAccountLog: (params: any) =>
|
||||
api.get('balance/accountLog', { params }),
|
||||
};
|
||||
1
src/admin/src/assets/icons/403.svg
Executable file
|
After Width: | Height: | Size: 6.2 KiB |
1
src/admin/src/assets/icons/404.svg
Executable file
|
After Width: | Height: | Size: 6.5 KiB |
1
src/admin/src/assets/icons/image-load-fail.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="M704 328a72 72 0 1 0 144 0 72 72 0 1 0-144 0z"/><path d="M999.904 116.608a32 32 0 0 0-21.952-10.912L521.76 73.792a31.552 31.552 0 0 0-27.2 11.904l-92.192 114.848a32 32 0 0 0 .672 40.896l146.144 169.952-147.456 194.656 36.48-173.376a32 32 0 0 0-11.136-31.424L235.616 245.504l79.616-125.696a32 32 0 0 0-29.28-49.024L45.76 87.552a32 32 0 0 0-29.696 34.176l55.808 798.016a32.064 32.064 0 0 0 34.304 29.696l176.512-13.184c17.632-1.312 30.848-16.672 29.504-34.272s-16.576-31.04-34.304-29.536L133.44 883.232l-6.432-92.512 125.312-12.576a32 32 0 0 0 28.672-35.04 32.16 32.16 0 0 0-35.04-28.672L122.56 726.848 82.144 149.184l145.152-10.144-60.96 96.224a32 32 0 0 0 6.848 41.952l198.4 161.344-58.752 279.296a30.912 30.912 0 0 0 .736 14.752 31.68 31.68 0 0 0 1.408 11.04l51.52 154.56a31.968 31.968 0 0 0 27.456 21.76l523.104 47.552a32.064 32.064 0 0 0 34.848-29.632l55.776-798.048a32.064 32.064 0 0 0-7.776-23.232zm-98.912 630.848-412.576-39.648a31.52 31.52 0 0 0-34.912 28.768 32 32 0 0 0 28.8 34.912l414.24 39.808-6.272 89.536-469.728-42.72-39.584-118.72 234.816-310.016a31.936 31.936 0 0 0-1.248-40.192L468.896 219.84l65.088-81.056 407.584 28.48-40.576 580.192z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
src/admin/src/assets/icons/toolbar-collapse.svg
Executable file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200.781" height="200" class="icon" viewBox="0 0 1028 1024"><path d="M989.867 234.667H499.2c-17.067 0-34.133-21.334-34.133-42.667 0-25.6 12.8-42.667 34.133-42.667h490.667c17.066 0 34.133 17.067 34.133 42.667 0 21.333-12.8 42.667-34.133 42.667zm-473.6 128h465.066c25.6 0 46.934 21.333 46.934 42.666 0 25.6-21.334 42.667-46.934 42.667H516.267c-25.6 0-46.934-17.067-46.934-42.667s21.334-42.666 46.934-42.666zm0 298.666c-25.6 0-46.934-21.333-46.934-42.666 0-25.6 21.334-42.667 46.934-42.667h465.066c25.6 0 46.934 17.067 46.934 42.667s-21.334 42.666-46.934 42.666H516.267zm4.266 128H972.8c29.867 0 51.2 17.067 51.2 42.667s-21.333 42.667-51.2 42.667H520.533c-29.866 0-51.2-17.067-51.2-42.667s21.334-42.667 51.2-42.667zm-192 25.6c-17.066 17.067-46.933 17.067-64 0L12.8 541.867c-17.067-17.067-17.067-51.2 0-68.267l251.733-273.067c17.067-17.066 46.934-17.066 64 0s17.067 51.2 0 68.267L106.667 507.733l221.866 238.934c17.067 21.333 17.067 51.2 0 68.266z"/></svg>
|
||||
|
After Width: | Height: | Size: 998 B |
142
src/admin/src/assets/styles/globals.scss
Executable file
@@ -0,0 +1,142 @@
|
||||
// 页面布局 CSS 变量
|
||||
:root {
|
||||
// 头部高度
|
||||
--g-header-height: 60px;
|
||||
// 侧边栏宽度
|
||||
--g-main-sidebar-width: 80px;
|
||||
--g-sub-sidebar-width: 220px;
|
||||
--g-sub-sidebar-collapse-width: 64px;
|
||||
// 侧边栏 Logo 区域高度
|
||||
--g-sidebar-logo-height: 50px;
|
||||
// 标签栏高度
|
||||
--g-tabbar-height: 50px;
|
||||
// 工具栏高度
|
||||
--g-toolbar-height: 50px;
|
||||
}
|
||||
|
||||
// 明暗模式 CSS 变量
|
||||
/* stylelint-disable-next-line no-duplicate-selectors */
|
||||
:root {
|
||||
&::view-transition-old(root),
|
||||
&::view-transition-new(root) {
|
||||
mix-blend-mode: normal;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&::view-transition-new(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
--g-box-shadow-color: rgb(0 0 0 / 12%);
|
||||
|
||||
&.dark {
|
||||
&::view-transition-old(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
&::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
--g-box-shadow-color: rgb(0 0 0 / 72%);
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(0 0 0 / 40%);
|
||||
background-clip: padding-box;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-family: Lato, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background-color: var(--g-container-bg);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
&.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
// 右侧内容区针对fixed元素,有横向铺满的需求,可在fixed元素上设置 [data-fixed-calc-width]
|
||||
[data-fixed-calc-width] {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
left: 50%;
|
||||
width: calc(100% - var(--g-main-sidebar-actual-width) - var(--g-sub-sidebar-actual-width));
|
||||
transform: translateX(-50%) translateX(calc(var(--g-main-sidebar-actual-width) / 2)) translateX(calc(var(--g-sub-sidebar-actual-width) / 2));
|
||||
}
|
||||
|
||||
[data-mode="mobile"] {
|
||||
[data-fixed-calc-width] {
|
||||
width: 100% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
}
|
||||
}
|
||||
// textarea 字体跟随系统
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Overrides Floating Vue */
|
||||
.v-popper--theme-dropdown,
|
||||
.v-popper--theme-tooltip {
|
||||
--at-apply: inline-flex;
|
||||
}
|
||||
|
||||
.v-popper--theme-dropdown .v-popper__inner,
|
||||
.v-popper--theme-tooltip .v-popper__inner {
|
||||
--at-apply: bg-white dark-bg-stone-8 text-dark dark-text-white rounded shadow ring-1 ring-gray-200 dark-ring-gray-800 border border-solid border-stone/20 text-xs font-normal;
|
||||
|
||||
box-shadow: 0 6px 30px rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.v-popper--theme-tooltip .v-popper__arrow-inner,
|
||||
.v-popper--theme-dropdown .v-popper__arrow-inner {
|
||||
visibility: visible;
|
||||
|
||||
--at-apply: border-white dark-border-stone-8;
|
||||
}
|
||||
|
||||
.v-popper--theme-tooltip .v-popper__arrow-outer,
|
||||
.v-popper--theme-dropdown .v-popper__arrow-outer {
|
||||
--at-apply: border-stone/20;
|
||||
}
|
||||
|
||||
.v-popper--theme-tooltip.v-popper--shown,
|
||||
.v-popper--theme-tooltip.v-popper--shown * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
[data-overlayscrollbars-contents] {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
63
src/admin/src/assets/styles/nprogress.scss
Executable file
@@ -0,0 +1,63 @@
|
||||
#nprogress {
|
||||
pointer-events: none;
|
||||
|
||||
.bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2000;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: rgb(var(--ui-primary));
|
||||
}
|
||||
|
||||
.peg {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
display: block;
|
||||
width: 100px;
|
||||
height: 100%;
|
||||
box-shadow: 0 0 10px rgb(var(--ui-primary)), 0 0 5px rgb(var(--ui-primary));
|
||||
opacity: 1;
|
||||
transform: rotate(3deg) translate(0, -4px);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: fixed;
|
||||
top: 11px;
|
||||
right: 14px;
|
||||
z-index: 2000;
|
||||
display: block;
|
||||
|
||||
.spinner-icon {
|
||||
box-sizing: border-box;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: solid 2px transparent;
|
||||
border-top-color: rgb(var(--ui-primary));
|
||||
border-left-color: rgb(var(--ui-primary));
|
||||
border-radius: 50%;
|
||||
animation: nprogress-spinner 400ms linear infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nprogress-custom-parent {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
#nprogress .spinner,
|
||||
#nprogress .bar {
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes nprogress-spinner {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes nprogress-spinner {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
53
src/admin/src/assets/styles/resources/utils.scss
Executable file
@@ -0,0 +1,53 @@
|
||||
// 文字超出隐藏,默认为单行超出隐藏,可设置多行
|
||||
@mixin text-overflow($line: 1, $fixed-width: true) {
|
||||
@if $line == 1 and $fixed-width == true {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
} @else {
|
||||
/* stylelint-disable-next-line value-no-vendor-prefix */
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: $line;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
// 定位居中,默认水平居中,可选择垂直居中,或者水平垂直都居中
|
||||
@mixin position-center($type: x) {
|
||||
position: absolute;
|
||||
|
||||
@if $type == x {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
@if $type == y {
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@if $type == xy {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
// 文字两端对齐
|
||||
%justify-align {
|
||||
text-align: justify;
|
||||
text-align-last: justify;
|
||||
}
|
||||
|
||||
// 清除浮动
|
||||
%clearfix {
|
||||
zoom: 1;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: block;
|
||||
clear: both;
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
1
src/admin/src/assets/styles/resources/variables.scss
Executable file
@@ -0,0 +1 @@
|
||||
// 全局变量
|
||||
20
src/admin/src/components/Auth/index.vue
Executable file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'Auth',
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
value: string | string[]
|
||||
}>()
|
||||
|
||||
function check() {
|
||||
return useAuth().auth(props.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot v-if="check()" />
|
||||
<slot v-else name="no-auth" />
|
||||
</div>
|
||||
</template>
|
||||
20
src/admin/src/components/AuthAll/index.vue
Executable file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'AuthAll',
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
value: string[]
|
||||
}>()
|
||||
|
||||
function check() {
|
||||
return useAuth().authAll(props.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<slot v-if="check()" />
|
||||
<slot v-else name="no-auth" />
|
||||
</div>
|
||||
</template>
|
||||
108
src/admin/src/components/FileUpload/index.vue
Executable file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProps, UploadUserFile } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
defineOptions({
|
||||
name: 'FileUpload',
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action: UploadProps['action']
|
||||
headers?: UploadProps['headers']
|
||||
data?: UploadProps['data']
|
||||
name?: UploadProps['name']
|
||||
size?: number
|
||||
max?: number
|
||||
files?: UploadUserFile[]
|
||||
notip?: boolean
|
||||
ext?: string[]
|
||||
}>(),
|
||||
{
|
||||
name: 'file',
|
||||
size: 2,
|
||||
max: 3,
|
||||
files: () => [],
|
||||
notip: false,
|
||||
ext: () => ['zip', 'rar'],
|
||||
},
|
||||
)
|
||||
|
||||
const emits = defineEmits<{
|
||||
onSuccess: [
|
||||
res: any,
|
||||
file: UploadUserFile,
|
||||
fileList: UploadUserFile[],
|
||||
]
|
||||
}>()
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
const fileName = file.name.split('.')
|
||||
const fileExt = fileName.at(-1) ?? ''
|
||||
const isTypeOk = props.ext.includes(fileExt)
|
||||
const isSizeOk = file.size / 1024 / 1024 < props.size
|
||||
if (!isTypeOk) {
|
||||
ElMessage.error(`上传文件只支持 ${props.ext.join(' / ')} 格式!`)
|
||||
}
|
||||
if (!isSizeOk) {
|
||||
ElMessage.error(`上传文件大小不能超过 ${props.size}MB!`)
|
||||
}
|
||||
return isTypeOk && isSizeOk
|
||||
}
|
||||
|
||||
const onExceed: UploadProps['onExceed'] = () => {
|
||||
ElMessage.warning('文件上传超过限制')
|
||||
}
|
||||
|
||||
const onSuccess: UploadProps['onSuccess'] = (res, file, fileList) => {
|
||||
emits('onSuccess', res, file, fileList)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElUpload
|
||||
:headers="headers"
|
||||
:action="action"
|
||||
:data="data"
|
||||
:name="name"
|
||||
:before-upload="beforeUpload"
|
||||
:on-exceed="onExceed"
|
||||
:on-success="onSuccess"
|
||||
:file-list="files"
|
||||
:limit="max"
|
||||
drag
|
||||
>
|
||||
<div class="slot">
|
||||
<SvgIcon name="i-ep:upload-filled" class="el-icon--upload" />
|
||||
<div class="el-upload__text">
|
||||
将文件拖到此处,或<em>点击上传</em>
|
||||
</div>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div v-if="!notip" class="el-upload__tip">
|
||||
<div style="display: inline-block;">
|
||||
<ElAlert :title="`上传文件支持 ${ext.join(' / ')} 格式,单个文件大小不超过 ${size}MB,且文件数量不超过 ${max} 个`" type="info" show-icon :closable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElUpload>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.el-upload.is-drag) {
|
||||
display: inline-block;
|
||||
|
||||
.el-upload-dragger {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&.is-dragover {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.slot {
|
||||
width: 300px;
|
||||
padding: 40px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
src/admin/src/components/FixedActionBar/index.vue
Executable file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'FixedActionBar',
|
||||
})
|
||||
|
||||
const isBottom = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
onScroll()
|
||||
window.addEventListener('scroll', onScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
})
|
||||
|
||||
function onScroll() {
|
||||
// 变量scrollTop是滚动条滚动时,滚动条上端距离顶部的距离
|
||||
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop
|
||||
// 变量windowHeight是可视区的高度
|
||||
const windowHeight = document.documentElement.clientHeight || document.body.clientHeight
|
||||
// 变量scrollHeight是滚动条的总高度(当前可滚动的页面的总高度)
|
||||
const scrollHeight = document.documentElement.scrollHeight || document.body.scrollHeight
|
||||
// 滚动条到底部
|
||||
isBottom.value = Math.ceil(scrollTop + windowHeight) >= scrollHeight
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="fixed-action-bar bottom-0 z-4 bg-[var(--g-container-bg)] p-5 text-center transition" :class="{ shadow: !isBottom }" data-fixed-calc-width
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fixed-action-bar {
|
||||
box-shadow: 0 0 1px 0 var(--g-box-shadow-color);
|
||||
|
||||
&.shadow {
|
||||
box-shadow: 0 -10px 10px -10px var(--g-box-shadow-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
src/admin/src/components/IconifyIcon/index.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, useAttrs } from 'vue';
|
||||
|
||||
interface Props {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
|
||||
const bindAttrs = computed<{ class: string; style: string }>(() => ({
|
||||
class: (attrs.class as string) || '',
|
||||
style: (attrs.style as string) || 'width: 2em, height: 2em',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon icon="icon" v-bind="bindAttrs" />
|
||||
</template>
|
||||
63
src/admin/src/components/ImagePreview/index.vue
Executable file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'ImagePreview',
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
src: string
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
}>(),
|
||||
{
|
||||
width: 200,
|
||||
height: 200,
|
||||
},
|
||||
)
|
||||
|
||||
const realWidth = computed(() => {
|
||||
return typeof props.width === 'string' ? props.width : `${props.width}px`
|
||||
})
|
||||
|
||||
const realHeight = computed(() => {
|
||||
return typeof props.height === 'string' ? props.height : `${props.height}px`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElImage :src="src" fit="cover" :style="`width:${realWidth};height:${realHeight};`" :preview-src-list="[src]" preview-teleported>
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<SvgIcon name="image-load-fail" />
|
||||
</div>
|
||||
</template>
|
||||
</ElImage>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.el-image {
|
||||
background-color: var(--el-fill-color);
|
||||
border-radius: 5px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
transition: background-color 0.3s, var(--el-transition-box-shadow);
|
||||
|
||||
:deep(.el-image__inner) {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.image-slot) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: 30px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
247
src/admin/src/components/ImageUpload/index.vue
Executable file
@@ -0,0 +1,247 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProps } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
defineOptions({
|
||||
name: 'ImageUpload',
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action: UploadProps['action']
|
||||
headers?: UploadProps['headers']
|
||||
data?: UploadProps['data']
|
||||
name?: UploadProps['name']
|
||||
size?: number
|
||||
width?: number
|
||||
height?: number
|
||||
placeholder?: string
|
||||
notip?: boolean
|
||||
ext?: string[]
|
||||
}>(),
|
||||
{
|
||||
name: 'file',
|
||||
size: 2,
|
||||
width: 150,
|
||||
height: 150,
|
||||
placeholder: '',
|
||||
notip: false,
|
||||
ext: () => ['jpg', 'png', 'gif', 'bmp'],
|
||||
},
|
||||
)
|
||||
|
||||
const emits = defineEmits<{
|
||||
onSuccess: [
|
||||
res: any,
|
||||
]
|
||||
}>()
|
||||
|
||||
const url = defineModel<string>({
|
||||
default: '',
|
||||
})
|
||||
|
||||
const uploadData = ref({
|
||||
imageViewerVisible: false,
|
||||
progress: {
|
||||
preview: '',
|
||||
percent: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// 预览
|
||||
function preview() {
|
||||
uploadData.value.imageViewerVisible = true
|
||||
}
|
||||
// 关闭预览
|
||||
function previewClose() {
|
||||
uploadData.value.imageViewerVisible = false
|
||||
}
|
||||
// 移除
|
||||
function remove() {
|
||||
url.value = ''
|
||||
}
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
const fileName = file.name.split('.')
|
||||
const fileExt = fileName.at(-1) ?? ''
|
||||
const isTypeOk = props.ext.includes(fileExt)
|
||||
const isSizeOk = file.size / 1024 / 1024 < props.size
|
||||
if (!isTypeOk) {
|
||||
ElMessage.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`)
|
||||
}
|
||||
if (!isSizeOk) {
|
||||
ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`)
|
||||
}
|
||||
if (isTypeOk && isSizeOk) {
|
||||
uploadData.value.progress.preview = URL.createObjectURL(file)
|
||||
}
|
||||
return isTypeOk && isSizeOk
|
||||
}
|
||||
const onProgress: UploadProps['onProgress'] = (file) => {
|
||||
uploadData.value.progress.percent = ~~file.percent
|
||||
}
|
||||
const onSuccess: UploadProps['onSuccess'] = (res) => {
|
||||
uploadData.value.progress.preview = ''
|
||||
uploadData.value.progress.percent = 0
|
||||
emits('onSuccess', res)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-container">
|
||||
<ElUpload
|
||||
:show-file-list="false"
|
||||
:headers="headers"
|
||||
:action="action"
|
||||
:data="data"
|
||||
:name="name"
|
||||
:before-upload="beforeUpload"
|
||||
:on-progress="onProgress"
|
||||
:on-success="onSuccess"
|
||||
drag
|
||||
class="image-upload"
|
||||
>
|
||||
<ElImage v-if="url === ''" :src="url === '' ? placeholder : url" :style="`width:${width}px;height:${height}px;`" fit="fill">
|
||||
<template #error>
|
||||
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
|
||||
<SvgIcon name="i-ep:plus" class="icon" />
|
||||
</div>
|
||||
</template>
|
||||
</ElImage>
|
||||
<div v-else class="image">
|
||||
<ElImage :src="url" :style="`width:${width}px;height:${height}px;`" fit="fill" />
|
||||
<div class="mask">
|
||||
<div class="actions">
|
||||
<span title="预览" @click.stop="preview">
|
||||
<SvgIcon name="i-ep:zoom-in" class="icon" />
|
||||
</span>
|
||||
<span title="移除" @click.stop="remove">
|
||||
<SvgIcon name="i-ep:delete" class="icon" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="url === '' && uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
|
||||
<ElImage :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
|
||||
<ElProgress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
|
||||
</div>
|
||||
</ElUpload>
|
||||
<div v-if="!notip" class="el-upload__tip">
|
||||
<div style="display: inline-block;">
|
||||
<ElAlert :title="`上传图片支持 ${ext.join(' / ')} 格式,且图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height}`" type="info" show-icon :closable="false" />
|
||||
</div>
|
||||
</div>
|
||||
<ElImageViewer v-if="uploadData.imageViewerVisible" :url-list="[url]" teleported @close="previewClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-container {
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
.el-image {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--el-overlay-color-lighter);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
|
||||
@include position-center(xy);
|
||||
|
||||
span {
|
||||
width: 50%;
|
||||
color: var(--el-color-white);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s, transform 0.1s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .mask {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.image-upload {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
.el-upload-dragger {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
|
||||
&.is-dragover {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.image-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-placeholder);
|
||||
background-color: transparent;
|
||||
|
||||
.icon {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: "";
|
||||
background-color: var(--el-overlay-color-lighter);
|
||||
}
|
||||
|
||||
.el-progress {
|
||||
z-index: 1;
|
||||
|
||||
@include position-center(xy);
|
||||
|
||||
.el-progress__text {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
272
src/admin/src/components/ImagesUpload/index.vue
Executable file
@@ -0,0 +1,272 @@
|
||||
<script setup lang="ts">
|
||||
import type { UploadProps } from 'element-plus'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
defineOptions({
|
||||
name: 'ImagesUpload',
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
action: UploadProps['action']
|
||||
headers?: UploadProps['headers']
|
||||
data?: UploadProps['data']
|
||||
name?: UploadProps['name']
|
||||
size?: number
|
||||
max?: number
|
||||
width?: number
|
||||
height?: number
|
||||
placeholder?: string
|
||||
notip?: boolean
|
||||
ext?: string[]
|
||||
}>(),
|
||||
{
|
||||
name: 'file',
|
||||
size: 2,
|
||||
max: 3,
|
||||
width: 150,
|
||||
height: 150,
|
||||
placeholder: '',
|
||||
notip: false,
|
||||
ext: () => ['jpg', 'png', 'gif', 'bmp'],
|
||||
},
|
||||
)
|
||||
|
||||
const emits = defineEmits<{
|
||||
onSuccess: [
|
||||
res: any,
|
||||
]
|
||||
}>()
|
||||
|
||||
const url = defineModel<string[]>({
|
||||
default: [],
|
||||
})
|
||||
|
||||
const uploadData = ref({
|
||||
dialogImageIndex: 0,
|
||||
imageViewerVisible: false,
|
||||
progress: {
|
||||
preview: '',
|
||||
percent: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// 预览
|
||||
function preview(index: number) {
|
||||
uploadData.value.dialogImageIndex = index
|
||||
uploadData.value.imageViewerVisible = true
|
||||
}
|
||||
// 关闭预览
|
||||
function previewClose() {
|
||||
uploadData.value.imageViewerVisible = false
|
||||
}
|
||||
// 移除
|
||||
function remove(index: number) {
|
||||
url.value.splice(index, 1)
|
||||
}
|
||||
// 移动
|
||||
function move(index: number, type: 'left' | 'right') {
|
||||
if (type === 'left' && index !== 0) {
|
||||
url.value[index] = url.value.splice(index - 1, 1, url.value[index])[0]
|
||||
}
|
||||
if (type === 'right' && index !== url.value.length - 1) {
|
||||
url.value[index] = url.value.splice(index + 1, 1, url.value[index])[0]
|
||||
}
|
||||
}
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||
const fileName = file.name.split('.')
|
||||
const fileExt = fileName.at(-1) ?? ''
|
||||
const isTypeOk = props.ext.includes(fileExt)
|
||||
const isSizeOk = file.size / 1024 / 1024 < props.size
|
||||
if (!isTypeOk) {
|
||||
ElMessage.error(`上传图片只支持 ${props.ext.join(' / ')} 格式!`)
|
||||
}
|
||||
if (!isSizeOk) {
|
||||
ElMessage.error(`上传图片大小不能超过 ${props.size}MB!`)
|
||||
}
|
||||
if (isTypeOk && isSizeOk) {
|
||||
uploadData.value.progress.preview = URL.createObjectURL(file)
|
||||
}
|
||||
return isTypeOk && isSizeOk
|
||||
}
|
||||
const onProgress: UploadProps['onProgress'] = (file) => {
|
||||
uploadData.value.progress.percent = ~~file.percent
|
||||
}
|
||||
const onSuccess: UploadProps['onSuccess'] = (res) => {
|
||||
uploadData.value.progress.preview = ''
|
||||
uploadData.value.progress.percent = 0
|
||||
emits('onSuccess', res)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="upload-container">
|
||||
<div v-for="(item, index) in (url as string[])" :key="index" class="images">
|
||||
<ElImage v-if="index < max" :src="item" :style="`width:${width}px;height:${height}px;`" fit="cover" />
|
||||
<div class="mask">
|
||||
<div class="actions">
|
||||
<span title="预览" @click="preview(index)">
|
||||
<SvgIcon name="i-ep:zoom-in" class="icon" />
|
||||
</span>
|
||||
<span title="移除" @click="remove(index)">
|
||||
<SvgIcon name="i-ep:delete" class="icon" />
|
||||
</span>
|
||||
<span v-show="url.length > 1" title="左移" :class="{ disabled: index === 0 }" @click="move(index, 'left')">
|
||||
<SvgIcon name="i-ep:back" class="icon" />
|
||||
</span>
|
||||
<span v-show="url.length > 1" title="右移" :class="{ disabled: index === url.length - 1 }" @click="move(index, 'right')">
|
||||
<SvgIcon name="i-ep:right" class="icon" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElUpload
|
||||
v-show="url.length < max"
|
||||
:show-file-list="false"
|
||||
:headers="headers"
|
||||
:action="action"
|
||||
:data="data"
|
||||
:name="name"
|
||||
:before-upload="beforeUpload"
|
||||
:on-progress="onProgress"
|
||||
:on-success="onSuccess"
|
||||
drag
|
||||
class="images-upload"
|
||||
>
|
||||
<div class="image-slot" :style="`width:${width}px;height:${height}px;`">
|
||||
<SvgIcon name="i-ep:plus" class="icon" />
|
||||
</div>
|
||||
<div v-show="uploadData.progress.percent" class="progress" :style="`width:${width}px;height:${height}px;`">
|
||||
<ElImage :src="uploadData.progress.preview" :style="`width:${width}px;height:${height}px;`" fit="fill" />
|
||||
<ElProgress type="circle" :width="Math.min(width, height) * 0.8" :percentage="uploadData.progress.percent" />
|
||||
</div>
|
||||
</ElUpload>
|
||||
<div v-if="!notip" class="el-upload__tip">
|
||||
<div style="display: inline-block;">
|
||||
<ElAlert :title="`上传图片支持 ${ext.join(' / ')} 格式,单张图片大小不超过 ${size}MB,建议图片尺寸为 ${width}*${height},且图片数量不超过 ${max} 张`" type="info" show-icon :closable="false" />
|
||||
</div>
|
||||
</div>
|
||||
<ElImageViewer v-if="uploadData.imageViewerVisible" :url-list="url as string[]" :initial-index="uploadData.dialogImageIndex" teleported @close="previewClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upload-container {
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
.el-image {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.images {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
overflow: hidden;
|
||||
border: 1px dashed var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
|
||||
.mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--el-overlay-color-lighter);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
|
||||
@include position-center(xy);
|
||||
|
||||
span {
|
||||
width: 50%;
|
||||
color: var(--el-color-white);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s, transform 0.1s;
|
||||
|
||||
&.disabled {
|
||||
color: var(--el-text-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .mask {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.images-upload {
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
:deep(.el-upload) {
|
||||
.el-upload-dragger {
|
||||
display: inline-block;
|
||||
padding: 0;
|
||||
|
||||
&.is-dragover {
|
||||
border-width: 1px;
|
||||
}
|
||||
|
||||
.image-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--el-text-color-placeholder);
|
||||
background-color: transparent;
|
||||
|
||||
.icon {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: "";
|
||||
background-color: var(--el-overlay-color-lighter);
|
||||
}
|
||||
|
||||
.el-progress {
|
||||
z-index: 1;
|
||||
|
||||
@include position-center(xy);
|
||||
|
||||
.el-progress__text {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
53
src/admin/src/components/NotAllowed/index.vue
Executable file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
defineOptions({
|
||||
name: 'NotAllowed',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const data = ref({
|
||||
inter: Number.NaN,
|
||||
countdown: 5,
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
data.value.inter && window.clearInterval(data.value.inter)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
data.value.inter = window.setInterval(() => {
|
||||
data.value.countdown--
|
||||
if (data.value.countdown === 0) {
|
||||
data.value.inter && window.clearInterval(data.value.inter)
|
||||
goBack()
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
router.push(settingsStore.settings.home.fullPath)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="absolute left-[50%] top-[50%] flex flex-col items-center justify-between lg-flex-row -translate-x-50% -translate-y-50% lg-gap-12">
|
||||
<SvgIcon name="403" class="text-[300px] lg-text-[400px]" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<h1 class="m-0 text-6xl font-sans">
|
||||
403
|
||||
</h1>
|
||||
<div class="desc mx-0 text-xl text-stone-5">
|
||||
抱歉,你无权访问该页面
|
||||
</div>
|
||||
<div>
|
||||
<HButton @click="goBack">
|
||||
{{ data.countdown }} 秒后,返回首页
|
||||
</HButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
32
src/admin/src/components/PageHeader/index.vue
Executable file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'PageHeader',
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
title?: string
|
||||
content?: string
|
||||
}>()
|
||||
|
||||
const slots = useSlots()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header mb-5 flex flex-wrap items-center justify-between gap-5 bg-[var(--g-container-bg)] px-5 py-4 transition-background-color-300">
|
||||
<div class="main flex-[1_1_70%]">
|
||||
<div class="text-2xl">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-stone-5 empty-hidden">
|
||||
<slot name="content">
|
||||
{{ content }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="slots.default" class="ml-a flex-none">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
47
src/admin/src/components/PageMain/index.vue
Executable file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'PageMain',
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
collaspe?: boolean
|
||||
height?: string
|
||||
}>(),
|
||||
{
|
||||
title: '',
|
||||
collaspe: false,
|
||||
height: '',
|
||||
},
|
||||
)
|
||||
|
||||
const titleSlot = !!useSlots().title
|
||||
|
||||
const isCollaspe = ref(props.collaspe)
|
||||
function unCollaspe() {
|
||||
isCollaspe.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="page-main relative m-4 flex flex-col bg-[var(--g-container-bg)] transition-background-color-300" :class="{
|
||||
'of-hidden': isCollaspe,
|
||||
}" :style="{
|
||||
height: isCollaspe ? height : '',
|
||||
}"
|
||||
>
|
||||
<div v-if="titleSlot || title" class="title-container border-b-1 border-b-[var(--g-bg)] border-b-solid px-5 py-4 transition-border-color-300">
|
||||
<slot name="title">
|
||||
{{ title }}
|
||||
</slot>
|
||||
</div>
|
||||
<div class="main-container p-5">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="isCollaspe" class="collaspe absolute bottom-0 w-full cursor-pointer from-transparent to-[var(--g-container-bg)] bg-gradient-to-b pb-2 pt-10 text-center" @click="unCollaspe">
|
||||
<SvgIcon name="i-ep:arrow-down" class="text-xl op-30 transition-opacity hover-op-100" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
143
src/admin/src/components/PcasCascader/index.vue
Executable file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
// 行政区划数据来源于 https://github.com/modood/Administrative-divisions-of-China
|
||||
import pcasRaw from './pcas-code.json'
|
||||
|
||||
defineOptions({
|
||||
name: 'PcasCascader',
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
type?: 'pc' | 'pca' | 'pcas'
|
||||
format?: 'code' | 'name' | 'both'
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
type: 'pca',
|
||||
format: 'code',
|
||||
},
|
||||
)
|
||||
|
||||
const value = defineModel<string[] | {
|
||||
code: string
|
||||
name: string
|
||||
}[]>({
|
||||
default: [],
|
||||
})
|
||||
|
||||
interface pcasItem {
|
||||
code: string
|
||||
name: string
|
||||
children?: pcasItem[]
|
||||
}
|
||||
|
||||
const pcasData = computed(() => {
|
||||
const data: pcasItem[] = []
|
||||
// 省份
|
||||
pcasRaw.forEach((p) => {
|
||||
const tempP: pcasItem = {
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
}
|
||||
const tempChildrenC: pcasItem[] = []
|
||||
// 城市
|
||||
p.children.forEach((c) => {
|
||||
const tempC: pcasItem = {
|
||||
code: c.code,
|
||||
name: c.name,
|
||||
}
|
||||
if (['pca', 'pcas'].includes(props.type)) {
|
||||
const tempChildrenA: pcasItem[] = []
|
||||
// 区县
|
||||
c.children.forEach((a) => {
|
||||
const tempA: pcasItem = {
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
}
|
||||
if (props.type === 'pcas') {
|
||||
const tempChildrenS: pcasItem[] = []
|
||||
// 街道
|
||||
a.children.forEach((s) => {
|
||||
const tempS: pcasItem = {
|
||||
code: s.code,
|
||||
name: s.name,
|
||||
}
|
||||
tempChildrenS.push(tempS)
|
||||
})
|
||||
tempA.children = tempChildrenS
|
||||
}
|
||||
tempChildrenA.push(tempA)
|
||||
})
|
||||
tempC.children = tempChildrenA
|
||||
}
|
||||
tempChildrenC.push(tempC)
|
||||
})
|
||||
tempP.children = tempChildrenC
|
||||
data.push(tempP)
|
||||
})
|
||||
return data
|
||||
})
|
||||
|
||||
const myValue = computed({
|
||||
// 将入参数据转成 code 码
|
||||
get: () => {
|
||||
return anyToCode(value.value)
|
||||
},
|
||||
// 将 code 码转成出参数据
|
||||
set: (val) => {
|
||||
value.value = val ? codeToAny(val) : []
|
||||
},
|
||||
})
|
||||
|
||||
function anyToCode(value: any[], dictionarie: any[] = pcasData.value) {
|
||||
const input: string[] = []
|
||||
if (value.length > 0) {
|
||||
const findItem = dictionarie.find((item) => {
|
||||
if (props.format === 'code') {
|
||||
return item.code === value[0]
|
||||
}
|
||||
else if (props.format === 'name') {
|
||||
return item.name === value[0]
|
||||
}
|
||||
else {
|
||||
return item.name === value[0].name && item.code === value[0].code
|
||||
}
|
||||
})
|
||||
input.push(findItem.code)
|
||||
if (findItem.children) {
|
||||
input.push(...anyToCode(value.slice(1 - value.length), findItem.children))
|
||||
}
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
function codeToAny(codes: string[], dictionarie: any[] = pcasData.value): any {
|
||||
const output = []
|
||||
const findItem = dictionarie.find(item => item.code === codes[0])
|
||||
if (findItem) {
|
||||
switch (props.format) {
|
||||
case 'code':
|
||||
output.push(findItem.code)
|
||||
break
|
||||
case 'name':
|
||||
output.push(findItem.name)
|
||||
break
|
||||
case 'both':
|
||||
output.push({
|
||||
code: findItem.code,
|
||||
name: findItem.name,
|
||||
})
|
||||
}
|
||||
const newCodes = codes.slice(1 - codes.length)
|
||||
if (newCodes.length > 0 && findItem.children) {
|
||||
output.push(...codeToAny(newCodes, findItem.children))
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCascader v-model="myValue" :options="pcasData as any[]" :props="{ value: 'code', label: 'name' }" :disabled="disabled" clearable filterable />
|
||||
</template>
|
||||
1
src/admin/src/components/PcasCascader/pcas-code.json
Executable file
47
src/admin/src/components/SearchBar/index.vue
Executable file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'SearchBar',
|
||||
})
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
showToggle?: boolean
|
||||
background?: boolean
|
||||
}>(),
|
||||
{
|
||||
showToggle: true,
|
||||
background: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emits = defineEmits<{
|
||||
toggle: [
|
||||
value: boolean,
|
||||
]
|
||||
}>()
|
||||
|
||||
const fold = defineModel<boolean>('fold', {
|
||||
default: true,
|
||||
})
|
||||
|
||||
function toggle() {
|
||||
fold.value = !fold.value
|
||||
emits('toggle', fold.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative" :class="{
|
||||
'py-4': showToggle,
|
||||
'px-4 bg-[var(--g-bg)] transition': background,
|
||||
}"
|
||||
>
|
||||
<slot :fold="fold" :toggle="toggle" />
|
||||
<div v-if="showToggle" class="absolute bottom-0 left-0 w-full translate-y-1/2 text-center">
|
||||
<button class="h-5 inline-flex cursor-pointer select-none items-center border-size-0 rounded bg-[var(--g-bg)] px-2 text-xs font-medium outline-none" @click="toggle">
|
||||
<SvgIcon :name="fold ? 'i-ep:caret-bottom' : 'i-ep:caret-top' " />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
66
src/admin/src/components/SvgIcon/index.vue
Executable file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'SvgIcon',
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
flip?: 'horizontal' | 'vertical' | 'both'
|
||||
rotate?: number
|
||||
color?: string
|
||||
size?: string | number
|
||||
}>()
|
||||
|
||||
const outputType = computed(() => {
|
||||
if (/^https?:\/\//.test(props.name)) {
|
||||
return 'img'
|
||||
}
|
||||
else if (/i-[^:]+:[^:]+/.test(props.name)) {
|
||||
return 'unocss'
|
||||
}
|
||||
else if (props.name.includes(':')) {
|
||||
return 'iconify'
|
||||
}
|
||||
else {
|
||||
return 'svg'
|
||||
}
|
||||
})
|
||||
|
||||
const style = computed(() => {
|
||||
const transform = []
|
||||
if (props.flip) {
|
||||
switch (props.flip) {
|
||||
case 'horizontal':
|
||||
transform.push('rotateY(180deg)')
|
||||
break
|
||||
case 'vertical':
|
||||
transform.push('rotateX(180deg)')
|
||||
break
|
||||
case 'both':
|
||||
transform.push('rotateX(180deg)')
|
||||
transform.push('rotateY(180deg)')
|
||||
break
|
||||
}
|
||||
}
|
||||
if (props.rotate) {
|
||||
transform.push(`rotate(${props.rotate % 360}deg)`)
|
||||
}
|
||||
return {
|
||||
...(props.color && { color: props.color }),
|
||||
...(props.size && { fontSize: typeof props.size === 'number' ? `${props.size}px` : props.size }),
|
||||
...(transform.length && { transform: transform.join(' ') }),
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<i class="relative h-[1em] w-[1em] flex-inline items-center justify-center fill-current leading-[1em]" :class="{ [name]: outputType === 'unocss' }" :style="style">
|
||||
<Icon v-if="outputType === 'iconify'" :icon="name" />
|
||||
<svg v-else-if="outputType === 'svg'" class="h-[1em] w-[1em]" aria-hidden="true">
|
||||
<use :xlink:href="`#icon-${name}`" />
|
||||
</svg>
|
||||
<img v-else-if="outputType === 'img'" :src="name" class="h-[1em] w-[1em]">
|
||||
</i>
|
||||
</template>
|
||||
56
src/admin/src/components/SystemInfo/index.vue
Executable file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import eventBus from '@/utils/eventBus'
|
||||
|
||||
const isShow = ref(false)
|
||||
|
||||
const { pkg, lastBuildTime } = __SYSTEM_INFO__
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('global-system-info-toggle', () => {
|
||||
isShow.value = !isShow.value
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HSlideover v-model="isShow" title="系统信息">
|
||||
<div class="px-4">
|
||||
<h2 class="m-0 text-lg font-bold">
|
||||
最后编译时间
|
||||
</h2>
|
||||
<div class="my-4 text-center text-lg font-sans">
|
||||
{{ lastBuildTime }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<h2 class="m-0 text-lg font-bold">
|
||||
生产环境依赖
|
||||
</h2>
|
||||
<ul class="list-none pl-0 text-sm">
|
||||
<li v-for="(val, key) in (pkg.dependencies as object)" :key="key" class="flex items-center justify-between rounded px-2 py-1.5 hover-bg-stone-1 dark-hover-bg-stone-9">
|
||||
<div class="font-bold">
|
||||
{{ key }}
|
||||
</div>
|
||||
<div class="font-sans">
|
||||
{{ val }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="px-4">
|
||||
<h2 class="m-0 text-lg font-bold">
|
||||
开发环境依赖
|
||||
</h2>
|
||||
<ul class="list-none pl-0 text-sm">
|
||||
<li v-for="(val, key) in (pkg.devDependencies as object)" :key="key" class="flex items-center justify-between rounded px-2 py-1.5 hover-bg-stone-1 dark-hover-bg-stone-9">
|
||||
<div class="font-bold">
|
||||
{{ key }}
|
||||
</div>
|
||||
<div class="font-sans">
|
||||
{{ val }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</HSlideover>
|
||||
</template>
|
||||
38
src/admin/src/components/Trend/index.vue
Executable file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'Trend',
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string
|
||||
type?: 'up' | 'down'
|
||||
prefix?: string
|
||||
suffix?: string
|
||||
reverse?: boolean
|
||||
}>(),
|
||||
{
|
||||
type: 'up',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
reverse: false,
|
||||
},
|
||||
)
|
||||
|
||||
const isUp = computed(() => {
|
||||
let isUp = props.type === 'up'
|
||||
if (props.reverse) {
|
||||
isUp = !isUp
|
||||
}
|
||||
return isUp
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center transition" :class="`${isUp ? 'c-green' : 'c-red'}`">
|
||||
<span v-if="prefix" class="prefix">{{ prefix }}</span>
|
||||
<span class="text">{{ value }}</span>
|
||||
<span v-if="suffix" class="suffix">{{ suffix }}</span>
|
||||
<SvgIcon name="i-ep:caret-top" :rotate="isUp ? 0 : 180" class="ml-1 transition" />
|
||||
</div>
|
||||
</template>
|
||||
17
src/admin/src/constants/copyright.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const copyRight = {
|
||||
wex: '5qyi6L+O5L2T6aqMTmluZUFJ',
|
||||
qnum: 'MjAyMyAtIDIwMjQ=',
|
||||
website: '',
|
||||
/* 下三个是个人的 上面是公开的 */
|
||||
// wex: 'Vng6IEpfbG9uZ3lhbg==',
|
||||
// qnum: 'UVE6IDkyNzg5ODYzOQ==',
|
||||
// website: 'aHR0cHM6Ly9haS5qaWFuZ2x5LmNvbQ==',
|
||||
name: 'TmluZSBBaQ==',
|
||||
};
|
||||
|
||||
export function atob(str: string) {
|
||||
if (!str) {
|
||||
return '';
|
||||
}
|
||||
return decodeURIComponent(escape(window.atob(str)));
|
||||
}
|
||||
418
src/admin/src/constants/index.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
export const USER_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '待激活' },
|
||||
{ value: 1, label: '正常' },
|
||||
{ value: 2, label: '已封禁' },
|
||||
{ value: 3, label: '黑名单' },
|
||||
];
|
||||
|
||||
export const USER_STATUS_MAP = {
|
||||
0: '待激活',
|
||||
1: '正常',
|
||||
2: '已封禁',
|
||||
3: '黑名单',
|
||||
};
|
||||
|
||||
export const USER_STATUS_TYPE_MAP = {
|
||||
0: 'info',
|
||||
1: 'success',
|
||||
2: 'danger',
|
||||
3: 'danger',
|
||||
} as const;
|
||||
|
||||
export type UserStatus = keyof typeof USER_STATUS_TYPE_MAP;
|
||||
|
||||
// 充值类型map 1: 注册赠送 2: 受邀请赠送 3: 邀请人赠送 4: 购买套餐赠送 5: 管理员赠送 6:扫码支付 7: 绘画失败退款 8: 签到奖励
|
||||
export const RECHARGE_TYPE_MAP = {
|
||||
1: '注册赠送',
|
||||
2: '受邀请赠送',
|
||||
3: '邀请人赠送',
|
||||
4: '购买套餐赠送',
|
||||
5: '管理员赠送',
|
||||
6: '扫码支付',
|
||||
7: '绘画失败退款',
|
||||
8: '签到奖励',
|
||||
};
|
||||
|
||||
// 充值数组
|
||||
export const RECHARGE_TYPE_OPTIONS = [
|
||||
{ value: 1, label: '注册赠送' },
|
||||
{ value: 2, label: '受邀请赠送' },
|
||||
{ value: 3, label: '邀请人赠送' },
|
||||
{ value: 4, label: '购买套餐赠送' },
|
||||
{ value: 5, label: '管理员赠送' },
|
||||
{ value: 6, label: '扫码支付' },
|
||||
{ value: 7, label: '绘画失败退款' },
|
||||
{ value: 8, label: '签到奖励' },
|
||||
];
|
||||
|
||||
// 是否开启额外赠送
|
||||
export const IS_OPTIONS = {
|
||||
0: '关闭',
|
||||
1: '开启',
|
||||
};
|
||||
|
||||
// 是否开启额外赠送类型
|
||||
export const IS_TYPE_MAP = {
|
||||
0: 'danger',
|
||||
1: 'success',
|
||||
};
|
||||
|
||||
export const PACKAGE_TYPE_OPTIONS = [
|
||||
{ value: 0, label: '禁用' },
|
||||
{ value: 1, label: '启动' },
|
||||
];
|
||||
|
||||
// 扣费形式 1: 按次数扣费 2:按Token扣费
|
||||
export const DEDUCTION_TYPE_OPTIONS = [
|
||||
{ value: 1, label: '按次数扣费' },
|
||||
{ value: 2, label: '按Token扣费' },
|
||||
];
|
||||
|
||||
// 扣费形式 map
|
||||
export const DEDUCTION_TYPE_MAP = {
|
||||
1: '按次数扣费',
|
||||
2: '按Token扣费',
|
||||
};
|
||||
|
||||
export const CRAMI_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '未使用' },
|
||||
{ value: 1, label: '已使用' },
|
||||
];
|
||||
|
||||
// 图片推荐状态0未推荐1已推荐
|
||||
export const RECOMMEND_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '未推荐' },
|
||||
{ value: 1, label: '已推荐' },
|
||||
];
|
||||
|
||||
// 0 禁用 1 启用
|
||||
export const ENABLE_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '禁用' },
|
||||
{ value: 1, label: '启用' },
|
||||
{ value: 3, label: '待审核' },
|
||||
{ value: 4, label: '拒绝共享' },
|
||||
{ value: 5, label: '通过共享' },
|
||||
];
|
||||
|
||||
// 问题状态 0 未解决 1 已解决
|
||||
export const QUESTION_STATUS_OPTIONS = [
|
||||
{ value: '0', label: '未启用' },
|
||||
{ value: '1', label: '已启用' },
|
||||
];
|
||||
|
||||
// 问题状态 0 未解决 1 已解决
|
||||
export const ORDER_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '待审核' },
|
||||
{ value: 1, label: '已通过' },
|
||||
{ value: -1, label: '已拒绝' },
|
||||
];
|
||||
|
||||
// 0:未推荐 1:已推荐 数组
|
||||
export const RECOMMEND_STATUS = [
|
||||
{ value: 0, label: '未推荐' },
|
||||
{ value: 1, label: '已推荐' },
|
||||
];
|
||||
|
||||
// 提现渠道 支付宝 微信
|
||||
export const WITHDRAW_CHANNEL_OPTIONS = [
|
||||
{ value: 1, label: '支付宝' },
|
||||
{ value: 2, label: '微信' },
|
||||
];
|
||||
|
||||
// 1 排队中 2 处理中 3 已完成 4 失败 5 超时
|
||||
export const WITHDRAW_STATUS_OPTIONS = [
|
||||
{ value: 1, label: '正在排队' },
|
||||
{ value: 2, label: '正在绘制' },
|
||||
{ value: 3, label: '绘制完成' },
|
||||
{ value: 4, label: '绘制失败' },
|
||||
{ value: 5, label: '绘制超时' },
|
||||
];
|
||||
|
||||
// 0 禁用 warning 1启用 状态 success
|
||||
export const ENABLE_STATUS_TYPE_MAP: QuestionStatusMap = {
|
||||
0: 'danger',
|
||||
1: 'success',
|
||||
};
|
||||
|
||||
interface QuestionStatusMap {
|
||||
[key: number]: string;
|
||||
}
|
||||
|
||||
// 问题状态 0 未解决 1 已解决 映射
|
||||
export const QUESTION_STATUS_MAP: QuestionStatusMap = {
|
||||
'-1': '欠费锁定',
|
||||
'0': '未启用',
|
||||
'1': '已启用',
|
||||
'3': '待审核',
|
||||
'4': '拒绝共享',
|
||||
'5': '通过共享',
|
||||
};
|
||||
|
||||
// 问题状态 0 被封号 1 正常 映射
|
||||
export const KEY_STATUS_MAP: QuestionStatusMap = {
|
||||
0: '被封禁',
|
||||
1: '工作中',
|
||||
};
|
||||
|
||||
// 模型列表
|
||||
export const MODEL_LIST = [
|
||||
// GPT-3.5
|
||||
'gpt-3.5-turbo',
|
||||
'gpt-3.5-turbo-1106',
|
||||
'gpt-3.5-turbo-0125',
|
||||
'gpt-3.5-turbo-instruct',
|
||||
'o1-mini',
|
||||
'o1-preview',
|
||||
// GPT-4
|
||||
'gpt-4',
|
||||
'gpt-4o',
|
||||
'gpt-4o-2024-05-13',
|
||||
'gpt-4o-2024-08-06',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4o-mini-2024-07-18',
|
||||
'gpt-4o-mini-2024-07-18',
|
||||
'gpt-4-turbo',
|
||||
'gpt-4-turbo-2024-04-09',
|
||||
'gpt-4-all',
|
||||
// claude
|
||||
'claude-2',
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-haiku-20240307',
|
||||
'claude-3-5-sonnet-20240620',
|
||||
// gemini
|
||||
'gemini-pro',
|
||||
'gemini-pro-vision',
|
||||
'gemini-pro-1.5',
|
||||
// 百度文心
|
||||
'ERNIE-Bot',
|
||||
'ERNIE-Bot-4',
|
||||
'ERNIE-3.5-8K',
|
||||
'ERNIE-Bot-turbo',
|
||||
// 阿里通义
|
||||
'qwen-turbo',
|
||||
'qwen-plus',
|
||||
'qwen-max',
|
||||
'qwen-max-1201',
|
||||
'qwen-max-longcontext',
|
||||
// 腾讯混元
|
||||
'hunyuan',
|
||||
// 清华智谱
|
||||
'chatglm_turbo',
|
||||
'chatglm_pro',
|
||||
'chatglm_std',
|
||||
'chatglm_lite',
|
||||
'glm-3-turbo',
|
||||
'glm-4',
|
||||
'glm-4v',
|
||||
// 百川智能
|
||||
'Baichuan2-53B',
|
||||
'Baichuan2-Turbo',
|
||||
'Baichuan2-Turbo-192k',
|
||||
// 零一万物
|
||||
'yi-34b-chat-0205',
|
||||
'yi-34b-chat-200k',
|
||||
'yi-vl-plus',
|
||||
// 360 智脑
|
||||
'360GPT_S2_V9',
|
||||
// 讯飞星火
|
||||
'SparkDesk',
|
||||
'SparkDesk-v1.1',
|
||||
'SparkDesk-v2.1',
|
||||
'SparkDesk-v3.1',
|
||||
'SparkDesk-v3.5',
|
||||
// deepseek
|
||||
'deepseek-chat',
|
||||
'deepseek-coder',
|
||||
// moonshot
|
||||
'moonshot-v1-8k',
|
||||
'moonshot-v1-32k',
|
||||
'moonshot-v1-128k',
|
||||
// DALL-E
|
||||
'dall-e-3',
|
||||
// Midjourney
|
||||
'midjourney',
|
||||
// 特殊模型
|
||||
'luma-video',
|
||||
'flux-draw',
|
||||
'cog-video',
|
||||
'tts-1',
|
||||
'gpts',
|
||||
'stable-diffusion',
|
||||
'suno-music',
|
||||
];
|
||||
|
||||
// 模型列表 0 mj 1 Dall-e
|
||||
export const DRAW_MODEL_LIST = [
|
||||
{ value: 'midjourney', label: 'Midjourney' },
|
||||
{ value: 'stable-diffusion', label: 'Stable-Diffusion' },
|
||||
{ value: 'dall-e-3', label: 'Dall-e-3' },
|
||||
];
|
||||
// 支付状态列表 status 0:未支付、1:已支付、2、支付失败、3:支付超时
|
||||
export const PAY_STATUS_OPTIONS = [
|
||||
{ value: 0, label: '未支付' },
|
||||
{ value: 1, label: '已支付' },
|
||||
{ value: 2, label: '支付失败' },
|
||||
{ value: 3, label: '支付超时' },
|
||||
];
|
||||
|
||||
// 支付状态 status 0:未支付、1:已支付、2、支付失败、3:支付超时
|
||||
export const PAY_STATUS_MAP: QuestionStatusMap = {
|
||||
0: '未支付',
|
||||
1: '已支付',
|
||||
2: '支付失败',
|
||||
3: '支付超时',
|
||||
};
|
||||
|
||||
// 平台列表 epay: 易支付 hupi:虎皮椒 ltzf:蓝兔支付
|
||||
export const PAY_PLATFORM_LIST = [
|
||||
{ value: 'epay', label: '易支付' },
|
||||
{ value: 'hupi', label: '虎皮椒' },
|
||||
{ value: 'wechat', label: '微信支付' },
|
||||
{ value: 'mpay', label: '码支付' },
|
||||
{ value: 'ltzf', label: '蓝兔支付' },
|
||||
];
|
||||
|
||||
// 支付对应
|
||||
export const PAY_PLATFORM_MAP = {
|
||||
epay: '易支付',
|
||||
hupi: '虎皮椒',
|
||||
wechat: '微信支付',
|
||||
mpay: '码支付',
|
||||
ltzf: '蓝兔支付',
|
||||
};
|
||||
|
||||
// 绘画状态 1: 等待中 2: 绘制中 3: 绘制完成 4: 绘制失败 5: 绘制超时
|
||||
export const DRAW_MJ_STATUS_LIST = [
|
||||
{ value: 1, label: '等待中' },
|
||||
{ value: 2, label: '绘制中' },
|
||||
{ value: 3, label: '绘制完成' },
|
||||
{ value: 4, label: '绘制失败' },
|
||||
{ value: 5, label: '绘制超时' },
|
||||
];
|
||||
|
||||
// App角色 系统 system 用户 user
|
||||
export const APP_ROLE_LIST = [
|
||||
{ value: 'system', label: '系统' },
|
||||
{ value: 'user', label: '用户' },
|
||||
];
|
||||
|
||||
// 绘画状态 1:排队中 2:绘制中 3:绘制完成 4:绘制失败 5:绘制超时
|
||||
export const DRAW_STATUS_MAP = {
|
||||
1: '排队中',
|
||||
2: '绘制中',
|
||||
3: '绘制完成',
|
||||
4: '绘制失败',
|
||||
5: '绘制超时',
|
||||
};
|
||||
|
||||
export const TYPEORIGINLIST = [
|
||||
{ value: '百度云检测', label: '百度云检测' },
|
||||
{ value: '自定义检测', label: '自定义检测' },
|
||||
];
|
||||
|
||||
export const MODELTYPELIST = [
|
||||
{ value: 1, label: '基础对话' },
|
||||
{ value: 2, label: '创意模型' },
|
||||
{ value: 3, label: '特殊模型' },
|
||||
];
|
||||
|
||||
export const MODELTYPEMAP = {
|
||||
1: '基础对话',
|
||||
2: '创意模型',
|
||||
3: '特殊模型',
|
||||
};
|
||||
|
||||
export const MODELSMAPLIST = {
|
||||
1: [
|
||||
// GPT-3.5
|
||||
'gpt-3.5-turbo',
|
||||
'gpt-3.5-turbo-1106',
|
||||
'gpt-3.5-turbo-0125',
|
||||
'gpt-3.5-turbo-instruct',
|
||||
'o1-mini',
|
||||
'o1-preview',
|
||||
// GPT-4
|
||||
'gpt-4',
|
||||
'gpt-4o',
|
||||
'gpt-4o-2024-05-13',
|
||||
'gpt-4o-2024-08-06',
|
||||
'gpt-4o-mini',
|
||||
'gpt-4o-mini-2024-07-18',
|
||||
'gpt-4o-mini-2024-07-18',
|
||||
'gpt-4-turbo',
|
||||
'gpt-4-turbo-2024-04-09',
|
||||
'gpt-4-all',
|
||||
// claude
|
||||
'claude-2',
|
||||
'claude-3-sonnet-20240229',
|
||||
'claude-3-opus-20240229',
|
||||
'claude-3-haiku-20240307',
|
||||
'claude-3-5-sonnet-20240620',
|
||||
// gemini
|
||||
'gemini-pro',
|
||||
'gemini-pro-vision',
|
||||
'gemini-pro-1.5',
|
||||
// 百度文心
|
||||
'ERNIE-Bot',
|
||||
'ERNIE-Bot-4',
|
||||
'ERNIE-3.5-8K',
|
||||
'ERNIE-Bot-turbo',
|
||||
// 阿里通义
|
||||
'qwen-turbo',
|
||||
'qwen-plus',
|
||||
'qwen-max',
|
||||
'qwen-max-1201',
|
||||
'qwen-max-longcontext',
|
||||
// 腾讯混元
|
||||
'hunyuan',
|
||||
// 清华智谱
|
||||
'chatglm_turbo',
|
||||
'chatglm_pro',
|
||||
'chatglm_std',
|
||||
'chatglm_lite',
|
||||
'glm-3-turbo',
|
||||
'glm-4',
|
||||
'glm-4v',
|
||||
// 百川智能
|
||||
'Baichuan2-53B',
|
||||
'Baichuan2-Turbo',
|
||||
'Baichuan2-Turbo-192k',
|
||||
// 零一万物
|
||||
'yi-34b-chat-0205',
|
||||
'yi-34b-chat-200k',
|
||||
'yi-vl-plus',
|
||||
// 360 智脑
|
||||
'360GPT_S2_V9',
|
||||
// 讯飞星火
|
||||
'SparkDesk',
|
||||
'SparkDesk-v1.1',
|
||||
'SparkDesk-v2.1',
|
||||
'SparkDesk-v3.1',
|
||||
'SparkDesk-v3.5',
|
||||
// deepseek
|
||||
'deepseek-chat',
|
||||
'deepseek-coder',
|
||||
// moonshot
|
||||
'moonshot-v1-8k',
|
||||
'moonshot-v1-32k',
|
||||
'moonshot-v1-128k',
|
||||
],
|
||||
2: [
|
||||
'dall-e-3',
|
||||
'midjourney',
|
||||
'stable-diffusion',
|
||||
'suno-music',
|
||||
'luma-video',
|
||||
'flux-draw',
|
||||
'cog-video',
|
||||
],
|
||||
3: ['tts-1', 'gpts'],
|
||||
};
|
||||
|
||||
/* 扣费类型 普通余额还是高级余额 */
|
||||
export const DEDUCTTYPELIST = [
|
||||
{ value: 1, label: '普通积分' },
|
||||
{ value: 2, label: '高级积分' },
|
||||
{ value: 3, label: '绘画积分' },
|
||||
];
|
||||
1
src/admin/src/iconify/data.json
Normal file
1
src/admin/src/iconify/index.json
Executable file
@@ -0,0 +1 @@
|
||||
{ "collections": ["ant-design", "ep", "flagpack", "icon-park", "mdi", "ri", "logos", "twemoji", "vscode-icons"], "isOfflineUse": false }
|
||||
9
src/admin/src/iconify/index.ts
Executable file
@@ -0,0 +1,9 @@
|
||||
import { addCollection } from '@iconify/vue'
|
||||
import data from './data.json'
|
||||
|
||||
export async function downloadAndInstall(name: string) {
|
||||
const data = Object.freeze(await fetch(`./icons/${name}-raw.json`).then(r => r.json()))
|
||||
addCollection(data)
|
||||
}
|
||||
|
||||
export const icons = data.sort((a, b) => a.info.name.localeCompare(b.info.name))
|
||||
446
src/admin/src/layouts/components/AppSetting/index.vue
Executable file
@@ -0,0 +1,446 @@
|
||||
<script setup lang="ts">
|
||||
import settingsDefault from '@/settings.default';
|
||||
import useMenuStore from '@/store/modules/menu';
|
||||
import useSettingsStore from '@/store/modules/settings';
|
||||
import eventBus from '@/utils/eventBus';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
|
||||
defineOptions({
|
||||
name: 'AppSetting',
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const menuStore = useMenuStore();
|
||||
|
||||
const isShow = ref(false);
|
||||
|
||||
watch(
|
||||
() => settingsStore.settings.menu.menuMode,
|
||||
(value) => {
|
||||
if (value === 'single') {
|
||||
menuStore.setActived(0);
|
||||
} else {
|
||||
menuStore.setActived(route.fullPath);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('global-app-setting-toggle', () => {
|
||||
isShow.value = !isShow.value;
|
||||
});
|
||||
});
|
||||
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
|
||||
// watch(copied, (val) => {
|
||||
// if (val) {
|
||||
// Message.success('复制成功,请粘贴到 src/settings.ts 文件中!', {
|
||||
// zIndex: 2000,
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
|
||||
function isObject(value: any) {
|
||||
return typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
// 比较两个对象,并提取出不同的部分
|
||||
function getObjectDiff(
|
||||
originalObj: Record<string, any>,
|
||||
diffObj: Record<string, any>
|
||||
) {
|
||||
if (!isObject(originalObj) || !isObject(diffObj)) {
|
||||
return diffObj;
|
||||
}
|
||||
const diff: Record<string, any> = {};
|
||||
for (const key in diffObj) {
|
||||
const originalValue = originalObj[key];
|
||||
const diffValue = diffObj[key];
|
||||
if (JSON.stringify(originalValue) !== JSON.stringify(diffValue)) {
|
||||
if (isObject(originalValue) && isObject(diffValue)) {
|
||||
const nestedDiff = getObjectDiff(originalValue, diffValue);
|
||||
if (Object.keys(nestedDiff).length > 0) {
|
||||
diff[key] = nestedDiff;
|
||||
}
|
||||
} else {
|
||||
diff[key] = diffValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return diff;
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
copy(
|
||||
JSON.stringify(
|
||||
getObjectDiff(settingsDefault, settingsStore.settings),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HSlideover v-model="isShow" title="应用配置">
|
||||
<div class="rounded-2 bg-rose/20 px-4 py-2 text-sm/6 c-rose">
|
||||
<p class="my-1">
|
||||
应用配置可实时预览效果,但只是临时生效,要想真正应用于项目,可以点击下方的「复制配置」按钮,并将配置粘贴到
|
||||
src/settings.ts 文件中。
|
||||
</p>
|
||||
<p class="my-1">注意:在生产环境中应关闭该模块。</p>
|
||||
</div>
|
||||
<div class="divider">颜色主题风格</div>
|
||||
<div class="flex items-center justify-center pb-4">
|
||||
<HTabList
|
||||
v-model="settingsStore.settings.app.colorScheme"
|
||||
:options="[
|
||||
{ icon: 'i-ri:sun-line', label: '明亮', value: 'light' },
|
||||
{ icon: 'i-ri:moon-line', label: '暗黑', value: 'dark' },
|
||||
{ icon: 'i-codicon:color-mode', label: '系统', value: '' },
|
||||
]"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="settingsStore.mode === 'pc'" class="divider">导航栏模式</div>
|
||||
<div v-if="settingsStore.mode === 'pc'" class="menu-mode">
|
||||
<HTooltip text="侧边栏模式 (含主导航)" placement="bottom" :delay="500">
|
||||
<div
|
||||
class="mode mode-side"
|
||||
:class="{ active: settingsStore.settings.menu.menuMode === 'side' }"
|
||||
@click="settingsStore.settings.menu.menuMode = 'side'"
|
||||
>
|
||||
<div class="mode-container" />
|
||||
</div>
|
||||
</HTooltip>
|
||||
<HTooltip text="顶部模式" placement="bottom" :delay="500">
|
||||
<div
|
||||
class="mode mode-head"
|
||||
:class="{ active: settingsStore.settings.menu.menuMode === 'head' }"
|
||||
@click="settingsStore.settings.menu.menuMode = 'head'"
|
||||
>
|
||||
<div class="mode-container" />
|
||||
</div>
|
||||
</HTooltip>
|
||||
<HTooltip text="侧边栏模式 (不含主导航)" placement="bottom" :delay="500">
|
||||
<div
|
||||
class="mode mode-single"
|
||||
:class="{ active: settingsStore.settings.menu.menuMode === 'single' }"
|
||||
@click="settingsStore.settings.menu.menuMode = 'single'"
|
||||
>
|
||||
<div class="mode-container" />
|
||||
</div>
|
||||
</HTooltip>
|
||||
</div>
|
||||
<div class="divider">导航栏</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
主导航切换跳转
|
||||
<HTooltip
|
||||
text="开启该功能后,切换主导航时,页面自动跳转至该主导航下,次导航里第一个导航"
|
||||
>
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle
|
||||
v-model="settingsStore.settings.menu.switchMainMenuAndPageJump"
|
||||
:disabled="['single'].includes(settingsStore.settings.menu.menuMode)"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
次导航保持展开一个
|
||||
<HTooltip text="开启该功能后,次导航只保持单个菜单的展开">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.menu.subMenuUniqueOpened" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">次导航是否折叠</div>
|
||||
<HToggle v-model="settingsStore.settings.menu.subMenuCollapse" />
|
||||
</div>
|
||||
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
|
||||
<div class="label">显示次导航折叠按钮</div>
|
||||
<HToggle
|
||||
v-model="settingsStore.settings.menu.enableSubMenuCollapseButton"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用快捷键</div>
|
||||
<HToggle
|
||||
v-model="settingsStore.settings.menu.enableHotkeys"
|
||||
:disabled="['single'].includes(settingsStore.settings.menu.menuMode)"
|
||||
/>
|
||||
</div>
|
||||
<div class="divider">顶栏</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">模式</div>
|
||||
<HCheckList
|
||||
v-model="settingsStore.settings.topbar.mode"
|
||||
:options="[
|
||||
{ label: '静止', value: 'static' },
|
||||
{ label: '固定', value: 'fixed' },
|
||||
{ label: '粘性', value: 'sticky' },
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="divider">标签栏</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用</div>
|
||||
<HToggle v-model="settingsStore.settings.tabbar.enable" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否显示图标</div>
|
||||
<HToggle
|
||||
v-model="settingsStore.settings.tabbar.enableIcon"
|
||||
:disabled="!settingsStore.settings.tabbar.enable"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用快捷键</div>
|
||||
<HToggle
|
||||
v-model="settingsStore.settings.tabbar.enableHotkeys"
|
||||
:disabled="!settingsStore.settings.tabbar.enable"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider">工具栏</div>
|
||||
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
|
||||
<div class="label">面包屑导航</div>
|
||||
<HToggle v-model="settingsStore.settings.toolbar.breadcrumb" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
导航搜索
|
||||
<HTooltip text="对导航进行快捷搜索">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.toolbar.navSearch" />
|
||||
</div>
|
||||
<div v-if="settingsStore.mode === 'pc'" class="setting-item">
|
||||
<div class="label">全屏</div>
|
||||
<HToggle v-model="settingsStore.settings.toolbar.fullscreen" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
页面刷新
|
||||
<HTooltip text="使用框架内提供的刷新功能进行页面刷新">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.toolbar.pageReload" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
颜色主题
|
||||
<HTooltip text="开启后可在明亮/暗黑模式中切换">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.toolbar.colorScheme" />
|
||||
</div>
|
||||
<div class="divider">页面</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用快捷键</div>
|
||||
<HToggle v-model="settingsStore.settings.mainPage.enableHotkeys" />
|
||||
</div>
|
||||
<div class="divider">导航搜索</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用快捷键</div>
|
||||
<HToggle
|
||||
v-model="settingsStore.settings.navSearch.enableHotkeys"
|
||||
:disabled="!settingsStore.settings.toolbar.navSearch"
|
||||
/>
|
||||
</div>
|
||||
<div class="divider">底部版权</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用</div>
|
||||
<HToggle v-model="settingsStore.settings.copyright.enable" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">日期</div>
|
||||
<HInput
|
||||
v-model="settingsStore.settings.copyright.dates"
|
||||
:disabled="!settingsStore.settings.copyright.enable"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">公司</div>
|
||||
<HInput
|
||||
v-model="settingsStore.settings.copyright.company"
|
||||
:disabled="!settingsStore.settings.copyright.enable"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">网址</div>
|
||||
<HInput
|
||||
v-model="settingsStore.settings.copyright.website"
|
||||
:disabled="!settingsStore.settings.copyright.enable"
|
||||
/>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">备案</div>
|
||||
<HInput
|
||||
v-model="settingsStore.settings.copyright.beian"
|
||||
:disabled="!settingsStore.settings.copyright.enable"
|
||||
/>
|
||||
</div>
|
||||
<div class="divider">主页</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
是否启用
|
||||
<HTooltip
|
||||
text="该功能开启时,登录成功默认进入主页,反之则默认进入导航栏里第一个导航页面"
|
||||
>
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.home.enable" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
主页名称
|
||||
<HTooltip text="开启国际化时,该设置无效">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HInput v-model="settingsStore.settings.home.title" />
|
||||
</div>
|
||||
<div class="divider">其它</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">是否启用权限</div>
|
||||
<HToggle v-model="settingsStore.settings.app.enablePermission" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
载入进度条
|
||||
<HTooltip text="该功能开启时,跳转路由会看到页面顶部有进度条">
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.app.enableProgress" />
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="label">
|
||||
动态标题
|
||||
<HTooltip
|
||||
text="该功能开启时,页面标题会显示当前路由标题,格式为“页面标题 - 网站名称”;关闭时则显示网站名称,网站名称在项目根目录下 .env.* 文件里配置"
|
||||
>
|
||||
<SvgIcon name="i-ri:question-line" />
|
||||
</HTooltip>
|
||||
</div>
|
||||
<HToggle v-model="settingsStore.settings.app.enableDynamicTitle" />
|
||||
</div>
|
||||
<template v-if="isSupported" #footer>
|
||||
<HButton block @click="handleCopy">
|
||||
<SvgIcon name="i-ep:document-copy" />
|
||||
复制配置
|
||||
</HButton>
|
||||
</template>
|
||||
</HSlideover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.divider {
|
||||
--at-apply: flex items-center justify-between gap-4 my-4 whitespace-nowrap
|
||||
text-sm font-500;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
--at-apply: content-empty w-full h-1px bg-stone-2 dark-bg-stone-6;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-mode {
|
||||
--at-apply: flex items-center justify-center gap-4 pb-4;
|
||||
|
||||
.mode {
|
||||
--at-apply: relative w-16 h-12 rounded-2 ring-1 ring-stone-2
|
||||
dark-ring-stone-7 cursor-pointer transition;
|
||||
|
||||
&.active {
|
||||
--at-apply: ring-ui-primary ring-2;
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after,
|
||||
.mode-container {
|
||||
--at-apply: absolute pointer-events-none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
--at-apply: content-empty bg-ui-primary;
|
||||
}
|
||||
|
||||
&::after {
|
||||
--at-apply: content-empty bg-ui-primary/60;
|
||||
}
|
||||
|
||||
.mode-container {
|
||||
--at-apply: bg-ui-primary/20 border-dashed border-ui-primary;
|
||||
|
||||
&::before {
|
||||
--at-apply: content-empty absolute w-full h-full;
|
||||
}
|
||||
}
|
||||
|
||||
&-side {
|
||||
&::before {
|
||||
--at-apply: top-2 bottom-2 left-2 w-2 rounded-tl-1 rounded-bl-1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
--at-apply: top-2 bottom-2 left-4.5 w-3;
|
||||
}
|
||||
|
||||
.mode-container {
|
||||
--at-apply: inset-t-2 inset-r-2 inset-b-2 inset-l-8 rounded-tr-1
|
||||
rounded-br-1;
|
||||
}
|
||||
}
|
||||
|
||||
&-head {
|
||||
&::before {
|
||||
--at-apply: top-2 left-2 right-2 h-2 rounded-tl-1 rounded-tr-1;
|
||||
}
|
||||
|
||||
&::after {
|
||||
--at-apply: top-4.5 left-2 bottom-2 w-3 rounded-bl-1;
|
||||
}
|
||||
|
||||
.mode-container {
|
||||
--at-apply: inset-t-4.5 inset-r-2 inset-b-2 inset-l-5.5 rounded-br-1;
|
||||
}
|
||||
}
|
||||
|
||||
&-single {
|
||||
&::after {
|
||||
--at-apply: top-2 left-2 bottom-2 w-3 rounded-tl-1 rounded-bl-1;
|
||||
}
|
||||
|
||||
.mode-container {
|
||||
--at-apply: inset-t-2 inset-r-2 inset-b-2 inset-l-5.5 rounded-tr-1
|
||||
rounded-br-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
--at-apply: flex items-center justify-between gap-4 px-4 py-2 rounded-2
|
||||
transition hover-bg-stone-1 dark-hover-bg-stone-9;
|
||||
|
||||
.label {
|
||||
--at-apply: flex items-center flex-shrink-0 gap-2 text-sm;
|
||||
|
||||
i {
|
||||
--at-apply: text-xl text-orange cursor-help;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
45
src/admin/src/layouts/components/BackTop/index.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'BackTop',
|
||||
})
|
||||
|
||||
const transitionClass = {
|
||||
enterActiveClass: 'ease-out duration-300',
|
||||
enterFromClass: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
|
||||
enterToClass: 'opacity-100 translate-y-0 lg-scale-100',
|
||||
leaveActiveClass: 'ease-in duration-200',
|
||||
leaveFromClass: 'opacity-100 translate-y-0 lg-scale-100',
|
||||
leaveToClass: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
handleScroll()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
})
|
||||
|
||||
const scrollTop = ref<number | null>(null)
|
||||
function handleScroll() {
|
||||
scrollTop.value = document.documentElement.scrollTop
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
document.documentElement.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition v-bind="transitionClass">
|
||||
<div v-if="scrollTop && scrollTop >= 200" class="fixed bottom-4 right-4 z-1000 h-12 w-12 flex cursor-pointer items-center justify-center rounded-full bg-white shadow-lg ring-1 ring-stone-3 ring-inset dark-bg-dark hover-bg-stone-1 dark-ring-stone-7 dark-hover-bg-dark/50" @click="handleClick">
|
||||
<SvgIcon name="i-icon-park-outline:to-top-one" :size="24" />
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
21
src/admin/src/layouts/components/Breadcrumb/index.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex items-center text-sm">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.breadcrumb-item) {
|
||||
&:first-child {
|
||||
.separator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
.text {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
src/admin/src/layouts/components/Breadcrumb/item.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
to?: RouteLocationRaw
|
||||
replace?: boolean
|
||||
separator?: string
|
||||
}>(),
|
||||
{
|
||||
separator: '/',
|
||||
},
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function onClick() {
|
||||
if (props.to) {
|
||||
props.replace ? router.replace(props.to) : router.push(props.to)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="breadcrumb-item flex items-center text-dark dark-text-white">
|
||||
<span class="separator mx-2">
|
||||
{{ separator }}
|
||||
</span>
|
||||
<span
|
||||
class="text flex items-center opacity-60"
|
||||
:class="{
|
||||
'is-link cursor-pointer transition-opacity hover-opacity-100': !!props.to,
|
||||
}" @click="onClick"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
37
src/admin/src/layouts/components/Copyright/index.vue
Executable file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
defineOptions({
|
||||
name: 'Copyright',
|
||||
})
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer v-if="settingsStore.settings.copyright.enable" class="copyright">
|
||||
<span>Copyright</span>
|
||||
<SvgIcon name="i-ri:copyright-line" :size="18" />
|
||||
<span v-if="settingsStore.settings.copyright.dates">{{ settingsStore.settings.copyright.dates }}</span>
|
||||
<template v-if="settingsStore.settings.copyright.company">
|
||||
<a v-if="settingsStore.settings.copyright.website" :href="settingsStore.settings.copyright.website" target="_blank" rel="noopener">{{ settingsStore.settings.copyright.company }}</a>
|
||||
<span v-else>{{ settingsStore.settings.copyright.company }}</span>
|
||||
</template>
|
||||
<a v-if="settingsStore.settings.copyright.beian" href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">{{ settingsStore.settings.copyright.beian }}</a>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.copyright {
|
||||
--at-apply: flex items-center justify-center flex-wrap my-4 px-4 text-sm text-stone-5;
|
||||
|
||||
span,
|
||||
a {
|
||||
--at-apply: px-1;
|
||||
}
|
||||
|
||||
a {
|
||||
--at-apply: text-center no-underline text-stone-5 hover-text-dark dark-hover-text-light transition;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
160
src/admin/src/layouts/components/Header/index.vue
Executable file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import Logo from '../Logo/index.vue'
|
||||
import ToolbarRightSide from '../Topbar/Toolbar/rightSide.vue'
|
||||
import useMenuStore from '@/store/modules/menu'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
defineOptions({
|
||||
name: 'LayoutHeader',
|
||||
})
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
const { switchTo } = useMenu()
|
||||
|
||||
const menuRef = ref()
|
||||
|
||||
// 顶部模式鼠标滚动
|
||||
function handlerMouserScroll(event: WheelEvent) {
|
||||
if (event.deltaY || event.detail !== 0) {
|
||||
menuRef.value.scrollBy({
|
||||
left: (event.deltaY || event.detail) > 0 ? 50 : -50,
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="header">
|
||||
<header v-if="settingsStore.mode === 'pc' && settingsStore.settings.menu.menuMode === 'head'">
|
||||
<div class="header-container">
|
||||
<Logo class="title" />
|
||||
<div ref="menuRef" class="menu-container" @wheel.prevent="handlerMouserScroll">
|
||||
<!-- 顶部模式 -->
|
||||
<div class="menu flex of-hidden transition-all">
|
||||
<template v-for="(item, index) in menuStore.allMenus" :key="index">
|
||||
<div
|
||||
class="menu-item relative transition-all" :class="{
|
||||
active: index === menuStore.actived,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="item.children && item.children.length !== 0" class="group menu-item-container h-full w-full flex cursor-pointer items-center justify-between gap-1 px-3 text-[var(--g-header-menu-color)] transition-all hover-(bg-[var(--g-header-menu-hover-bg)] text-[var(--g-header-menu-hover-color)])" :class="{
|
||||
'text-[var(--g-header-menu-active-color)]! bg-[var(--g-header-menu-active-bg)]!': index === menuStore.actived,
|
||||
}" :title="typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title" @click="switchTo(index)"
|
||||
>
|
||||
<div class="inline-flex flex-1 items-center justify-center gap-1">
|
||||
<SvgIcon v-if="item.meta?.icon" :name="item.meta?.icon" :size="20" class="menu-item-container-icon transition-transform group-hover-scale-120" async />
|
||||
<span class="w-full flex-1 truncate text-sm transition-height transition-opacity transition-width">
|
||||
{{ typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<ToolbarRightSide />
|
||||
</div>
|
||||
</header>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: var(--g-header-height);
|
||||
padding: 0 20px;
|
||||
margin: 0 auto;
|
||||
color: var(--g-header-color);
|
||||
background-color: var(--g-header-bg);
|
||||
box-shadow: -1px 0 0 0 var(--g-border-color), 1px 0 0 0 var(--g-border-color), 0 1px 0 0 var(--g-border-color);
|
||||
transition: background-color 0.3s;
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
|
||||
:deep(a.title) {
|
||||
position: relative;
|
||||
flex: 0;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
padding: inherit;
|
||||
background-color: inherit;
|
||||
|
||||
.logo {
|
||||
width: initial;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 20px;
|
||||
color: var(--g-header-color);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
padding: 0 20px;
|
||||
overflow-x: auto;
|
||||
mask-image: linear-gradient(to right, transparent, #000 20px, #000 calc(100% - 20px), transparent);
|
||||
|
||||
// firefox隐藏滚动条
|
||||
scrollbar-width: none;
|
||||
|
||||
// chrome隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: inline-flex;
|
||||
height: 100%;
|
||||
|
||||
:deep(.menu-item) {
|
||||
.menu-item-container {
|
||||
color: var(--g-header-menu-color);
|
||||
|
||||
&:hover {
|
||||
color: var(--g-header-menu-hover-color);
|
||||
background-color: var(--g-header-menu-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
&.active .menu-item-container {
|
||||
color: var(--g-header-menu-active-color);
|
||||
background-color: var(--g-header-menu-active-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 头部动画
|
||||
.header-enter-active,
|
||||
.header-leave-active {
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.header-enter-from,
|
||||
.header-leave-to {
|
||||
transform: translateY(calc(var(--g-header-height) * -1));
|
||||
}
|
||||
</style>
|
||||
88
src/admin/src/layouts/components/HotkeysIntro/index.vue
Executable file
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import eventBus from '@/utils/eventBus'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
defineOptions({
|
||||
name: 'HotkeysIntro',
|
||||
})
|
||||
|
||||
const isShow = ref(false)
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('global-hotkeys-intro-toggle', () => {
|
||||
isShow.value = !isShow.value
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HDialog v-model="isShow" title="快捷键介绍">
|
||||
<div class="px-4">
|
||||
<div class="grid gap-2 sm-grid-cols-2">
|
||||
<div>
|
||||
<h2 class="m-0 text-lg font-bold">
|
||||
全局
|
||||
</h2>
|
||||
<ul class="list-none pl-4 text-sm">
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>I</HKbd>
|
||||
查看系统信息
|
||||
</li>
|
||||
<li v-if="settingsStore.settings.toolbar.navSearch && settingsStore.settings.navSearch.enableHotkeys" class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>S</HKbd>
|
||||
唤起导航搜索
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="settingsStore.settings.menu.enableHotkeys && ['side', 'head'].includes(settingsStore.settings.menu.menuMode)">
|
||||
<h2 class="m-0 text-lg font-bold">
|
||||
主导航
|
||||
</h2>
|
||||
<ul class="list-none pl-4 text-sm">
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>`</HKbd>
|
||||
激活下一个主导航
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="settingsStore.settings.tabbar.enable && settingsStore.settings.tabbar.enableHotkeys">
|
||||
<h2 class="m-0 text-lg font-bold">
|
||||
标签栏
|
||||
</h2>
|
||||
<ul class="list-none pl-4 text-sm">
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>←</HKbd>
|
||||
切换到上一个标签页
|
||||
</li>
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>→</HKbd>
|
||||
切换到下一个标签页
|
||||
</li>
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>W</HKbd>
|
||||
关闭当前标签页
|
||||
</li>
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>1~9</HKbd>
|
||||
切换到第 n 个标签页
|
||||
</li>
|
||||
<li class="py-1">
|
||||
<HKbd>{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }}</HKbd>
|
||||
<HKbd>0</HKbd>
|
||||
切换到最后一个标签页
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HDialog>
|
||||
</template>
|
||||
42
src/admin/src/layouts/components/Logo/index.vue
Executable file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
defineOptions({
|
||||
name: 'Logo',
|
||||
})
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
showLogo?: boolean
|
||||
showTitle?: boolean
|
||||
}>(),
|
||||
{
|
||||
showLogo: true,
|
||||
showTitle: true,
|
||||
},
|
||||
)
|
||||
|
||||
const { pkg } = __SYSTEM_INFO__
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const title = ref(import.meta.env.VITE_APP_TITLE)
|
||||
|
||||
// 校验 title 是否包含 "AIWeb"
|
||||
const encodedKeyword = 'QUlXZWI=' // "AIWeb" 的 Base64 编码
|
||||
const decodedKeyword = atob(encodedKeyword)
|
||||
|
||||
if (!title.value.includes(decodedKeyword)) {
|
||||
document.body.innerHTML = '<h1></h1>'
|
||||
throw new Error('')
|
||||
}
|
||||
|
||||
const to = computed(() => settingsStore.settings.home.enable ? settingsStore.settings.home.fullPath : '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink :to="to" class="h-[var(--g-sidebar-logo-height)] w-inherit flex-center gap-2 px-3 text-inherit no-underline" :class="{ 'cursor-pointer': settingsStore.settings.home.enable }" :title="title">
|
||||
<!-- <img v-if="showLogo" :src="logo" class="logo h-[30px] w-[30px] object-contain"> -->
|
||||
<span v-if="showTitle" class="block truncate font-bold">{{ title }}-{{ pkg.version }}</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
111
src/admin/src/layouts/components/MainSidebar/index.vue
Executable file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import Logo from '../Logo/index.vue'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
import useMenuStore from '@/store/modules/menu'
|
||||
|
||||
defineOptions({
|
||||
name: 'MainSidebar',
|
||||
})
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
const { switchTo } = useMenu()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="main-sidebar">
|
||||
<div v-if="settingsStore.settings.menu.menuMode === 'side' || (settingsStore.mode === 'mobile' && settingsStore.settings.menu.menuMode !== 'single')" class="main-sidebar-container">
|
||||
<Logo :show-title="false" class="sidebar-logo" />
|
||||
<!-- 侧边栏模式(含主导航) -->
|
||||
<div class="menu flex flex-col of-hidden transition-all">
|
||||
<template v-for="(item, index) in menuStore.allMenus" :key="index">
|
||||
<div
|
||||
class="menu-item relative transition-all" :class="{
|
||||
active: index === menuStore.actived,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="item.children && item.children.length !== 0" class="group menu-item-container h-full w-full flex cursor-pointer items-center justify-between gap-1 py-4 text-[var(--g-main-sidebar-menu-color)] transition-all hover-(bg-[var(--g-main-sidebar-menu-hover-bg)] text-[var(--g-main-sidebar-menu-hover-color)]) px-2!" :class="{
|
||||
'text-[var(--g-main-sidebar-menu-active-color)]! bg-[var(--g-main-sidebar-menu-active-bg)]!': index === menuStore.actived,
|
||||
}" :title="typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title" @click="switchTo(index)"
|
||||
>
|
||||
<div class="w-full inline-flex flex-1 flex-col items-center justify-center gap-[2px]">
|
||||
<SvgIcon v-if="item.meta?.icon" :name="item.meta?.icon" :size="20" class="menu-item-container-icon transition-transform group-hover-scale-120" async />
|
||||
<span class="w-full flex-1 truncate text-center text-sm transition-height transition-opacity transition-width">
|
||||
{{ typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-sidebar-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--g-main-sidebar-width);
|
||||
color: var(--g-main-sidebar-menu-color);
|
||||
background-color: var(--g-main-sidebar-bg);
|
||||
box-shadow: 1px 0 0 0 var(--g-border-color);
|
||||
transition: background-color 0.3s, color 0.3s, box-shadow 0.3s;
|
||||
|
||||
.sidebar-logo {
|
||||
background-color: var(--g-main-sidebar-bg);
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.menu {
|
||||
flex: 1;
|
||||
width: initial;
|
||||
overflow: hidden auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
// firefox隐藏滚动条
|
||||
scrollbar-width: none;
|
||||
|
||||
// chrome隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.menu-item) {
|
||||
.menu-item-container {
|
||||
padding-block: 8px;
|
||||
color: var(--g-main-sidebar-menu-color);
|
||||
|
||||
&:hover {
|
||||
color: var(--g-main-sidebar-menu-hover-color);
|
||||
background-color: var(--g-main-sidebar-menu-hover-bg);
|
||||
}
|
||||
|
||||
.menu-item-container-icon {
|
||||
font-size: 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.active .menu-item-container {
|
||||
color: var(--g-main-sidebar-menu-active-color) !important;
|
||||
background-color: var(--g-main-sidebar-menu-active-bg) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主侧边栏动画
|
||||
.main-sidebar-enter-active,
|
||||
.main-sidebar-leave-active {
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.main-sidebar-enter-from,
|
||||
.main-sidebar-leave-to {
|
||||
transform: translateX(calc(var(--g-main-sidebar-width) * -1));
|
||||
}
|
||||
</style>
|
||||
179
src/admin/src/layouts/components/Menu/index.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import SubMenu from './sub.vue'
|
||||
import Item from './item.vue'
|
||||
import type { MenuInjection, MenuProps } from './types'
|
||||
import { rootMenuInjectionKey } from './types'
|
||||
|
||||
defineOptions({
|
||||
name: 'MainMenu',
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<MenuProps>(),
|
||||
{
|
||||
accordion: true,
|
||||
defaultOpeneds: () => [],
|
||||
mode: 'vertical',
|
||||
collapse: false,
|
||||
showCollapseName: false,
|
||||
},
|
||||
)
|
||||
|
||||
const activeIndex = ref<MenuInjection['activeIndex']>(props.value)
|
||||
const items = ref<MenuInjection['items']>({})
|
||||
const subMenus = ref<MenuInjection['subMenus']>({})
|
||||
const openedMenus = ref<MenuInjection['openedMenus']>(props.defaultOpeneds.slice(0))
|
||||
const mouseInMenu = ref<MenuInjection['mouseInMenu']>([])
|
||||
const isMenuPopup = computed<MenuInjection['isMenuPopup']>(() => {
|
||||
return props.mode === 'horizontal' || (props.mode === 'vertical' && props.collapse)
|
||||
})
|
||||
|
||||
// 解析传入的 menu 数据,并保存到 items 和 subMenus 对象中
|
||||
function initItems(menu: MenuProps['menu'], parentPaths: string[] = []) {
|
||||
menu.forEach((item) => {
|
||||
const index = item.path ?? JSON.stringify(item)
|
||||
if (item.children) {
|
||||
const indexPath = [...parentPaths, index]
|
||||
subMenus.value[index] = {
|
||||
index,
|
||||
indexPath,
|
||||
active: false,
|
||||
}
|
||||
initItems(item.children, indexPath)
|
||||
}
|
||||
else {
|
||||
items.value[index] = {
|
||||
index,
|
||||
indexPath: parentPaths,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openMenu: MenuInjection['openMenu'] = (index, indexPath) => {
|
||||
if (openedMenus.value.includes(index)) {
|
||||
return
|
||||
}
|
||||
if (props.accordion) {
|
||||
openedMenus.value = openedMenus.value.filter(key => indexPath.includes(key))
|
||||
}
|
||||
openedMenus.value.push(index)
|
||||
}
|
||||
const closeMenu: MenuInjection['closeMenu'] = (index) => {
|
||||
if (Array.isArray(index)) {
|
||||
nextTick(() => {
|
||||
closeMenu(index.at(-1)!)
|
||||
if (index.length > 1) {
|
||||
closeMenu(index.slice(0, -1))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
Object.keys(subMenus.value).forEach((item) => {
|
||||
if (subMenus.value[item].indexPath.includes(index)) {
|
||||
openedMenus.value = openedMenus.value.filter(item => item !== index)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setSubMenusActive(index: string) {
|
||||
for (const key in subMenus.value) {
|
||||
subMenus.value[key].active = false
|
||||
}
|
||||
subMenus.value[index]?.indexPath.forEach((idx) => {
|
||||
subMenus.value[idx].active = true
|
||||
})
|
||||
items.value[index]?.indexPath.forEach((idx) => {
|
||||
subMenus.value[idx].active = true
|
||||
})
|
||||
}
|
||||
|
||||
const handleMenuItemClick: MenuInjection['handleMenuItemClick'] = (index) => {
|
||||
if (props.mode === 'horizontal' || props.collapse) {
|
||||
openedMenus.value = []
|
||||
}
|
||||
setSubMenusActive(index)
|
||||
}
|
||||
const handleSubMenuClick: MenuInjection['handleSubMenuClick'] = (index, indexPath) => {
|
||||
if (openedMenus.value.includes(index)) {
|
||||
closeMenu(index)
|
||||
}
|
||||
else {
|
||||
openMenu(index, indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
function initMenu() {
|
||||
const activeItem = activeIndex.value && items.value[activeIndex.value]
|
||||
setSubMenusActive(activeIndex.value)
|
||||
if (!activeItem || props.collapse) {
|
||||
return
|
||||
}
|
||||
// 展开该菜单项的路径上所有子菜单
|
||||
activeItem.indexPath.forEach((index) => {
|
||||
const subMenu = subMenus.value[index]
|
||||
subMenu && openMenu(index, subMenu.indexPath)
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.menu, (val) => {
|
||||
initItems(val)
|
||||
initMenu()
|
||||
}, {
|
||||
deep: true,
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
watch(() => props.value, (currentValue) => {
|
||||
if (!items.value[currentValue]) {
|
||||
activeIndex.value = ''
|
||||
}
|
||||
const item = items.value[currentValue] || (activeIndex.value && items.value[activeIndex.value]) || items.value[props.value]
|
||||
if (item) {
|
||||
activeIndex.value = item.index
|
||||
}
|
||||
else {
|
||||
activeIndex.value = currentValue
|
||||
}
|
||||
initMenu()
|
||||
})
|
||||
|
||||
watch(() => props.collapse, (value) => {
|
||||
if (value) {
|
||||
openedMenus.value = []
|
||||
}
|
||||
initMenu()
|
||||
})
|
||||
|
||||
provide(rootMenuInjectionKey, reactive({
|
||||
props,
|
||||
items,
|
||||
subMenus,
|
||||
activeIndex,
|
||||
openedMenus,
|
||||
mouseInMenu,
|
||||
isMenuPopup,
|
||||
openMenu,
|
||||
closeMenu,
|
||||
handleMenuItemClick,
|
||||
handleSubMenuClick,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col of-hidden transition-all" :class="{
|
||||
'w-[200px]': !isMenuPopup && props.mode === 'vertical',
|
||||
'w-[64px]': isMenuPopup && props.mode === 'vertical',
|
||||
'h-[80px]': props.mode === 'horizontal',
|
||||
'flex-row! w-auto': isMenuPopup && props.mode === 'horizontal',
|
||||
}"
|
||||
>
|
||||
<template v-for="item in menu" :key="item.path ?? JSON.stringify(item)">
|
||||
<template v-if="item.meta?.menu !== false">
|
||||
<SubMenu v-if="item.children?.length" :menu="item" :unique-key="[item.path ?? JSON.stringify(item)]" />
|
||||
<Item v-else :item="item" :unique-key="[item.path ?? JSON.stringify(item)]" @click="handleMenuItemClick(item.path ?? JSON.stringify(item))" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
93
src/admin/src/layouts/components/Menu/item.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import type { SubMenuItemProps } from './types'
|
||||
import { rootMenuInjectionKey } from './types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SubMenuItemProps>(),
|
||||
{
|
||||
level: 0,
|
||||
subMenu: false,
|
||||
expand: false,
|
||||
},
|
||||
)
|
||||
|
||||
const rootMenu = inject(rootMenuInjectionKey)!
|
||||
|
||||
const itemRef = ref<HTMLElement>()
|
||||
|
||||
const isActived = computed(() => {
|
||||
return props.subMenu
|
||||
? rootMenu.subMenus[props.uniqueKey.at(-1)!].active
|
||||
: rootMenu.activeIndex === props.uniqueKey.at(-1)!
|
||||
})
|
||||
|
||||
const isItemActive = computed(() => {
|
||||
return isActived.value && (!props.subMenu || rootMenu.isMenuPopup)
|
||||
})
|
||||
|
||||
// 缩进样式
|
||||
const indentStyle = computed(() => {
|
||||
return !rootMenu.isMenuPopup
|
||||
? `padding-left: ${20 * (props.level ?? 0)}px`
|
||||
: ''
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
ref: itemRef,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="itemRef" class="menu-item relative transition-all" :class="{
|
||||
active: isItemActive,
|
||||
}"
|
||||
>
|
||||
<router-link v-slot="{ href, navigate }" custom :to="uniqueKey.at(-1) ?? ''">
|
||||
<HTooltip :enable="rootMenu.isMenuPopup && level === 0 && !subMenu" :text="typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title" placement="right" class="h-full w-full">
|
||||
<component
|
||||
:is="subMenu ? 'div' : 'a'" v-bind="{
|
||||
...(!subMenu && {
|
||||
href: item.meta?.link ? item.meta.link : href,
|
||||
target: item.meta?.link ? '_blank' : '_self',
|
||||
class: 'no-underline',
|
||||
}),
|
||||
}" class="group menu-item-container h-full w-full flex cursor-pointer items-center justify-between gap-1 px-5 py-4 text-[var(--g-sub-sidebar-menu-color)] transition-all hover-(bg-[var(--g-sub-sidebar-menu-hover-bg)] text-[var(--g-sub-sidebar-menu-hover-color)])" :class="{
|
||||
'text-[var(--g-sub-sidebar-menu-active-color)]! bg-[var(--g-sub-sidebar-menu-active-bg)]!': isItemActive,
|
||||
'px-3!': rootMenu.isMenuPopup && level === 0,
|
||||
}" :title="typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title" v-on="{
|
||||
...(!subMenu && {
|
||||
click: navigate,
|
||||
}),
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="inline-flex flex-1 items-center justify-center gap-[12px]" :class="{
|
||||
'flex-col': rootMenu.isMenuPopup && level === 0 && rootMenu.props.mode === 'vertical',
|
||||
'gap-1!': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName,
|
||||
'w-full': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName && rootMenu.props.mode === 'vertical',
|
||||
}" :style="indentStyle"
|
||||
>
|
||||
<SvgIcon v-if="props.item.meta?.icon" :name="props.item.meta.icon" :size="20" class="menu-item-container-icon transition-transform group-hover-scale-120" async />
|
||||
<span
|
||||
v-if="!(rootMenu.isMenuPopup && level === 0 && !rootMenu.props.showCollapseName)" class="w-0 flex-1 truncate text-sm transition-height transition-opacity transition-width"
|
||||
:class="{
|
||||
'opacity-0 w-0 h-0': rootMenu.isMenuPopup && level === 0 && !rootMenu.props.showCollapseName,
|
||||
'w-full text-center': rootMenu.isMenuPopup && level === 0 && rootMenu.props.showCollapseName,
|
||||
}"
|
||||
>
|
||||
{{ typeof item.meta?.title === 'function' ? item.meta?.title() : item.meta?.title }}
|
||||
</span>
|
||||
</div>
|
||||
<i
|
||||
v-if="subMenu && !(rootMenu.isMenuPopup && level === 0)" class="relative ml-1 w-[10px] after:(absolute h-[1.5px] w-[6px] bg-current transition-transform-200 content-empty -translate-y-[1px]) before:(absolute h-[1.5px] w-[6px] bg-current transition-transform-200 content-empty -translate-y-[1px])" :class="[
|
||||
expand ? 'before:(-rotate-45 -translate-x-[2px]) after:(rotate-45 translate-x-[2px])' : 'before:(rotate-45 -translate-x-[2px]) after:(-rotate-45 translate-x-[2px])',
|
||||
rootMenu.isMenuPopup && level === 0 && 'opacity-0',
|
||||
rootMenu.isMenuPopup && level !== 0 && '-rotate-90 -top-[1.5px]',
|
||||
]"
|
||||
/>
|
||||
</component>
|
||||
</HTooltip>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
197
src/admin/src/layouts/components/Menu/sub.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script setup lang="ts">
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-vue'
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue'
|
||||
import Item from './item.vue'
|
||||
import type { SubMenuProps } from './types'
|
||||
import { rootMenuInjectionKey } from './types'
|
||||
|
||||
defineOptions({
|
||||
name: 'SubMenu',
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SubMenuProps>(),
|
||||
{
|
||||
level: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const index = props.menu.path ?? JSON.stringify(props.menu)
|
||||
const itemRef = shallowRef()
|
||||
const subMenuRef = shallowRef<OverlayScrollbarsComponentRef>()
|
||||
const rootMenu = inject(rootMenuInjectionKey)!
|
||||
|
||||
const opened = computed(() => {
|
||||
return rootMenu.openedMenus.includes(props.uniqueKey.at(-1)!)
|
||||
})
|
||||
|
||||
const transitionEvent = computed(() => {
|
||||
return rootMenu.isMenuPopup
|
||||
? {
|
||||
enter(el: HTMLElement) {
|
||||
if (el.offsetHeight > window.innerHeight) {
|
||||
el.style.height = `${window.innerHeight}px`
|
||||
}
|
||||
},
|
||||
afterEnter: () => {},
|
||||
beforeLeave: (el: HTMLElement) => {
|
||||
el.style.overflow = 'hidden'
|
||||
el.style.maxHeight = `${el.offsetHeight}px`
|
||||
},
|
||||
leave: (el: HTMLElement) => {
|
||||
el.style.maxHeight = '0'
|
||||
},
|
||||
afterLeave(el: HTMLElement) {
|
||||
el.style.overflow = ''
|
||||
el.style.maxHeight = ''
|
||||
},
|
||||
}
|
||||
: {
|
||||
enter(el: HTMLElement) {
|
||||
const memorizedHeight = el.offsetHeight
|
||||
el.style.maxHeight = '0'
|
||||
el.style.overflow = 'hidden'
|
||||
void el.offsetHeight
|
||||
el.style.maxHeight = `${memorizedHeight}px`
|
||||
},
|
||||
afterEnter(el: HTMLElement) {
|
||||
el.style.overflow = ''
|
||||
el.style.maxHeight = ''
|
||||
},
|
||||
beforeLeave(el: HTMLElement) {
|
||||
el.style.overflow = 'hidden'
|
||||
el.style.maxHeight = `${el.offsetHeight}px`
|
||||
},
|
||||
leave(el: HTMLElement) {
|
||||
el.style.maxHeight = '0'
|
||||
},
|
||||
afterLeave(el: HTMLElement) {
|
||||
el.style.overflow = ''
|
||||
el.style.maxHeight = ''
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const transitionClass = computed(() => {
|
||||
return rootMenu.isMenuPopup
|
||||
? {
|
||||
enterActiveClass: 'ease-in-out duration-300',
|
||||
enterFromClass: 'opacity-0 translate-x-4',
|
||||
enterToClass: 'opacity-100',
|
||||
leaveActiveClass: 'ease-in-out duration-300',
|
||||
leaveFromClass: 'opacity-100',
|
||||
leaveToClass: 'opacity-0',
|
||||
}
|
||||
: {
|
||||
enterActiveClass: 'ease-in-out duration-300',
|
||||
enterFromClass: 'opacity-0',
|
||||
enterToClass: 'opacity-100',
|
||||
leaveActiveClass: 'ease-in-out duration-300',
|
||||
leaveFromClass: 'opacity-100',
|
||||
leaveToClass: 'opacity-0',
|
||||
}
|
||||
})
|
||||
|
||||
const hasChildren = computed(() => {
|
||||
let flag = true
|
||||
if (props.menu.children) {
|
||||
if (props.menu.children.every((item: any) => item.meta?.menu === false)) {
|
||||
flag = false
|
||||
}
|
||||
}
|
||||
else {
|
||||
flag = false
|
||||
}
|
||||
return flag
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (rootMenu.isMenuPopup && hasChildren.value) {
|
||||
return
|
||||
}
|
||||
if (hasChildren.value) {
|
||||
rootMenu.handleSubMenuClick(index, props.uniqueKey)
|
||||
}
|
||||
else {
|
||||
rootMenu.handleMenuItemClick(index)
|
||||
}
|
||||
}
|
||||
|
||||
let timeout: (() => void) | undefined
|
||||
|
||||
function handleMouseenter() {
|
||||
if (!rootMenu.isMenuPopup) {
|
||||
return
|
||||
}
|
||||
rootMenu.mouseInMenu = props.uniqueKey
|
||||
timeout?.()
|
||||
;({ stop: timeout } = useTimeoutFn(() => {
|
||||
if (hasChildren.value) {
|
||||
rootMenu.openMenu(index, props.uniqueKey)
|
||||
nextTick(() => {
|
||||
const el = itemRef.value.ref
|
||||
let top = 0
|
||||
let left = 0
|
||||
if (rootMenu.props.mode === 'vertical' || props.level !== 0) {
|
||||
top = el.getBoundingClientRect().top + el.scrollTop
|
||||
left = el.getBoundingClientRect().left + el.getBoundingClientRect().width
|
||||
if (top + subMenuRef.value!.getElement()!.offsetHeight > window.innerHeight) {
|
||||
top = window.innerHeight - subMenuRef.value!.getElement()!.offsetHeight
|
||||
}
|
||||
}
|
||||
else {
|
||||
top = el.getBoundingClientRect().top + el.getBoundingClientRect().height
|
||||
left = el.getBoundingClientRect().left
|
||||
if (top + subMenuRef.value!.getElement()!.offsetHeight > window.innerHeight) {
|
||||
subMenuRef.value!.getElement()!.style.height = `${window.innerHeight - top}px`
|
||||
}
|
||||
}
|
||||
subMenuRef.value!.getElement()!.style.top = `${top}px`
|
||||
subMenuRef.value!.getElement()!.style.left = `${left}px`
|
||||
})
|
||||
}
|
||||
else {
|
||||
const path = props.menu.children ? rootMenu.subMenus[index].indexPath.at(-1)! : rootMenu.items[index].indexPath.at(-1)!
|
||||
rootMenu.openMenu(path, rootMenu.subMenus[path].indexPath)
|
||||
}
|
||||
}, 300))
|
||||
}
|
||||
|
||||
function handleMouseleave() {
|
||||
if (!rootMenu.isMenuPopup) {
|
||||
return
|
||||
}
|
||||
rootMenu.mouseInMenu = []
|
||||
timeout?.()
|
||||
;({ stop: timeout } = useTimeoutFn(() => {
|
||||
if (rootMenu.mouseInMenu.length === 0) {
|
||||
rootMenu.closeMenu(props.uniqueKey)
|
||||
}
|
||||
else {
|
||||
if (hasChildren.value) {
|
||||
!rootMenu.mouseInMenu.includes(props.uniqueKey.at(-1)!) && rootMenu.closeMenu(props.uniqueKey.at(-1)!)
|
||||
}
|
||||
}
|
||||
}, 300))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Item ref="itemRef" :unique-key="uniqueKey" :item="menu" :level="level" :sub-menu="hasChildren" :expand="opened" @click="handleClick" @mouseenter="handleMouseenter" @mouseleave="handleMouseleave" />
|
||||
<Teleport v-if="hasChildren" to="body" :disabled="!rootMenu.isMenuPopup">
|
||||
<Transition v-bind="transitionClass" v-on="transitionEvent">
|
||||
<OverlayScrollbarsComponent
|
||||
v-if="opened" ref="subMenuRef" :options="{ scrollbars: { visibility: 'hidden' } }" defer class="sub-menu" :class="{
|
||||
'bg-[var(--g-sub-sidebar-bg)]': rootMenu.isMenuPopup,
|
||||
'ring-1 ring-stone-2 dark-ring-stone-8 shadow-xl fixed z-3000 w-[200px]': rootMenu.isMenuPopup,
|
||||
'mx-2': rootMenu.isMenuPopup && (rootMenu.props.mode === 'vertical' || level !== 0),
|
||||
}"
|
||||
>
|
||||
<template v-for="item in menu.children" :key="item.path ?? JSON.stringify(item)">
|
||||
<SubMenu v-if="item.meta?.menu !== false" :unique-key="[...uniqueKey, item.path ?? JSON.stringify(item)]" :menu="item" :level="level + 1" />
|
||||
</template>
|
||||
</OverlayScrollbarsComponent>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
48
src/admin/src/layouts/components/Menu/types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createInjectionKey } from '@/utils/injectionKeys'
|
||||
import type { Menu } from '#/global'
|
||||
|
||||
export interface MenuItem {
|
||||
index: string
|
||||
indexPath: string[]
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export interface MenuProps {
|
||||
menu: Menu.recordRaw[]
|
||||
value: string
|
||||
accordion?: boolean
|
||||
defaultOpeneds?: string[]
|
||||
mode?: 'horizontal' | 'vertical'
|
||||
collapse?: boolean
|
||||
showCollapseName?: boolean
|
||||
}
|
||||
|
||||
export interface MenuInjection {
|
||||
props: MenuProps
|
||||
items: Record<string, MenuItem>
|
||||
subMenus: Record<string, MenuItem>
|
||||
activeIndex: MenuProps['value']
|
||||
openedMenus: string[]
|
||||
mouseInMenu: string[]
|
||||
isMenuPopup: boolean
|
||||
openMenu: (index: string, indexPath: string[]) => void
|
||||
closeMenu: (index: string | string[]) => void
|
||||
handleMenuItemClick: (index: string) => void
|
||||
handleSubMenuClick: (index: string, indexPath: string[]) => void
|
||||
}
|
||||
|
||||
export const rootMenuInjectionKey = createInjectionKey<MenuInjection>('rootMenu')
|
||||
|
||||
export interface SubMenuProps {
|
||||
uniqueKey: string[]
|
||||
menu: Menu.recordRaw
|
||||
level?: number
|
||||
}
|
||||
|
||||
export interface SubMenuItemProps {
|
||||
uniqueKey: string[]
|
||||
item: Menu.recordRaw
|
||||
level?: number
|
||||
subMenu?: boolean
|
||||
expand?: boolean
|
||||
}
|
||||
316
src/admin/src/layouts/components/Search/index.vue
Executable file
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { Dialog, DialogDescription, DialogPanel, TransitionChild, TransitionRoot } from '@headlessui/vue'
|
||||
import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-vue'
|
||||
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import hotkeys from 'hotkeys-js'
|
||||
import Breadcrumb from '../Breadcrumb/index.vue'
|
||||
import BreadcrumbItem from '../Breadcrumb/item.vue'
|
||||
import { resolveRoutePath } from '@/utils'
|
||||
import eventBus from '@/utils/eventBus'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
import useMenuStore from '@/store/modules/menu'
|
||||
import type { Menu } from '@/types/global'
|
||||
|
||||
defineOptions({
|
||||
name: 'Search',
|
||||
})
|
||||
|
||||
const overlayTransitionClass = ref({
|
||||
enter: 'ease-in-out duration-500',
|
||||
enterFrom: 'opacity-0',
|
||||
enterTo: 'opacity-100',
|
||||
leave: 'ease-in-out duration-500',
|
||||
leaveFrom: 'opacity-100',
|
||||
leaveTo: 'opacity-0',
|
||||
})
|
||||
|
||||
const transitionClass = computed(() => {
|
||||
return {
|
||||
enter: 'ease-out duration-300',
|
||||
enterFrom: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
|
||||
enterTo: 'opacity-100 translate-y-0 lg-scale-100',
|
||||
leave: 'ease-in duration-200',
|
||||
leaveFrom: 'opacity-100 translate-y-0 lg-scale-100',
|
||||
leaveTo: 'opacity-0 translate-y-4 lg-translate-y-0 lg-scale-95',
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const settingsStore = useSettingsStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
interface listTypes {
|
||||
path: string
|
||||
icon?: string
|
||||
title?: string | (() => string)
|
||||
link?: string
|
||||
breadcrumb: {
|
||||
title?: string | (() => string)
|
||||
}[]
|
||||
}
|
||||
|
||||
const isShow = ref(false)
|
||||
const searchInput = ref('')
|
||||
const sourceList = ref<listTypes[]>([])
|
||||
const actived = ref(-1)
|
||||
|
||||
const searchInputRef = ref()
|
||||
const searchResultRef = ref<OverlayScrollbarsComponentRef>()
|
||||
const searchResultItemRef = ref<HTMLElement[]>([])
|
||||
onBeforeUpdate(() => {
|
||||
searchResultItemRef.value = []
|
||||
})
|
||||
|
||||
const resultList = computed(() => {
|
||||
let result = []
|
||||
result = sourceList.value.filter((item) => {
|
||||
let flag = false
|
||||
if (item.title) {
|
||||
if (typeof item.title === 'function') {
|
||||
if (item.title().includes(searchInput.value)) {
|
||||
flag = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (item.title.includes(searchInput.value)) {
|
||||
flag = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (item.path.includes(searchInput.value)) {
|
||||
flag = true
|
||||
}
|
||||
if (item.breadcrumb.some((b) => {
|
||||
if (typeof b.title === 'function') {
|
||||
if (b.title().includes(searchInput.value)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (b.title?.includes(searchInput.value)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})) {
|
||||
flag = true
|
||||
}
|
||||
return flag
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
watch(() => isShow.value, (val) => {
|
||||
if (val) {
|
||||
searchInput.value = ''
|
||||
actived.value = -1
|
||||
// 当搜索显示的时候绑定上、下、回车快捷键,隐藏的时候再解绑。另外当 input 处于 focus 状态时,采用 vue 来绑定键盘事件
|
||||
hotkeys('up', keyUp)
|
||||
hotkeys('down', keyDown)
|
||||
hotkeys('enter', keyEnter)
|
||||
}
|
||||
else {
|
||||
hotkeys.unbind('up', keyUp)
|
||||
hotkeys.unbind('down', keyDown)
|
||||
hotkeys.unbind('enter', keyEnter)
|
||||
}
|
||||
})
|
||||
watch(() => resultList.value, () => {
|
||||
actived.value = -1
|
||||
handleScroll()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
eventBus.on('global-search-toggle', () => {
|
||||
if (!isShow.value) {
|
||||
initSourceList()
|
||||
}
|
||||
isShow.value = !isShow.value
|
||||
})
|
||||
hotkeys('alt+s', (e) => {
|
||||
if (settingsStore.settings.toolbar.navSearch && settingsStore.settings.navSearch.enableHotkeys) {
|
||||
e.preventDefault()
|
||||
initSourceList()
|
||||
isShow.value = true
|
||||
}
|
||||
})
|
||||
hotkeys('esc', (e) => {
|
||||
if (settingsStore.settings.toolbar.navSearch && settingsStore.settings.navSearch.enableHotkeys) {
|
||||
e.preventDefault()
|
||||
isShow.value = false
|
||||
}
|
||||
})
|
||||
initSourceList()
|
||||
})
|
||||
|
||||
function initSourceList() {
|
||||
sourceList.value = []
|
||||
menuStore.allMenus.forEach((item) => {
|
||||
getSourceListByMenus(item.children)
|
||||
})
|
||||
}
|
||||
|
||||
function hasChildren(item: Menu.recordRaw) {
|
||||
let flag = true
|
||||
if (item.children?.every(i => i.meta?.menu === false)) {
|
||||
flag = false
|
||||
}
|
||||
return flag
|
||||
}
|
||||
function getSourceListByMenus(arr: Menu.recordRaw[], basePath?: string, icon?: string, breadcrumb?: { title?: string | (() => string) }[]) {
|
||||
arr.forEach((item) => {
|
||||
if (item.meta?.menu !== false) {
|
||||
const breadcrumbTemp = cloneDeep(breadcrumb) || []
|
||||
if (item.children && hasChildren(item)) {
|
||||
breadcrumbTemp.push({
|
||||
title: item.meta?.title,
|
||||
})
|
||||
getSourceListByMenus(item.children, resolveRoutePath(basePath, item.path), item.meta?.icon ?? icon, breadcrumbTemp)
|
||||
}
|
||||
else {
|
||||
breadcrumbTemp.push({
|
||||
title: item.meta?.title,
|
||||
})
|
||||
sourceList.value.push({
|
||||
path: resolveRoutePath(basePath, item.path),
|
||||
icon: item.meta?.icon ?? icon,
|
||||
title: item.meta?.title,
|
||||
link: item.meta?.link,
|
||||
breadcrumb: breadcrumbTemp,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function keyUp() {
|
||||
if (resultList.value.length) {
|
||||
actived.value -= 1
|
||||
if (actived.value < 0) {
|
||||
actived.value = resultList.value.length - 1
|
||||
}
|
||||
handleScroll()
|
||||
}
|
||||
}
|
||||
function keyDown() {
|
||||
if (resultList.value.length) {
|
||||
actived.value += 1
|
||||
if (actived.value > resultList.value.length - 1) {
|
||||
actived.value = 0
|
||||
}
|
||||
handleScroll()
|
||||
}
|
||||
}
|
||||
function keyEnter() {
|
||||
if (actived.value !== -1) {
|
||||
searchResultItemRef.value.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.click()
|
||||
}
|
||||
}
|
||||
function handleScroll() {
|
||||
if (searchResultRef.value) {
|
||||
const contentDom = searchResultRef.value.osInstance()!.elements().content
|
||||
let scrollTo = 0
|
||||
if (actived.value !== -1) {
|
||||
scrollTo = contentDom.scrollTop
|
||||
const activedOffsetTop = searchResultItemRef.value.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.offsetTop ?? 0
|
||||
const activedClientHeight = searchResultItemRef.value.find(item => Number.parseInt(item.dataset.index!) === actived.value)?.clientHeight ?? 0
|
||||
const searchScrollTop = contentDom.scrollTop
|
||||
const searchClientHeight = contentDom.clientHeight
|
||||
if (activedOffsetTop + activedClientHeight > searchScrollTop + searchClientHeight) {
|
||||
scrollTo = activedOffsetTop + activedClientHeight - searchClientHeight
|
||||
}
|
||||
else if (activedOffsetTop <= searchScrollTop) {
|
||||
scrollTo = activedOffsetTop
|
||||
}
|
||||
}
|
||||
contentDom.scrollTo({
|
||||
top: scrollTo,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function pageJump(path: listTypes['path'], link: listTypes['link']) {
|
||||
if (link) {
|
||||
window.open(link, '_blank')
|
||||
}
|
||||
else {
|
||||
router.push(path)
|
||||
}
|
||||
isShow.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TransitionRoot as="template" :show="isShow">
|
||||
<Dialog :initial-focus="searchInputRef" class="fixed inset-0 z-2000 flex" @close="isShow && eventBus.emit('global-search-toggle')">
|
||||
<TransitionChild as="template" v-bind="overlayTransitionClass">
|
||||
<div class="fixed inset-0 bg-stone-200/75 backdrop-blur-sm transition-opacity dark-bg-stone-8/75" />
|
||||
</TransitionChild>
|
||||
<div class="fixed inset-0">
|
||||
<div class="h-full flex items-end justify-center p-4 text-center lg-items-center">
|
||||
<TransitionChild as="template" v-bind="transitionClass">
|
||||
<DialogPanel class="relative h-full max-h-4/5 w-full flex flex-col text-left lg-max-w-2xl">
|
||||
<div class="flex flex-col overflow-y-auto rounded-xl bg-white shadow-xl dark-bg-stone-8">
|
||||
<div class="flex items-center px-4 py-3" border-b="~ solid stone-2 dark-stone-7">
|
||||
<SvgIcon name="i-ep:search" :size="18" class="text-stone-5" />
|
||||
<input ref="searchInputRef" v-model="searchInput" placeholder="搜索页面,支持标题、URL模糊查询" class="w-full border-0 rounded-md bg-transparent px-3 text-base text-dark dark-text-white focus-outline-none placeholder-stone-4 dark-placeholder-stone-5" @keydown.esc="eventBus.emit('global-search-toggle')" @keydown.up.prevent="keyUp" @keydown.down.prevent="keyDown" @keydown.enter.prevent="keyEnter">
|
||||
</div>
|
||||
<DialogDescription class="relative m-0 of-y-hidden">
|
||||
<OverlayScrollbarsComponent ref="searchResultRef" :options="{ scrollbars: { autoHide: 'leave', autoHideDelay: 300 } }" defer class="h-full">
|
||||
<template v-if="resultList.length > 0">
|
||||
<a v-for="(item, index) in resultList" ref="searchResultItemRef" :key="item.path" class="flex cursor-pointer items-center" :class="{ 'bg-stone-2/40 dark-bg-stone-7/40': index === actived }" :data-index="index" @click="pageJump(item.path, item.link)" @mouseover="actived = index">
|
||||
<SvgIcon v-if="item.icon" :name="item.icon" :size="20" class="basis-16 transition" :class="{ 'scale-120 text-ui-primary': index === actived }" />
|
||||
<div class="flex flex-1 flex-col gap-1 truncate px-4 py-3" border-l="~ solid stone-2 dark-stone-7">
|
||||
<div class="truncate text-base font-bold">{{ (typeof item.title === 'function' ? item.title() : item.title) ?? '[ 无标题 ]' }}</div>
|
||||
<Breadcrumb v-if="item.breadcrumb.length" class="truncate">
|
||||
<BreadcrumbItem v-for="(bc, bcIndex) in item.breadcrumb" :key="bcIndex" class="text-xs">
|
||||
{{ (typeof bc.title === 'function' ? bc.title() : bc.title) ?? '[ 无标题 ]' }}
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div flex="center col" py-6 text-stone-5>
|
||||
<SvgIcon name="i-tabler:mood-empty" :size="40" />
|
||||
<p m-2 text-base>
|
||||
没有找到你想要的
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</OverlayScrollbarsComponent>
|
||||
</DialogDescription>
|
||||
<div v-if="settingsStore.mode === 'pc'" class="flex justify-between px-4 py-3" border-t="~ solid stone-2 dark-stone-7">
|
||||
<div class="flex gap-8">
|
||||
<div class="inline-flex items-center gap-1 text-xs">
|
||||
<HKbd>
|
||||
<SvgIcon name="i-ion:md-return-left" :size="14" />
|
||||
</HKbd>
|
||||
<span>访问</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1 text-xs">
|
||||
<HKbd>
|
||||
<SvgIcon name="i-ant-design:caret-up-filled" :size="14" />
|
||||
</HKbd>
|
||||
<HKbd>
|
||||
<SvgIcon name="i-ant-design:caret-down-filled" :size="14" />
|
||||
</HKbd>
|
||||
<span>切换</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="settingsStore.settings.navSearch.enableHotkeys" class="inline-flex items-center gap-1 text-xs">
|
||||
<HKbd>
|
||||
ESC
|
||||
</HKbd>
|
||||
<span>退出</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</TransitionRoot>
|
||||
</template>
|
||||
161
src/admin/src/layouts/components/SubSidebar/index.vue
Executable file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import Logo from '../Logo/index.vue'
|
||||
import Menu from '../Menu/index.vue'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
import useMenuStore from '@/store/modules/menu'
|
||||
|
||||
defineOptions({
|
||||
name: 'SubSidebar',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
const subSidebarRef = ref()
|
||||
const showShadowTop = ref(false)
|
||||
const showShadowBottom = ref(false)
|
||||
function onSidebarScroll() {
|
||||
const scrollTop = subSidebarRef.value.scrollTop
|
||||
showShadowTop.value = scrollTop > 0
|
||||
const clientHeight = subSidebarRef.value.clientHeight
|
||||
const scrollHeight = subSidebarRef.value.scrollHeight
|
||||
showShadowBottom.value = Math.ceil(scrollTop + clientHeight) < scrollHeight
|
||||
}
|
||||
|
||||
const menuRef = ref()
|
||||
|
||||
onMounted(() => {
|
||||
onSidebarScroll()
|
||||
const { height } = useElementSize(menuRef)
|
||||
watch(() => height.value, () => {
|
||||
if (height.value > 0) {
|
||||
onSidebarScroll()
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sub-sidebar-container" :class="{
|
||||
'is-collapse': settingsStore.mode === 'pc' && settingsStore.settings.menu.subMenuCollapse,
|
||||
}"
|
||||
>
|
||||
<Logo
|
||||
:show-logo="settingsStore.settings.menu.menuMode === 'single'" class="sidebar-logo" :class="{
|
||||
'sidebar-logo-bg': settingsStore.settings.menu.menuMode === 'single',
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
ref="subSidebarRef" class="sub-sidebar flex-1 transition-shadow-300" :class="{
|
||||
'shadow-top': showShadowTop,
|
||||
'shadow-bottom': showShadowBottom,
|
||||
}" @scroll="onSidebarScroll"
|
||||
>
|
||||
<div ref="menuRef">
|
||||
<TransitionGroup name="sub-sidebar">
|
||||
<template v-for="(mainItem, mainIndex) in menuStore.allMenus" :key="mainIndex">
|
||||
<div v-show="mainIndex === menuStore.actived">
|
||||
<Menu :menu="mainItem.children" :value="route.meta.activeMenu || route.path" :default-openeds="menuStore.defaultOpenedPaths" :accordion="settingsStore.settings.menu.subMenuUniqueOpened" :collapse="settingsStore.mode === 'pc' && settingsStore.settings.menu.subMenuCollapse" class="menu" />
|
||||
</div>
|
||||
</template>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="settingsStore.mode === 'pc'" class="relative flex items-center px-4 py-3" :class="[settingsStore.settings.menu.subMenuCollapse ? 'justify-center' : 'justify-end']">
|
||||
<span v-show="settingsStore.settings.menu.enableSubMenuCollapseButton" class="flex-center cursor-pointer rounded bg-stone-1 p-2 transition dark-bg-stone-9 hover-bg-stone-2 dark-hover-bg-stone-8" :class="{ '-rotate-z-180': settingsStore.settings.menu.subMenuCollapse }" @click="settingsStore.toggleSidebarCollapse()">
|
||||
<SvgIcon name="toolbar-collapse" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sub-sidebar-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--g-sub-sidebar-width);
|
||||
background-color: var(--g-sub-sidebar-bg);
|
||||
transition: background-color 0.3s, left 0.3s, width 0.3s;
|
||||
|
||||
&.is-collapse {
|
||||
width: var(--g-sub-sidebar-collapse-width);
|
||||
|
||||
.sidebar-logo {
|
||||
&:not(.sidebar-logo-bg) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(span) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
background-color: var(--g-sub-sidebar-bg);
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&.sidebar-logo-bg {
|
||||
background-color: var(--g-sub-sidebar-logo-bg);
|
||||
|
||||
:deep(span) {
|
||||
color: var(--g-sub-sidebar-logo-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sub-sidebar {
|
||||
overflow: hidden auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
// firefox隐藏滚动条
|
||||
scrollbar-width: none;
|
||||
|
||||
// chrome隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.shadow-top {
|
||||
box-shadow: inset 0 10px 10px -10px var(--g-box-shadow-color), inset 0 0 0 transparent;
|
||||
}
|
||||
|
||||
&.shadow-bottom {
|
||||
box-shadow: inset 0 0 0 transparent, inset 0 -10px 10px -10px var(--g-box-shadow-color);
|
||||
}
|
||||
|
||||
&.shadow-top.shadow-bottom {
|
||||
box-shadow: inset 0 10px 10px -10px var(--g-box-shadow-color), inset 0 -10px 10px -10px var(--g-box-shadow-color);
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 次侧边栏动画
|
||||
.sub-sidebar-enter-active {
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.sub-sidebar-enter-from,
|
||||
.sub-sidebar-leave-active {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) skewY(10deg);
|
||||
}
|
||||
|
||||
.sub-sidebar-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
461
src/admin/src/layouts/components/Topbar/Tabbar/index.vue
Normal file
@@ -0,0 +1,461 @@
|
||||
<script setup lang="ts">
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
|
||||
import hotkeys from 'hotkeys-js'
|
||||
import Message from 'vue-m-message'
|
||||
import { useMagicKeys } from '@vueuse/core'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
import useTabbarStore from '@/store/modules/tabbar'
|
||||
import type { Tabbar } from '#/global'
|
||||
|
||||
defineOptions({
|
||||
name: 'Tabbar',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const tabbarStore = useTabbarStore()
|
||||
|
||||
const tabbar = useTabbar()
|
||||
const mainPage = useMainPage()
|
||||
|
||||
const keys = useMagicKeys({ reactive: true })
|
||||
|
||||
const activedTabId = computed(() => tabbar.getId())
|
||||
|
||||
const tabsRef = ref()
|
||||
const tabContainerRef = ref()
|
||||
const tabRef = shallowRef<HTMLElement[]>([])
|
||||
onBeforeUpdate(() => {
|
||||
tabRef.value = []
|
||||
})
|
||||
|
||||
watch(() => route, (val) => {
|
||||
if (settingsStore.settings.tabbar.enable) {
|
||||
tabbarStore.add(val).then(() => {
|
||||
const index = tabbarStore.list.findIndex(item => item.tabId === activedTabId.value)
|
||||
if (index !== -1) {
|
||||
scrollTo(tabRef.value[index].offsetLeft)
|
||||
tabbarScrollTip()
|
||||
}
|
||||
})
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
deep: true,
|
||||
})
|
||||
function tabbarScrollTip() {
|
||||
if (tabContainerRef.value.$el.clientWidth > tabsRef.value.clientWidth && localStorage.getItem('tabbarScrollTip') === undefined) {
|
||||
localStorage.setItem('tabbarScrollTip', '')
|
||||
Message.info('标签栏数量超过展示区域范围,可以将鼠标移到标签栏上,通过鼠标滚轮滑动浏览', {
|
||||
title: '温馨提示',
|
||||
duration: 5000,
|
||||
closable: true,
|
||||
zIndex: 2000,
|
||||
})
|
||||
}
|
||||
}
|
||||
function handlerMouserScroll(event: WheelEvent) {
|
||||
tabsRef.value.scrollBy({
|
||||
left: event.deltaY || event.detail,
|
||||
})
|
||||
}
|
||||
function scrollTo(offsetLeft: number) {
|
||||
tabsRef.value.scrollTo({
|
||||
left: offsetLeft - 50,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
function onTabbarContextmenu(event: MouseEvent, routeItem: Tabbar.recordRaw) {
|
||||
event.preventDefault()
|
||||
ContextMenu.showContextMenu({
|
||||
x: event.x,
|
||||
y: event.y,
|
||||
zIndex: 1050,
|
||||
iconFontClass: '',
|
||||
customClass: 'tabbar-contextmenu',
|
||||
items: [
|
||||
{
|
||||
label: '重新加载',
|
||||
icon: 'i-ri:refresh-line',
|
||||
disabled: routeItem.tabId !== activedTabId.value,
|
||||
onClick: () => mainPage.reload(),
|
||||
},
|
||||
{
|
||||
label: '关闭标签页',
|
||||
icon: 'i-ri:close-line',
|
||||
disabled: tabbarStore.list.length <= 1,
|
||||
divided: true,
|
||||
onClick: () => {
|
||||
tabbar.closeById(routeItem.tabId)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '关闭其他标签页',
|
||||
disabled: !tabbar.checkCloseOtherSide(routeItem.tabId),
|
||||
onClick: () => {
|
||||
tabbar.closeOtherSide(routeItem.tabId)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '关闭左侧标签页',
|
||||
disabled: !tabbar.checkCloseLeftSide(routeItem.tabId),
|
||||
onClick: () => {
|
||||
tabbar.closeLeftSide(routeItem.tabId)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '关闭右侧标签页',
|
||||
disabled: !tabbar.checkCloseRightSide(routeItem.tabId),
|
||||
onClick: () => {
|
||||
tabbar.closeRightSide(routeItem.tabId)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
hotkeys('alt+left,alt+right,alt+w,alt+1,alt+2,alt+3,alt+4,alt+5,alt+6,alt+7,alt+8,alt+9,alt+0', (e, handle) => {
|
||||
if (settingsStore.settings.tabbar.enable && settingsStore.settings.tabbar.enableHotkeys) {
|
||||
e.preventDefault()
|
||||
switch (handle.key) {
|
||||
// 切换到当前标签页紧邻的上一个标签页
|
||||
case 'alt+left':
|
||||
if (tabbarStore.list[0].tabId !== activedTabId.value) {
|
||||
const index = tabbarStore.list.findIndex(item => item.tabId === activedTabId.value)
|
||||
router.push(tabbarStore.list[index - 1].fullPath)
|
||||
}
|
||||
break
|
||||
// 切换到当前标签页紧邻的下一个标签页
|
||||
case 'alt+right':
|
||||
if (tabbarStore.list.at(-1)?.tabId !== activedTabId.value) {
|
||||
const index = tabbarStore.list.findIndex(item => item.tabId === activedTabId.value)
|
||||
router.push(tabbarStore.list[index + 1].fullPath)
|
||||
}
|
||||
break
|
||||
// 关闭当前标签页
|
||||
case 'alt+w':
|
||||
tabbar.closeById(activedTabId.value)
|
||||
break
|
||||
// 切换到第 n 个标签页
|
||||
case 'alt+1':
|
||||
case 'alt+2':
|
||||
case 'alt+3':
|
||||
case 'alt+4':
|
||||
case 'alt+5':
|
||||
case 'alt+6':
|
||||
case 'alt+7':
|
||||
case 'alt+8':
|
||||
case 'alt+9':
|
||||
{
|
||||
const number = Number(handle.key.split('+')[1])
|
||||
tabbarStore.list[number - 1]?.fullPath && router.push(tabbarStore.list[number - 1].fullPath)
|
||||
break
|
||||
}
|
||||
// 切换到最后一个标签页
|
||||
case 'alt+0':
|
||||
router.push(tabbarStore.list[tabbarStore.list.length - 1].fullPath)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
onUnmounted(() => {
|
||||
hotkeys.unbind('alt+left,alt+right,alt+w,alt+1,alt+2,alt+3,alt+4,alt+5,alt+6,alt+7,alt+8,alt+9,alt+0')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tabbar-container">
|
||||
<div ref="tabsRef" class="tabs" @wheel.prevent="handlerMouserScroll">
|
||||
<TransitionGroup ref="tabContainerRef" name="tabbar" tag="div" class="tab-container">
|
||||
<div
|
||||
v-for="(element, index) in tabbarStore.list" :key="element.tabId"
|
||||
ref="tabRef" :data-index="index" class="tab" :class="{
|
||||
actived: element.tabId === activedTabId,
|
||||
}" :title="typeof element?.title === 'function' ? element.title() : element.title" @click="router.push(element.fullPath)" @contextmenu="onTabbarContextmenu($event, element)"
|
||||
>
|
||||
<div class="tab-dividers" />
|
||||
<div class="tab-background" />
|
||||
<div class="tab-content">
|
||||
<div :key="element.tabId" class="title">
|
||||
<SvgIcon v-if="settingsStore.settings.tabbar.enableIcon && element.icon" :name="element.icon" class="icon" />
|
||||
{{ typeof element?.title === 'function' ? element.title() : element.title }}
|
||||
</div>
|
||||
<div v-if="tabbarStore.list.length > 1" class="action-icon" @click.stop="tabbar.closeById(element.tabId)">
|
||||
<SvgIcon name="i-ri:close-fill" />
|
||||
</div>
|
||||
<div v-show="keys.alt && index < 9" class="hotkey-number">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.tabbar-contextmenu {
|
||||
z-index: 1000;
|
||||
|
||||
.mx-context-menu {
|
||||
--at-apply: fixed ring-1 ring-stone-2 dark-ring-stone-7 shadow-2xl;
|
||||
|
||||
background-color: var(--g-container-bg);
|
||||
|
||||
.mx-context-menu-items .mx-context-menu-item {
|
||||
--at-apply: transition-background-color;
|
||||
|
||||
&:not(.disabled):hover {
|
||||
--at-apply: cursor-pointer bg-stone-1 dark-bg-stone-9;
|
||||
}
|
||||
|
||||
span {
|
||||
color: initial;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: initial;
|
||||
}
|
||||
|
||||
&.disabled span,
|
||||
&.disabled .icon {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.mx-context-menu-item-sperator {
|
||||
background-color: var(--g-container-bg);
|
||||
|
||||
&::after {
|
||||
--at-apply: bg-stone-2 dark-bg-stone-7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tabbar-container {
|
||||
position: relative;
|
||||
height: var(--g-tabbar-height);
|
||||
background-color: var(--g-bg);
|
||||
transition: background-color 0.3s;
|
||||
|
||||
.tabs {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
// firefox隐藏滚动条
|
||||
scrollbar-width: none;
|
||||
|
||||
// chrome隐藏滚动条
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
display: inline-block;
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 150px;
|
||||
height: var(--g-tabbar-height);
|
||||
font-size: 14px;
|
||||
line-height: calc(var(--g-tabbar-height) - 2px);
|
||||
vertical-align: bottom;
|
||||
pointer-events: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:not(.actived):hover {
|
||||
z-index: 3;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
& + .tab .tab-dividers::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.title,
|
||||
.action-icon {
|
||||
color: var(--g-tabbar-tab-hover-color);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-background {
|
||||
background-color: var(--g-tabbar-tab-hover-bg);
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&.actived {
|
||||
z-index: 5;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
& + .tab .tab-dividers::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
.title,
|
||||
.action-icon {
|
||||
color: var(--g-tabbar-tab-active-color);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-background {
|
||||
background-color: var(--g-container-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-dividers {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -1px;
|
||||
left: -1px;
|
||||
z-index: 0;
|
||||
height: 14px;
|
||||
transform: translateY(-50%);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 1px;
|
||||
display: block;
|
||||
width: 1px;
|
||||
content: "";
|
||||
background-color: var(--g-tabbar-dividers-bg);
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease, background-color 0.3s;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child .tab-dividers::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.tab-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s, background-color 0.3s;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: all;
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
padding: 0 10px;
|
||||
margin-right: 10px;
|
||||
overflow: hidden;
|
||||
color: var(--g-tabbar-tab-color);
|
||||
white-space: nowrap;
|
||||
mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent);
|
||||
transition: margin-right 0.3s;
|
||||
|
||||
&:has(+ .action-icon) {
|
||||
margin-right: 28px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.5em;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
font-size: 12px;
|
||||
color: var(--g-tabbar-tab-color);
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
&:hover {
|
||||
--at-apply: ring-1 ring-stone-3 dark-ring-stone-7;
|
||||
|
||||
background-color: var(--g-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.hotkey-number {
|
||||
--at-apply: ring-1 ring-stone-3 dark-ring-stone-7;
|
||||
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.5em;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
font-size: 12px;
|
||||
color: var(--g-tabbar-tab-color);
|
||||
background-color: var(--g-bg);
|
||||
border-radius: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标签栏动画
|
||||
.tabs {
|
||||
.tabbar-move,
|
||||
.tabbar-enter-active,
|
||||
.tabbar-leave-active {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tabbar-enter-from,
|
||||
.tabbar-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.tabbar-leave-active {
|
||||
position: absolute !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { compile } from 'path-to-regexp'
|
||||
import Breadcrumb from '../../../Breadcrumb/index.vue'
|
||||
import BreadcrumbItem from '../../../Breadcrumb/item.vue'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const breadcrumbList = computed(() => {
|
||||
const breadcrumbList = []
|
||||
if (settingsStore.settings.home.enable) {
|
||||
breadcrumbList.push({
|
||||
path: settingsStore.settings.home.fullPath,
|
||||
title: settingsStore.settings.home.title,
|
||||
})
|
||||
}
|
||||
if (route.meta.breadcrumbNeste) {
|
||||
route.meta.breadcrumbNeste.forEach((item) => {
|
||||
if (item.hide === false) {
|
||||
breadcrumbList.push({
|
||||
path: item.path,
|
||||
title: item.title,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return breadcrumbList
|
||||
})
|
||||
|
||||
function pathCompile(path: string) {
|
||||
const toPath = compile(path)
|
||||
return toPath(route.params)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Breadcrumb v-if="settingsStore.mode === 'pc' && settingsStore.settings.app.routeBaseOn !== 'filesystem'" class="breadcrumb whitespace-nowrap px-2">
|
||||
<TransitionGroup name="breadcrumb">
|
||||
<BreadcrumbItem v-for="(item, index) in breadcrumbList" :key="`${index}_${item.path}_${item.title}`" :to="index < breadcrumbList.length - 1 && item.path !== '' ? pathCompile(item.path) : ''">
|
||||
{{ item.title }}
|
||||
</BreadcrumbItem>
|
||||
</TransitionGroup>
|
||||
</Breadcrumb>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// 面包屑动画
|
||||
.breadcrumb-enter-active {
|
||||
transition: transform 0.3s, opacity 0.3s;
|
||||
}
|
||||
|
||||
.breadcrumb-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(30px) skewX(-50deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
defineOptions({
|
||||
name: 'ColorScheme',
|
||||
})
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
function toggleColorScheme(event: MouseEvent) {
|
||||
const { startViewTransition } = useViewTransition(() => {
|
||||
settingsStore.currentColorScheme && settingsStore.setColorScheme(settingsStore.currentColorScheme === 'dark' ? 'light' : 'dark')
|
||||
})
|
||||
startViewTransition()?.ready.then(() => {
|
||||
const x = event.clientX
|
||||
const y = event.clientY
|
||||
const endRadius = Math.hypot(
|
||||
Math.max(x, innerWidth - x),
|
||||
Math.max(y, innerHeight - y),
|
||||
)
|
||||
const clipPath = [
|
||||
`circle(0px at ${x}px ${y}px)`,
|
||||
`circle(${endRadius}px at ${x}px ${y}px)`,
|
||||
]
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath: settingsStore.settings.app.colorScheme !== 'dark' ? clipPath : clipPath.reverse(),
|
||||
},
|
||||
{
|
||||
duration: 300,
|
||||
easing: 'ease-out',
|
||||
pseudoElement: settingsStore.settings.app.colorScheme !== 'dark' ? '::view-transition-new(root)' : '::view-transition-old(root)',
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HDropdown class="flex-center cursor-pointer px-2 py-1">
|
||||
<SvgIcon
|
||||
:name="{
|
||||
'': 'i-codicon:color-mode',
|
||||
'light': 'i-ri:sun-line',
|
||||
'dark': 'i-ri:moon-line',
|
||||
}[settingsStore.settings.app.colorScheme]" @click="toggleColorScheme"
|
||||
/>
|
||||
<template #dropdown>
|
||||
<HTabList
|
||||
v-model="settingsStore.settings.app.colorScheme"
|
||||
:options="[
|
||||
{ icon: 'i-ri:sun-line', label: '', value: 'light' },
|
||||
{ icon: 'i-ri:moon-line', label: '', value: 'dark' },
|
||||
{ icon: 'i-codicon:color-mode', label: '', value: '' },
|
||||
]"
|
||||
class="m-3"
|
||||
/>
|
||||
</template>
|
||||
</HDropdown>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { useFullscreen } from '@vueuse/core'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
defineOptions({
|
||||
name: 'Fullscreen',
|
||||
})
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const { isFullscreen, toggle } = useFullscreen()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="settingsStore.mode === 'pc'" class="flex-center cursor-pointer px-2 py-1" @click="toggle">
|
||||
<SvgIcon :name="isFullscreen ? 'i-ri:fullscreen-exit-line' : 'i-ri:fullscreen-line'" />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import eventBus from '@/utils/eventBus'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
defineOptions({
|
||||
name: 'ToolbarRightSide',
|
||||
})
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex-center cursor-pointer px-2 py-1" @click="eventBus.emit('global-search-toggle')">
|
||||
<SvgIcon v-if="settingsStore.mode === 'mobile'" name="i-ri:search-line" />
|
||||
<span v-else class="group inline-flex cursor-pointer items-center gap-1 whitespace-nowrap rounded-2 bg-stone-1 px-2 py-1.5 text-dark ring-stone-3 ring-inset transition dark-bg-stone-9 dark-text-white hover-ring-1 dark-ring-stone-7">
|
||||
<SvgIcon name="i-ri:search-line" />
|
||||
<span class="text-sm text-stone-5 transition group-hover-text-dark dark-group-hover-text-white">搜索</span>
|
||||
<HKbd v-if="settingsStore.settings.navSearch.enableHotkeys" class="ml-2">{{ settingsStore.os === 'mac' ? '⌥' : 'Alt' }} S</HKbd>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: 'PageReload',
|
||||
})
|
||||
|
||||
const mainPage = useMainPage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="flex-center cursor-pointer px-2 py-1" @click="mainPage.reload()">
|
||||
<SvgIcon name="i-iconoir:refresh-double" />
|
||||
</span>
|
||||
</template>
|
||||
30
src/admin/src/layouts/components/Topbar/Toolbar/index.vue
Executable file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import LeftSide from './leftSide.vue'
|
||||
import RightSide from './rightSide.vue'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
defineOptions({
|
||||
name: 'Toolbar',
|
||||
})
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toolbar-container flex items-center justify-between">
|
||||
<div class="h-full flex items-center of-hidden pl-2 pr-16" style="mask-image: linear-gradient(90deg, #000 0%, #000 calc(100% - 50px), transparent);">
|
||||
<LeftSide />
|
||||
</div>
|
||||
<div v-show="['side', 'single'].includes(settingsStore.settings.menu.menuMode)" class="h-full flex items-center px-2">
|
||||
<RightSide />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.toolbar-container {
|
||||
height: var(--g-toolbar-height);
|
||||
background-color: var(--g-container-bg);
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
</style>
|
||||
19
src/admin/src/layouts/components/Topbar/Toolbar/leftSide.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import Breadcrumb from './Breadcrumb/index.vue'
|
||||
import useSettingsStore from '@/store/modules/settings'
|
||||
|
||||
defineOptions({
|
||||
name: 'ToolbarLeftSide',
|
||||
})
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<div v-if="settingsStore.mode === 'mobile'" class="flex-center cursor-pointer px-2 py-1 -rotate-z-180" @click="settingsStore.toggleSidebarCollapse()">
|
||||
<SvgIcon name="toolbar-collapse" />
|
||||
</div>
|
||||
<Breadcrumb v-if="settingsStore.settings.toolbar.breadcrumb" />
|
||||
</div>
|
||||
</template>
|
||||