feat: init

This commit is contained in:
李宇 2023-12-14 13:49:56 +08:00
parent 5d47adf11c
commit 16cc780576
82 changed files with 10967 additions and 0 deletions

1
web/.env.development Normal file
View file

@ -0,0 +1 @@
VITE_FORCE_MOCK = true // 是否强制开启 Mock

2
web/.env.production Normal file
View file

@ -0,0 +1,2 @@
REPORT = true
VITE_FORCE_MOCK = true // 是否强制开启 Mock

34
web/.github/workflows/main.yml vendored Normal file
View 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
View 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
View 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
View 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
View file

0
web/README.md Normal file
View file

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

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

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

View file

@ -0,0 +1,9 @@
/**
* Whether to generate package preview
*
*/
export default {};
export function isReportMode(): boolean {
return process.env.REPORT === 'true';
}

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

View file

@ -0,0 +1,12 @@
import { mergeConfig } from 'vite';
import baseConfig from './vite.config.base';
export default mergeConfig(
{
mode: 'development',
server: {
open: true,
},
},
baseConfig,
);

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

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,2 @@
// all api
export * from './user';

36
web/src/api/user.ts Normal file
View 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',
});
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
web/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View 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性能更优|

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

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

View file

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

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

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

@ -0,0 +1,5 @@
import './user';
Mock.setup({
timeout: '600-1000',
});

103
web/src/mock/user.ts Normal file
View 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);
});
},
});

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

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

View file

@ -0,0 +1,7 @@
export default function setupPermissionGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
// TODO: permission logic
next();
NProgress.done();
});
}

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

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

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

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

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

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

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

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

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

@ -0,0 +1,5 @@
export interface MockParams {
url: string
type: string
body: string
}

19
web/src/utils/auth.ts Normal file
View 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
View file

@ -0,0 +1,3 @@
const debug = import.meta.env.MODE !== 'production';
export default debug;

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

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

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

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

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

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

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

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

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