feat: init
This commit is contained in:
parent
5d47adf11c
commit
16cc780576
82 changed files with 10967 additions and 0 deletions
1
web/.env.development
Normal file
1
web/.env.development
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_FORCE_MOCK = true // 是否强制开启 Mock
|
||||||
2
web/.env.production
Normal file
2
web/.env.production
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
REPORT = true
|
||||||
|
VITE_FORCE_MOCK = true // 是否强制开启 Mock
|
||||||
34
web/.github/workflows/main.yml
vendored
Normal file
34
web/.github/workflows/main.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node-version: [18]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 7.18.2
|
||||||
|
run_install: true
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: pnpm i
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
uses: JamesIves/github-pages-deploy-action@releases/v4
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.ACCESS_TOKEN }}
|
||||||
|
folder: dist
|
||||||
31
web/.gitignore
vendored
Normal file
31
web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
auto-imports.d.ts
|
||||||
|
components.d.ts
|
||||||
|
.eslintrc-auto-import.json
|
||||||
42
web/.vscode/settings.json
vendored
Normal file
42
web/.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"prettier.enable": false,
|
||||||
|
// Enable the ESlint flat config support
|
||||||
|
"eslint.experimental.useFlatConfig": true,
|
||||||
|
|
||||||
|
// Disable the default formatter, use eslint instead
|
||||||
|
"editor.formatOnSave": false,
|
||||||
|
|
||||||
|
// Auto fix
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": true,
|
||||||
|
"source.organizeImports": false
|
||||||
|
},
|
||||||
|
|
||||||
|
// Silent the stylistic rules in you IDE, but still auto fix them
|
||||||
|
// "eslint.rules.customizations": [
|
||||||
|
// { "rule": "style/*", "severity": "off" },
|
||||||
|
// { "rule": "*-indent", "severity": "off" },
|
||||||
|
// { "rule": "*-spacing", "severity": "off" },
|
||||||
|
// { "rule": "*-spaces", "severity": "off" },
|
||||||
|
// { "rule": "*-order", "severity": "off" },
|
||||||
|
// { "rule": "*-dangle", "severity": "off" },
|
||||||
|
// { "rule": "*-newline", "severity": "off" },
|
||||||
|
// { "rule": "*quotes", "severity": "off" },
|
||||||
|
// { "rule": "*semi", "severity": "off" }
|
||||||
|
// ],
|
||||||
|
|
||||||
|
// Enable eslint for all supported languages
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"javascriptreact",
|
||||||
|
"typescript",
|
||||||
|
"typescriptreact",
|
||||||
|
"vue",
|
||||||
|
"html",
|
||||||
|
"markdown",
|
||||||
|
"json",
|
||||||
|
"jsonc",
|
||||||
|
"yaml"
|
||||||
|
]
|
||||||
|
}
|
||||||
21
web/LICENSE
Normal file
21
web/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023
|
||||||
|
|
||||||
|
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 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.
|
||||||
0
web/README-zh_CN.md
Normal file
0
web/README-zh_CN.md
Normal file
0
web/README.md
Normal file
0
web/README.md
Normal file
30
web/config/plugin/compress.ts
Normal file
30
web/config/plugin/compress.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* Used to package and output gzip. Note that this does not work properly in Vite, the specific reason is still being investigated
|
||||||
|
* gzip压缩
|
||||||
|
* https://github.com/anncwb/vite-plugin-compression
|
||||||
|
*/
|
||||||
|
import type { Plugin } from 'vite';
|
||||||
|
import compressPlugin from 'vite-plugin-compression';
|
||||||
|
|
||||||
|
export default function configCompressPlugin(
|
||||||
|
compress: 'gzip' | 'brotli',
|
||||||
|
deleteOriginFile = false,
|
||||||
|
): Plugin | Plugin[] {
|
||||||
|
const plugins: Plugin[] = [];
|
||||||
|
|
||||||
|
if (compress === 'gzip') {
|
||||||
|
plugins.push(compressPlugin({
|
||||||
|
ext: '.gz',
|
||||||
|
deleteOriginFile,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compress === 'brotli') {
|
||||||
|
plugins.push(compressPlugin({
|
||||||
|
ext: '.br',
|
||||||
|
algorithm: 'brotliCompress',
|
||||||
|
deleteOriginFile,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
37
web/config/plugin/imagemin.ts
Normal file
37
web/config/plugin/imagemin.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* Image resource files used to compress the output of the production environment
|
||||||
|
* 图片压缩
|
||||||
|
* https://github.com/anncwb/vite-plugin-imagemin
|
||||||
|
*/
|
||||||
|
import viteImagemin from 'vite-plugin-imagemin';
|
||||||
|
|
||||||
|
export default function configImageminPlugin() {
|
||||||
|
const imageminPlugin = viteImagemin({
|
||||||
|
gifsicle: {
|
||||||
|
optimizationLevel: 7,
|
||||||
|
interlaced: false,
|
||||||
|
},
|
||||||
|
optipng: {
|
||||||
|
optimizationLevel: 7,
|
||||||
|
},
|
||||||
|
mozjpeg: {
|
||||||
|
quality: 20,
|
||||||
|
},
|
||||||
|
pngquant: {
|
||||||
|
quality: [0.8, 0.9],
|
||||||
|
speed: 4,
|
||||||
|
},
|
||||||
|
svgo: {
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: 'removeViewBox',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'removeEmptyAttrs',
|
||||||
|
active: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return imageminPlugin;
|
||||||
|
}
|
||||||
18
web/config/plugin/visualizer.ts
Normal file
18
web/config/plugin/visualizer.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* Generation packaging analysis
|
||||||
|
* 生成打包分析
|
||||||
|
*/
|
||||||
|
import visualizer from 'rollup-plugin-visualizer';
|
||||||
|
import { isReportMode } from '../utils';
|
||||||
|
|
||||||
|
export default function configVisualizerPlugin() {
|
||||||
|
if (isReportMode()) {
|
||||||
|
return visualizer({
|
||||||
|
filename: './node_modules/.cache/visualizer/stats.html',
|
||||||
|
open: true,
|
||||||
|
gzipSize: true,
|
||||||
|
brotliSize: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
9
web/config/utils/index.ts
Normal file
9
web/config/utils/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* Whether to generate package preview
|
||||||
|
* 是否生成打包报告
|
||||||
|
*/
|
||||||
|
export default {};
|
||||||
|
|
||||||
|
export function isReportMode(): boolean {
|
||||||
|
return process.env.REPORT === 'true';
|
||||||
|
}
|
||||||
108
web/config/vite.config.base.ts
Normal file
108
web/config/vite.config.base.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { URL, fileURLToPath } from 'node:url';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import vueJsx from '@vitejs/plugin-vue-jsx';
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite';
|
||||||
|
import Components from 'unplugin-vue-components/vite';
|
||||||
|
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
|
||||||
|
import UnoCSS from 'unocss/vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueJsx(),
|
||||||
|
AutoImport({
|
||||||
|
eslintrc: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
/\.[tj]sx?$/, // .ts, .tsx, .js, .jsx
|
||||||
|
/\.vue$/, /\.vue\?vue/, // .vue
|
||||||
|
],
|
||||||
|
imports: [
|
||||||
|
'vue',
|
||||||
|
'vue-router',
|
||||||
|
'@vueuse/core',
|
||||||
|
{
|
||||||
|
'axios': [
|
||||||
|
['default', 'axios'],
|
||||||
|
],
|
||||||
|
'@vueuse/integrations/useAxios': [
|
||||||
|
'useAxios',
|
||||||
|
],
|
||||||
|
'ant-design-vue': ['message'],
|
||||||
|
'nprogress': [
|
||||||
|
['default', 'NProgress'],
|
||||||
|
],
|
||||||
|
'mitt': [
|
||||||
|
['default', 'mitt'],
|
||||||
|
],
|
||||||
|
'mockjs': [
|
||||||
|
['default', 'Mock'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'vue-router',
|
||||||
|
imports: ['LocationQueryRaw', 'Router', 'RouteLocationNormalized', 'RouteRecordRaw', 'RouteRecordNormalized', 'RouteLocationRaw'],
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'mitt',
|
||||||
|
imports: ['Handler'],
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: 'axios',
|
||||||
|
imports: ['RawAxiosRequestConfig'],
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dirs: [
|
||||||
|
'./src/utils',
|
||||||
|
'./src/components',
|
||||||
|
'./src/hooks',
|
||||||
|
'./src/store',
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
dts: true,
|
||||||
|
resolvers: [
|
||||||
|
AntDesignVueResolver({
|
||||||
|
importStyle: false,
|
||||||
|
resolveIcons: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
UnoCSS(),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('../src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
'process.env': {},
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
less: {
|
||||||
|
// DO NOT REMOVE THIS LINE
|
||||||
|
javascriptEnabled: true,
|
||||||
|
modifyVars: {
|
||||||
|
// hack: `true; @import 'ant-design-vue/dist/antd.variable.less'`,
|
||||||
|
// '@primary-color': '#eb2f96', // 全局主色
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: [
|
||||||
|
'@ant-design/icons-vue',
|
||||||
|
'ant-design-vue',
|
||||||
|
'@ant-design-vue/pro-layout',
|
||||||
|
'ant-design-vue/es',
|
||||||
|
'vue',
|
||||||
|
'vue-router',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
12
web/config/vite.config.dev.ts
Normal file
12
web/config/vite.config.dev.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { mergeConfig } from 'vite';
|
||||||
|
import baseConfig from './vite.config.base';
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
{
|
||||||
|
mode: 'development',
|
||||||
|
server: {
|
||||||
|
open: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
baseConfig,
|
||||||
|
);
|
||||||
25
web/config/vite.config.prod.ts
Normal file
25
web/config/vite.config.prod.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { mergeConfig } from 'vite';
|
||||||
|
import baseConfig from './vite.config.base';
|
||||||
|
import configCompressPlugin from './plugin/compress';
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
{
|
||||||
|
mode: 'production',
|
||||||
|
base: '/docgpt',
|
||||||
|
plugins: [
|
||||||
|
configCompressPlugin('gzip'),
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
ant: ['ant-design-vue', '@ant-design-vue/pro-layout'],
|
||||||
|
vue: ['vue', 'vue-router', 'pinia', '@vueuse/core'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chunkSizeWarningLimit: 2000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
baseConfig,
|
||||||
|
);
|
||||||
6
web/env.d.ts
vendored
Normal file
6
web/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pages/client" />
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
readonly PKG: Record<string, string>
|
||||||
|
}
|
||||||
14
web/eslint.config.js
Normal file
14
web/eslint.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
const chuhoman = require('@chuhoman/eslint-config').default;
|
||||||
|
|
||||||
|
module.exports = chuhoman(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'no-console': 'off',
|
||||||
|
'prefer-regex-literals': 'off',
|
||||||
|
'@typescript-eslint/consistent-type-assertions': 'off',
|
||||||
|
'no-undef': 'off',
|
||||||
|
'ts/type-annotation-spacing': ['error', {}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
152
web/index.html
Normal file
152
web/index.html
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="shortcut icon" type="image/x-icon" href="//aliyuncdn.antdv.com/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>docgpt</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<style>
|
||||||
|
html[data-theme='dark'] .app-loading {
|
||||||
|
background-color: #2c344a;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme='dark'] .app-loading .app-loading-title {
|
||||||
|
color: rgb(255 255 255 / 85%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #f4f7f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading .app-loading-wrap {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
transform: translate3d(-50%, -50%, 0);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading .dots {
|
||||||
|
display: flex;
|
||||||
|
padding: 98px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading .app-loading-title {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 30px;
|
||||||
|
color: rgb(0 0 0 / 85%);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-loading .app-loading-logo {
|
||||||
|
display: block;
|
||||||
|
width: 90px;
|
||||||
|
margin: 0 auto;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 32px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
box-sizing: border-box;
|
||||||
|
animation: antRotate 1.2s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot i {
|
||||||
|
position: absolute;
|
||||||
|
display: block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: #0065cc;
|
||||||
|
border-radius: 100%;
|
||||||
|
opacity: 30%;
|
||||||
|
transform: scale(0.75);
|
||||||
|
animation: antSpinMove 1s infinite linear alternate;
|
||||||
|
transform-origin: 50% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot i:nth-child(1) {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot i:nth-child(2) {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot i:nth-child(3) {
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
animation-delay: 0.8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot i:nth-child(4) {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
animation-delay: 1.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes antRotate {
|
||||||
|
to {
|
||||||
|
transform: rotate(405deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes antRotate {
|
||||||
|
to {
|
||||||
|
transform: rotate(405deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes antSpinMove {
|
||||||
|
to {
|
||||||
|
opacity: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes antSpinMove {
|
||||||
|
to {
|
||||||
|
opacity: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="app-loading">
|
||||||
|
<div class="app-loading-wrap">
|
||||||
|
<img src="https://alicdn.antdv.com/v2/assets/logo.1ef800a8.svg">
|
||||||
|
<div class="app-loading-dots">
|
||||||
|
<span class="dot dot-spin"><i></i><i></i><i></i><i></i></span>
|
||||||
|
</div>
|
||||||
|
<div class="app-loading-title">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
63
web/package.json
Normal file
63
web/package.json
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
{
|
||||||
|
"name": "docgpt",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": [
|
||||||
|
"vue3",
|
||||||
|
"ant-design-vue",
|
||||||
|
"ant-design-pro",
|
||||||
|
"admin",
|
||||||
|
"template-project"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --config ./config/vite.config.dev.ts",
|
||||||
|
"build": "vite build --config ./config/vite.config.prod.ts",
|
||||||
|
"preview": "npm run build && vite preview --host",
|
||||||
|
"typecheck": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design-vue/pro-layout": "^3.2.4",
|
||||||
|
"@ant-design/icons-vue": "^6.1.0",
|
||||||
|
"@formily/core": "^2.2.29",
|
||||||
|
"@formily/vue": "^2.2.29",
|
||||||
|
"@vueuse/core": "^10.1.2",
|
||||||
|
"@vueuse/integrations": "^10.1.2",
|
||||||
|
"ant-design-vue": "4.0.7",
|
||||||
|
"axios": "^1.4.0",
|
||||||
|
"dayjs": "^1.11.9",
|
||||||
|
"echarts": "^5.4.3",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"mitt": "^3.0.0",
|
||||||
|
"nprogress": "^0.2.0",
|
||||||
|
"pinia": "^2.1.3",
|
||||||
|
"vue": "^3.2.35",
|
||||||
|
"vue-router": "^4.2.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@chuhoman/eslint-config": "^1.0.2",
|
||||||
|
"@rushstack/eslint-patch": "^1.2.0",
|
||||||
|
"@types/lodash-es": "^4.17.7",
|
||||||
|
"@types/mockjs": "^1.0.7",
|
||||||
|
"@types/node": "^17.0.43",
|
||||||
|
"@types/nprogress": "^0.2.0",
|
||||||
|
"@vitejs/plugin-vue": "^4.2.0",
|
||||||
|
"@vitejs/plugin-vue-jsx": "^3.0.1",
|
||||||
|
"eslint": "^8.53.0",
|
||||||
|
"less": "^4.1.3",
|
||||||
|
"mockjs": "^1.1.0",
|
||||||
|
"rollup-plugin-visualizer": "^5.9.0",
|
||||||
|
"typescript": "~4.5.5",
|
||||||
|
"unocss": "^0.57.7",
|
||||||
|
"unplugin-auto-import": "^0.15.0",
|
||||||
|
"unplugin-vue-components": "^0.25.2",
|
||||||
|
"vite": "^4.3.5",
|
||||||
|
"vite-plugin-compression": "^0.5.1",
|
||||||
|
"vite-plugin-imagemin": "^0.6.1",
|
||||||
|
"vue-tsc": "^1.0.24"
|
||||||
|
}
|
||||||
|
}
|
||||||
7975
web/pnpm-lock.yaml
generated
Normal file
7975
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
34
web/public/favicon.svg
Normal file
34
web/public/favicon.svg
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
|
||||||
|
<title>Vue Pro Components</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="linearGradient-2" y2="157.637507%" x2="138.57919%" y1="-36.7931464%" x1="-19.8191553%">
|
||||||
|
<stop offset="0%" stop-color="#008CFF"/>
|
||||||
|
<stop offset="100%" stop-color="#0063FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="linearGradient-3" y2="114.942679%" x2="30.4400914%" y1="-35.6905737%" x1="68.1279872%">
|
||||||
|
<stop offset="0%" stop-color="#FF8384"/>
|
||||||
|
<stop offset="100%" stop-color="#FF4553"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="svg_1" y2="100%" x2="NaN" y1="0%" x1="NaN">
|
||||||
|
<stop offset="0%" stop-color="#0E89FF"/>
|
||||||
|
<stop offset="58.999999%" stop-color="#04b4ff"/>
|
||||||
|
<stop offset="100%" stop-color="#046fd6"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<g>
|
||||||
|
<title>background</title>
|
||||||
|
<rect fill="none" id="canvas_background" height="402" width="582" y="-1" x="-1"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<title>Layer 1</title>
|
||||||
|
<g fill-rule="evenodd" fill="none" id="Vue">
|
||||||
|
<g id="Group">
|
||||||
|
<path fill-rule="nonzero" fill="url(#svg_1)" id="Path-Copy" d="m41.870002,99.48c11.38,3 21.63,-7.12 22.34,-8l20.95955,-20.25077c1.15807,-1.11891 1.81777,-2.65635 1.83075,-4.26659l0.32614,-40.44165c0.00867,-1.07512 0.44979,-2.10148 1.22387,-2.84764l13.63369,-13.14181c1.59052,-1.53315 4.12276,-1.48663 5.6559,0.1039c0.71859,0.74548 1.1201,1.74057 1.1201,2.776l0,59.97504c0,2.69431 -1.08722,5.27463 -3.01541,7.15649l-37.71505,36.80909c-2.34617,2.28981 -6.0959,2.27255 -8.42088,-0.03878"/>
|
||||||
|
<path fill-rule="nonzero" fill="url(#linearGradient-2)" id="Path" d="m87,99.11631c-11.38,3 -22.54,-6.75631 -23.25,-7.63631l-20.95955,-20.25077c-1.15807,-1.11891 -1.81777,-2.65635 -1.83075,-4.26659l-0.32614,-40.44165c-0.00867,-1.07512 -0.44979,-2.10148 -1.22387,-2.84764l-13.63369,-13.14181c-1.59052,-1.53315 -4.12276,-1.48663 -5.6559,0.1039c-0.71859,0.74548 -1.1201,1.74057 -1.1201,2.776l0,59.97504c0,2.69431 1.08722,5.27463 3.01541,7.15649l37.7653,36.85813c2.32622,2.27034 6.03731,2.27542 8.36973,0.01146"/>
|
||||||
|
<path fill="url(#linearGradient-3)" id="Path" d="m62.29835,43.587129l-15.74174,-15.21673c-0.79418,-0.76769 -0.81565,-2.03384 -0.04796,-2.82802c0.37683,-0.38983 0.89579,-0.60997 1.43799,-0.60997l31.44586,0c1.10457,0 2,0.89543 2,2c0,0.54137 -0.21946,1.05961 -0.60825,1.43633l-15.70412,15.21673c-0.77497,0.75092 -2.00591,0.75165 -2.78178,0.00166z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
18
web/src/App.vue
Normal file
18
web/src/App.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ConfigProvider, StyleProvider } from 'ant-design-vue';
|
||||||
|
import zhCN from 'ant-design-vue/es/locale/zh_CN';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
import { useUserTheme } from './hooks/useTheme';
|
||||||
|
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
useUserTheme();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<style-provider hash-priority="high">
|
||||||
|
<config-provider :locale="zhCN">
|
||||||
|
<router-view />
|
||||||
|
</config-provider>
|
||||||
|
</style-provider>
|
||||||
|
</template>
|
||||||
2
web/src/api/index.ts
Normal file
2
web/src/api/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// all api
|
||||||
|
export * from './user';
|
||||||
36
web/src/api/user.ts
Normal file
36
web/src/api/user.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { RouteRecordNormalized } from 'vue-router';
|
||||||
|
import { UserState } from '@/store/modules/user/types';
|
||||||
|
|
||||||
|
export interface LoginData {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRes {
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
export function login(data: LoginData) {
|
||||||
|
return useAxiosApi<LoginRes>('/api/user/login', {
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
return useAxiosApi<LoginRes>('/api/user/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserInfo() {
|
||||||
|
return useAxiosApi<UserState>('/api/user/info', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMenuList() {
|
||||||
|
return useAxiosApi<RouteRecordNormalized[]>('/api/user/menu', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
1
web/src/assets/images/cloud_download.svg
Normal file
1
web/src/assets/images/cloud_download.svg
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1702526924526" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1488" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M1024 640.192C1024 782.912 919.872 896 787.648 896h-512C123.904 896 0 761.6 0 597.504 0 451.968 94.656 331.52 226.432 302.976 284.16 195.456 391.808 128 512 128c152.32 0 282.112 108.416 323.392 261.12C941.888 413.44 1024 519.04 1024 640.192zM341.312 570.176L512 756.48l170.688-186.24H341.312z m213.376 0v-256H469.312v256h85.376z" fill="#bfbfbf" p-id="1489"></path></svg>
|
||||||
|
After Width: | Height: | Size: 703 B |
BIN
web/src/assets/images/login-banner.png
Normal file
BIN
web/src/assets/images/login-banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
web/src/assets/logo.png
Normal file
BIN
web/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
12
web/src/components/base-chart/README.md
Normal file
12
web/src/components/base-chart/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
# base-chart
|
||||||
|
基于[echarts](https://echarts.apache.org/zh/index.html)@5.X
|
||||||
|
|
||||||
|
## Props
|
||||||
|
| 参数 | 说明 | 类型 | 默认值 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| option | 对应echarts的option,数据更新触发渲染 | Object | null |
|
||||||
|
|
||||||
|
## Event
|
||||||
|
| 事件 | 参数 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| setOption | (option) | 手动调用触发更新渲染,比Props性能更优|
|
||||||
40
web/src/components/base-chart/echarts.ts
Normal file
40
web/src/components/base-chart/echarts.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import * as echarts from 'echarts/core';
|
||||||
|
import {
|
||||||
|
GridComponent,
|
||||||
|
GridComponentOption,
|
||||||
|
LegendComponent,
|
||||||
|
LegendComponentOption,
|
||||||
|
TitleComponent,
|
||||||
|
TitleComponentOption,
|
||||||
|
TooltipComponent,
|
||||||
|
TooltipComponentOption,
|
||||||
|
} from 'echarts/components';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
BarSeriesOption,
|
||||||
|
LineChart,
|
||||||
|
LineSeriesOption,
|
||||||
|
PieChart,
|
||||||
|
PieSeriesOption,
|
||||||
|
} from 'echarts/charts';
|
||||||
|
import { LabelLayout, UniversalTransition } from 'echarts/features';
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers';
|
||||||
|
|
||||||
|
echarts.use([
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
GridComponent,
|
||||||
|
PieChart,
|
||||||
|
LineChart,
|
||||||
|
CanvasRenderer,
|
||||||
|
LabelLayout,
|
||||||
|
UniversalTransition,
|
||||||
|
BarChart,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type EChartsOption = echarts.ComposeOption<
|
||||||
|
TooltipComponentOption | TitleComponentOption | LegendComponentOption | PieSeriesOption | LineSeriesOption | GridComponentOption | BarSeriesOption
|
||||||
|
>;
|
||||||
|
|
||||||
|
export default echarts;
|
||||||
60
web/src/components/base-chart/index.vue
Normal file
60
web/src/components/base-chart/index.vue
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { throttle } from 'lodash-es';
|
||||||
|
import echarts, { type EChartsOption } from './echarts';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
option: {
|
||||||
|
type: Object || null,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let chart: echarts.ECharts | null = null;
|
||||||
|
const chartRef = ref();
|
||||||
|
|
||||||
|
function setOption(option: EChartsOption) {
|
||||||
|
chart?.setOption(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
return throttle(() => {
|
||||||
|
chart?.resize();
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
resize()();
|
||||||
|
});
|
||||||
|
onMounted(() => {
|
||||||
|
chart = echarts.init(chartRef.value);
|
||||||
|
if (props.option) {
|
||||||
|
setOption(props.option);
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver.observe(chartRef?.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver.unobserve(chartRef?.value);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
chart?.dispose();
|
||||||
|
chart = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.option,
|
||||||
|
(val) => {
|
||||||
|
setOption(val);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
setOption,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="w-full h-full" />
|
||||||
|
</template>
|
||||||
0
web/src/components/base-table/README.md
Normal file
0
web/src/components/base-table/README.md
Normal file
7
web/src/components/base-table/constants.ts
Normal file
7
web/src/components/base-table/constants.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { Input, RangePicker, Select } from 'ant-design-vue';
|
||||||
|
|
||||||
|
export const componentsMap: Record<string, any> = {
|
||||||
|
input: Input,
|
||||||
|
rangePicker: RangePicker,
|
||||||
|
select: Select,
|
||||||
|
};
|
||||||
242
web/src/components/base-table/index.vue
Normal file
242
web/src/components/base-table/index.vue
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, ref, unref, useAttrs } from 'vue';
|
||||||
|
import { createForm, onFormValuesChange, setValidateLanguage } from '@formily/core';
|
||||||
|
import { Form, FormItemProps, TableProps, message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FormProvider,
|
||||||
|
connect,
|
||||||
|
mapProps,
|
||||||
|
} from '@formily/vue';
|
||||||
|
import type { PaginationProps } from 'ant-design-vue';
|
||||||
|
import { assign, isUndefined } from 'lodash-es';
|
||||||
|
import type { IQueryParams, ISearchColumn } from './types';
|
||||||
|
import { componentsMap } from './constants';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
hasCustomFields?: boolean
|
||||||
|
searchColumn?: Array<ISearchColumn>
|
||||||
|
pagination?: PaginationProps
|
||||||
|
searchLabelCol?: FormItemProps['labelCol']
|
||||||
|
request?(searchValues: IQueryParams['searchValues'],
|
||||||
|
pagination: IQueryParams['pagination'],
|
||||||
|
filters: IQueryParams['filters'],
|
||||||
|
sorter: IQueryParams['sorter'],): Promise<any>
|
||||||
|
}>(), {
|
||||||
|
searchColumn: () => {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
tabOption: () => {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const emits = defineEmits(['reset', 'search', 'tabChange', 'searchFormChange']);
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
setValidateLanguage('cn');
|
||||||
|
|
||||||
|
const FormItem = connect(
|
||||||
|
Form.Item,
|
||||||
|
mapProps({
|
||||||
|
validateStatus: true,
|
||||||
|
title: 'label',
|
||||||
|
required: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const form = createForm({
|
||||||
|
effects() {
|
||||||
|
onFormValuesChange((form) => {
|
||||||
|
emits('searchFormChange', form);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const expand = ref(false);
|
||||||
|
const searchColumn = computed(() => {
|
||||||
|
return props.searchColumn;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表格相关
|
||||||
|
const tableRef = ref();
|
||||||
|
const attrs = useAttrs() as unknown as TableProps;
|
||||||
|
const pageIndex = ref(1);
|
||||||
|
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||||
|
const pageSize = ref(props.pagination?.defaultPageSize || 10);
|
||||||
|
const total = ref(0);
|
||||||
|
const filterValue = ref<Record<string, any>>();
|
||||||
|
const sortValue = ref<Record<string, any>>();
|
||||||
|
const finalPagination = computed(() => ({
|
||||||
|
total: total.value,
|
||||||
|
current: pageIndex.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
position: ['bottomRight'],
|
||||||
|
showTotal: (total: number, range: Array<number>) => `第 ${range[0]}-${range[1]} 条/总共 ${total} 条`,
|
||||||
|
showSizeChanger: true,
|
||||||
|
...props.pagination,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function finalHandleTableChange(pagination: IQueryParams['pagination'], filters: IQueryParams['filters'], sorter: IQueryParams['sorter'], source: IQueryParams['source']) {
|
||||||
|
filterValue.value = filters;
|
||||||
|
sortValue.value = sorter;
|
||||||
|
if (attrs?.onChange) {
|
||||||
|
pageSize.value = pagination.pageSize!;
|
||||||
|
pageIndex.value = pagination.current!;
|
||||||
|
attrs?.onChange(pagination, filters, sorter, source);
|
||||||
|
} else {
|
||||||
|
query(pagination, filters, sorter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableLoading = ref(false);
|
||||||
|
const dataSource = ref<Array<any>>([]);
|
||||||
|
const tableRequest = computed(() => {
|
||||||
|
return props.request;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function query(pagination?: IQueryParams['pagination'], filters?: IQueryParams['filters'], sorter?: IQueryParams['sorter']) {
|
||||||
|
tableLoading.value = true;
|
||||||
|
|
||||||
|
if (pagination) {
|
||||||
|
pageSize.value = pagination.pageSize!;
|
||||||
|
pageIndex.value = pagination.current!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = unref(tableRequest)!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await req(form.values, {
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
current: pageIndex.value,
|
||||||
|
}, filters!, sorter!);
|
||||||
|
const { result, page } = unref(data);
|
||||||
|
dataSource.value = result;
|
||||||
|
total.value = page.total;
|
||||||
|
pageIndex.value = page.current;
|
||||||
|
tableLoading.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('error---', error);
|
||||||
|
message.error('fail to fetch table');
|
||||||
|
tableLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
form.reset();
|
||||||
|
query();
|
||||||
|
emits('reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
query({
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
current: 1,
|
||||||
|
});
|
||||||
|
emits('search');
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload(params: IQueryParams['pagination']) {
|
||||||
|
query(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalAttrs = computed(() => {
|
||||||
|
return {
|
||||||
|
...attrs,
|
||||||
|
onChange: finalHandleTableChange,
|
||||||
|
pagination: finalPagination,
|
||||||
|
dataSource: dataSource.value,
|
||||||
|
loading: isUndefined(attrs.loading) ? tableLoading : attrs.loading,
|
||||||
|
scroll: assign({ y: 800 }, attrs?.scroll || {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
search,
|
||||||
|
reload,
|
||||||
|
getTableValue: () => {
|
||||||
|
return {
|
||||||
|
pagination: {
|
||||||
|
current: pageIndex.value,
|
||||||
|
size: pageSize.value,
|
||||||
|
},
|
||||||
|
searchValue: form.values,
|
||||||
|
filters: filterValue.value,
|
||||||
|
sorter: sortValue.value,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getSearchForm: () => {
|
||||||
|
return form;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
query();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="base-table-wrap">
|
||||||
|
<div
|
||||||
|
v-if="searchColumn.length > 0 || hasCustomFields"
|
||||||
|
class="base-table-search"
|
||||||
|
>
|
||||||
|
<form-provider :form="form">
|
||||||
|
<a-row :gutter="24" type="flex">
|
||||||
|
<template v-for="item of searchColumn" :key="item.name">
|
||||||
|
<a-col
|
||||||
|
v-show="expand || !item?.hide"
|
||||||
|
:span="4"
|
||||||
|
>
|
||||||
|
<field
|
||||||
|
:name="item.name"
|
||||||
|
:title="item?.title || ''"
|
||||||
|
:decorator="[FormItem]"
|
||||||
|
:initial-value="item.initialValue"
|
||||||
|
:display="item.display"
|
||||||
|
:component="[componentsMap[item.type], { ...item.props }]"
|
||||||
|
:reactions="item.reactions"
|
||||||
|
/>
|
||||||
|
</a-col>
|
||||||
|
</template>
|
||||||
|
<slot name="custom-fields" />
|
||||||
|
<a-col v-if="searchColumn.length > 0">
|
||||||
|
<div class="flex" :class="{ 'justify-end': searchColumn.length > 2 }">
|
||||||
|
<a-space>
|
||||||
|
<a-button
|
||||||
|
:disabled="tableLoading"
|
||||||
|
@click="reset"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" :loading="tableLoading" @click="search">
|
||||||
|
查询
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</form-provider>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a-table v-bind="finalAttrs" ref="tableRef" :loading="tableLoading">
|
||||||
|
<template #title>
|
||||||
|
<div class="base-table-opt">
|
||||||
|
<slot name="operation" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #headerCell="{ column, record, index }">
|
||||||
|
<slot :column="column" :record="record" :index="index" name="headerCell" />
|
||||||
|
</template>
|
||||||
|
<template #bodyCell="{ column, record, index }">
|
||||||
|
<slot :column="column" :record="record" :index="index" name="bodyCell" />
|
||||||
|
</template>
|
||||||
|
<template v-if="$slots.expandColumnTitle" #expandColumnTitle="{ record }">
|
||||||
|
<slot :record="record" name="expandColumnTitle" />
|
||||||
|
</template>
|
||||||
|
<template v-if="$slots.expandedRowRender" #expandedRowRender="{ record }">
|
||||||
|
<slot :record="record" name="expandedRowRender" />
|
||||||
|
</template>
|
||||||
|
<slot />
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
22
web/src/components/base-table/types.ts
Normal file
22
web/src/components/base-table/types.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { TablePaginationConfig } from 'ant-design-vue';
|
||||||
|
import type { FilterValue, SorterResult, TableCurrentDataSource } from 'ant-design-vue/lib/table/interface';
|
||||||
|
import type { DefaultRecordType } from 'ant-design-vue/lib/vc-table/interface';
|
||||||
|
|
||||||
|
export interface ISearchColumn {
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
hide?: boolean
|
||||||
|
type: string
|
||||||
|
initialValue?: any
|
||||||
|
display?: 'visible' | 'hidden' | 'none'
|
||||||
|
props?: Record<string, any>
|
||||||
|
reactions?: [() => void]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IQueryParams {
|
||||||
|
searchValues: Record<string, string>
|
||||||
|
pagination: TablePaginationConfig
|
||||||
|
filters: Record<string, FilterValue | null>
|
||||||
|
sorter: SorterResult<DefaultRecordType> | SorterResult<DefaultRecordType>[]
|
||||||
|
source: TableCurrentDataSource<DefaultRecordType>
|
||||||
|
}
|
||||||
4
web/src/components/index.ts
Normal file
4
web/src/components/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { default as RightContent } from './right-content/index.vue';
|
||||||
|
export { default as TabBar } from './tab-bar/index.vue';
|
||||||
|
export { default as BaseTable } from './base-table/index.vue';
|
||||||
|
export { default as BaseChart } from './base-chart/index.vue';
|
||||||
42
web/src/components/right-content/index.vue
Normal file
42
web/src/components/right-content/index.vue
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
export interface CurrentUser {
|
||||||
|
nickname: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
currentUser: CurrentUser
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { logout } = useUser();
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div style="margin-right: 12px">
|
||||||
|
<a-space :size="24">
|
||||||
|
<a-dropdown :trigger="['click']">
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item>
|
||||||
|
<template #icon>
|
||||||
|
<setting-outlined />
|
||||||
|
</template>
|
||||||
|
<span>个人设置</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item @click="handleLogout">
|
||||||
|
<template #icon>
|
||||||
|
<logout-outlined />
|
||||||
|
</template>
|
||||||
|
<span>退出登录</span>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
<a-avatar shape="circle" size="32">
|
||||||
|
TEST
|
||||||
|
</a-avatar>
|
||||||
|
</a-dropdown>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
89
web/src/components/tab-bar/index.vue
Normal file
89
web/src/components/tab-bar/index.vue
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onUnmounted, ref } from 'vue';
|
||||||
|
import type { RouteLocationNormalized } from 'vue-router';
|
||||||
|
import type { Affix } from 'ant-design-vue';
|
||||||
|
import tabItem from './tab-item.vue';
|
||||||
|
import {
|
||||||
|
listenerRouteChange,
|
||||||
|
removeRouteListener,
|
||||||
|
} from '@/utils/route-listener';
|
||||||
|
import { useTabBarStore } from '@/store';
|
||||||
|
|
||||||
|
const tabBarStore = useTabBarStore();
|
||||||
|
|
||||||
|
const affixRef = ref<InstanceType<typeof Affix>>();
|
||||||
|
const tagList = computed(() => {
|
||||||
|
return tabBarStore.getTabList;
|
||||||
|
});
|
||||||
|
|
||||||
|
listenerRouteChange((route: RouteLocationNormalized) => {
|
||||||
|
if (
|
||||||
|
!route.meta.noAffix
|
||||||
|
&& !tagList.value.some(tag => tag.fullPath === route.fullPath)
|
||||||
|
) {
|
||||||
|
tabBarStore.updateTabList(route);
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
removeRouteListener();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tab-bar-container">
|
||||||
|
<a-affix ref="affixRef">
|
||||||
|
<div class="tab-bar-box">
|
||||||
|
<div class="tab-bar-scroll">
|
||||||
|
<div class="tags-wrap">
|
||||||
|
<tab-item
|
||||||
|
v-for="(tag, index) in tagList"
|
||||||
|
:key="tag.fullPath"
|
||||||
|
:index="index"
|
||||||
|
:item-data="tag"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tag-bar-operation" />
|
||||||
|
</div>
|
||||||
|
</a-affix>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.tab-bar-container {
|
||||||
|
position: relative;
|
||||||
|
background-color: #fff;
|
||||||
|
.tab-bar-box {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 0 0 20px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid rgb(229,230,235);
|
||||||
|
.tab-bar-scroll {
|
||||||
|
height: 32px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
.tags-wrap {
|
||||||
|
padding: 7px 0;
|
||||||
|
height: 48px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
:deep(.ant-tag) {
|
||||||
|
cursor: pointer;
|
||||||
|
&:first-child {
|
||||||
|
.ant-tag-close-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-bar-operation {
|
||||||
|
width: 100px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
154
web/src/components/tab-bar/tab-item.vue
Normal file
154
web/src/components/tab-bar/tab-item.vue
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { PropType, computed } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import { useTabBarStore } from '@/store';
|
||||||
|
import { DEFAULT_ROUTE_NAME, REDIRECT_ROUTE_NAME } from '@/router/constants';
|
||||||
|
|
||||||
|
export interface TagProps {
|
||||||
|
title: string
|
||||||
|
name: string
|
||||||
|
fullPath: string
|
||||||
|
query?: any
|
||||||
|
ignoreCache?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
itemData: {
|
||||||
|
type: Object as PropType<TagProps>,
|
||||||
|
default() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
index: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
enum Eaction {
|
||||||
|
reload = 'reload',
|
||||||
|
current = 'current',
|
||||||
|
left = 'left',
|
||||||
|
right = 'right',
|
||||||
|
others = 'others',
|
||||||
|
all = 'all',
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const tabBarStore = useTabBarStore();
|
||||||
|
|
||||||
|
const goto = (tag: TagProps) => {
|
||||||
|
router.push({ ...tag });
|
||||||
|
};
|
||||||
|
const tagList = computed(() => {
|
||||||
|
return tabBarStore.getTabList;
|
||||||
|
});
|
||||||
|
|
||||||
|
const disabledReload = computed(() => {
|
||||||
|
return props.itemData.fullPath !== route.fullPath;
|
||||||
|
});
|
||||||
|
|
||||||
|
const disabledCurrent = computed(() => {
|
||||||
|
return props.index === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const disabledLeft = computed(() => {
|
||||||
|
return [0, 1].includes(props.index);
|
||||||
|
});
|
||||||
|
|
||||||
|
const disabledRight = computed(() => {
|
||||||
|
return props.index === tagList.value.length - 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagClose = (tag: TagProps, idx: number) => {
|
||||||
|
tabBarStore.deleteTag(idx, tag);
|
||||||
|
if (props.itemData.fullPath === route.fullPath) {
|
||||||
|
const latest = tagList.value[idx - 1]; // 获取队列的前一个tab
|
||||||
|
router.push({ ...latest });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findCurrentRouteIndex = () => {
|
||||||
|
return tagList.value.findIndex(el => el.fullPath === route.fullPath);
|
||||||
|
};
|
||||||
|
const actionSelect = async (value: { key: string }) => {
|
||||||
|
const { key } = value;
|
||||||
|
const { itemData, index } = props;
|
||||||
|
const copyTagList = [...tagList.value];
|
||||||
|
if (key === Eaction.current) {
|
||||||
|
tagClose(itemData, index);
|
||||||
|
} else if (key === Eaction.left) {
|
||||||
|
const currentRouteIdx = findCurrentRouteIndex();
|
||||||
|
copyTagList.splice(1, props.index - 1);
|
||||||
|
|
||||||
|
tabBarStore.freshTabList(copyTagList);
|
||||||
|
if (currentRouteIdx < index) {
|
||||||
|
router.push({ name: itemData.name });
|
||||||
|
}
|
||||||
|
} else if (key === Eaction.right) {
|
||||||
|
const currentRouteIdx = findCurrentRouteIndex();
|
||||||
|
copyTagList.splice(props.index + 1);
|
||||||
|
|
||||||
|
tabBarStore.freshTabList(copyTagList);
|
||||||
|
if (currentRouteIdx > index) {
|
||||||
|
router.push({ name: itemData.name });
|
||||||
|
}
|
||||||
|
} else if (key === Eaction.others) {
|
||||||
|
const filterList = tagList.value.filter((el: any, idx: number) => {
|
||||||
|
return idx === 0 || idx === props.index;
|
||||||
|
});
|
||||||
|
tabBarStore.freshTabList(filterList);
|
||||||
|
router.push({ name: itemData.name });
|
||||||
|
} else if (key === Eaction.reload) {
|
||||||
|
tabBarStore.deleteCache(itemData);
|
||||||
|
await router.push({
|
||||||
|
name: REDIRECT_ROUTE_NAME,
|
||||||
|
params: {
|
||||||
|
path: route.fullPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
tabBarStore.addCache(itemData.name);
|
||||||
|
} else {
|
||||||
|
tabBarStore.resetTabList();
|
||||||
|
router.push({ name: DEFAULT_ROUTE_NAME });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-dropdown
|
||||||
|
:trigger="['contextmenu']"
|
||||||
|
>
|
||||||
|
<a-tag
|
||||||
|
closable
|
||||||
|
:color="itemData.fullPath === $route.fullPath ? 'processing' : 'default'"
|
||||||
|
@close="tagClose(itemData, index)"
|
||||||
|
@click="goto(itemData)"
|
||||||
|
>
|
||||||
|
{{ itemData.title }}
|
||||||
|
</a-tag>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu @click="actionSelect">
|
||||||
|
<a-menu-item :key="Eaction.reload" :disabled="disabledReload">
|
||||||
|
<span>重新加载</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item :key="Eaction.current" :disabled="disabledCurrent">
|
||||||
|
<span>关闭当前标签页</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item :key="Eaction.left" :disabled="disabledLeft">
|
||||||
|
<span>关闭左侧标签页</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item :key="Eaction.right" :disabled="disabledRight">
|
||||||
|
<span>关闭右侧标签页</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item :key="Eaction.others">
|
||||||
|
<span>关闭其它标签页</span>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item :key="Eaction.all">
|
||||||
|
<span>关闭全部标签页</span>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
</template>
|
||||||
19
web/src/hooks/useLoading.ts
Normal file
19
web/src/hooks/useLoading.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export default function useLoading(initValue = false) {
|
||||||
|
const loading = ref(initValue);
|
||||||
|
|
||||||
|
const setLoading = (value: boolean) => {
|
||||||
|
loading.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
loading.value = !loading.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
setLoading,
|
||||||
|
toggle,
|
||||||
|
};
|
||||||
|
}
|
||||||
46
web/src/hooks/useTheme.ts
Normal file
46
web/src/hooks/useTheme.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { onBeforeMount } from 'vue';
|
||||||
|
import { ConfigProvider } from 'ant-design-vue';
|
||||||
|
|
||||||
|
const LOCAL_THEME = 'local_theme';
|
||||||
|
|
||||||
|
export const colors: string[] = [
|
||||||
|
'#f5222d',
|
||||||
|
'#fa541c',
|
||||||
|
'#fa8c16',
|
||||||
|
'#a0d911',
|
||||||
|
'#13c2c2',
|
||||||
|
'#1890ff',
|
||||||
|
'#722ed1',
|
||||||
|
'#eb2f96',
|
||||||
|
'#faad14',
|
||||||
|
'#52c41a',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const randomTheme = (): string => {
|
||||||
|
const i = Math.floor(Math.random() * 10);
|
||||||
|
return colors[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const load = () => {
|
||||||
|
const color = localStorage.getItem(LOCAL_THEME) || '#1890ff';
|
||||||
|
return color;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const save = (color: string) => {
|
||||||
|
localStorage.setItem(LOCAL_THEME, color);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const apply = (color: string) => {
|
||||||
|
ConfigProvider.config({
|
||||||
|
theme: {
|
||||||
|
primaryColor: color,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
save(color);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUserTheme = () => {
|
||||||
|
onBeforeMount(() => {
|
||||||
|
apply(load());
|
||||||
|
});
|
||||||
|
};
|
||||||
21
web/src/hooks/useUser.ts
Normal file
21
web/src/hooks/useUser.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { useUserStore } from '@/store';
|
||||||
|
|
||||||
|
export default function useUser() {
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const logout = async (logoutTo?: string) => {
|
||||||
|
await userStore.logout();
|
||||||
|
const currentRoute = router.currentRoute.value;
|
||||||
|
message.success('登出成功');
|
||||||
|
router.push({
|
||||||
|
name: logoutTo && typeof logoutTo === 'string' ? logoutTo : 'login',
|
||||||
|
query: {
|
||||||
|
...router.currentRoute.value.query,
|
||||||
|
redirect: currentRoute.name as string,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
logout,
|
||||||
|
};
|
||||||
|
}
|
||||||
80
web/src/layouts/basic-layout.vue
Normal file
80
web/src/layouts/basic-layout.vue
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterLink, RouterView, useRouter } from "vue-router";
|
||||||
|
import {
|
||||||
|
type RouteContextProps,
|
||||||
|
clearMenuItem,
|
||||||
|
getMenuData,
|
||||||
|
} from "@ant-design-vue/pro-layout";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { menuData } = getMenuData(clearMenuItem(router.getRoutes()));
|
||||||
|
|
||||||
|
const state = reactive<Omit<RouteContextProps, "menuData">>({
|
||||||
|
collapsed: false, // default collapsed
|
||||||
|
openKeys: [], // defualt openKeys
|
||||||
|
selectedKeys: [], // default selectedKeys
|
||||||
|
});
|
||||||
|
//
|
||||||
|
const loading = ref<boolean>(false);
|
||||||
|
|
||||||
|
const proConfig = ref({
|
||||||
|
layout: "side",
|
||||||
|
navTheme: "light",
|
||||||
|
fixedHeader: true,
|
||||||
|
fixSiderbar: false,
|
||||||
|
splitMenus: true,
|
||||||
|
contentWidth: 'Fluid',
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentUser = reactive({
|
||||||
|
nickname: "Admin",
|
||||||
|
avatar: "A",
|
||||||
|
});
|
||||||
|
|
||||||
|
const drawerVisible = ref(true);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
router.currentRoute,
|
||||||
|
() => {
|
||||||
|
const matched = router.currentRoute.value.matched.concat();
|
||||||
|
state.selectedKeys = matched
|
||||||
|
.filter((r) => r.name !== "index")
|
||||||
|
.map((r) => r.path);
|
||||||
|
state.openKeys = matched
|
||||||
|
.filter((r) => r.path !== router.currentRoute.value.path)
|
||||||
|
.map((r) => r.path);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-drawer v-model:open="drawerVisible"></a-drawer>
|
||||||
|
|
||||||
|
<!-- <router-view v-slot="{ Component, route }">
|
||||||
|
<transition name="slide-left" mode="out-in">
|
||||||
|
<component :is="Component" :key="route.path" />
|
||||||
|
</transition>
|
||||||
|
</router-view> -->
|
||||||
|
|
||||||
|
|
||||||
|
<pro-layout v-model:collapsed="state.collapsed" v-model:selectedKeys="state.selectedKeys"
|
||||||
|
v-model:openKeys="state.openKeys" :loading="loading" header-theme="light" :menu-data="menuData" disable-content-margin
|
||||||
|
style="min-height: 100vh" v-bind="proConfig">
|
||||||
|
<template #menuHeaderRender>
|
||||||
|
<router-link :to="{ path: '/' }">
|
||||||
|
<h1 style="text-align: center;">docGPT</h1>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
<template #rightContentRender>
|
||||||
|
<RightContent :current-user="currentUser" />
|
||||||
|
</template>
|
||||||
|
<router-view v-slot="{ Component, route }">
|
||||||
|
<transition name="slide-left" mode="out-in">
|
||||||
|
<component :is="Component" :key="route.path" />
|
||||||
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</pro-layout>
|
||||||
|
</template>
|
||||||
3
web/src/layouts/blank-layout.vue
Normal file
3
web/src/layouts/blank-layout.vue
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
129
web/src/layouts/nested-layout.vue
Normal file
129
web/src/layouts/nested-layout.vue
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watchEffect } from 'vue';
|
||||||
|
import { type RouteRecordName, type RouteRecordRaw, useRouter } from 'vue-router';
|
||||||
|
import { Avatar as AAvatar, Breadcrumb as ABreadcrumb, BreadcrumbItem as ABreadcrumbItem } from 'ant-design-vue';
|
||||||
|
import { WaterMark, clearMenuItem, getMenuData } from '@ant-design-vue/pro-layout';
|
||||||
|
import type { RouteContextProps } from '@ant-design-vue/pro-layout';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const watermarkContent = ref('Pro Layout');
|
||||||
|
const router = useRouter();
|
||||||
|
const currentRouteKey = computed(() => router.currentRoute.value.matched.concat()[1].name);
|
||||||
|
const { menuData } = getMenuData(clearMenuItem(router.getRoutes()));
|
||||||
|
// flat menus
|
||||||
|
const routes = menuData.map((item) => {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
children: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const cachedMap = menuData.reduce((pre, cur) => {
|
||||||
|
const key = cur.name || cur.path;
|
||||||
|
const child = cur.children || [];
|
||||||
|
pre[key] = child as unknown as RouteRecordRaw;
|
||||||
|
return pre;
|
||||||
|
}, {} as Record<RouteRecordName, RouteRecordRaw>);
|
||||||
|
|
||||||
|
console.log('cachedMap', cachedMap);
|
||||||
|
|
||||||
|
const baseState = reactive<Omit<RouteContextProps, 'menuData'>>({
|
||||||
|
selectedKeys: ['/welcome'],
|
||||||
|
openKeys: [],
|
||||||
|
childrenSelectedKeys: [],
|
||||||
|
childrenOpenKeys: [],
|
||||||
|
collapsed: false,
|
||||||
|
});
|
||||||
|
const breadcrumb = computed(() =>
|
||||||
|
router.currentRoute.value.matched.concat().map((item) => {
|
||||||
|
return {
|
||||||
|
path: item.path,
|
||||||
|
icon: item.meta.icon,
|
||||||
|
params: item.meta?.params,
|
||||||
|
breadcrumbName: item.meta.title || '',
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
watchEffect(() => {
|
||||||
|
if (router.currentRoute) {
|
||||||
|
const matched = router.currentRoute.value.matched.concat();
|
||||||
|
baseState.selectedKeys = matched.filter(r => r.name !== 'index').map(r => r.path);
|
||||||
|
baseState.childrenSelectedKeys = matched.filter(r => r.name !== 'index').map(r => r.path);
|
||||||
|
baseState.childrenOpenKeys = matched.filter(r => r.path !== router.currentRoute.value.path).map(r => r.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onMounted(() => {
|
||||||
|
loading.value = true;
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve('simple admin');
|
||||||
|
}, 2000);
|
||||||
|
}).then((res) => {
|
||||||
|
watermarkContent.value = res;
|
||||||
|
loading.value = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<pro-layout
|
||||||
|
v-model:selectedKeys="baseState.selectedKeys"
|
||||||
|
v-model:openKeys="baseState.openKeys"
|
||||||
|
collapsed
|
||||||
|
:loading="loading"
|
||||||
|
:breadcrumb="{ routes: breadcrumb }"
|
||||||
|
:header-render="false"
|
||||||
|
:fix-siderbar="true"
|
||||||
|
:collapsed-button-render="false"
|
||||||
|
:menu-data="routes"
|
||||||
|
disable-content-margin
|
||||||
|
style="min-height: 100vh"
|
||||||
|
iconfont-url="//at.alicdn.com/t/font_2804900_2sp8hxw3ln8.js"
|
||||||
|
>
|
||||||
|
<template #menuHeaderRender>
|
||||||
|
<a>
|
||||||
|
<img src="/favicon.svg">
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
<water-mark :content="watermarkContent">
|
||||||
|
<pro-layout
|
||||||
|
v-model:collapsed="baseState.collapsed"
|
||||||
|
v-model:selectedKeys="baseState.childrenSelectedKeys"
|
||||||
|
v-model:openKeys="baseState.childrenOpenKeys"
|
||||||
|
nav-theme="light"
|
||||||
|
:menu-header-render="false"
|
||||||
|
:menu-data="cachedMap[currentRouteKey as RouteRecordName]"
|
||||||
|
:fix-siderbar="true"
|
||||||
|
:is-children-layout="true"
|
||||||
|
style="min-height: 100vh"
|
||||||
|
disable-content-margin
|
||||||
|
>
|
||||||
|
<!-- custom right-content -->
|
||||||
|
<template #rightContentRender>
|
||||||
|
<div style="margin-right: 12px">
|
||||||
|
<a-avatar shape="square" size="small">
|
||||||
|
<template #icon>
|
||||||
|
<user-outlined />
|
||||||
|
</template>
|
||||||
|
</a-avatar>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #headerContentRender>
|
||||||
|
<div style="height: 100%; display: flex; align-items: center">
|
||||||
|
<a-breadcrumb>
|
||||||
|
<a-breadcrumb-item v-for="item of breadcrumb" :key="item.path">
|
||||||
|
<router-link :to="{ path: item.path, item: item.params } as RouteLocationRaw">
|
||||||
|
{{ item.breadcrumbName }}
|
||||||
|
</router-link>
|
||||||
|
</a-breadcrumb-item>
|
||||||
|
</a-breadcrumb>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- content begin -->
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<component :is="Component" />
|
||||||
|
</router-view>
|
||||||
|
</pro-layout>
|
||||||
|
</water-mark>
|
||||||
|
</pro-layout>
|
||||||
|
</template>
|
||||||
13
web/src/main.ts
Normal file
13
web/src/main.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import './styles/index.less';
|
||||||
|
import 'virtual:uno.css';
|
||||||
|
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import { ConfigProvider } from 'ant-design-vue';
|
||||||
|
import ProLayout, { PageContainer } from '@ant-design-vue/pro-layout';
|
||||||
|
import router from './router';
|
||||||
|
import store from './store';
|
||||||
|
import App from './App.vue';
|
||||||
|
|
||||||
|
import './mock';
|
||||||
|
|
||||||
|
createApp(App).use(router).use(store).use(ConfigProvider).use(ProLayout).use(PageContainer).mount('#app');
|
||||||
5
web/src/mock/index.ts
Normal file
5
web/src/mock/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import './user';
|
||||||
|
|
||||||
|
Mock.setup({
|
||||||
|
timeout: '600-1000',
|
||||||
|
});
|
||||||
103
web/src/mock/user.ts
Normal file
103
web/src/mock/user.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import setupMock, {
|
||||||
|
failResponseWrap,
|
||||||
|
successResponseWrap,
|
||||||
|
} from '@/utils/setup-mock';
|
||||||
|
|
||||||
|
import { MockParams } from '@/types/mock';
|
||||||
|
|
||||||
|
setupMock({
|
||||||
|
setup() {
|
||||||
|
// Mock.XHR.prototype.withCredentials = true;
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
|
Mock.mock(new RegExp('.*/api/user/info'), () => {
|
||||||
|
if (isLogin()) {
|
||||||
|
const role = window.localStorage.getItem('userRole') || 'admin';
|
||||||
|
return successResponseWrap({
|
||||||
|
name: '王立群',
|
||||||
|
avatar:
|
||||||
|
'//lf1-xgcdn-tos.pstatp.com/obj/vcloud/vadmin/start.8e0e4855ee346a46ccff8ff3e24db27b.png',
|
||||||
|
email: 'wangliqun@email.com',
|
||||||
|
job: 'frontend',
|
||||||
|
jobName: '前端艺术家',
|
||||||
|
organization: 'Frontend',
|
||||||
|
organizationName: '前端',
|
||||||
|
location: 'beijing',
|
||||||
|
locationName: '北京',
|
||||||
|
introduction: '人潇洒,性温存',
|
||||||
|
personalWebsite: 'https://www.arco.design',
|
||||||
|
phone: '150****0000',
|
||||||
|
registrationDate: '2013-05-10 12:10:00',
|
||||||
|
accountId: '15012312300',
|
||||||
|
certification: 1,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return failResponseWrap(null, '未登录', 50008);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登录
|
||||||
|
Mock.mock(new RegExp('.*/api/user/login'), (params: MockParams) => {
|
||||||
|
const { username, password } = JSON.parse(params.body);
|
||||||
|
if (!username) {
|
||||||
|
return failResponseWrap(null, '用户名不能为空', 50000);
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
return failResponseWrap(null, '密码不能为空', 50000);
|
||||||
|
}
|
||||||
|
if (username === 'admin' && password === 'admin') {
|
||||||
|
window.localStorage.setItem('userRole', 'admin');
|
||||||
|
return successResponseWrap({
|
||||||
|
token: '12345',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (username === 'user' && password === 'user') {
|
||||||
|
window.localStorage.setItem('userRole', 'user');
|
||||||
|
return successResponseWrap({
|
||||||
|
token: '54321',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return failResponseWrap(null, '账号或者密码错误', 50000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 登出
|
||||||
|
Mock.mock(new RegExp('.*/api/user/logout'), () => {
|
||||||
|
return successResponseWrap(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 用户的服务端菜单
|
||||||
|
Mock.mock(new RegExp('.*/api/user/menu'), () => {
|
||||||
|
const menuList = [
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'dashboard',
|
||||||
|
meta: {
|
||||||
|
locale: 'menu.server.dashboard',
|
||||||
|
requiresAuth: true,
|
||||||
|
icon: 'icon-dashboard',
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'workplace',
|
||||||
|
name: 'Workplace',
|
||||||
|
meta: {
|
||||||
|
locale: 'menu.server.workplace',
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'https://arco.design',
|
||||||
|
name: 'arcoWebsite',
|
||||||
|
meta: {
|
||||||
|
locale: 'menu.arcoWebsite',
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return successResponseWrap(menuList);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
13
web/src/router/constants.ts
Normal file
13
web/src/router/constants.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export const DEFAULT_ROUTE_NAME = 'welcome';
|
||||||
|
|
||||||
|
export const REDIRECT_ROUTE_NAME = 'Redirect';
|
||||||
|
|
||||||
|
export const NOT_FOUND_ROUTE_NAME = 'notFound';
|
||||||
|
|
||||||
|
export const LOGIN_ROUTE_NAME = 'login';
|
||||||
|
|
||||||
|
export const DEFAULT_ROUTE = {
|
||||||
|
title: '工作台',
|
||||||
|
name: DEFAULT_ROUTE_NAME,
|
||||||
|
fullPath: '/welcome',
|
||||||
|
};
|
||||||
15
web/src/router/guard/index.ts
Normal file
15
web/src/router/guard/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import setupUserLoginInfoGuard from './userLoginInfo';
|
||||||
|
import setupPermissionGuard from './permission';
|
||||||
|
|
||||||
|
function setupPageGuard(router: Router) {
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
// emit route change
|
||||||
|
setRouteEmitter(to);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function createRouteGuard(router: Router) {
|
||||||
|
setupPageGuard(router);
|
||||||
|
setupUserLoginInfoGuard(router);
|
||||||
|
setupPermissionGuard(router);
|
||||||
|
}
|
||||||
7
web/src/router/guard/permission.ts
Normal file
7
web/src/router/guard/permission.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default function setupPermissionGuard(router: Router) {
|
||||||
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
// TODO: permission logic
|
||||||
|
next();
|
||||||
|
NProgress.done();
|
||||||
|
});
|
||||||
|
}
|
||||||
37
web/src/router/guard/userLoginInfo.ts
Normal file
37
web/src/router/guard/userLoginInfo.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
export default function setupUserLoginInfoGuard(router: Router) {
|
||||||
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
NProgress.start();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
if (isLogin()) {
|
||||||
|
if (userStore.role) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await userStore.info();
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
await userStore.logout();
|
||||||
|
next({
|
||||||
|
name: 'login',
|
||||||
|
query: {
|
||||||
|
redirect: to.name,
|
||||||
|
...to.query,
|
||||||
|
} as LocationQueryRaw,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (to.name === 'login') {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next({
|
||||||
|
name: 'login',
|
||||||
|
query: {
|
||||||
|
redirect: to.name,
|
||||||
|
...to.query,
|
||||||
|
} as LocationQueryRaw,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
41
web/src/router/index.ts
Normal file
41
web/src/router/index.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { NOT_FOUND_ROUTE, REDIRECT_MAIN } from './routes/base';
|
||||||
|
import createRouteGuard from './guard';
|
||||||
|
import { appRoutes } from './routes';
|
||||||
|
import { DEFAULT_ROUTE_NAME } from './constants';
|
||||||
|
import BasicLayout from '@/layouts/basic-layout.vue';
|
||||||
|
import 'nprogress/nprogress.css';
|
||||||
|
|
||||||
|
NProgress.configure({ showSpinner: false }); // NProgress Configuration
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
scrollBehavior() {
|
||||||
|
return { top: 0 };
|
||||||
|
},
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'index',
|
||||||
|
meta: { title: 'Home' },
|
||||||
|
component: BasicLayout,
|
||||||
|
redirect: {
|
||||||
|
name: DEFAULT_ROUTE_NAME,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
...appRoutes,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('@/views/login/index.vue'),
|
||||||
|
},
|
||||||
|
NOT_FOUND_ROUTE,
|
||||||
|
REDIRECT_MAIN,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
createRouteGuard(router);
|
||||||
|
|
||||||
|
export default router;
|
||||||
28
web/src/router/routes/base.ts
Normal file
28
web/src/router/routes/base.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
export const DEFAULT_LAYOUT = () => import('@/layouts/basic-layout.vue');
|
||||||
|
|
||||||
|
export const REDIRECT_MAIN: RouteRecordRaw = {
|
||||||
|
path: '/redirect',
|
||||||
|
name: 'redirectWrapper',
|
||||||
|
component: DEFAULT_LAYOUT,
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
hideInMenu: true,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/redirect/:path',
|
||||||
|
name: 'Redirect',
|
||||||
|
component: () => import('@/views/redirect/index.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
hideInMenu: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NOT_FOUND_ROUTE: RouteRecordRaw = {
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'notFound',
|
||||||
|
component: () => import('@/views/not-found/index.vue'),
|
||||||
|
};
|
||||||
26
web/src/router/routes/index.ts
Normal file
26
web/src/router/routes/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
const modules = import.meta.glob('./modules/*.ts', { eager: true });
|
||||||
|
const externalModules = import.meta.glob('./externalModules/*.ts', {
|
||||||
|
eager: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatModules(_modules: any, result: RouteRecordNormalized[]) {
|
||||||
|
Object.keys(_modules).forEach((key) => {
|
||||||
|
const defaultModule = _modules[key].default;
|
||||||
|
if (!defaultModule) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleList = Array.isArray(defaultModule)
|
||||||
|
? [...defaultModule]
|
||||||
|
: [defaultModule];
|
||||||
|
result.push(...moduleList);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appRoutes: RouteRecordNormalized[] = formatModules(modules, []);
|
||||||
|
|
||||||
|
export const appExternalRoutes: RouteRecordNormalized[] = formatModules(
|
||||||
|
externalModules,
|
||||||
|
[],
|
||||||
|
);
|
||||||
59
web/src/router/routes/modules/admins.ts
Normal file
59
web/src/router/routes/modules/admins.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import BlankLayout from '@/layouts/blank-layout.vue';
|
||||||
|
|
||||||
|
const admins = [
|
||||||
|
{
|
||||||
|
path: '/welcome',
|
||||||
|
name: 'welcome',
|
||||||
|
meta: { title: '我的文件', icon: 'video-camera-out-lined' },
|
||||||
|
component: () => import('@/views/admins/result.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/result?type=1',
|
||||||
|
name: 'result',
|
||||||
|
meta: { title: '视频', icon: 'icon-icon-test' },
|
||||||
|
component: () => import('@/views/admins/result.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/result?type=2',
|
||||||
|
name: 'result',
|
||||||
|
meta: { title: '图片', icon: 'icon-icon-test' },
|
||||||
|
component: () => import('@/views/admins/result.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/result?type=3',
|
||||||
|
name: 'result',
|
||||||
|
meta: { title: '音乐', icon: 'icon-icon-test' },
|
||||||
|
component: () => import('@/views/admins/result.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/result?type=4',
|
||||||
|
name: 'result',
|
||||||
|
meta: { title: '文档', icon: 'icon-icon-test' },
|
||||||
|
component: () => import('@/views/admins/result.vue'),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// path: '/admins',
|
||||||
|
// name: 'admins',
|
||||||
|
// meta: { title: '管理页', icon: 'icon-tuijian', flat: true },
|
||||||
|
// component: BlankLayout,
|
||||||
|
// redirect: () => ({ name: 'page1' }),
|
||||||
|
// children: [
|
||||||
|
// {
|
||||||
|
// path: 'page-search',
|
||||||
|
// name: 'page-search',
|
||||||
|
// meta: { title: '查询表格' },
|
||||||
|
// component: () => import('@/views/admins/page-search.vue'),
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
{
|
||||||
|
path: '/version',
|
||||||
|
name: 'version',
|
||||||
|
meta: { title: 'Version', icon: 'icon-antdesign' },
|
||||||
|
component: () => import('@/views/Detail.vue'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default admins;
|
||||||
17
web/src/router/typings.d.ts
vendored
Normal file
17
web/src/router/typings.d.ts
vendored
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import 'vue-router';
|
||||||
|
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
icon?: string | VNode // 菜单的icon
|
||||||
|
type?: string // 有 children 的菜单的组件类型 可选值 'group'
|
||||||
|
title?: string // 自定义菜单的国际化 key,如果没有则返回自身
|
||||||
|
authority?: string | string[] // 内建授权信息
|
||||||
|
target?: TargetType // 打开目标位置 '_blank' | '_self' | null | undefined
|
||||||
|
hideChildInMenu?: boolean // 在菜单中隐藏子节点
|
||||||
|
hideInMenu?: boolean // 在菜单中隐藏自己和子节点
|
||||||
|
disabled?: boolean // disable 菜单选项
|
||||||
|
flatMenu?: boolean // 隐藏自己,并且将子节点提升到与自己平级
|
||||||
|
ignoreCache?: boolean // 是否忽略 tab cache
|
||||||
|
noAffix?: boolean // 是否在tab中显示
|
||||||
|
}
|
||||||
|
}
|
||||||
8
web/src/store/index.ts
Normal file
8
web/src/store/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import useUserStore from './modules/user';
|
||||||
|
import useTabBarStore from './modules/tab-bar';
|
||||||
|
|
||||||
|
const pinia = createPinia();
|
||||||
|
|
||||||
|
export { useUserStore, useTabBarStore };
|
||||||
|
export default pinia;
|
||||||
81
web/src/store/modules/tab-bar/index.ts
Normal file
81
web/src/store/modules/tab-bar/index.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import type { RouteLocationNormalized } from 'vue-router';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { isString } from 'lodash-es';
|
||||||
|
import { TabBarState, TagProps } from './types';
|
||||||
|
import {
|
||||||
|
DEFAULT_ROUTE,
|
||||||
|
DEFAULT_ROUTE_NAME,
|
||||||
|
LOGIN_ROUTE_NAME,
|
||||||
|
NOT_FOUND_ROUTE_NAME,
|
||||||
|
REDIRECT_ROUTE_NAME,
|
||||||
|
} from '@/router/constants';
|
||||||
|
|
||||||
|
const formatTag = (route: RouteLocationNormalized): TagProps => {
|
||||||
|
const { name, meta, fullPath, query, params } = route;
|
||||||
|
return {
|
||||||
|
title: meta.title || '',
|
||||||
|
name: String(name),
|
||||||
|
fullPath,
|
||||||
|
query,
|
||||||
|
params,
|
||||||
|
ignoreCache: meta.ignoreCache,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const BAN_LIST = [REDIRECT_ROUTE_NAME, NOT_FOUND_ROUTE_NAME, LOGIN_ROUTE_NAME];
|
||||||
|
|
||||||
|
const useAppStore = defineStore('tabBar', {
|
||||||
|
state: (): TabBarState => ({
|
||||||
|
cacheTabList: new Set([DEFAULT_ROUTE_NAME]),
|
||||||
|
tagList: [DEFAULT_ROUTE],
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
getTabList(): TagProps[] {
|
||||||
|
return this.tagList;
|
||||||
|
},
|
||||||
|
getCacheList(): string[] {
|
||||||
|
return Array.from(this.cacheTabList);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
updateTabList(route: RouteLocationNormalized) {
|
||||||
|
if (BAN_LIST.includes(route.name as string)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.tagList.push(formatTag(route));
|
||||||
|
if (!route.meta.ignoreCache) {
|
||||||
|
this.cacheTabList.add(route.name as string);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteTag(idx: number, tag: TagProps) {
|
||||||
|
this.tagList.splice(idx, 1);
|
||||||
|
this.cacheTabList.delete(tag.name);
|
||||||
|
},
|
||||||
|
addCache(name: string) {
|
||||||
|
if (isString(name) && name !== '') {
|
||||||
|
this.cacheTabList.add(name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteCache(tag: TagProps) {
|
||||||
|
this.cacheTabList.delete(tag.name);
|
||||||
|
},
|
||||||
|
freshTabList(tags: TagProps[]) {
|
||||||
|
this.tagList = tags;
|
||||||
|
this.cacheTabList.clear();
|
||||||
|
// 要先判断ignoreCache
|
||||||
|
this.tagList
|
||||||
|
.filter(el => !el.ignoreCache)
|
||||||
|
.map(el => el.name)
|
||||||
|
.forEach(x => this.cacheTabList.add(x));
|
||||||
|
},
|
||||||
|
resetTabList() {
|
||||||
|
this.tagList = [DEFAULT_ROUTE];
|
||||||
|
this.cacheTabList.clear();
|
||||||
|
this.cacheTabList.add(DEFAULT_ROUTE_NAME);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default useAppStore;
|
||||||
13
web/src/store/modules/tab-bar/types.ts
Normal file
13
web/src/store/modules/tab-bar/types.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export interface TagProps {
|
||||||
|
title: string
|
||||||
|
name: string
|
||||||
|
fullPath: string
|
||||||
|
query?: any
|
||||||
|
params?: any
|
||||||
|
ignoreCache?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabBarState {
|
||||||
|
tagList: TagProps[]
|
||||||
|
cacheTabList: Set<string>
|
||||||
|
}
|
||||||
88
web/src/store/modules/user/index.ts
Normal file
88
web/src/store/modules/user/index.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { UserState } from './types';
|
||||||
|
import {
|
||||||
|
LoginData,
|
||||||
|
getUserInfo,
|
||||||
|
login as userLogin,
|
||||||
|
logout as userLogout,
|
||||||
|
} from '@/api/user';
|
||||||
|
import { clearToken, setToken } from '@/utils/auth';
|
||||||
|
import { removeRouteListener } from '@/utils/route-listener';
|
||||||
|
|
||||||
|
const useUserStore = defineStore('user', {
|
||||||
|
state: (): UserState => ({
|
||||||
|
name: undefined,
|
||||||
|
avatar: undefined,
|
||||||
|
job: undefined,
|
||||||
|
organization: undefined,
|
||||||
|
location: undefined,
|
||||||
|
email: undefined,
|
||||||
|
introduction: undefined,
|
||||||
|
personalWebsite: undefined,
|
||||||
|
jobName: undefined,
|
||||||
|
organizationName: undefined,
|
||||||
|
locationName: undefined,
|
||||||
|
phone: undefined,
|
||||||
|
registrationDate: undefined,
|
||||||
|
accountId: undefined,
|
||||||
|
certification: undefined,
|
||||||
|
role: '',
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
userInfo(state: UserState): UserState {
|
||||||
|
return { ...state };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
switchRoles() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.role = this.role === 'user' ? 'admin' : 'user';
|
||||||
|
resolve(this.role);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Set user's information
|
||||||
|
setInfo(partial: Partial<UserState>) {
|
||||||
|
this.$patch(partial);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reset user's information
|
||||||
|
resetInfo() {
|
||||||
|
this.$reset();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get user's information
|
||||||
|
async info() {
|
||||||
|
const res = await getUserInfo();
|
||||||
|
|
||||||
|
this.setInfo(unref(res.data)!);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Login
|
||||||
|
async login(loginForm: LoginData) {
|
||||||
|
try {
|
||||||
|
const res = await userLogin(loginForm);
|
||||||
|
setToken(unref(res.data)!.token);
|
||||||
|
} catch (err) {
|
||||||
|
clearToken();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logoutCallBack() {
|
||||||
|
this.resetInfo();
|
||||||
|
clearToken();
|
||||||
|
removeRouteListener();
|
||||||
|
},
|
||||||
|
// Logout
|
||||||
|
async logout() {
|
||||||
|
try {
|
||||||
|
await userLogout();
|
||||||
|
} finally {
|
||||||
|
this.logoutCallBack();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default useUserStore;
|
||||||
19
web/src/store/modules/user/types.ts
Normal file
19
web/src/store/modules/user/types.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
export type RoleType = '' | '*' | 'admin' | 'user';
|
||||||
|
export interface UserState {
|
||||||
|
name?: string
|
||||||
|
avatar?: string
|
||||||
|
job?: string
|
||||||
|
organization?: string
|
||||||
|
location?: string
|
||||||
|
email?: string
|
||||||
|
introduction?: string
|
||||||
|
personalWebsite?: string
|
||||||
|
jobName?: string
|
||||||
|
organizationName?: string
|
||||||
|
locationName?: string
|
||||||
|
phone?: string
|
||||||
|
registrationDate?: string
|
||||||
|
accountId?: string
|
||||||
|
certification?: number
|
||||||
|
role: RoleType
|
||||||
|
}
|
||||||
14
web/src/styles/antd.less
Normal file
14
web/src/styles/antd.less
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
@import '@ant-design-vue/pro-layout/dist/style.css';
|
||||||
|
|
||||||
|
.ant-pro-sider-logo {
|
||||||
|
& > a {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
& h1 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
web/src/styles/index.less
Normal file
32
web/src/styles/index.less
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
@import './antd.less';
|
||||||
|
@import './scroll-bar.less';
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-pro-sider {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-left-enter-active,
|
||||||
|
.slide-left-leave-active,
|
||||||
|
.slide-right-enter-active,
|
||||||
|
.slide-right-leave-active {
|
||||||
|
transition-duration: 0.5s;
|
||||||
|
transition-property: height, opacity, transform;
|
||||||
|
transition-timing-function: cubic-bezier(0.55, 0, 0.1, 1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-left-enter,
|
||||||
|
.slide-right-leave-active {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(2em, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-left-leave-active,
|
||||||
|
.slide-right-enter {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-2em, 0);
|
||||||
|
}
|
||||||
13
web/src/styles/scroll-bar.less
Normal file
13
web/src/styles/scroll-bar.less
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px; /* 滚动条宽度 */
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background-color: #f1f1f1; /* 滚动条背景色 */
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #888; /* 滚动条滑块颜色 */
|
||||||
|
border-radius: 5px; /* 滑块圆角 */
|
||||||
|
}
|
||||||
5
web/src/types/mock.ts
Normal file
5
web/src/types/mock.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export interface MockParams {
|
||||||
|
url: string
|
||||||
|
type: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
19
web/src/utils/auth.ts
Normal file
19
web/src/utils/auth.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
const TOKEN_KEY = '__SIMPLE_ADMIN_TOKEN__';
|
||||||
|
|
||||||
|
const isLogin = () => {
|
||||||
|
return !!localStorage.getItem(TOKEN_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getToken = () => {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setToken = (token: string) => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearToken = () => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { isLogin, getToken, setToken, clearToken };
|
||||||
3
web/src/utils/env.ts
Normal file
3
web/src/utils/env.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
const debug = import.meta.env.MODE !== 'production';
|
||||||
|
|
||||||
|
export default debug;
|
||||||
24
web/src/utils/route-listener.ts
Normal file
24
web/src/utils/route-listener.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
const emitter = mitt();
|
||||||
|
|
||||||
|
const key = Symbol('ROUTE_CHANGE');
|
||||||
|
|
||||||
|
let latestRoute: RouteLocationNormalized;
|
||||||
|
|
||||||
|
export function setRouteEmitter(to: RouteLocationNormalized) {
|
||||||
|
emitter.emit(key, to);
|
||||||
|
latestRoute = to;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listenerRouteChange(
|
||||||
|
handler: (route: RouteLocationNormalized) => void,
|
||||||
|
immediate = true,
|
||||||
|
) {
|
||||||
|
emitter.on(key, handler as Handler);
|
||||||
|
if (immediate && latestRoute) {
|
||||||
|
handler(latestRoute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeRouteListener() {
|
||||||
|
emitter.off(key);
|
||||||
|
}
|
||||||
25
web/src/utils/setup-mock.ts
Normal file
25
web/src/utils/setup-mock.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import debug from './env';
|
||||||
|
|
||||||
|
export default ({ mock, setup }: { mock?: boolean; setup(): void }) => {
|
||||||
|
if ((mock !== false && debug) || import.meta.env.VITE_FORCE_MOCK) {
|
||||||
|
setup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const successResponseWrap = (data: unknown) => {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
status: 'ok',
|
||||||
|
msg: '请求成功',
|
||||||
|
code: 200,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const failResponseWrap = (data: unknown, msg: string, code = 500) => {
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
status: 'fail',
|
||||||
|
msg,
|
||||||
|
code,
|
||||||
|
};
|
||||||
|
};
|
||||||
52
web/src/utils/useAxiosApi.ts
Normal file
52
web/src/utils/useAxiosApi.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { useAxios } from '@vueuse/integrations/useAxios';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
// create an axios instance
|
||||||
|
const instance = axios.create({
|
||||||
|
withCredentials: false,
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// request interceptor
|
||||||
|
instance.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
// do something before request is sent
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
// do something with request error
|
||||||
|
console.log(error); // for debug
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// response interceptor
|
||||||
|
instance.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
const res = response.data;
|
||||||
|
if (res.code !== 200) {
|
||||||
|
message.error(res.msg);
|
||||||
|
if (res.code === 412) {
|
||||||
|
// store.dispatch('user/userLogout');
|
||||||
|
}
|
||||||
|
return Promise.reject(res.msg || 'Error');
|
||||||
|
} else {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.log(`err${error}`);
|
||||||
|
message.error(error.message);
|
||||||
|
return Promise.reject(error.message);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* reactive useFetchApi
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default function useAxiosApi<T>(url: string, config?: RawAxiosRequestConfig) {
|
||||||
|
const fnConfig = config || {};
|
||||||
|
return useAxios<T>(url, fnConfig, instance);
|
||||||
|
}
|
||||||
32
web/src/views/Detail.vue
Normal file
32
web/src/views/Detail.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { dependencies } from '../../package.json';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<page-container title="Version" sub-title="show current project dependencies">
|
||||||
|
<template #content>
|
||||||
|
<strong>Content Area</strong>
|
||||||
|
</template>
|
||||||
|
<template #extra>
|
||||||
|
<strong>Extra Area</strong>
|
||||||
|
</template>
|
||||||
|
<template #extraContent>
|
||||||
|
<strong>ExtraContent Area</strong>
|
||||||
|
</template>
|
||||||
|
<template #tags>
|
||||||
|
<a-tag>Tag1</a-tag>
|
||||||
|
<a-tag color="pink">
|
||||||
|
Tag2
|
||||||
|
</a-tag>
|
||||||
|
</template>
|
||||||
|
<a-card title="Project Version">
|
||||||
|
<p v-for="(dep, key) in dependencies" :key="key">
|
||||||
|
<strong style="margin-right: 12px">{{ key }}:</strong>
|
||||||
|
<a-tag>{{ dep }}</a-tag>
|
||||||
|
</p>
|
||||||
|
<p v-for="d in new Array(50)" :key="d">
|
||||||
|
text block...
|
||||||
|
</p>
|
||||||
|
</a-card>
|
||||||
|
</page-container>
|
||||||
|
</template>
|
||||||
29
web/src/views/Page1.vue
Normal file
29
web/src/views/Page1.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import { PageContainer } from '@ant-design-vue/pro-layout';
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
console.log('info');
|
||||||
|
message.info('BackHome button clicked!');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<page-container>
|
||||||
|
<a-result
|
||||||
|
status="404"
|
||||||
|
:style="{
|
||||||
|
height: '100%',
|
||||||
|
background: '#fff',
|
||||||
|
}"
|
||||||
|
title="Hello World"
|
||||||
|
sub-title="Sorry, you are not authorized to access this page."
|
||||||
|
>
|
||||||
|
<template #extra>
|
||||||
|
<a-button type="primary" @click="handleClick">
|
||||||
|
Back Home
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</a-result>
|
||||||
|
</page-container>
|
||||||
|
</template>
|
||||||
58
web/src/views/Page2.vue
Normal file
58
web/src/views/Page2.vue
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const selectedKeys = ref<string[]>(['2']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-layout class="layout">
|
||||||
|
<a-layout-header>
|
||||||
|
<div class="logo" />
|
||||||
|
<a-menu v-model:selectedKeys="selectedKeys" theme="dark" mode="horizontal" :style="{ lineHeight: '64px' }">
|
||||||
|
<a-menu-item key="1">
|
||||||
|
nav 1
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="2">
|
||||||
|
nav 2
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item key="3">
|
||||||
|
nav 3
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</a-layout-header>
|
||||||
|
<a-layout-content style="padding: 0 50px">
|
||||||
|
<a-breadcrumb style="margin: 16px 0">
|
||||||
|
<a-breadcrumb-item>Home</a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item>List</a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item>App</a-breadcrumb-item>
|
||||||
|
</a-breadcrumb>
|
||||||
|
<div :style="{ background: '#fff', padding: '24px', minHeight: '280px' }">
|
||||||
|
Content
|
||||||
|
</div>
|
||||||
|
</a-layout-content>
|
||||||
|
<a-layout-footer style="text-align: center">
|
||||||
|
Ant Design ©2018 Created by Ant UED
|
||||||
|
</a-layout-footer>
|
||||||
|
</a-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.site-layout-content {
|
||||||
|
min-height: 280px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
#components-layout-demo-top .logo {
|
||||||
|
float: left;
|
||||||
|
width: 120px;
|
||||||
|
height: 31px;
|
||||||
|
margin: 16px 24px 16px 0;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
.ant-row-rtl #components-layout-demo-top .logo {
|
||||||
|
float: right;
|
||||||
|
margin: 16px 0 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] .site-layout-content {
|
||||||
|
background: #141414;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
40
web/src/views/admins/result.vue
Normal file
40
web/src/views/admins/result.vue
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<template>
|
||||||
|
<div class="result-header">
|
||||||
|
<a-breadcrumb>
|
||||||
|
<a-breadcrumb-item> </a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item><a href="">Application Center</a></a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item><a href="">Application List</a></a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item>An Application</a-breadcrumb-item>
|
||||||
|
</a-breadcrumb>
|
||||||
|
<a-button class="operate-button" type="text" size="large">
|
||||||
|
<template #icon>
|
||||||
|
<img width="66%" src="@/assets/images/cloud_download.svg" />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty :image="simpleImage" description="empty" :imageStyle="{ width: '100px', margin: '0 auto' }" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { h } from 'vue';
|
||||||
|
import { CloudDownloadOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { Empty } from 'ant-design-vue';
|
||||||
|
const simpleImage = Empty.PRESENTED_IMAGE_SIMPLE;
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.result-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operate-button {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
web/src/views/login/components/banner.vue
Normal file
107
web/src/views/login/components/banner.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import bannerImage from '@/assets/images/login-banner.png';
|
||||||
|
|
||||||
|
const carouselItem = computed(() => [
|
||||||
|
{
|
||||||
|
slogan: '开箱即用的模板',
|
||||||
|
subSlogan: '丰富的的页面模板,覆盖大多数典型业务场景',
|
||||||
|
image: bannerImage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slogan: '内置了常见问题的解决方案',
|
||||||
|
subSlogan: '路由配置,状态管理应有尽有',
|
||||||
|
image: bannerImage,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="banner">
|
||||||
|
<div class="banner-inner">
|
||||||
|
<a-carousel class="carousel" animation-name="fade" arrows autoplay>
|
||||||
|
<template #prevArrow>
|
||||||
|
<div class="custom-slick-arrow" style="left: 10px">
|
||||||
|
<left-circle-outlined />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #nextArrow>
|
||||||
|
<div class="custom-slick-arrow" style="right: 10px">
|
||||||
|
<right-circle-outlined />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-for="item in carouselItem" :key="item.slogan">
|
||||||
|
<div :key="item.slogan" class="carousel-item" style="height: 100vh;">
|
||||||
|
<div class="carousel-title">
|
||||||
|
{{ item.slogan }}
|
||||||
|
</div>
|
||||||
|
<div class="carousel-sub-title">
|
||||||
|
{{ item.subSlogan }}
|
||||||
|
</div>
|
||||||
|
<img class="carousel-image" :src="item.image">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-carousel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.ant-carousel :deep(.slick-arrow.custom-slick-arrow) {
|
||||||
|
width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
font-size: 25px;
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(31, 45, 61, 0.11);
|
||||||
|
opacity: 0.3;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.ant-carousel :deep(.custom-slick-arrow:before) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ant-carousel :deep(.custom-slick-arrow:hover) {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&-inner {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel {
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
color: rgb(247, 248, 250);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-sub-title {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: rgb(134, 144, 156);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-image {
|
||||||
|
width: 320px;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
135
web/src/views/login/components/login-form.vue
Normal file
135
web/src/views/login/components/login-form.vue
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useStorage } from '@vueuse/core';
|
||||||
|
import { useUserStore } from '@/store';
|
||||||
|
import type { LoginData } from '@/api/user';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const errorMessage = ref('');
|
||||||
|
const { loading, setLoading } = useLoading();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
const loginConfig = useStorage('login-config', {
|
||||||
|
rememberPassword: true,
|
||||||
|
username: 'admin', // 演示默认值
|
||||||
|
password: 'admin', // demo default value
|
||||||
|
});
|
||||||
|
|
||||||
|
const userInfo = reactive({
|
||||||
|
username: loginConfig.value.username,
|
||||||
|
password: loginConfig.value.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (values: Record<string, any>) => {
|
||||||
|
if (loading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await userStore.login(values as LoginData);
|
||||||
|
const { redirect, ...othersQuery } = router.currentRoute.value.query;
|
||||||
|
router.push({
|
||||||
|
name: (redirect as string) || 'welcome',
|
||||||
|
query: {
|
||||||
|
...othersQuery,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
message.success('欢迎使用');
|
||||||
|
const { rememberPassword } = loginConfig.value;
|
||||||
|
const { username, password } = values;
|
||||||
|
// 实际生产环境需要进行加密存储。
|
||||||
|
// The actual production environment requires encrypted storage.
|
||||||
|
loginConfig.value.username = rememberPassword ? username : '';
|
||||||
|
loginConfig.value.password = rememberPassword ? password : '';
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage.value = (err as Error).message;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-form-wrapper">
|
||||||
|
<div class="login-form-title">
|
||||||
|
登录
|
||||||
|
</div>
|
||||||
|
<div class="login-form-sub-title">
|
||||||
|
登录
|
||||||
|
</div>
|
||||||
|
<div class="login-form-error-msg">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
<a-form :model="userInfo" name="login" class="login-form" autocomplete="off" @finish="handleSubmit">
|
||||||
|
<a-form-item name="username" :rules="[{ required: true, message: '用户名不能为空' }]"
|
||||||
|
:validate-trigger="['change', 'blur']" hide-label>
|
||||||
|
<a-input v-model:value="userInfo.username" placeholder="用户名:admin">
|
||||||
|
<template #prefix>
|
||||||
|
<user-outlined type="user" />
|
||||||
|
</template>
|
||||||
|
</a-input>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item name="password" :rules="[{ required: true, message: '密码不能为空' }]" :validate-trigger="['change', 'blur']"
|
||||||
|
hide-label>
|
||||||
|
<a-input-password v-model:value="userInfo.password" placeholder="密码:admin" allow-clear>
|
||||||
|
<template #prefix>
|
||||||
|
<lock-outlined />
|
||||||
|
</template>
|
||||||
|
</a-input-password>
|
||||||
|
</a-form-item>
|
||||||
|
<a-space :size="16" direction="vertical" style="width: 100%;">
|
||||||
|
<div class="login-form-password-actions">
|
||||||
|
<a-checkbox v-model:checked="loginConfig.rememberPassword">
|
||||||
|
记住密码
|
||||||
|
</a-checkbox>
|
||||||
|
<a-button type="link" size="small">
|
||||||
|
忘记密码
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<a-button type="primary" block :loading="loading" html-type="submit">
|
||||||
|
登录
|
||||||
|
</a-button>
|
||||||
|
<a-button type="text" block class="login-form-register-btn">
|
||||||
|
注册
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</a-form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.login-form {
|
||||||
|
&-wrapper {
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-title {
|
||||||
|
color: rgb(29, 33, 41);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-sub-title {
|
||||||
|
color: rgb(134, 144, 156);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-error-msg {
|
||||||
|
height: 32px;
|
||||||
|
color: rgb(var(--red-6));
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-password-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-register-btn {
|
||||||
|
color: rgb(134, 144, 156) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
81
web/src/views/login/index.vue
Normal file
81
web/src/views/login/index.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import LoginBanner from './components/banner.vue';
|
||||||
|
import LoginForm from './components/login-form.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="https://alicdn.antdv.com/v2/assets/logo.1ef800a8.svg">
|
||||||
|
<div class="logo-text">
|
||||||
|
Admin
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<login-banner />
|
||||||
|
<div class="content">
|
||||||
|
<div class="content-inner">
|
||||||
|
<login-form />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
width: 550px;
|
||||||
|
background: linear-gradient(163.85deg, #1d2129 0%, #00308f 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
position: fixed;
|
||||||
|
top: 24px;
|
||||||
|
left: 22px;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&-text {
|
||||||
|
margin-right: 4px;
|
||||||
|
margin-left: 4px;
|
||||||
|
color: rgb(247, 248, 250);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 33px;
|
||||||
|
height: 33px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
// responsive
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.container {
|
||||||
|
.banner {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
29
web/src/views/not-found/index.vue
Normal file
29
web/src/views/not-found/index.vue
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
const router = useRouter();
|
||||||
|
const back = () => {
|
||||||
|
// warning: Go to the node that has the permission
|
||||||
|
router.push({ name: 'welcome' });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="content">
|
||||||
|
<a-result class="result" status="404" title="404" sub-title="not found" />
|
||||||
|
<div class="operation-row">
|
||||||
|
<a-button key="back" type="primary" @click="back">
|
||||||
|
back
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.content {
|
||||||
|
position: absolute;
|
||||||
|
top: 30%;
|
||||||
|
left: 50%;
|
||||||
|
margin-left: -95px;
|
||||||
|
margin-top: -121px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
web/src/views/redirect/index.vue
Normal file
16
web/src/views/redirect/index.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const gotoPath = route.params.path as string;
|
||||||
|
|
||||||
|
router.replace({ path: gotoPath });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="less"></style>
|
||||||
20
web/tsconfig.json
Normal file
20
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
19
web/uno.config.ts
Normal file
19
web/uno.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { defineConfig, presetAttributify, presetIcons, presetUno } from 'unocss';
|
||||||
|
|
||||||
|
const presetIconsConfig = {
|
||||||
|
extraProperties: {
|
||||||
|
display: 'inline-block',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
export default defineConfig({
|
||||||
|
content: {
|
||||||
|
pipeline: {
|
||||||
|
exclude: ['node_modules', '.git', '.github', '.husky', '.vscode', 'build', 'config', 'dist', 'public'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
presets: [
|
||||||
|
presetUno({ important: true }),
|
||||||
|
presetAttributify(),
|
||||||
|
presetIcons(presetIconsConfig),
|
||||||
|
],
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue