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