v-4.0.0 开源可二开源码

This commit is contained in:
vastxie
2024-12-12 23:10:51 +08:00
parent c831009379
commit 22ee5a71b2
1017 changed files with 69316 additions and 9248 deletions

15
src/.editorconfig Normal file
View 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
View 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
View File

@@ -0,0 +1,7 @@
node_modules
service/.env
test
AIWebQuickDeploy
service/public
service/public
.env

8
src/.prettierrc Normal file
View 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
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "dbaeumer.vscode-eslint"]
}

98
src/.vscode/settings.json vendored Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,7 @@
node_modules
.DS_Store
dist*
dist-ssr
*.local
.eslintcache
.stylelintcache

4
src/admin/.lintstagedrc Executable file
View File

@@ -0,0 +1,4 @@
{
"*.{ts,tsx,vue}": "eslint --cache --fix",
"*.{css,scss,vue}": "stylelint --cache --fix"
}

1
src/admin/.node-version Normal file
View File

@@ -0,0 +1 @@
18

3
src/admin/.npmrc Executable file
View File

@@ -0,0 +1,3 @@
shamefully-hoist=true
strict-peer-dependencies=false
engine-strict=true

10
src/admin/.vscode/extensions.json vendored Executable file
View 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
View 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
View 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
View 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"
}
}
}

View 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>

View 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
},
}

View 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,
},
}
},
},
]

View 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
},
}

View 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>

View 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
},
}

View 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

View 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
View 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
View File

@@ -0,0 +1,5 @@
export default {
plugins: {
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

92
src/admin/public/loading.css Executable file
View 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: "…";
}

View 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
View 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
View 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;

View 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),
};

View 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),
}

View 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),
}

View 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),
};

View 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),
};

View 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;

View 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),
};

View 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'),
};

View 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),
};

View 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),
};

View 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 }),
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path d="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

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200.781" height="200" class="icon" viewBox="0 0 1028 1024"><path d="M989.867 234.667H499.2c-17.067 0-34.133-21.334-34.133-42.667 0-25.6 12.8-42.667 34.133-42.667h490.667c17.066 0 34.133 17.067 34.133 42.667 0 21.333-12.8 42.667-34.133 42.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

View 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;
}

View 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); }
}

View File

@@ -0,0 +1,53 @@
// 文字超出隐藏,默认为单行超出隐藏,可设置多行
@mixin text-overflow($line: 1, $fixed-width: true) {
@if $line == 1 and $fixed-width == true {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} @else {
/* stylelint-disable-next-line value-no-vendor-prefix */
display: -webkit-box;
-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: "";
}
}

View File

@@ -0,0 +1 @@
// 全局变量

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because one or more lines are too long

View 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>

View 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>

View 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>

View 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>

View 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)));
}

View 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: '绘画积分' },
];

File diff suppressed because one or more lines are too long

View 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
View File

@@ -0,0 +1,9 @@
import { addCollection } from '@iconify/vue'
import data from './data.json'
export async function downloadAndInstall(name: string) {
const data = Object.freeze(await fetch(`./icons/${name}-raw.json`).then(r => r.json()))
addCollection(data)
}
export const icons = data.sort((a, b) => a.info.name.localeCompare(b.info.name))

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router'
const props = withDefaults(
defineProps<{
to?: RouteLocationRaw
replace?: boolean
separator?: string
}>(),
{
separator: '/',
},
)
const router = useRouter()
function onClick() {
if (props.to) {
props.replace ? router.replace(props.to) : router.push(props.to)
}
}
</script>
<template>
<div class="breadcrumb-item 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
}

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

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