ci(other): cicd
48
.cz-config.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
module.exports = {
|
||||||
|
types: [
|
||||||
|
{ value: 'feat', name: 'feat: 新增功能' },
|
||||||
|
{ value: 'fix', name: 'fix: 修复bug' },
|
||||||
|
{ value: 'docs', name: 'docs: 文档变更' },
|
||||||
|
{ value: 'style', name: 'style: 代码格式(不影响功能,例如空格、分号等格式修正)' },
|
||||||
|
{ value: 'refactor', name: 'refactor: 代码重构(不包括 bug 修复、功能新增)' },
|
||||||
|
{ value: 'perf', name: 'perf: 性能优化' },
|
||||||
|
{ value: 'test', name: 'test: 添加、修改测试用例' },
|
||||||
|
{
|
||||||
|
value: 'build',
|
||||||
|
name: 'build: 构建流程、外部依赖变更(如升级 npm 包、修改 脚手架 配置等)',
|
||||||
|
},
|
||||||
|
{ value: 'ci', name: 'ci: 修改 CI 配置、脚本' },
|
||||||
|
{ value: 'chore', name: 'chore: 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)' },
|
||||||
|
{ value: 'revert', name: 'revert: 回滚 commit' },
|
||||||
|
{ value: 'wip', name: 'wip: 开发中' },
|
||||||
|
{ value: 'mod', name: 'mod: 不确定分类的修改' },
|
||||||
|
{ value: 'release', name: 'release: 发布' },
|
||||||
|
],
|
||||||
|
scopes: [
|
||||||
|
['custom', '自定义'],
|
||||||
|
['projects', '项目搭建'],
|
||||||
|
['components', '组件相关'],
|
||||||
|
['utils', 'utils 相关'],
|
||||||
|
['styles', '样式相关'],
|
||||||
|
['deps', '项目依赖'],
|
||||||
|
['other', '其他修改'],
|
||||||
|
].map(([value, description]) => {
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
name: `${value.padEnd(30)} (${description})`,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
messages: {
|
||||||
|
type: '确保本次提交遵循 Angular 规范!选择你要提交的类型:\n',
|
||||||
|
scope: '选择一个 scope(可选):',
|
||||||
|
customScope: '请输入自定义的 scope:',
|
||||||
|
subject: '填写简短精炼的变更描述:',
|
||||||
|
body: '填写更加详细的变更描述(可选)。使用 "|" 换行:',
|
||||||
|
breaking: '列举非兼容性重大的变更(可选):',
|
||||||
|
footer: '列举出所有变更的 Issues Closed(可选)。 例如: #31, #34:',
|
||||||
|
confirmCommit: '确认提交?',
|
||||||
|
},
|
||||||
|
allowBreakingChanges: ['feat', 'fix'],
|
||||||
|
subjectLimit: 100,
|
||||||
|
breaklineChar: '|',
|
||||||
|
}
|
||||||
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
5
.editorconfig
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
15
.env.development
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 资源公共路径,需要以 /开头和结尾
|
||||||
|
VITE_PUBLIC_PATH='/'
|
||||||
|
|
||||||
|
# 是否启用MOCK
|
||||||
|
VITE_USE_MOCK=false
|
||||||
|
|
||||||
|
# 是否启用代理
|
||||||
|
VITE_USE_PROXY=true
|
||||||
|
|
||||||
|
# base api
|
||||||
|
VITE_BASE_API='/api'
|
||||||
|
VITE_BASE_API_1='/api1'
|
||||||
|
|
||||||
|
VITE_ADMIN_API='/admin'
|
||||||
|
VITE_ADMIN_API_1='/admin1'
|
||||||
18
.env.production
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 资源公共路径,需要以 /开头和结尾
|
||||||
|
VITE_PUBLIC_PATH='/'
|
||||||
|
|
||||||
|
# 是否启用MOCK
|
||||||
|
VITE_USE_MOCK=false
|
||||||
|
|
||||||
|
# base api
|
||||||
|
VITE_BASE_API='//www.wanzhuanyongcheng.cn/store'
|
||||||
|
VITE_BASE_API_1='//api.gxwzwh.com/store'
|
||||||
|
|
||||||
|
VITE_ADMIN_API='//www.wanzhuanyongcheng.cn'
|
||||||
|
VITE_ADMIN_API_1='//api.gxwzwh.com'
|
||||||
|
|
||||||
|
# 是否启用压缩
|
||||||
|
VITE_USE_COMPRESS=true
|
||||||
|
|
||||||
|
# 压缩类型
|
||||||
|
VITE_COMPRESS_TYPE=gzip
|
||||||
15
.env.test
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
VITE_PUBLIC_PATH='/'
|
||||||
|
|
||||||
|
# 是否启用MOCK
|
||||||
|
VITE_USE_MOCK=false
|
||||||
|
|
||||||
|
# base api
|
||||||
|
VITE_BASE_API='//test.wanzhuanyongcheng.cn/store'
|
||||||
|
|
||||||
|
VITE_ADMIN_API='//test.wanzhuanyongcheng.cn'
|
||||||
|
|
||||||
|
# 是否启用压缩
|
||||||
|
VITE_USE_COMPRESS=true
|
||||||
|
|
||||||
|
# 压缩类型
|
||||||
|
VITE_COMPRESS_TYPE=gzip
|
||||||
62
.eslint-global-variables.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"globals": {
|
||||||
|
"$loadingBar": true,
|
||||||
|
"$message": true,
|
||||||
|
"defineOptions": true,
|
||||||
|
"$dialog": true,
|
||||||
|
"$notification": true,
|
||||||
|
"EffectScope": true,
|
||||||
|
"computed": true,
|
||||||
|
"createApp": true,
|
||||||
|
"customRef": true,
|
||||||
|
"defineAsyncComponent": true,
|
||||||
|
"defineComponent": true,
|
||||||
|
"effectScope": true,
|
||||||
|
"getCurrentInstance": true,
|
||||||
|
"getCurrentScope": true,
|
||||||
|
"h": true,
|
||||||
|
"inject": true,
|
||||||
|
"isProxy": true,
|
||||||
|
"isReactive": true,
|
||||||
|
"isReadonly": true,
|
||||||
|
"isRef": true,
|
||||||
|
"markRaw": true,
|
||||||
|
"nextTick": true,
|
||||||
|
"onActivated": true,
|
||||||
|
"onBeforeMount": true,
|
||||||
|
"onBeforeUnmount": true,
|
||||||
|
"onBeforeUpdate": true,
|
||||||
|
"onDeactivated": true,
|
||||||
|
"onErrorCaptured": true,
|
||||||
|
"onMounted": true,
|
||||||
|
"onRenderTracked": true,
|
||||||
|
"onRenderTriggered": true,
|
||||||
|
"onScopeDispose": true,
|
||||||
|
"onServerPrefetch": true,
|
||||||
|
"onUnmounted": true,
|
||||||
|
"onUpdated": true,
|
||||||
|
"provide": true,
|
||||||
|
"reactive": true,
|
||||||
|
"readonly": true,
|
||||||
|
"ref": true,
|
||||||
|
"resolveComponent": true,
|
||||||
|
"shallowReactive": true,
|
||||||
|
"shallowReadonly": true,
|
||||||
|
"shallowRef": true,
|
||||||
|
"toRaw": true,
|
||||||
|
"toRef": true,
|
||||||
|
"toRefs": true,
|
||||||
|
"triggerRef": true,
|
||||||
|
"unref": true,
|
||||||
|
"useAttrs": true,
|
||||||
|
"useCssModule": true,
|
||||||
|
"useCssVars": true,
|
||||||
|
"useRoute": true,
|
||||||
|
"useRouter": true,
|
||||||
|
"useSlots": true,
|
||||||
|
"watch": true,
|
||||||
|
"watchEffect": true,
|
||||||
|
"watchPostEffect": true,
|
||||||
|
"watchSyncEffect": true
|
||||||
|
}
|
||||||
|
}
|
||||||
4
.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
public
|
||||||
|
package.json
|
||||||
162
.gitea/workflows/ci.yaml
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
name: CI Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- test
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy-dev:
|
||||||
|
if: gitea.ref_name == 'test'
|
||||||
|
runs-on: gitea_act_runner
|
||||||
|
container:
|
||||||
|
image: node:24-alpine
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: |
|
||||||
|
npm config set registry https://registry.npmmirror.com/
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
- name: Build (test)
|
||||||
|
run: pnpm build:test
|
||||||
|
|
||||||
|
- name: Pack artifacts
|
||||||
|
run: |
|
||||||
|
rm -rf dist.tar
|
||||||
|
tar -zcvf dist.tar ./dist ./default.conf ./Dockerfile
|
||||||
|
|
||||||
|
- name: Upload artifacts to server
|
||||||
|
uses: appleboy/scp-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.HOST_DEV }}
|
||||||
|
username: ${{ secrets.USER_DEV }}
|
||||||
|
password: ${{ secrets.PWD_DEV }}
|
||||||
|
port: 22
|
||||||
|
source: 'dist.tar'
|
||||||
|
target: '/www/builder'
|
||||||
|
strip_components: 0
|
||||||
|
|
||||||
|
- name: Deploy over SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.HOST_DEV }}
|
||||||
|
username: ${{ secrets.USER_DEV }}
|
||||||
|
password: ${{ secrets.PWD_DEV }}
|
||||||
|
port: 22
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
cd /www/builder
|
||||||
|
rm -rf jdt-mer-dev
|
||||||
|
mkdir -p jdt-mer-dev
|
||||||
|
tar -xzvf dist.tar -C /www/builder/jdt-mer-dev
|
||||||
|
rm -rf dist.tar
|
||||||
|
cd jdt-mer-dev
|
||||||
|
docker build -t jdt-mer-dev .
|
||||||
|
docker stop jdt-mer-dev || true
|
||||||
|
docker rm jdt-mer-dev || true
|
||||||
|
docker run -d -p 8083:80 --restart=always --name jdt-mer-dev jdt-mer-dev
|
||||||
|
cd ..
|
||||||
|
rm -rf jdt-mer-dev
|
||||||
|
|
||||||
|
- name: Notify WeCom (Dev)
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
WEBHOOK_KEY: ${{ secrets.QYWX_WEBHOOK_KEY }}
|
||||||
|
STATUS: ${{ job.status }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
RUN_URL: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}
|
||||||
|
BRANCH: ${{ gitea.ref_name }}
|
||||||
|
COMMIT: ${{ gitea.sha }}
|
||||||
|
ACTOR: ${{ gitea.actor }}
|
||||||
|
run: |
|
||||||
|
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories || true
|
||||||
|
apk add --no-cache curl jq
|
||||||
|
EMOJI=$( [ "$STATUS" = "success" ] && echo "✅" || echo "❌" )
|
||||||
|
MSG="$(printf "%s**%s** (Run #%s)\n>**构建结果**: %s\n>**构建详情**: [点击查看](%s)\n>**代码分支**: %s\n>**提交标识**: %s\n>**提交发起**: %s\n" "$EMOJI" "$REPO" "${{ gitea.run_number }}" "$STATUS" "$RUN_URL" "$BRANCH" "$COMMIT" "$ACTOR")"
|
||||||
|
curl -sS -H 'Content-Type: application/json' -d "{\"msgtype\":\"markdown\",\"markdown\":{\"content\":\"$MSG\"}}" "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${WEBHOOK_KEY}"
|
||||||
|
|
||||||
|
build-and-deploy-prod:
|
||||||
|
if: gitea.ref_name == 'master'
|
||||||
|
runs-on: gitea_act_runner
|
||||||
|
container:
|
||||||
|
image: node:24-alpine
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 8
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
run: |
|
||||||
|
npm config set registry https://registry.npmmirror.com/
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
- name: Build (prod)
|
||||||
|
run: pnpm build:prod
|
||||||
|
|
||||||
|
- name: Pack artifacts
|
||||||
|
run: |
|
||||||
|
rm -rf dist.tar
|
||||||
|
tar -zcvf dist.tar ./dist ./default.conf ./Dockerfile
|
||||||
|
|
||||||
|
- name: Upload artifacts to server
|
||||||
|
uses: appleboy/scp-action@v0.1.7
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.HOST_PROD }}
|
||||||
|
username: ${{ secrets.USER_PROD }}
|
||||||
|
password: ${{ secrets.PWD_PROD }}
|
||||||
|
port: 22
|
||||||
|
source: 'dist.tar'
|
||||||
|
target: '/www/builder'
|
||||||
|
strip_components: 0
|
||||||
|
|
||||||
|
- name: Deploy over SSH
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.HOST_PROD }}
|
||||||
|
username: ${{ secrets.USER_PROD }}
|
||||||
|
password: ${{ secrets.PWD_PROD }}
|
||||||
|
port: 22
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
cd /www/builder
|
||||||
|
rm -rf jdt-mer-prod
|
||||||
|
mkdir -p jdt-mer-prod
|
||||||
|
tar -xzvf dist.tar -C /www/builder/jdt-mer-prod
|
||||||
|
rm -rf dist.tar
|
||||||
|
cd jdt-mer-prod
|
||||||
|
docker build -t jdt-mer-prod .
|
||||||
|
docker stop jdt-mer-prod || true
|
||||||
|
docker rm jdt-mer-prod || true
|
||||||
|
docker run -d -p 8083:80 --restart=always --name jdt-mer-prod jdt-mer-prod
|
||||||
|
cd ..
|
||||||
|
rm -rf jdt-mer-prod
|
||||||
|
|
||||||
|
- name: Notify WeCom (Prod)
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
WEBHOOK_KEY: ${{ secrets.QYWX_WEBHOOK_KEY }}
|
||||||
|
STATUS: ${{ job.status }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
RUN_URL: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}
|
||||||
|
BRANCH: ${{ gitea.ref_name }}
|
||||||
|
COMMIT: ${{ gitea.sha }}
|
||||||
|
ACTOR: ${{ gitea.actor }}
|
||||||
|
run: |
|
||||||
|
sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories || true
|
||||||
|
apk add --no-cache curl jq
|
||||||
|
EMOJI=$( [ "$STATUS" = "success" ] && echo "✅" || echo "❌" )
|
||||||
|
MSG="$(printf "%s**%s** (Run #%s)\n>**构建结果**: %s\n>**构建详情**: [点击查看](%s)\n>**代码分支**: %s\n>**提交标识**: %s\n>**提交发起**: %s\n" "$EMOJI" "$REPO" "${{ gitea.run_number }}" "$STATUS" "$RUN_URL" "$BRANCH" "$COMMIT" "$ACTOR")"
|
||||||
|
curl -sS -H 'Content-Type: application/json' -d "{\"msgtype\":\"markdown\",\"markdown\":{\"content\":\"$MSG\"}}" "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${WEBHOOK_KEY}"
|
||||||
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.local
|
||||||
|
stats.html
|
||||||
4
.husky/commit-msg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npx --no-install commitlint --edit "$1"
|
||||||
4
.husky/pre-commit
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npm run lint:staged
|
||||||
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/node_modules/**
|
||||||
|
/dist/*
|
||||||
|
/public/*
|
||||||
7
.prettierrc.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": false,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"htmlWhitespaceSensitivity": "ignore"
|
||||||
|
}
|
||||||
9
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"vue.volar",
|
||||||
|
"antfu.iconify",
|
||||||
|
"mikestead.dotenv",
|
||||||
|
"sdras.vue-vscode-snippets",
|
||||||
|
"cipchk.cssrem"
|
||||||
|
]
|
||||||
|
}
|
||||||
14
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "一键启动",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run-script", "dev"],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"skipFiles": ["<node_internals>/**"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
37
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"path-intellisense.mappings": {
|
||||||
|
"@/": "${workspaceFolder}/src",
|
||||||
|
"~/": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"prettier.printWidth": 120,
|
||||||
|
"prettier.singleQuote": true,
|
||||||
|
"prettier.semi": false,
|
||||||
|
"prettier.endOfLine": "lf",
|
||||||
|
"files.eol": "\n",
|
||||||
|
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.formatOnSave": false
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.formatOnSave": false
|
||||||
|
},
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.formatOnSave": false
|
||||||
|
},
|
||||||
|
"[vue]": {
|
||||||
|
"editor.formatOnSave": false
|
||||||
|
},
|
||||||
|
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": "explicit"
|
||||||
|
},
|
||||||
|
"files.associations": {
|
||||||
|
"*.env.*": "dotenv",
|
||||||
|
"*.css": "postcss"
|
||||||
|
},
|
||||||
|
"[nginx]": {
|
||||||
|
"editor.defaultFormatter": "ahmadalli.vscode-nginx-conf"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY dist/ /usr/share/nginx/html
|
||||||
|
|
||||||
|
COPY default.conf /etc/nginx/conf.d/default.conf
|
||||||
BIN
build/.DS_Store
vendored
Normal file
37
build/constant.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export const OUTPUT_DIR = 'dist'
|
||||||
|
|
||||||
|
export const PROXY_CONFIG = {
|
||||||
|
/**
|
||||||
|
* @desc 主接口代理
|
||||||
|
* @请求路径 http://localhost:3100/api/login
|
||||||
|
* @转发路径 http://localhost:3000/api/login
|
||||||
|
*/
|
||||||
|
'/api': {
|
||||||
|
target: 'https://test.wanzhuanyongcheng.cn',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '/store'),
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @desc 备用接口代理
|
||||||
|
* @请求路径 http://localhost:3100/api1/login
|
||||||
|
* @转发路径 http://localhost:3001/api/login
|
||||||
|
*/
|
||||||
|
'/api1': {
|
||||||
|
target: 'https://api.gxwzwh.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api1/, '/store'),
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @desc null
|
||||||
|
*/
|
||||||
|
'/admin': {
|
||||||
|
target: 'https://test.wanzhuanyongcheng.cn',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/admin/, ''),
|
||||||
|
},
|
||||||
|
'/admin1': {
|
||||||
|
target: 'https://api.gxwzwh.com',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/admin1/, ''),
|
||||||
|
},
|
||||||
|
}
|
||||||
15
build/plugin/html.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createHtmlPlugin } from 'vite-plugin-html'
|
||||||
|
|
||||||
|
export function configHtmlPlugin(viteEnv, isBuild) {
|
||||||
|
const { VITE_TITLE } = viteEnv
|
||||||
|
|
||||||
|
const htmlPlugin = createHtmlPlugin({
|
||||||
|
minify: isBuild,
|
||||||
|
inject: {
|
||||||
|
data: {
|
||||||
|
title: VITE_TITLE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return htmlPlugin
|
||||||
|
}
|
||||||
39
build/plugin/index.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * unocss插件,原子css
|
||||||
|
* https://github.com/antfu/unocss
|
||||||
|
*/
|
||||||
|
import Unocss from 'unocss/vite'
|
||||||
|
|
||||||
|
// rollup打包分析插件
|
||||||
|
import visualizer from 'rollup-plugin-visualizer'
|
||||||
|
// 压缩
|
||||||
|
import viteCompression from 'vite-plugin-compression'
|
||||||
|
|
||||||
|
import { configHtmlPlugin } from './html'
|
||||||
|
import { configMockPlugin } from './mock'
|
||||||
|
import unplugin from './unplugin'
|
||||||
|
|
||||||
|
export function createVitePlugins(viteEnv, isBuild) {
|
||||||
|
const plugins = [vue(), ...unplugin, configHtmlPlugin(viteEnv, isBuild), Unocss()]
|
||||||
|
|
||||||
|
if (viteEnv?.VITE_USE_MOCK) {
|
||||||
|
plugins.push(configMockPlugin(isBuild))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viteEnv.VITE_USE_COMPRESS) {
|
||||||
|
plugins.push(viteCompression({ algorithm: viteEnv.VITE_COMPRESS_TYPE || 'gzip' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBuild) {
|
||||||
|
plugins.push(
|
||||||
|
visualizer({
|
||||||
|
open: false,
|
||||||
|
gzipSize: true,
|
||||||
|
brotliSize: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return plugins
|
||||||
|
}
|
||||||
13
build/plugin/mock.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { viteMockServe } from 'vite-plugin-mock'
|
||||||
|
|
||||||
|
export function configMockPlugin(isBuild) {
|
||||||
|
return viteMockServe({
|
||||||
|
mockPath: 'mock/api',
|
||||||
|
localEnabled: !isBuild,
|
||||||
|
prodEnabled: isBuild,
|
||||||
|
injectCode: `
|
||||||
|
import { setupProdMockServer } from '../mock';
|
||||||
|
setupProdMockServer();
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
}
|
||||||
48
build/plugin/unplugin.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
|
import Components from 'unplugin-vue-components/vite'
|
||||||
|
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
|
||||||
|
import IconsResolver from 'unplugin-icons/resolver'
|
||||||
|
import mkcert from 'vite-plugin-mkcert'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * unplugin-icons插件,自动引入iconify图标
|
||||||
|
* usage: https://github.com/antfu/unplugin-icons
|
||||||
|
* 图标库: https://icones.js.org/
|
||||||
|
*/
|
||||||
|
import Icons from 'unplugin-icons/vite'
|
||||||
|
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'
|
||||||
|
|
||||||
|
import { getSrcPath } from '../utils'
|
||||||
|
|
||||||
|
const customIconPath = resolve(getSrcPath(), 'assets/svg')
|
||||||
|
|
||||||
|
export default [
|
||||||
|
AutoImport({
|
||||||
|
imports: ['vue', 'vue-router'],
|
||||||
|
dts: false,
|
||||||
|
}),
|
||||||
|
Icons({
|
||||||
|
compiler: 'vue3',
|
||||||
|
customCollections: {
|
||||||
|
custom: FileSystemIconLoader(customIconPath),
|
||||||
|
},
|
||||||
|
scale: 1,
|
||||||
|
defaultClass: 'inline-block',
|
||||||
|
}),
|
||||||
|
Components({
|
||||||
|
resolvers: [
|
||||||
|
NaiveUiResolver(),
|
||||||
|
IconsResolver({ customCollections: ['custom'], componentPrefix: 'icon' }),
|
||||||
|
],
|
||||||
|
dts: false,
|
||||||
|
}),
|
||||||
|
createSvgIconsPlugin({
|
||||||
|
iconDirs: [customIconPath],
|
||||||
|
symbolId: 'icon-custom-[dir]-[name]',
|
||||||
|
inject: 'body-last',
|
||||||
|
customDomId: '__CUSTOM_SVG_ICON__',
|
||||||
|
}),
|
||||||
|
mkcert(),
|
||||||
|
]
|
||||||
15
build/script/build-cname.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { resolve } from 'path'
|
||||||
|
import chalk from 'chalk'
|
||||||
|
import { writeFileSync } from 'fs-extra'
|
||||||
|
import { OUTPUT_DIR } from '../constant'
|
||||||
|
import { getEnvConfig, getRootPath } from '../utils'
|
||||||
|
|
||||||
|
export function runBuildCNAME() {
|
||||||
|
const { VITE_CNAME } = getEnvConfig()
|
||||||
|
if (!VITE_CNAME) return
|
||||||
|
try {
|
||||||
|
writeFileSync(resolve(getRootPath(), `${OUTPUT_DIR}/CNAME`), VITE_CNAME)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(chalk.red('CNAME file failed to package:\n' + error))
|
||||||
|
}
|
||||||
|
}
|
||||||
14
build/script/index.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import chalk from 'chalk'
|
||||||
|
import { runBuildCNAME } from './build-cname'
|
||||||
|
|
||||||
|
export const runBuild = async () => {
|
||||||
|
try {
|
||||||
|
runBuildCNAME()
|
||||||
|
console.log(`✨ ${chalk.cyan('build successfully!')}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(chalk.red('vite build error:\n' + error))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runBuild()
|
||||||
70
build/utils.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * 项目根路径
|
||||||
|
* @description 结尾不带/
|
||||||
|
*/
|
||||||
|
export function getRootPath() {
|
||||||
|
return path.resolve(process.cwd())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* * 项目src路径
|
||||||
|
* @param srcName src目录名称(默认: "src")
|
||||||
|
* @description 结尾不带斜杠
|
||||||
|
*/
|
||||||
|
export function getSrcPath(srcName = 'src') {
|
||||||
|
return path.resolve(getRootPath(), srcName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertEnv(envOptions) {
|
||||||
|
const result = {}
|
||||||
|
if (!envOptions) return result
|
||||||
|
|
||||||
|
for (const envKey in envOptions) {
|
||||||
|
let envVal = envOptions[envKey]
|
||||||
|
if (['true', 'false'].includes(envVal)) envVal = envVal === 'true'
|
||||||
|
|
||||||
|
if (['VITE_PORT'].includes(envKey)) envVal = +envVal
|
||||||
|
|
||||||
|
result[envKey] = envVal
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前环境下生效的配置文件名
|
||||||
|
*/
|
||||||
|
function getConfFiles() {
|
||||||
|
const script = process.env.npm_lifecycle_script
|
||||||
|
const reg = new RegExp('--mode ([a-z_\\d]+)')
|
||||||
|
const result = reg.exec(script)
|
||||||
|
if (result) {
|
||||||
|
const mode = result[1]
|
||||||
|
return ['.env', '.env.local', `.env.${mode}`]
|
||||||
|
}
|
||||||
|
return ['.env', '.env.local', '.env.production']
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnvConfig(match = 'VITE_', confFiles = getConfFiles()) {
|
||||||
|
let envConfig = {}
|
||||||
|
confFiles.forEach((item) => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(path.resolve(process.cwd(), item))) {
|
||||||
|
const env = dotenv.parse(fs.readFileSync(path.resolve(process.cwd(), item)))
|
||||||
|
envConfig = { ...envConfig, ...env }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error in parsing ${item}`, e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const reg = new RegExp(`^(${match})`)
|
||||||
|
Object.keys(envConfig).forEach((key) => {
|
||||||
|
if (!reg.test(key)) {
|
||||||
|
Reflect.deleteProperty(envConfig, key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return envConfig
|
||||||
|
}
|
||||||
26
commitlint.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
module.exports = {
|
||||||
|
ignores: [(commit) => commit.includes('init')],
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
rules: {
|
||||||
|
'type-enum': [
|
||||||
|
2,
|
||||||
|
'always',
|
||||||
|
[
|
||||||
|
'feat',
|
||||||
|
'fix',
|
||||||
|
'docs',
|
||||||
|
'style',
|
||||||
|
'refactor',
|
||||||
|
'perf',
|
||||||
|
'test',
|
||||||
|
'build',
|
||||||
|
'ci',
|
||||||
|
'chore',
|
||||||
|
'revert',
|
||||||
|
'wip',
|
||||||
|
'mod',
|
||||||
|
'release',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
18
default.conf
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
server {
|
||||||
|
# 监听ipv4
|
||||||
|
listen 80;
|
||||||
|
# 监听ipv6
|
||||||
|
listen [::]:80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
index.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="cn">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="Expires" content="0" />
|
||||||
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="Cache-control" content="no-cache" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="/favicon.png" />
|
||||||
|
<link rel="stylesheet" href="/resource/loading.css" />
|
||||||
|
|
||||||
|
<title><%= title %></title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<!-- 白屏时的loading效果 -->
|
||||||
|
<div class="loading-container">
|
||||||
|
<img src="/resource/logo.png" alt="logo" height="128" />
|
||||||
|
<div class="loading-spin__container">
|
||||||
|
<div class="loading-spin">
|
||||||
|
<div class="left-0 top-0 loading-spin-item"></div>
|
||||||
|
<div class="left-0 bottom-0 loading-spin-item loading-delay-500"></div>
|
||||||
|
<div class="right-0 top-0 loading-spin-item loading-delay-1000"></div>
|
||||||
|
<div class="right-0 bottom-0 loading-spin-item loading-delay-1500"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="loading-title"><%= title %></div>
|
||||||
|
</div>
|
||||||
|
<script src="/resource/loading.js"></script>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
jsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["./*"],
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"jsx": "preserve",
|
||||||
|
"allowJs": true
|
||||||
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
84
package.json
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"name": "vue-naive-admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"build:prod": "vite build",
|
||||||
|
"build:test": "vite build --mode test",
|
||||||
|
"cz": "cz",
|
||||||
|
"dev": "vite",
|
||||||
|
"lint": "eslint --ext .js,.vue .",
|
||||||
|
"lint:fix": "eslint --fix --ext .js,.vue .",
|
||||||
|
"lint:staged": "lint-staged",
|
||||||
|
"prepare": "husky install",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lf": "npx prettier --write --end-of-line lf ."
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,vue}": [
|
||||||
|
"eslint --ext .js,.vue .",
|
||||||
|
"npx prettier --write --end-of-line lf ."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"commitizen": {
|
||||||
|
"path": "node_modules/cz-customizable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"@zclzone",
|
||||||
|
"@unocss",
|
||||||
|
".eslint-global-variables.json"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@unocss/eslint-config": "^0.55.7",
|
||||||
|
"@vueuse/core": "^10.11.1",
|
||||||
|
"@wangeditor/editor": "^5.1.23",
|
||||||
|
"@wangeditor/editor-for-vue": "5.1.12",
|
||||||
|
"axios": "^1.13.1",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
|
"echarts": "^5.6.0",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"md-editor-v3": "^4.21.3",
|
||||||
|
"mockjs": "^1.1.0",
|
||||||
|
"pinia": "^2.3.1",
|
||||||
|
"vite": "^4.5.14",
|
||||||
|
"vue": "3.3.4",
|
||||||
|
"vue-echarts": "^6.7.3",
|
||||||
|
"vue-router": "^4.6.3",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^17.8.1",
|
||||||
|
"@commitlint/config-conventional": "^17.8.1",
|
||||||
|
"@iconify/json": "^2.2.402",
|
||||||
|
"@iconify/vue": "^4.3.0",
|
||||||
|
"@unocss/preset-rem-to-px": "^0.55.7",
|
||||||
|
"@vitejs/plugin-vue": "^4.6.2",
|
||||||
|
"@vue/compiler-sfc": "^3.5.22",
|
||||||
|
"@zclzone/eslint-config": "^0.0.4",
|
||||||
|
"chalk": "^5.6.2",
|
||||||
|
"commitizen": "^4.3.1",
|
||||||
|
"cz-conventional-changelog": "^3.3.0",
|
||||||
|
"cz-customizable": "^7.5.1",
|
||||||
|
"dotenv": "^16.6.1",
|
||||||
|
"esno": "^0.17.0",
|
||||||
|
"fs-extra": "^11.3.2",
|
||||||
|
"husky": "^8.0.3",
|
||||||
|
"lint-staged": "^14.0.1",
|
||||||
|
"naive-ui": "^2.43.1",
|
||||||
|
"rollup-plugin-visualizer": "^5.14.0",
|
||||||
|
"sass": "^1.93.2",
|
||||||
|
"unocss": "0.55.3",
|
||||||
|
"unplugin-auto-import": "^0.16.7",
|
||||||
|
"unplugin-icons": "^0.16.6",
|
||||||
|
"unplugin-vue-components": "^0.25.2",
|
||||||
|
"vite-plugin-compression": "^0.5.1",
|
||||||
|
"vite-plugin-html": "^3.2.2",
|
||||||
|
"vite-plugin-mkcert": "^1.17.9",
|
||||||
|
"vite-plugin-mock": "2.9.6",
|
||||||
|
"vite-plugin-svg-icons": "^2.0.1"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.1.4+sha512.9df9cf27c91715646c7d675d1c9c8e41f6fce88246f1318c1aa6a1ed1aeb3c4f032fcdf4ba63cc69c4fe6d634279176b5358727d8f2cc1e65b65f43ce2f8bfb0"
|
||||||
|
}
|
||||||
9388
pnpm-lock.yaml
generated
Normal file
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
2232
public/favicon.svg
Normal file
|
After Width: | Height: | Size: 170 KiB |
85
public/resource/loading.css
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
.loading-container {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spin__container {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
margin: 36px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spin {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
animation: loadingSpin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-0 {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.right-0 {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.top-0 {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.bottom-0 {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spin-item {
|
||||||
|
position: absolute;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
-webkit-animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
animation: loadingPulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loadingSpin {
|
||||||
|
from {
|
||||||
|
-webkit-transform: rotate(0deg);
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
-webkit-transform: rotate(360deg);
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loadingPulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-delay-500 {
|
||||||
|
-webkit-animation-delay: 500ms;
|
||||||
|
animation-delay: 500ms;
|
||||||
|
}
|
||||||
|
.loading-delay-1000 {
|
||||||
|
-webkit-animation-delay: 1000ms;
|
||||||
|
animation-delay: 1000ms;
|
||||||
|
}
|
||||||
|
.loading-delay-1500 {
|
||||||
|
-webkit-animation-delay: 1500ms;
|
||||||
|
animation-delay: 1500ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6a6a6a;
|
||||||
|
}
|
||||||
9
public/resource/loading.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
function addThemeColorCssVars() {
|
||||||
|
const key = '__THEME_COLOR__'
|
||||||
|
const defaultColor = '#316c72'
|
||||||
|
const themeColor = window.localStorage.getItem(key) || defaultColor
|
||||||
|
const cssVars = `--primary-color: ${themeColor}`
|
||||||
|
document.documentElement.style.cssText = cssVars
|
||||||
|
}
|
||||||
|
|
||||||
|
addThemeColorCssVars()
|
||||||
BIN
public/resource/logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
1
settings/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './theme.json'
|
||||||
37
settings/theme.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"header": {
|
||||||
|
"height": 60
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"visible": true,
|
||||||
|
"height": 50
|
||||||
|
},
|
||||||
|
"naiveThemeOverrides": {
|
||||||
|
"common": {
|
||||||
|
"primaryColor": "#316C72FF",
|
||||||
|
"primaryColorHover": "#316C72E3",
|
||||||
|
"primaryColorPressed": "#2B4C59FF",
|
||||||
|
"primaryColorSuppl": "#316C72E3",
|
||||||
|
|
||||||
|
"infoColor": "#2080F0FF",
|
||||||
|
"infoColorHover": "#4098FCFF",
|
||||||
|
"infoColorPressed": "#1060C9FF",
|
||||||
|
"infoColorSuppl": "#4098FCFF",
|
||||||
|
|
||||||
|
"successColor": "#18A058FF",
|
||||||
|
"successColorHover": "#36AD6AFF",
|
||||||
|
"successColorPressed": "#0C7A43FF",
|
||||||
|
"successColorSuppl": "#36AD6AFF",
|
||||||
|
|
||||||
|
"warningColor": "#F0A020FF",
|
||||||
|
"warningColorHover": "#FCB040FF",
|
||||||
|
"warningColorPressed": "#C97C10FF",
|
||||||
|
"warningColorSuppl": "#FCB040FF",
|
||||||
|
|
||||||
|
"errorColor": "#D03050FF",
|
||||||
|
"errorColorHover": "#DE576DFF",
|
||||||
|
"errorColorPressed": "#AB1F3FFF",
|
||||||
|
"errorColorSuppl": "#DE576DFF"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/.DS_Store
vendored
Normal file
11
src/App.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<AppProvider>
|
||||||
|
<router-view v-slot="{ Component }">
|
||||||
|
<component :is="Component" />
|
||||||
|
</router-view>
|
||||||
|
</AppProvider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import AppProvider from '@/components/common/AppProvider.vue'
|
||||||
|
</script>
|
||||||
15
src/api/index.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { request } from '@/utils'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getUser: () => request.get('/user'),
|
||||||
|
refreshToken: () => request.post('/auth/refreshToken', null, { noNeedTip: true }),
|
||||||
|
// 获取商家信息
|
||||||
|
getMerchantInfo: () => request.post('/info'),
|
||||||
|
// 上传图片
|
||||||
|
uploadImg: (data) =>
|
||||||
|
request.post('/upload', data, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
BIN
src/assets/images/404.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src/assets/images/login_banner.webp
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src/assets/images/login_bg.webp
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
1
src/assets/svg/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 448 512"><path fill="currentColor" d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm-92.2 312.9c-63.4 0-85.4-28.6-97.1-64.1c-16.3-51-21.5-84.3-63-84.3c-22.4 0-45.1 16.1-45.1 61.2c0 35.2 18 57.2 43.3 57.2c28.6 0 47.6-21.3 47.6-21.3l11.7 31.9s-19.8 19.4-61.2 19.4c-51.3 0-79.9-30.1-79.9-85.8c0-57.9 28.6-92 82.5-92c73.5 0 80.8 41.4 100.8 101.9c8.8 26.8 24.2 46.2 61.2 46.2c24.9 0 38.1-5.5 38.1-19.1c0-19.9-21.8-22-49.9-28.6c-30.4-7.3-42.5-23.1-42.5-48c0-40 32.3-52.4 65.2-52.4c37.4 0 60.1 13.6 63 46.6l-36.7 4.4c-1.5-15.8-11-22.4-28.6-22.4c-16.1 0-26 7.3-26 19.8c0 11 4.8 17.6 20.9 21.3c32.7 7.1 71.8 12 71.8 57.5c.1 36.7-30.7 50.6-76.1 50.6z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 811 B |
51
src/components/Echarts.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<v-chart :loading="loading" class="chart" :option="option" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { use } from 'echarts/core'
|
||||||
|
import { BarChart, PieChart } from 'echarts/charts'
|
||||||
|
import {
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
GridComponent,
|
||||||
|
DataZoomComponent,
|
||||||
|
} from 'echarts/components'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
|
||||||
|
use([
|
||||||
|
TitleComponent,
|
||||||
|
TooltipComponent,
|
||||||
|
LegendComponent,
|
||||||
|
GridComponent,
|
||||||
|
BarChart,
|
||||||
|
CanvasRenderer,
|
||||||
|
PieChart,
|
||||||
|
DataZoomComponent,
|
||||||
|
])
|
||||||
|
|
||||||
|
import VChart, { THEME_KEY } from 'vue-echarts'
|
||||||
|
|
||||||
|
import { provide } from 'vue'
|
||||||
|
|
||||||
|
provide(THEME_KEY, 'white')
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
option: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.chart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
src/components/Editor.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="_ref" style="border: 1px solid #ccc">
|
||||||
|
<Toolbar
|
||||||
|
style="border-bottom: 1px solid #ccc"
|
||||||
|
:editor="editorRef"
|
||||||
|
:default-config="toolbarConfig"
|
||||||
|
mode="default"
|
||||||
|
/>
|
||||||
|
<Editor
|
||||||
|
v-model="htmlValue"
|
||||||
|
style="height: 500px; overflow-y: hidden"
|
||||||
|
:default-config="editorConfig"
|
||||||
|
mode="default"
|
||||||
|
@on-created="handleCreated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import '@wangeditor/editor/dist/css/style.css' // 引入 css
|
||||||
|
|
||||||
|
import { onBeforeUnmount, shallowRef, ref } from 'vue'
|
||||||
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
valueHtml: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:valueHtml'])
|
||||||
|
|
||||||
|
const editorRef = shallowRef()
|
||||||
|
|
||||||
|
const _ref = ref(null)
|
||||||
|
|
||||||
|
const toolbarConfig = {}
|
||||||
|
|
||||||
|
const editorConfig = { placeholder: '请输入商品详情...', MENU_CONF: {} }
|
||||||
|
|
||||||
|
const htmlValue = computed({
|
||||||
|
get() {
|
||||||
|
return props.valueHtml
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
emit('update:valueHtml', value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
editorConfig.MENU_CONF['uploadImage'] = {
|
||||||
|
allowedFileTypes: ['image/*'],
|
||||||
|
base64LimitSize: 5 * 1024, // 5kb
|
||||||
|
// 超时时间,默认为 10 秒
|
||||||
|
timeout: 5 * 1000, // 5 秒
|
||||||
|
|
||||||
|
async customUpload(file, insertFn) {
|
||||||
|
await upFile(file, insertFn)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
editorConfig.MENU_CONF['uploadVideo'] = {
|
||||||
|
allowedFileTypes: ['video/*'],
|
||||||
|
|
||||||
|
async customUpload(file, insertFn) {
|
||||||
|
await upFile(file, insertFn)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件销毁时,也及时销毁编辑器
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
const editor = editorRef.value
|
||||||
|
if (editor == null) return
|
||||||
|
editor.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreated = (editor) => {
|
||||||
|
editorRef.value = editor // 记录 editor 实例,重要!
|
||||||
|
editor.on('fullScreen', () => {
|
||||||
|
_ref.value.style.zIndex = 10
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const upFile = async (file, insertFn) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const res = await api.uploadImg(formData)
|
||||||
|
insertFn(res.data.data)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
527
src/components/TiandituPicker.vue
Normal file
@@ -0,0 +1,527 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tianditu-picker">
|
||||||
|
<!-- 顶部搜索框 -->
|
||||||
|
<div class="search-header">
|
||||||
|
<n-input-group>
|
||||||
|
<n-auto-complete
|
||||||
|
v-model:value="keyword"
|
||||||
|
placeholder="搜索地点"
|
||||||
|
clearable
|
||||||
|
@select="selectFromSearch"
|
||||||
|
@input:value="handleInput"
|
||||||
|
/>
|
||||||
|
<n-button type="primary" @click="search">搜索位置</n-button>
|
||||||
|
</n-input-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 地图区域 -->
|
||||||
|
<div id="mapDiv" class="map-container"></div>
|
||||||
|
|
||||||
|
<!-- 底部位置信息和搜索结果 -->
|
||||||
|
<div class="location-footer">
|
||||||
|
<!-- 统一的结果列表 -->
|
||||||
|
<div class="results-section">
|
||||||
|
<div class="results-header">位置选择</div>
|
||||||
|
<div class="results-list">
|
||||||
|
<!-- 我的位置项 -->
|
||||||
|
<div
|
||||||
|
class="result-item current-location-item"
|
||||||
|
:class="{ selected: !showSearchResults }"
|
||||||
|
@click="relocateToCurrentPosition"
|
||||||
|
>
|
||||||
|
<div class="location-icon">📍</div>
|
||||||
|
<div class="result-content">
|
||||||
|
<div class="result-name">我的位置</div>
|
||||||
|
<div class="result-address">
|
||||||
|
{{ currentLocation?.address || '请点击获取当前位置' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索结果项 -->
|
||||||
|
<div
|
||||||
|
v-for="(result, index) in searchResults"
|
||||||
|
:key="`search-${index}`"
|
||||||
|
class="result-item search-result-item"
|
||||||
|
@click="selectSearchResult(result)"
|
||||||
|
>
|
||||||
|
<div class="location-icon">📍</div>
|
||||||
|
<div class="result-content">
|
||||||
|
<div class="result-name">{{ result.name }}</div>
|
||||||
|
<div class="result-address">{{ result.address }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="action-buttons">
|
||||||
|
<n-space justify="space-between">
|
||||||
|
<n-button type="tertiary" @click="getCurrentLocation">定位当前位置</n-button>
|
||||||
|
<n-space>
|
||||||
|
<n-button @click="$emit('cancel')">取消</n-button>
|
||||||
|
<n-button type="primary" @click="confirmLocation">确认位置</n-button>
|
||||||
|
</n-space>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const emit = defineEmits(['confirm', 'cancel'])
|
||||||
|
|
||||||
|
const keyword = ref('')
|
||||||
|
const selectedLocation = ref(null)
|
||||||
|
const currentLocation = ref(null)
|
||||||
|
const searchResults = ref([])
|
||||||
|
const showSearchResults = ref(false)
|
||||||
|
let map = null
|
||||||
|
let marker = null
|
||||||
|
|
||||||
|
const TDT_KEY = '42db4f3dfd1a18d31e73ee90aa2ce054'
|
||||||
|
|
||||||
|
// 初始化地图
|
||||||
|
const initMap = () => {
|
||||||
|
if (typeof T === 'undefined') {
|
||||||
|
console.error('天地图API未加载完成')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
map = new window.T.Map('mapDiv')
|
||||||
|
map.centerAndZoom(new window.T.LngLat(116.40969, 39.89945), 12)
|
||||||
|
|
||||||
|
// 地图点击事件
|
||||||
|
map.addEventListener('click', function (e) {
|
||||||
|
const lnglat = e.lnglat
|
||||||
|
addMarker(lnglat)
|
||||||
|
getAddress(lnglat)
|
||||||
|
// 点击地图时隐藏搜索结果
|
||||||
|
showSearchResults.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取当前位置
|
||||||
|
getCurrentLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加标记
|
||||||
|
const addMarker = (lnglat) => {
|
||||||
|
if (marker) {
|
||||||
|
map.removeOverLay(marker)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建自定义图标
|
||||||
|
const icon = new window.T.Icon({
|
||||||
|
iconUrl:
|
||||||
|
'data:image/svg+xml;base64,' +
|
||||||
|
btoa(`
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="40" viewBox="0 0 32 40">
|
||||||
|
<path fill="#ff4757" d="M16 0C7.2 0 0 7.2 0 16c0 8.8 16 24 16 24s16-15.2 16-24C32 7.2 24.8 0 16 0zm0 22c-3.3 0-6-2.7-6-6s2.7-6 6-6 6 2.7 6 6-2.7 6-6 6z"/>
|
||||||
|
<circle fill="white" cx="16" cy="16" r="4"/>
|
||||||
|
</svg>
|
||||||
|
`),
|
||||||
|
iconSize: new window.T.Point(32, 40),
|
||||||
|
iconAnchor: new window.T.Point(16, 40),
|
||||||
|
})
|
||||||
|
|
||||||
|
marker = new window.T.Marker(lnglat, { icon })
|
||||||
|
map.addOverLay(marker)
|
||||||
|
map.panTo(lnglat)
|
||||||
|
|
||||||
|
selectedLocation.value = {
|
||||||
|
lat: lnglat.lat,
|
||||||
|
lng: lnglat.lng,
|
||||||
|
address: '正在获取地址...',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 地理编码
|
||||||
|
const getAddress = (lnglat) => {
|
||||||
|
const geocoder = new window.T.Geocoder()
|
||||||
|
geocoder.getLocation(lnglat, function (result) {
|
||||||
|
const address = result.getAddress()
|
||||||
|
if (selectedLocation.value) {
|
||||||
|
selectedLocation.value.address = address
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前位置
|
||||||
|
const getCurrentLocation = () => {
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => {
|
||||||
|
const lat = position.coords.latitude
|
||||||
|
const lng = position.coords.longitude
|
||||||
|
const lnglat = new window.T.LngLat(lng, lat)
|
||||||
|
|
||||||
|
map.centerAndZoom(lnglat, 15)
|
||||||
|
addMarker(lnglat)
|
||||||
|
|
||||||
|
// 获取地址信息,专门为当前位置设置
|
||||||
|
const geocoder = new window.T.Geocoder()
|
||||||
|
geocoder.getLocation(lnglat, function (result) {
|
||||||
|
const address = result.getAddress()
|
||||||
|
// 同时更新当前位置和选中位置
|
||||||
|
currentLocation.value = {
|
||||||
|
lat: lat,
|
||||||
|
lng: lng,
|
||||||
|
address: address,
|
||||||
|
}
|
||||||
|
if (selectedLocation.value) {
|
||||||
|
selectedLocation.value.address = address
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('获取位置失败:', error)
|
||||||
|
$message.warning('无法获取当前位置,请手动选择')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
$message.warning('浏览器不支持定位功能')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用axios调用天地图HTTP API搜索
|
||||||
|
const searchWithAPI = async (query) => {
|
||||||
|
try {
|
||||||
|
console.log('搜索关键词:', query)
|
||||||
|
|
||||||
|
// 构建搜索参数
|
||||||
|
const postStr = {
|
||||||
|
keyWord: query,
|
||||||
|
level: 12,
|
||||||
|
mapBound: '116.02524,39.83833,116.65592,39.99185',
|
||||||
|
queryType: 1,
|
||||||
|
start: 0,
|
||||||
|
count: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get('https://api.tianditu.gov.cn/v2/search', {
|
||||||
|
params: {
|
||||||
|
postStr: JSON.stringify(postStr),
|
||||||
|
type: 'query',
|
||||||
|
tk: TDT_KEY,
|
||||||
|
},
|
||||||
|
timeout: 10000, // 10秒超时
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('搜索响应:', response.data)
|
||||||
|
|
||||||
|
if (
|
||||||
|
response.data.status.infocode === 1000 &&
|
||||||
|
response.data.pois &&
|
||||||
|
response.data.pois.length > 0
|
||||||
|
) {
|
||||||
|
return response.data.pois.map((poi) => ({
|
||||||
|
name: poi.name,
|
||||||
|
address: poi.address,
|
||||||
|
lonlat: poi.lonlat,
|
||||||
|
adminName: poi.adminName,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索失败:', error)
|
||||||
|
|
||||||
|
// 更详细的错误处理
|
||||||
|
if (error.code === 'ECONNABORTED') {
|
||||||
|
$message.error('请求超时,请重试')
|
||||||
|
} else if (error.response) {
|
||||||
|
$message.error(`搜索失败: ${error.response.status}`)
|
||||||
|
} else if (error.request) {
|
||||||
|
$message.error('网络请求失败,请检查网络连接')
|
||||||
|
} else {
|
||||||
|
$message.error('搜索出错,请重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索功能
|
||||||
|
const search = async () => {
|
||||||
|
if (!keyword.value || !map) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await searchWithAPI(keyword.value)
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
searchResults.value = results
|
||||||
|
showSearchResults.value = true
|
||||||
|
console.log('搜索结果:', results)
|
||||||
|
} else {
|
||||||
|
$message.warning('未找到相关位置')
|
||||||
|
searchResults.value = []
|
||||||
|
showSearchResults.value = false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索出错:', error)
|
||||||
|
$message.error('搜索失败,请重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择搜索结果
|
||||||
|
const selectSearchResult = (result) => {
|
||||||
|
if (!result.lonlat) return
|
||||||
|
|
||||||
|
const [lng, lat] = result.lonlat.split(',').map(Number)
|
||||||
|
const lnglat = new window.T.LngLat(lng, lat)
|
||||||
|
|
||||||
|
addMarker(lnglat)
|
||||||
|
// 只更新选中位置,不影响当前位置
|
||||||
|
if (selectedLocation.value) {
|
||||||
|
selectedLocation.value.address = `${result.name} - ${result.address}`
|
||||||
|
}
|
||||||
|
showSearchResults.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理搜索输入
|
||||||
|
const handleInput = (value) => {
|
||||||
|
if (value && value.length > 1) {
|
||||||
|
// 延迟搜索,避免频繁请求
|
||||||
|
clearTimeout(handleInput.timer)
|
||||||
|
handleInput.timer = setTimeout(() => {
|
||||||
|
search()
|
||||||
|
}, 500)
|
||||||
|
} else {
|
||||||
|
searchResults.value = []
|
||||||
|
showSearchResults.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从搜索建议中选择
|
||||||
|
const selectFromSearch = (value, option) => {
|
||||||
|
if (option && option.data) {
|
||||||
|
selectSearchResult(option.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认选择的位置
|
||||||
|
const confirmLocation = () => {
|
||||||
|
if (selectedLocation.value) {
|
||||||
|
emit('confirm', {
|
||||||
|
latlng: {
|
||||||
|
lat: selectedLocation.value.lat,
|
||||||
|
lng: selectedLocation.value.lng,
|
||||||
|
},
|
||||||
|
address: selectedLocation.value.address || '',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
$message.warning('请先选择位置')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新定位到当前位置
|
||||||
|
const relocateToCurrentPosition = () => {
|
||||||
|
if (currentLocation.value) {
|
||||||
|
// 如果已有当前位置信息,直接使用
|
||||||
|
const lnglat = new window.T.LngLat(currentLocation.value.lng, currentLocation.value.lat)
|
||||||
|
addMarker(lnglat)
|
||||||
|
if (selectedLocation.value) {
|
||||||
|
selectedLocation.value.address = currentLocation.value.address
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 重新获取当前位置
|
||||||
|
getCurrentLocation()
|
||||||
|
}
|
||||||
|
showSearchResults.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载天地图API
|
||||||
|
const loadTiandituScript = () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (typeof T !== 'undefined') {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.type = 'text/javascript'
|
||||||
|
script.src = `https://api.tianditu.gov.cn/api?v=4.0&tk=${TDT_KEY}`
|
||||||
|
script.onload = resolve
|
||||||
|
script.onerror = reject
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await loadTiandituScript()
|
||||||
|
await nextTick()
|
||||||
|
initMap()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载天地图API失败:', error)
|
||||||
|
$message.error('地图加载失败,请检查网络连接')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (map) {
|
||||||
|
map = null
|
||||||
|
marker = null
|
||||||
|
}
|
||||||
|
if (handleInput.timer) {
|
||||||
|
clearTimeout(handleInput.timer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.tianditu-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
|
||||||
|
.search-header {
|
||||||
|
padding: 12px 0;
|
||||||
|
background-color: #fff;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-footer {
|
||||||
|
background-color: #fff;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.results-section {
|
||||||
|
.results-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
max-height: 180px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-right: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-content {
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
.result-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-address {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-location-item {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深色模式适配
|
||||||
|
.dark {
|
||||||
|
.tianditu-picker {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
|
||||||
|
.search-header {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-bottom-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-footer {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-top-color: #3a3a3a;
|
||||||
|
|
||||||
|
.results-section {
|
||||||
|
.results-header {
|
||||||
|
background-color: #333;
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-list {
|
||||||
|
.result-item {
|
||||||
|
border-bottom-color: #3a3a3a;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-name {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-address {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-location-item {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-top-color: #3a3a3a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
68
src/components/Upload.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<n-upload
|
||||||
|
v-model:file-list="List"
|
||||||
|
:max="max"
|
||||||
|
list-type="image-card"
|
||||||
|
:custom-request="customRequest"
|
||||||
|
:multiple="multiple"
|
||||||
|
@before-upload="beforeUpload"
|
||||||
|
>
|
||||||
|
点击上传
|
||||||
|
</n-upload>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const prop = defineProps({
|
||||||
|
list: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
max: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
multiple: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:list'])
|
||||||
|
|
||||||
|
const List = computed({
|
||||||
|
get() {
|
||||||
|
return prop.list
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
emit('update:list', val)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 图片上传限制
|
||||||
|
const beforeUpload = (data) => {
|
||||||
|
if (!(data.file.file?.type === 'image/png' || data.file.file?.type === 'image/jpeg')) {
|
||||||
|
$message.error('只能上传png或jpg格式的图片文件,请重新上传')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const customRequest = async ({ file, onFinish, onError }) => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file.file)
|
||||||
|
const res = await api.uploadImg(formData)
|
||||||
|
$message.success(res.msg)
|
||||||
|
file.url = res.data.data
|
||||||
|
onFinish()
|
||||||
|
} catch (e) {
|
||||||
|
onError()
|
||||||
|
$message.error('上传失败')
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped></style>
|
||||||
23
src/components/common/AppFooter.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<footer f-c-c flex-col text-14 color="#6a6a6a">
|
||||||
|
<!-- <p>
|
||||||
|
Copyright © 2022-present
|
||||||
|
<a
|
||||||
|
href="https://github.com/zclzone"
|
||||||
|
target="__blank"
|
||||||
|
hover="decoration-underline color-primary"
|
||||||
|
>
|
||||||
|
Ronnie Zhang
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a
|
||||||
|
href="http://beian.miit.gov.cn/"
|
||||||
|
target="__blank"
|
||||||
|
hover="decoration-underline color-primary"
|
||||||
|
>
|
||||||
|
赣ICP备2020015008号-1
|
||||||
|
</a>
|
||||||
|
</p> -->
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
30
src/components/common/AppProvider.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<n-config-provider
|
||||||
|
wh-full
|
||||||
|
:locale="zhCN"
|
||||||
|
:date-locale="dateZhCN"
|
||||||
|
:theme="appStore.isDark ? darkTheme : undefined"
|
||||||
|
:theme-overrides="naiveThemeOverrides"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</n-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { zhCN, dateZhCN, darkTheme } from 'naive-ui'
|
||||||
|
import { useCssVar } from '@vueuse/core'
|
||||||
|
import { kebabCase } from 'lodash-es'
|
||||||
|
import { naiveThemeOverrides } from '~/settings'
|
||||||
|
import { useAppStore } from '@/store'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
function setupCssVar() {
|
||||||
|
const common = naiveThemeOverrides.common
|
||||||
|
for (const key in common) {
|
||||||
|
useCssVar(`--${kebabCase(key)}`, document.documentElement).value = common[key] || ''
|
||||||
|
if (key === 'primaryColor') window.localStorage.setItem('__THEME_COLOR__', common[key] || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setupCssVar()
|
||||||
|
</script>
|
||||||
162
src/components/common/ScrollX.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="wrapper" @mousewheel.prevent="handleMouseWheel">
|
||||||
|
<template v-if="showArrow && isOverflow">
|
||||||
|
<div class="left dark:bg-dark!" @click="handleMouseWheel({ wheelDelta: 120 })">
|
||||||
|
<icon-ic:baseline-keyboard-arrow-left />
|
||||||
|
</div>
|
||||||
|
<div class="right dark:bg-dark!" @click="handleMouseWheel({ wheelDelta: -120 })">
|
||||||
|
<icon-ic:baseline-keyboard-arrow-right />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="content"
|
||||||
|
class="content"
|
||||||
|
:class="{ overflow: isOverflow && showArrow }"
|
||||||
|
:style="{
|
||||||
|
transform: `translateX(${translateX}px)`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { debounce, useResize } from '@/utils'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
showArrow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const translateX = ref(0)
|
||||||
|
const content = ref(null)
|
||||||
|
const wrapper = ref(null)
|
||||||
|
const isOverflow = ref(false)
|
||||||
|
|
||||||
|
const refreshIsOverflow = debounce(() => {
|
||||||
|
const wrapperWidth = wrapper.value?.offsetWidth
|
||||||
|
const contentWidth = content.value?.offsetWidth
|
||||||
|
isOverflow.value = contentWidth > wrapperWidth
|
||||||
|
resetTranslateX(wrapperWidth, contentWidth)
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
function handleMouseWheel(e) {
|
||||||
|
const { wheelDelta } = e
|
||||||
|
const wrapperWidth = wrapper.value?.offsetWidth
|
||||||
|
const contentWidth = content.value?.offsetWidth
|
||||||
|
/**
|
||||||
|
* @wheelDelta 平行滚动的值 >0: 右移 <0: 左移
|
||||||
|
* @translateX 内容translateX的值
|
||||||
|
* @wrapperWidth 容器的宽度
|
||||||
|
* @contentWidth 内容的宽度
|
||||||
|
*/
|
||||||
|
if (wheelDelta < 0) {
|
||||||
|
if (wrapperWidth > contentWidth && translateX.value < -10) return
|
||||||
|
if (wrapperWidth <= contentWidth && contentWidth + translateX.value - wrapperWidth < -10) return
|
||||||
|
}
|
||||||
|
if (wheelDelta > 0 && translateX.value > 10) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
translateX.value += wheelDelta
|
||||||
|
resetTranslateX(wrapperWidth, contentWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetTranslateX = debounce(function (wrapperWidth, contentWidth) {
|
||||||
|
if (!isOverflow.value) {
|
||||||
|
translateX.value = 0
|
||||||
|
} else if (-translateX.value > contentWidth - wrapperWidth) {
|
||||||
|
translateX.value = wrapperWidth - contentWidth
|
||||||
|
} else if (translateX.value > 0) {
|
||||||
|
translateX.value = 0
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
const observers = ref([])
|
||||||
|
onMounted(() => {
|
||||||
|
refreshIsOverflow()
|
||||||
|
|
||||||
|
observers.value.push(useResize(document.body, refreshIsOverflow))
|
||||||
|
observers.value.push(useResize(content.value, refreshIsOverflow))
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observers.value.forEach((item) => {
|
||||||
|
item?.disconnect()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleScroll(x, width) {
|
||||||
|
const wrapperWidth = wrapper.value?.offsetWidth
|
||||||
|
const contentWidth = content.value?.offsetWidth
|
||||||
|
if (contentWidth <= wrapperWidth) return
|
||||||
|
|
||||||
|
// 当 x 小于可视范围的最小值时
|
||||||
|
if (x < -translateX.value + 150) {
|
||||||
|
translateX.value = -(x - 150)
|
||||||
|
resetTranslateX(wrapperWidth, contentWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当 x 大于可视范围的最大值时
|
||||||
|
if (x + width > -translateX.value + wrapperWidth) {
|
||||||
|
translateX.value = wrapperWidth - (x + width)
|
||||||
|
resetTranslateX(wrapperWidth, contentWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
handleScroll,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
background-color: #fff;
|
||||||
|
|
||||||
|
z-index: 9;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
.content {
|
||||||
|
padding: 0 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
transition: transform 0.5s;
|
||||||
|
&.overflow {
|
||||||
|
padding-left: 30px;
|
||||||
|
padding-right: 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.left,
|
||||||
|
.right {
|
||||||
|
background-color: #fff;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: auto;
|
||||||
|
|
||||||
|
width: 20px;
|
||||||
|
height: 35px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
font-size: 18px;
|
||||||
|
border: 1px solid #e0e0e6;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
z-index: 2;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.left {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.right {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
src/components/icon/CustomIcon.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup>
|
||||||
|
/** 自定义图标 */
|
||||||
|
const props = defineProps({
|
||||||
|
/** 图标名称(assets/svg下的文件名) */
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
default: 14,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TheIcon type="custom" v-bind="props" />
|
||||||
|
</template>
|
||||||
24
src/components/icon/SvgIcon.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
prefix: {
|
||||||
|
type: String,
|
||||||
|
default: 'icon-custom',
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: 'currentColor',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const symbolId = computed(() => `#${props.prefix}-${props.icon}`)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg aria-hidden="true" width="1em" height="1em">
|
||||||
|
<use :xlink:href="symbolId" :fill="color" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
33
src/components/icon/TheIcon.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup>
|
||||||
|
import { renderIcon, renderCustomIcon } from '@/utils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: Number,
|
||||||
|
default: 14,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
/** iconify | custom */
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'iconify',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconCom = computed(() =>
|
||||||
|
props.type === 'iconify'
|
||||||
|
? renderIcon(props.icon, { size: props.size, color: props.color })
|
||||||
|
: renderCustomIcon(props.icon, { size: props.size, color: props.color })
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component :is="iconCom" />
|
||||||
|
</template>
|
||||||
18
src/components/page/AppPage.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<transition name="fade-slide" mode="out-in" appear>
|
||||||
|
<section class="cus-scroll-y wh-full flex-col bg-[#f5f6fb] p-15 dark:bg-hex-121212">
|
||||||
|
<slot />
|
||||||
|
<AppFooter v-if="showFooter" mt-15 />
|
||||||
|
<n-back-top :bottom="20" />
|
||||||
|
</section>
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
showFooter: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
33
src/components/page/CommonPage.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<AppPage :show-footer="showFooter">
|
||||||
|
<header v-if="showHeader" mb-15 min-h-45 flex items-center justify-between px-15>
|
||||||
|
<slot v-if="$slots.header" name="header" />
|
||||||
|
<template v-else>
|
||||||
|
<h2 text-22 font-normal text-hex-333 dark:text-hex-ccc>{{ title || route.meta?.title }}</h2>
|
||||||
|
<slot name="action" />
|
||||||
|
</template>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<n-card flex-1 rounded-10>
|
||||||
|
<slot />
|
||||||
|
</n-card>
|
||||||
|
</AppPage>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
showFooter: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showHeader: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const route = useRoute()
|
||||||
|
</script>
|
||||||
27
src/components/query-bar/QueryBar.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
bg="#fafafc"
|
||||||
|
min-h-60
|
||||||
|
flex
|
||||||
|
items-start
|
||||||
|
justify-between
|
||||||
|
b-1
|
||||||
|
rounded-8
|
||||||
|
p-15
|
||||||
|
bc-ccc
|
||||||
|
dark:bg-black
|
||||||
|
>
|
||||||
|
<n-space wrap :size="[35, 15]">
|
||||||
|
<slot />
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<div flex-shrink-0>
|
||||||
|
<n-button secondary type="primary" @click="emit('reset')">重置</n-button>
|
||||||
|
<n-button ml-20 type="primary" @click="emit('search')">搜索</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const emit = defineEmits(['search', 'reset'])
|
||||||
|
</script>
|
||||||
34
src/components/query-bar/QueryBarItem.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div flex items-center>
|
||||||
|
<label
|
||||||
|
v-if="!isNullOrWhitespace(label)"
|
||||||
|
w-80
|
||||||
|
flex-shrink-0
|
||||||
|
:style="{ width: labelWidth + 'px' }"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
<div :style="{ width: contentWidth + 'px' }" flex-shrink-0>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { isNullOrWhitespace } from '@/utils'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
labelWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 80,
|
||||||
|
},
|
||||||
|
contentWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 220,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
55
src/components/table/CrudModal.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<n-modal
|
||||||
|
v-model:show="show"
|
||||||
|
:style="{ width }"
|
||||||
|
preset="card"
|
||||||
|
:title="title"
|
||||||
|
size="huge"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<template v-if="showFooter" #footer>
|
||||||
|
<footer flex justify-end>
|
||||||
|
<slot name="footer">
|
||||||
|
<n-button @click="show = false">取消</n-button>
|
||||||
|
<n-button :loading="loading" ml-20 type="primary" @click="emit('onSave')">保存</n-button>
|
||||||
|
</slot>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
width: {
|
||||||
|
type: String,
|
||||||
|
default: '600px',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
showFooter: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'onSave'])
|
||||||
|
const show = computed({
|
||||||
|
get() {
|
||||||
|
return props.visible
|
||||||
|
},
|
||||||
|
set(v) {
|
||||||
|
emit('update:visible', v)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
</script>
|
||||||
149
src/components/table/CrudTable.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<QueryBar v-if="$slots.queryBar" mb-30 @search="handleSearch" @reset="handleReset">
|
||||||
|
<slot name="queryBar" />
|
||||||
|
</QueryBar>
|
||||||
|
|
||||||
|
<n-data-table
|
||||||
|
:remote="remote"
|
||||||
|
:loading="loading"
|
||||||
|
:scroll-x="scrollX"
|
||||||
|
:columns="columns"
|
||||||
|
:data="tableData"
|
||||||
|
:row-key="(row) => row[rowKey]"
|
||||||
|
:pagination="isPagination ? pagination : false"
|
||||||
|
@update:checked-row-keys="onChecked"
|
||||||
|
@update:page="onPageChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { utils, writeFile } from 'xlsx'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
/**
|
||||||
|
* @remote true: 后端分页 false: 前端分页
|
||||||
|
*/
|
||||||
|
remote: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* @remote 是否分页
|
||||||
|
*/
|
||||||
|
isPagination: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
scrollX: {
|
||||||
|
type: Number,
|
||||||
|
default: 1200,
|
||||||
|
},
|
||||||
|
rowKey: {
|
||||||
|
type: String,
|
||||||
|
default: 'id',
|
||||||
|
},
|
||||||
|
columns: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
/** queryBar中的参数 */
|
||||||
|
queryItems: {
|
||||||
|
type: Object,
|
||||||
|
default() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/** 补充参数(可选) */
|
||||||
|
extraParams: {
|
||||||
|
type: Object,
|
||||||
|
default() {
|
||||||
|
return {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* ! 约定接口入参出参
|
||||||
|
* * 分页模式需约定分页接口入参
|
||||||
|
* @pageSize 分页参数:一页展示多少条,默认10
|
||||||
|
* @pageNo 分页参数:页码,默认1
|
||||||
|
* * 需约定接口出参
|
||||||
|
* @pageData 分页模式必须,非分页模式如果没有pageData则取上一层data
|
||||||
|
* @total 分页模式必须,非分页模式如果没有total则取上一层data.length
|
||||||
|
*/
|
||||||
|
getData: {
|
||||||
|
type: Function,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:queryItems', 'onChecked', 'onDataChange'])
|
||||||
|
const loading = ref(false)
|
||||||
|
const initQuery = { ...props.queryItems }
|
||||||
|
const tableData = ref([])
|
||||||
|
const pagination = reactive({ page: 1, pageSize: 10 })
|
||||||
|
|
||||||
|
async function handleQuery() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
let paginationParams = {}
|
||||||
|
// 如果非分页模式或者使用前端分页,则无需传分页参数
|
||||||
|
if (props.isPagination && props.remote) {
|
||||||
|
paginationParams = { pageNo: pagination.page, pageSize: pagination.pageSize }
|
||||||
|
}
|
||||||
|
const { data } = await props.getData({
|
||||||
|
...props.queryItems,
|
||||||
|
...props.extraParams,
|
||||||
|
...paginationParams,
|
||||||
|
})
|
||||||
|
tableData.value = data?.pageData || data
|
||||||
|
pagination.itemCount = data.total ?? data.length
|
||||||
|
} catch (error) {
|
||||||
|
tableData.value = []
|
||||||
|
pagination.itemCount = 0
|
||||||
|
} finally {
|
||||||
|
emit('onDataChange', tableData.value)
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleSearch() {
|
||||||
|
pagination.page = 1
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
async function handleReset() {
|
||||||
|
const queryItems = { ...props.queryItems }
|
||||||
|
for (const key in queryItems) {
|
||||||
|
queryItems[key] = ''
|
||||||
|
}
|
||||||
|
emit('update:queryItems', { ...queryItems, ...initQuery })
|
||||||
|
await nextTick()
|
||||||
|
pagination.page = 1
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
function onPageChange(currentPage) {
|
||||||
|
pagination.page = currentPage
|
||||||
|
if (props.remote) {
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onChecked(rowKeys) {
|
||||||
|
if (props.columns.some((item) => item.type === 'selection')) {
|
||||||
|
emit('onChecked', rowKeys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleExport(columns = props.columns, data = tableData.value) {
|
||||||
|
if (!data?.length) return $message.warning('没有数据')
|
||||||
|
const columnsData = columns.filter((item) => !!item.title && !item.hideInExcel)
|
||||||
|
const thKeys = columnsData.map((item) => item.key)
|
||||||
|
const thData = columnsData.map((item) => item.title)
|
||||||
|
const trData = data.map((item) => thKeys.map((key) => item[key]))
|
||||||
|
const sheet = utils.aoa_to_sheet([thData, ...trData])
|
||||||
|
const workBook = utils.book_new()
|
||||||
|
utils.book_append_sheet(workBook, sheet, '数据报表')
|
||||||
|
writeFile(workBook, '数据报表.xlsx')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
handleSearch,
|
||||||
|
handleReset,
|
||||||
|
handleExport,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
1
src/composables/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as useCRUD } from './useCRUD'
|
||||||
103
src/composables/useCRUD.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { isNullOrWhitespace } from '@/utils'
|
||||||
|
|
||||||
|
const ACTIONS = {
|
||||||
|
view: '查看',
|
||||||
|
edit: '编辑',
|
||||||
|
add: '新增',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ({ name, initForm = {}, doCreate, doDelete, doUpdate, refresh }) {
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const modalAction = ref('')
|
||||||
|
const modalTitle = computed(() => ACTIONS[modalAction.value] + name)
|
||||||
|
const modalLoading = ref(false)
|
||||||
|
const modalFormRef = ref(null)
|
||||||
|
const modalForm = ref({ ...initForm })
|
||||||
|
|
||||||
|
/** 新增 */
|
||||||
|
function handleAdd() {
|
||||||
|
modalAction.value = 'add'
|
||||||
|
modalVisible.value = true
|
||||||
|
modalForm.value = { ...initForm }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改 */
|
||||||
|
function handleEdit(row) {
|
||||||
|
modalAction.value = 'edit'
|
||||||
|
modalVisible.value = true
|
||||||
|
modalForm.value = { ...row }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查看 */
|
||||||
|
function handleView(row) {
|
||||||
|
modalAction.value = 'view'
|
||||||
|
modalVisible.value = true
|
||||||
|
modalForm.value = { ...row }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存 */
|
||||||
|
function handleSave() {
|
||||||
|
if (!['edit', 'add'].includes(modalAction.value)) {
|
||||||
|
modalVisible.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modalFormRef.value?.validate(async (err) => {
|
||||||
|
if (err) return
|
||||||
|
const actions = {
|
||||||
|
add: {
|
||||||
|
api: () => doCreate(modalForm.value),
|
||||||
|
cb: () => $message.success('新增成功'),
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
api: () => doUpdate(modalForm.value),
|
||||||
|
cb: () => $message.success('编辑成功'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const action = actions[modalAction.value]
|
||||||
|
|
||||||
|
try {
|
||||||
|
modalLoading.value = true
|
||||||
|
const data = await action.api()
|
||||||
|
action.cb()
|
||||||
|
modalLoading.value = modalVisible.value = false
|
||||||
|
data && refresh(data)
|
||||||
|
} catch (error) {
|
||||||
|
modalLoading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 */
|
||||||
|
function handleDelete(id, confirmOptions) {
|
||||||
|
if (isNullOrWhitespace(id)) return
|
||||||
|
$dialog.confirm({
|
||||||
|
content: '确定删除?',
|
||||||
|
async confirm() {
|
||||||
|
try {
|
||||||
|
modalLoading.value = true
|
||||||
|
const data = await doDelete(id)
|
||||||
|
$message.success('删除成功')
|
||||||
|
modalLoading.value = false
|
||||||
|
refresh(data)
|
||||||
|
} catch (error) {
|
||||||
|
modalLoading.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...confirmOptions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
modalVisible,
|
||||||
|
modalAction,
|
||||||
|
modalTitle,
|
||||||
|
modalLoading,
|
||||||
|
handleAdd,
|
||||||
|
handleDelete,
|
||||||
|
handleEdit,
|
||||||
|
handleView,
|
||||||
|
handleSave,
|
||||||
|
modalForm,
|
||||||
|
modalFormRef,
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/layout/components/AppMain.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<router-view v-slot="{ Component, route }">
|
||||||
|
<KeepAlive :include="keepAliveNames">
|
||||||
|
<component :is="Component" v-if="!tagStore.reloading" :key="route.fullPath" />
|
||||||
|
</KeepAlive>
|
||||||
|
</router-view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useTagsStore } from '@/store'
|
||||||
|
const tagStore = useTagsStore()
|
||||||
|
|
||||||
|
const keepAliveNames = computed(() => {
|
||||||
|
return tagStore.tags.filter((item) => item.keepAlive).map((item) => item.name)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
30
src/layout/components/header/components/BreadCrumb.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<n-breadcrumb>
|
||||||
|
<n-breadcrumb-item
|
||||||
|
v-for="item in route.matched.filter((item) => !!item.meta?.title)"
|
||||||
|
:key="item.path"
|
||||||
|
@click="handleBreadClick(item.path)"
|
||||||
|
>
|
||||||
|
<component :is="getIcon(item.meta)" />
|
||||||
|
{{ item.meta.title }}
|
||||||
|
</n-breadcrumb-item>
|
||||||
|
</n-breadcrumb>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { renderCustomIcon, renderIcon } from '@/utils'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
function handleBreadClick(path) {
|
||||||
|
if (path === route.path) return
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcon(meta) {
|
||||||
|
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
|
||||||
|
if (meta?.icon) return renderIcon(meta.icon, { size: 18 })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
12
src/layout/components/header/components/FullScreen.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<n-icon mr20 size="18" style="cursor: pointer" @click="toggle">
|
||||||
|
<icon-ant-design:fullscreen-exit-outlined v-if="isFullscreen" />
|
||||||
|
<icon-ant-design:fullscreen-outlined v-else />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useFullscreen } from '@vueuse/core'
|
||||||
|
|
||||||
|
const { isFullscreen, toggle } = useFullscreen()
|
||||||
|
</script>
|
||||||
11
src/layout/components/header/components/GithubSite.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<n-icon mr-20 size="18" style="cursor: pointer" @click="handleLinkClick">
|
||||||
|
<icon-mdi:github />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
function handleLinkClick() {
|
||||||
|
window.open('https://github.com/zclzone/vue-naive-admin')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
12
src/layout/components/header/components/MenuCollapse.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<n-icon size="20" cursor-pointer @click="appStore.switchCollapsed">
|
||||||
|
<icon-mdi:format-indent-increase v-if="appStore.collapsed" />
|
||||||
|
<icon-mdi:format-indent-decrease v-else />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAppStore } from '@/store'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<n-popover trigger="click" placement="bottom" @update:show="handlePopoverShow">
|
||||||
|
<template #trigger>
|
||||||
|
<n-badge :value="count" mr-20 cursor-pointer>
|
||||||
|
<n-icon size="18" color-black dark="color-hex-fff">
|
||||||
|
<icon-material-symbols:notifications-outline />
|
||||||
|
</n-icon>
|
||||||
|
</n-badge>
|
||||||
|
</template>
|
||||||
|
<n-tabs v-model:value="activeTab" type="line" justify-content="space-around" animated>
|
||||||
|
<n-tab-pane
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.name"
|
||||||
|
:name="tab.name"
|
||||||
|
:tab="tab.title + `(${tab.messages.length})`"
|
||||||
|
>
|
||||||
|
<ul class="cus-scroll-y max-h-200 w-220">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in tab.messages"
|
||||||
|
:key="index"
|
||||||
|
class="flex-col py-12"
|
||||||
|
border-t="1 solid gray-200"
|
||||||
|
:style="index > 0 ? '' : 'border: none;'"
|
||||||
|
>
|
||||||
|
<span mb-4 text-ellipsis>{{ item.content }}</span>
|
||||||
|
<span text-hex-bbb>{{ item.time }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</n-popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { formatDateTime } from '@/utils'
|
||||||
|
|
||||||
|
const activeTab = ref('')
|
||||||
|
const tabs = [
|
||||||
|
{
|
||||||
|
name: 'zan',
|
||||||
|
title: '点赞',
|
||||||
|
messages: [
|
||||||
|
{ content: '你的文章《XX》收到一条点赞', time: formatDateTime() },
|
||||||
|
{ content: '你的文章《YY》收到一条点赞', time: formatDateTime() },
|
||||||
|
{ content: '你的文章《AA》收到一条点赞', time: formatDateTime() },
|
||||||
|
{ content: '你的文章《BB》收到一条点赞', time: formatDateTime() },
|
||||||
|
{ content: '你的文章《CC》收到一条点赞', time: formatDateTime() },
|
||||||
|
{ content: '你的文章《DD》收到一条点赞', time: formatDateTime() },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'star',
|
||||||
|
title: '关注',
|
||||||
|
messages: [
|
||||||
|
{ content: '张三 关注了你', time: formatDateTime() },
|
||||||
|
{ content: '王五 关注了你', time: formatDateTime() },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'comment',
|
||||||
|
title: '评论',
|
||||||
|
messages: [
|
||||||
|
{ content: '张三 评论了你的文章《XX》"学到了"', time: formatDateTime() },
|
||||||
|
{ content: '李四 评论了你的文章《YY》"不如Vue"', time: formatDateTime() },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const count = ref(tabs.map((item) => item.messages).flat().length)
|
||||||
|
|
||||||
|
watch(activeTab, (v) => {
|
||||||
|
if (count.value === 0) return
|
||||||
|
const tabIndex = tabs.findIndex((item) => item.name === v)
|
||||||
|
count.value -= tabs[tabIndex].messages.length
|
||||||
|
if (count.value < 0) count.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
function handlePopoverShow(show) {
|
||||||
|
if (show && !activeTab.value) {
|
||||||
|
activeTab.value = tabs[0]?.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
18
src/layout/components/header/components/ThemeMode.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useAppStore } from '@/store'
|
||||||
|
import { useDark, useToggle } from '@vueuse/core'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
const isDark = useDark()
|
||||||
|
const toggleDark = () => {
|
||||||
|
appStore.toggleDark()
|
||||||
|
useToggle(isDark)()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<n-icon mr-20 cursor-pointer size="18" @click="toggleDark">
|
||||||
|
<icon-mdi-moon-waning-crescent v-if="isDark" />
|
||||||
|
<icon-mdi-white-balance-sunny v-else />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
37
src/layout/components/header/components/UserAvatar.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<n-dropdown :options="options" @select="handleSelect">
|
||||||
|
<div flex cursor-pointer items-center>
|
||||||
|
<img :src="userStore.avatar" mr10 h-35 w-35 rounded-full />
|
||||||
|
<span>{{ userStore.name }}</span>
|
||||||
|
</div>
|
||||||
|
</n-dropdown>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useUserStore } from '@/store'
|
||||||
|
import { renderIcon } from '@/utils'
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
label: '退出登录',
|
||||||
|
key: 'logout',
|
||||||
|
icon: renderIcon('mdi:exit-to-app', { size: '14px' }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function handleSelect(key) {
|
||||||
|
if (key === 'logout') {
|
||||||
|
$dialog.confirm({
|
||||||
|
title: '提示',
|
||||||
|
type: 'info',
|
||||||
|
content: '确认退出?',
|
||||||
|
confirm() {
|
||||||
|
userStore.logout()
|
||||||
|
$message.success('已退出登录')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
23
src/layout/components/header/index.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<div flex items-center>
|
||||||
|
<MenuCollapse />
|
||||||
|
<BreadCrumb ml-15 hidden sm:block />
|
||||||
|
</div>
|
||||||
|
<div ml-auto flex items-center>
|
||||||
|
<!-- <MessageNotification /> -->
|
||||||
|
<ThemeMode />
|
||||||
|
<!-- <GithubSite /> -->
|
||||||
|
<FullScreen />
|
||||||
|
<UserAvatar />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import BreadCrumb from './components/BreadCrumb.vue'
|
||||||
|
import MenuCollapse from './components/MenuCollapse.vue'
|
||||||
|
import FullScreen from './components/FullScreen.vue'
|
||||||
|
import UserAvatar from './components/UserAvatar.vue'
|
||||||
|
// import GithubSite from './components/GithubSite.vue'
|
||||||
|
import ThemeMode from './components/ThemeMode.vue'
|
||||||
|
// import MessageNotification from './components/MessageNotification.vue'
|
||||||
|
</script>
|
||||||
15
src/layout/components/sidebar/components/SideLogo.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<router-link h-60 f-c-c to="/">
|
||||||
|
<img src="@/assets/images/logo.png" height="42" />
|
||||||
|
<h2 v-show="!appStore.collapsed" ml-10 max-w-140 flex-shrink-0 text-16 font-bold color-primary>
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useAppStore } from '@/store'
|
||||||
|
const title = import.meta.env.VITE_TITLE
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
</script>
|
||||||
121
src/layout/components/sidebar/components/SideMenu.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<n-menu
|
||||||
|
ref="menu"
|
||||||
|
class="side-menu"
|
||||||
|
accordion
|
||||||
|
:indent="18"
|
||||||
|
:collapsed-icon-size="22"
|
||||||
|
:collapsed-width="64"
|
||||||
|
:options="menuOptions"
|
||||||
|
:value="activeKey"
|
||||||
|
@update:value="handleMenuSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { usePermissionStore } from '@/store'
|
||||||
|
import { renderCustomIcon, renderIcon, isExternal } from '@/utils'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const curRoute = useRoute()
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
|
||||||
|
const activeKey = computed(() => curRoute.meta?.activeMenu || curRoute.name)
|
||||||
|
|
||||||
|
const menuOptions = computed(() => {
|
||||||
|
return permissionStore.menus.map((item) => getMenuItem(item)).sort((a, b) => a.order - b.order)
|
||||||
|
})
|
||||||
|
|
||||||
|
const menu = ref(null)
|
||||||
|
watch(curRoute, async () => {
|
||||||
|
await nextTick()
|
||||||
|
menu.value?.showOption()
|
||||||
|
})
|
||||||
|
|
||||||
|
function resolvePath(basePath, path) {
|
||||||
|
if (isExternal(path)) return path
|
||||||
|
return (
|
||||||
|
'/' +
|
||||||
|
[basePath, path]
|
||||||
|
.filter((path) => !!path && path !== '/')
|
||||||
|
.map((path) => path.replace(/(^\/)|(\/$)/g, ''))
|
||||||
|
.join('/')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMenuItem(route, basePath = '') {
|
||||||
|
let menuItem = {
|
||||||
|
label: (route.meta && route.meta.title) || route.name,
|
||||||
|
key: route.name,
|
||||||
|
path: resolvePath(basePath, route.path),
|
||||||
|
icon: getIcon(route.meta),
|
||||||
|
order: route.meta?.order || 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleChildren = route.children
|
||||||
|
? route.children.filter((item) => item.name && !item.isHidden)
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (!visibleChildren.length) return menuItem
|
||||||
|
|
||||||
|
// if (visibleChildren.length === 1) {
|
||||||
|
// 单个子路由处理
|
||||||
|
// const singleRoute = visibleChildren[0]
|
||||||
|
// menuItem = {
|
||||||
|
// ...menuItem,
|
||||||
|
// label: singleRoute.meta?.title || singleRoute.name,
|
||||||
|
// key: singleRoute.name,
|
||||||
|
// path: resolvePath(menuItem.path, singleRoute.path),
|
||||||
|
// icon: getIcon(singleRoute.meta),
|
||||||
|
// }
|
||||||
|
// const visibleItems = singleRoute.children
|
||||||
|
// ? singleRoute.children.filter((item) => item.name && !item.isHidden)
|
||||||
|
// : []
|
||||||
|
|
||||||
|
// if (visibleItems.length === 1) {
|
||||||
|
// menuItem = getMenuItem(visibleItems[0], menuItem.path)
|
||||||
|
// } else if (visibleItems.length > 1) {
|
||||||
|
// menuItem.children = visibleItems
|
||||||
|
// .map((item) => getMenuItem(item, menuItem.path))
|
||||||
|
// .sort((a, b) => a.order - b.order)
|
||||||
|
// }
|
||||||
|
// } else {
|
||||||
|
menuItem.children = visibleChildren
|
||||||
|
.map((item) => getMenuItem(item, menuItem.path))
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
// }
|
||||||
|
return menuItem
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcon(meta) {
|
||||||
|
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
|
||||||
|
if (meta?.icon) return renderIcon(meta.icon, { size: 18 })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMenuSelect(key, item) {
|
||||||
|
if (isExternal(item.path)) {
|
||||||
|
window.open(item.path)
|
||||||
|
} else {
|
||||||
|
router.push(item.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.side-menu:not(.n-menu--collapsed) {
|
||||||
|
.n-menu-item-content {
|
||||||
|
&::before {
|
||||||
|
left: 5px;
|
||||||
|
right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.n-menu-item-content--selected,
|
||||||
|
&:hover {
|
||||||
|
&::before {
|
||||||
|
border-left: 4px solid var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
9
src/layout/components/sidebar/index.vue
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup>
|
||||||
|
import SideLogo from './components/SideLogo.vue'
|
||||||
|
import SideMenu from './components/SideMenu.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SideLogo />
|
||||||
|
<SideMenu />
|
||||||
|
</template>
|
||||||
118
src/layout/components/tags/ContextMenu.vue
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
<template>
|
||||||
|
<n-dropdown
|
||||||
|
:show="show"
|
||||||
|
:options="options"
|
||||||
|
:x="x"
|
||||||
|
:y="y"
|
||||||
|
placement="bottom-start"
|
||||||
|
@clickoutside="handleHideDropdown"
|
||||||
|
@select="handleSelect"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useTagsStore } from '@/store'
|
||||||
|
import { renderIcon } from '@/utils'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
show: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
currentPath: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:show'])
|
||||||
|
|
||||||
|
const tagsStore = useTagsStore()
|
||||||
|
|
||||||
|
const options = computed(() => [
|
||||||
|
{
|
||||||
|
label: '重新加载',
|
||||||
|
key: 'reload',
|
||||||
|
disabled: props.currentPath !== tagsStore.activeTag,
|
||||||
|
icon: renderIcon('mdi:refresh', { size: '14px' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '关闭',
|
||||||
|
key: 'close',
|
||||||
|
disabled: tagsStore.tags.length <= 1,
|
||||||
|
icon: renderIcon('mdi:close', { size: '14px' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '关闭其他',
|
||||||
|
key: 'close-other',
|
||||||
|
disabled: tagsStore.tags.length <= 1,
|
||||||
|
icon: renderIcon('mdi:arrow-expand-horizontal', { size: '14px' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '关闭左侧',
|
||||||
|
key: 'close-left',
|
||||||
|
disabled: tagsStore.tags.length <= 1 || props.currentPath === tagsStore.tags[0].path,
|
||||||
|
icon: renderIcon('mdi:arrow-expand-left', { size: '14px' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '关闭右侧',
|
||||||
|
key: 'close-right',
|
||||||
|
disabled:
|
||||||
|
tagsStore.tags.length <= 1 ||
|
||||||
|
props.currentPath === tagsStore.tags[tagsStore.tags.length - 1].path,
|
||||||
|
icon: renderIcon('mdi:arrow-expand-right', { size: '14px' }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const actionMap = new Map([
|
||||||
|
[
|
||||||
|
'reload',
|
||||||
|
() => {
|
||||||
|
tagsStore.reloadTag(route.path, route.meta?.keepAlive)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'close',
|
||||||
|
() => {
|
||||||
|
tagsStore.removeTag(props.currentPath)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'close-other',
|
||||||
|
() => {
|
||||||
|
tagsStore.removeOther(props.currentPath)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'close-left',
|
||||||
|
() => {
|
||||||
|
tagsStore.removeLeft(props.currentPath)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'close-right',
|
||||||
|
() => {
|
||||||
|
tagsStore.removeRight(props.currentPath)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
function handleHideDropdown() {
|
||||||
|
emit('update:show', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(key) {
|
||||||
|
const actionFn = actionMap.get(key)
|
||||||
|
actionFn && actionFn()
|
||||||
|
handleHideDropdown()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
106
src/layout/components/tags/index.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<ScrollX ref="scrollXRef" class="bg-white dark:bg-dark!">
|
||||||
|
<n-tag
|
||||||
|
v-for="tag in tagsStore.tags"
|
||||||
|
ref="tabRefs"
|
||||||
|
:key="tag.path"
|
||||||
|
class="mx-5 cursor-pointer rounded-4 px-15 hover:color-primary"
|
||||||
|
:type="tagsStore.activeTag === tag.path ? 'primary' : 'default'"
|
||||||
|
:closable="tagsStore.tags.length > 1"
|
||||||
|
@click="handleTagClick(tag.path)"
|
||||||
|
@close.stop="tagsStore.removeTag(tag.path)"
|
||||||
|
@contextmenu.prevent="handleContextMenu($event, tag)"
|
||||||
|
>
|
||||||
|
<template v-if="tag.icon" #icon>
|
||||||
|
<TheIcon :icon="tag.icon" class="mr-4" />
|
||||||
|
</template>
|
||||||
|
{{ tag.title }}
|
||||||
|
</n-tag>
|
||||||
|
<ContextMenu
|
||||||
|
v-if="contextMenuOption.show"
|
||||||
|
v-model:show="contextMenuOption.show"
|
||||||
|
:current-path="contextMenuOption.currentPath"
|
||||||
|
:x="contextMenuOption.x"
|
||||||
|
:y="contextMenuOption.y"
|
||||||
|
/>
|
||||||
|
</ScrollX>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ContextMenu from './ContextMenu.vue'
|
||||||
|
import { useTagsStore } from '@/store'
|
||||||
|
import ScrollX from '@/components/common/ScrollX.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const tagsStore = useTagsStore()
|
||||||
|
const tabRefs = ref([])
|
||||||
|
const scrollXRef = ref(null)
|
||||||
|
|
||||||
|
const contextMenuOption = reactive({
|
||||||
|
show: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
currentPath: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
() => {
|
||||||
|
const { name, fullPath: path } = route
|
||||||
|
const title = route.meta?.title
|
||||||
|
const icon = route.meta?.icon
|
||||||
|
const keepAlive = route.meta?.keepAlive
|
||||||
|
tagsStore.addTag({ name, path, title, icon, keepAlive })
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => tagsStore.activeIndex,
|
||||||
|
async (activeIndex) => {
|
||||||
|
await nextTick()
|
||||||
|
const activeTabElement = tabRefs.value[activeIndex]?.$el
|
||||||
|
if (!activeTabElement) return
|
||||||
|
const { offsetLeft: x, offsetWidth: width } = activeTabElement
|
||||||
|
scrollXRef.value?.handleScroll(x + width, width)
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleTagClick = (path) => {
|
||||||
|
tagsStore.setActiveTag(path)
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showContextMenu() {
|
||||||
|
contextMenuOption.show = true
|
||||||
|
}
|
||||||
|
function hideContextMenu() {
|
||||||
|
contextMenuOption.show = false
|
||||||
|
}
|
||||||
|
function setContextMenu(x, y, currentPath) {
|
||||||
|
Object.assign(contextMenuOption, { x, y, currentPath })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右击菜单
|
||||||
|
async function handleContextMenu(e, tagItem) {
|
||||||
|
const { clientX, clientY } = e
|
||||||
|
hideContextMenu()
|
||||||
|
setContextMenu(clientX, clientY, tagItem.path)
|
||||||
|
await nextTick()
|
||||||
|
showContextMenu()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.n-tag__close {
|
||||||
|
box-sizing: content-box;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px;
|
||||||
|
transform: scale(0.9);
|
||||||
|
transform: translateX(5px);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
42
src/layout/index.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<template>
|
||||||
|
<n-layout has-sider wh-full>
|
||||||
|
<n-layout-sider
|
||||||
|
bordered
|
||||||
|
collapse-mode="width"
|
||||||
|
:collapsed-width="64"
|
||||||
|
:width="220"
|
||||||
|
:native-scrollbar="false"
|
||||||
|
:collapsed="appStore.collapsed"
|
||||||
|
>
|
||||||
|
<SideBar />
|
||||||
|
</n-layout-sider>
|
||||||
|
|
||||||
|
<article flex-col flex-1 overflow-hidden>
|
||||||
|
<header
|
||||||
|
border-b="1 solid #eee"
|
||||||
|
class="flex items-center bg-white px-15"
|
||||||
|
dark="bg-dark border-0"
|
||||||
|
:style="`height: ${header.height}px`"
|
||||||
|
>
|
||||||
|
<AppHeader />
|
||||||
|
</header>
|
||||||
|
<section v-if="tags.visible" hidden border-b bc-eee sm:block dark:border-0>
|
||||||
|
<AppTags :style="{ height: `${tags.height}px` }" />
|
||||||
|
</section>
|
||||||
|
<section flex-1 overflow-hidden bg-hex-f5f6fb dark:bg-hex-101014>
|
||||||
|
<AppMain />
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</n-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import AppHeader from './components/header/index.vue'
|
||||||
|
import SideBar from './components/sidebar/index.vue'
|
||||||
|
import AppMain from './components/AppMain.vue'
|
||||||
|
import AppTags from './components/tags/index.vue'
|
||||||
|
import { useAppStore } from '@/store'
|
||||||
|
import { header, tags } from '~/settings'
|
||||||
|
|
||||||
|
const appStore = useAppStore()
|
||||||
|
</script>
|
||||||
28
src/main.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/** 重置样式 */
|
||||||
|
import '@/styles/reset.css'
|
||||||
|
import 'uno.css'
|
||||||
|
import '@/styles/global.scss'
|
||||||
|
import 'virtual:svg-icons-register'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { setupRouter } from '@/router'
|
||||||
|
import { setupStore } from '@/store'
|
||||||
|
import App from './App.vue'
|
||||||
|
import { setupNaiveDiscreteApi } from './utils'
|
||||||
|
import { initApiEndpoint } from '@/utils/api-config'
|
||||||
|
|
||||||
|
async function setupApp() {
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 初始化接口配置
|
||||||
|
initApiEndpoint()
|
||||||
|
|
||||||
|
setupStore(app)
|
||||||
|
setupNaiveDiscreteApi()
|
||||||
|
|
||||||
|
await setupRouter(app)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
|
}
|
||||||
|
|
||||||
|
setupApp()
|
||||||
9
src/router/guard/index.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createPageLoadingGuard } from './page-loading-guard'
|
||||||
|
import { createPageTitleGuard } from './page-title-guard'
|
||||||
|
import { createPermissionGuard } from './permission-guard'
|
||||||
|
|
||||||
|
export function setupRouterGuard(router) {
|
||||||
|
createPageLoadingGuard(router)
|
||||||
|
createPermissionGuard(router)
|
||||||
|
createPageTitleGuard(router)
|
||||||
|
}
|
||||||
15
src/router/guard/page-loading-guard.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function createPageLoadingGuard(router) {
|
||||||
|
router.beforeEach(() => {
|
||||||
|
window.$loadingBar?.start()
|
||||||
|
})
|
||||||
|
|
||||||
|
router.afterEach(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.$loadingBar?.finish()
|
||||||
|
}, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
router.onError(() => {
|
||||||
|
window.$loadingBar?.error()
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/router/guard/page-title-guard.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
const baseTitle = import.meta.env.VITE_TITLE
|
||||||
|
|
||||||
|
export function createPageTitleGuard(router) {
|
||||||
|
router.afterEach((to) => {
|
||||||
|
const pageTitle = to.meta?.title
|
||||||
|
if (pageTitle) {
|
||||||
|
document.title = `${pageTitle} | ${baseTitle}`
|
||||||
|
} else {
|
||||||
|
document.title = baseTitle
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
38
src/router/guard/permission-guard.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { getToken, refreshAccessToken, isNullOrWhitespace } from '@/utils'
|
||||||
|
import { addDynamicRoutes } from '@/router'
|
||||||
|
|
||||||
|
const WHITE_LIST = ['/login', '/404']
|
||||||
|
export function createPermissionGuard(router) {
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
const token = getToken()
|
||||||
|
|
||||||
|
/** 没有token的情况 */
|
||||||
|
if (isNullOrWhitespace(token)) {
|
||||||
|
if (WHITE_LIST.includes(to.path)) return true
|
||||||
|
return { path: 'login', query: { ...to.query, redirect: to.path } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单点登录的情况 */
|
||||||
|
if (to.query.tk) return true
|
||||||
|
|
||||||
|
/** 有token的情况 */
|
||||||
|
if (to.path === '/login') return { path: '/' }
|
||||||
|
|
||||||
|
// 确保动态路由已加载
|
||||||
|
if (token && !router.hasRoute('Dashboard')) {
|
||||||
|
try {
|
||||||
|
await addDynamicRoutes()
|
||||||
|
// 如果当前路径不存在,重定向到工作台
|
||||||
|
if (to.path !== '/' && !router.hasRoute(to.name)) {
|
||||||
|
return { path: '/workbench' }
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('动态路由加载失败:', error)
|
||||||
|
return { path: '/login' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshAccessToken()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
97
src/router/index.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
|
||||||
|
import { setupRouterGuard } from './guard'
|
||||||
|
import { basicRoutes, EMPTY_ROUTE, NOT_FOUND_ROUTE } from './routes'
|
||||||
|
import { getToken, isNullOrWhitespace } from '@/utils'
|
||||||
|
import { useUserStore, usePermissionStore } from '@/store'
|
||||||
|
|
||||||
|
const isHash = false
|
||||||
|
export const router = createRouter({
|
||||||
|
history: isHash ? createWebHashHistory('/') : createWebHistory('/'),
|
||||||
|
routes: basicRoutes,
|
||||||
|
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function setupRouter(app) {
|
||||||
|
await addDynamicRoutes()
|
||||||
|
setupRouterGuard(router)
|
||||||
|
app.use(router)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetRouter() {
|
||||||
|
const basicRouteNames = getRouteNames(basicRoutes)
|
||||||
|
router.getRoutes().forEach((route) => {
|
||||||
|
const name = route.name
|
||||||
|
if (!basicRouteNames.includes(name)) {
|
||||||
|
router.removeRoute(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addDynamicRoutes() {
|
||||||
|
const token = getToken()
|
||||||
|
|
||||||
|
// 没有token情况
|
||||||
|
if (isNullOrWhitespace(token)) {
|
||||||
|
router.addRoute(EMPTY_ROUTE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有token的情况
|
||||||
|
try {
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
!userStore.userId && (await userStore.getUserInfo())
|
||||||
|
const accessRoutes = permissionStore.generateRoutes(userStore.role)
|
||||||
|
|
||||||
|
// 确保路由按正确顺序添加
|
||||||
|
accessRoutes.forEach((route) => {
|
||||||
|
if (!router.hasRoute(route.name)) {
|
||||||
|
router.addRoute(route)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 移除空路由,添加404路由
|
||||||
|
if (router.hasRoute(EMPTY_ROUTE.name)) {
|
||||||
|
router.removeRoute(EMPTY_ROUTE.name)
|
||||||
|
}
|
||||||
|
router.addRoute(NOT_FOUND_ROUTE)
|
||||||
|
|
||||||
|
// 确保根路径重定向到工作台
|
||||||
|
if (!router.hasRoute('Dashboard')) {
|
||||||
|
const workbenchRoute = {
|
||||||
|
name: 'Dashboard',
|
||||||
|
path: '/',
|
||||||
|
component: () => import('@/layout/index.vue'),
|
||||||
|
redirect: '/workbench',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'Workbench',
|
||||||
|
path: 'workbench',
|
||||||
|
component: () => import('@/views/workbench/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '工作台',
|
||||||
|
icon: 'mdi:index',
|
||||||
|
order: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
router.addRoute(workbenchRoute)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRouteNames(routes) {
|
||||||
|
return routes.map((route) => getRouteName(route)).flat(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRouteName(route) {
|
||||||
|
const names = [route.name]
|
||||||
|
if (route.children && route.children.length) {
|
||||||
|
names.push(...route.children.map((item) => getRouteName(item)).flat(1))
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
78
src/router/routes/index.js
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
// const Layout = () => import('@/layout/index.vue')
|
||||||
|
|
||||||
|
export const basicRoutes = [
|
||||||
|
{
|
||||||
|
name: '404',
|
||||||
|
path: '/404',
|
||||||
|
component: () => import('@/views/error-page/404.vue'),
|
||||||
|
isHidden: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'Login',
|
||||||
|
path: '/login',
|
||||||
|
component: () => import('@/views/login/index.vue'),
|
||||||
|
isHidden: true,
|
||||||
|
meta: {
|
||||||
|
title: '登录页',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// {
|
||||||
|
// name: 'ExternalLink',
|
||||||
|
// path: '/external-link',
|
||||||
|
// component: Layout,
|
||||||
|
// meta: {
|
||||||
|
// title: '外部链接',
|
||||||
|
// icon: 'mdi:link-variant',
|
||||||
|
// order: 4,
|
||||||
|
// },
|
||||||
|
// children: [
|
||||||
|
// {
|
||||||
|
// name: 'LinkGithubSrc',
|
||||||
|
// path: 'https://github.com/zclzone/vue-naive-admin',
|
||||||
|
// meta: {
|
||||||
|
// title: '源码 - github',
|
||||||
|
// icon: 'mdi:github',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'LinkGiteeSrc',
|
||||||
|
// path: 'https://gitee.com/zclzone/vue-naive-admin',
|
||||||
|
// meta: {
|
||||||
|
// title: '源码 - gitee',
|
||||||
|
// icon: 'simple-icons:gitee',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'LinkDocs',
|
||||||
|
// path: 'https://zclzone.github.io/vue-naive-admin-docs',
|
||||||
|
// meta: {
|
||||||
|
// title: '文档 - vuepress',
|
||||||
|
// icon: 'mdi:vuejs',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const NOT_FOUND_ROUTE = {
|
||||||
|
name: 'NotFound',
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/404',
|
||||||
|
isHidden: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_ROUTE = {
|
||||||
|
name: 'Empty',
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
component: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules = import.meta.glob('@/views/**/route.js', { eager: true })
|
||||||
|
const asyncRoutes = []
|
||||||
|
Object.keys(modules).forEach((key) => {
|
||||||
|
asyncRoutes.push(modules[key].default)
|
||||||
|
})
|
||||||
|
|
||||||
|
export { asyncRoutes }
|
||||||
7
src/store/index.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
export function setupStore(app) {
|
||||||
|
app.use(createPinia())
|
||||||
|
}
|
||||||
|
|
||||||
|
export * from './modules'
|
||||||
28
src/store/modules/app/index.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { useDark } from '@vueuse/core'
|
||||||
|
|
||||||
|
const isDark = useDark()
|
||||||
|
export const useAppStore = defineStore('app', {
|
||||||
|
state() {
|
||||||
|
return {
|
||||||
|
collapsed: false,
|
||||||
|
isDark,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
switchCollapsed() {
|
||||||
|
this.collapsed = !this.collapsed
|
||||||
|
},
|
||||||
|
setCollapsed(collapsed) {
|
||||||
|
this.collapsed = collapsed
|
||||||
|
},
|
||||||
|
/** 设置暗黑模式 */
|
||||||
|
setDark(isDark) {
|
||||||
|
this.isDark = isDark
|
||||||
|
},
|
||||||
|
/** 切换/关闭 暗黑模式 */
|
||||||
|
toggleDark() {
|
||||||
|
this.isDark = !this.isDark
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
19
src/store/modules/goods/index.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useGoodsStore = defineStore('goods', {
|
||||||
|
state() {
|
||||||
|
return {
|
||||||
|
goodType: 'add',
|
||||||
|
row: {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setRow(row) {
|
||||||
|
this.row = row
|
||||||
|
},
|
||||||
|
|
||||||
|
setType(row) {
|
||||||
|
this.goodType = row
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
5
src/store/modules/index.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './app'
|
||||||
|
export * from './permission'
|
||||||
|
export * from './tags'
|
||||||
|
export * from './user'
|
||||||
|
export * from './goods'
|
||||||
60
src/store/modules/permission/index.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { asyncRoutes, basicRoutes } from '@/router/routes'
|
||||||
|
|
||||||
|
function hasPermission(route, role) {
|
||||||
|
// * 不需要权限直接返回true
|
||||||
|
if (!route.meta?.requireAuth) return true
|
||||||
|
|
||||||
|
const routeRole = route.meta?.role ? route.meta.role : []
|
||||||
|
|
||||||
|
// * 登录用户没有角色或者路由没有设置角色判定为没有权限
|
||||||
|
if (!role.length || !routeRole.length) return false
|
||||||
|
|
||||||
|
// * 路由指定的角色包含任一登录用户角色则判定有权限
|
||||||
|
return role.some((item) => routeRole.includes(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterAsyncRoutes(routes = [], role) {
|
||||||
|
const ret = []
|
||||||
|
routes.forEach((route) => {
|
||||||
|
if (hasPermission(route, role)) {
|
||||||
|
const curRoute = {
|
||||||
|
...route,
|
||||||
|
children: [],
|
||||||
|
}
|
||||||
|
if (route.children && route.children.length) {
|
||||||
|
curRoute.children = filterAsyncRoutes(route.children, role)
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(curRoute, 'children')
|
||||||
|
}
|
||||||
|
ret.push(curRoute)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePermissionStore = defineStore('permission', {
|
||||||
|
state() {
|
||||||
|
return {
|
||||||
|
accessRoutes: [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
routes() {
|
||||||
|
return basicRoutes.concat(this.accessRoutes)
|
||||||
|
},
|
||||||
|
menus() {
|
||||||
|
return this.routes.filter((route) => route.name && !route.isHidden)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
generateRoutes(role = []) {
|
||||||
|
const accessRoutes = filterAsyncRoutes(asyncRoutes, role)
|
||||||
|
this.accessRoutes = accessRoutes
|
||||||
|
return accessRoutes
|
||||||
|
},
|
||||||
|
resetPermission() {
|
||||||
|
this.$reset()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
6
src/store/modules/tags/helpers.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { sStorage } from '@/utils'
|
||||||
|
|
||||||
|
export const activeTag = sStorage.get('activeTag')
|
||||||
|
export const tags = sStorage.get('tags')
|
||||||
|
|
||||||
|
export const WITHOUT_TAG_PATHS = ['/404', '/login']
|
||||||
83
src/store/modules/tags/index.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { activeTag, tags, WITHOUT_TAG_PATHS } from './helpers'
|
||||||
|
import { router } from '@/router'
|
||||||
|
import { sStorage } from '@/utils'
|
||||||
|
|
||||||
|
export const useTagsStore = defineStore('tag', {
|
||||||
|
state() {
|
||||||
|
return {
|
||||||
|
tags: tags || [],
|
||||||
|
activeTag: activeTag || '',
|
||||||
|
reloading: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
activeIndex() {
|
||||||
|
return this.tags.findIndex((item) => item.path === this.activeTag)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
setActiveTag(path) {
|
||||||
|
this.activeTag = path
|
||||||
|
sStorage.set('activeTag', path)
|
||||||
|
},
|
||||||
|
setTags(tags) {
|
||||||
|
this.tags = tags
|
||||||
|
sStorage.set('tags', tags)
|
||||||
|
},
|
||||||
|
addTag(tag = {}) {
|
||||||
|
if (WITHOUT_TAG_PATHS.includes(tag.path)) return
|
||||||
|
let findItem = this.tags.find((item) => item.path === tag.path)
|
||||||
|
if (findItem) findItem = tag
|
||||||
|
else this.setTags([...this.tags, tag])
|
||||||
|
this.setActiveTag(tag.path)
|
||||||
|
},
|
||||||
|
async reloadTag(path, keepAlive) {
|
||||||
|
const findItem = this.tags.find((item) => item.path === path)
|
||||||
|
// 更新key可让keepAlive失效
|
||||||
|
if (findItem && keepAlive) findItem.keepAlive = false
|
||||||
|
|
||||||
|
$loadingBar.start()
|
||||||
|
this.reloading = true
|
||||||
|
await nextTick()
|
||||||
|
this.reloading = false
|
||||||
|
findItem.keepAlive = keepAlive
|
||||||
|
setTimeout(() => {
|
||||||
|
document.documentElement.scrollTo({ left: 0, top: 0 })
|
||||||
|
$loadingBar.finish()
|
||||||
|
}, 100)
|
||||||
|
},
|
||||||
|
removeTag(path) {
|
||||||
|
this.setTags(this.tags.filter((tag) => tag.path !== path))
|
||||||
|
if (path === this.activeTag) {
|
||||||
|
router.push(this.tags[this.tags.length - 1].path)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeOther(curPath = this.activeTag) {
|
||||||
|
this.setTags(this.tags.filter((tag) => tag.path === curPath))
|
||||||
|
if (curPath !== this.activeTag) {
|
||||||
|
router.push(this.tags[this.tags.length - 1].path)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeLeft(curPath) {
|
||||||
|
const curIndex = this.tags.findIndex((item) => item.path === curPath)
|
||||||
|
const filterTags = this.tags.filter((item, index) => index >= curIndex)
|
||||||
|
this.setTags(filterTags)
|
||||||
|
if (!filterTags.find((item) => item.path === this.activeTag)) {
|
||||||
|
router.push(filterTags[filterTags.length - 1].path)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeRight(curPath) {
|
||||||
|
const curIndex = this.tags.findIndex((item) => item.path === curPath)
|
||||||
|
const filterTags = this.tags.filter((item, index) => index <= curIndex)
|
||||||
|
this.setTags(filterTags)
|
||||||
|
if (!filterTags.find((item) => item.path === this.activeTag)) {
|
||||||
|
router.push(filterTags[filterTags.length - 1].path)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetTags() {
|
||||||
|
this.setTags([])
|
||||||
|
this.setActiveTag('')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
56
src/store/modules/user/index.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { resetRouter } from '@/router'
|
||||||
|
import { useTagsStore, usePermissionStore } from '@/store'
|
||||||
|
import { removeToken, toLogin } from '@/utils'
|
||||||
|
// import api from '@/api'
|
||||||
|
|
||||||
|
export const useUserStore = defineStore('user', {
|
||||||
|
state() {
|
||||||
|
return {
|
||||||
|
userInfo: {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getters: {
|
||||||
|
userId() {
|
||||||
|
return this.userInfo?.id
|
||||||
|
},
|
||||||
|
name() {
|
||||||
|
return this.userInfo?.name
|
||||||
|
},
|
||||||
|
avatar() {
|
||||||
|
return this.userInfo?.avatar || 'https://v2.xxapi.cn/api/head?return=302'
|
||||||
|
},
|
||||||
|
role() {
|
||||||
|
return this.userInfo?.role || []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
async getUserInfo() {
|
||||||
|
// const typeMer = localStorage.getItem('type')
|
||||||
|
// this.userInfo = {
|
||||||
|
// role: [typeMer],
|
||||||
|
// }
|
||||||
|
// try {
|
||||||
|
// const res = await api.getUser()
|
||||||
|
// const { id, name, avatar, role } = res.data
|
||||||
|
// this.userInfo = { id, name, avatar, role }
|
||||||
|
// return Promise.resolve(res.data)
|
||||||
|
// } catch (error) {
|
||||||
|
// return Promise.reject(error)
|
||||||
|
// }
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
const { resetTags } = useTagsStore()
|
||||||
|
const { resetPermission } = usePermissionStore()
|
||||||
|
removeToken()
|
||||||
|
resetTags()
|
||||||
|
resetPermission()
|
||||||
|
await resetRouter()
|
||||||
|
this.$reset()
|
||||||
|
toLogin()
|
||||||
|
},
|
||||||
|
setUserInfo(userInfo = {}) {
|
||||||
|
this.userInfo = { ...this.userInfo, ...userInfo }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
74
src/styles/global.scss
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 4px; // * 1rem = 4px 方便unocss计算:在unocss中 1字体单位 = 0.25rem,相当于 1等份 = 1px
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* transition fade-slide */
|
||||||
|
.fade-slide-leave-active,
|
||||||
|
.fade-slide-enter-active {
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义滚动条样式 */
|
||||||
|
.cus-scroll {
|
||||||
|
overflow: auto;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cus-scroll-x {
|
||||||
|
overflow-x: auto;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 0;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cus-scroll-y {
|
||||||
|
overflow-y: auto;
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cus-scroll,
|
||||||
|
.cus-scroll-x,
|
||||||
|
.cus-scroll-y {
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: #bfbfbf;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/styles/reset.css
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
html {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover,
|
||||||
|
a:link,
|
||||||
|
a:visited,
|
||||||
|
a:active {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol,
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||