feat(other): init

This commit is contained in:
2026-01-19 03:06:04 +08:00
commit 2b97d4ef6c
195 changed files with 30912 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

48
.cz-config.js Normal file
View 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
View File

@@ -0,0 +1 @@
node_modules

5
.editorconfig Normal file
View File

@@ -0,0 +1,5 @@
root = true
[*]
charset = utf-8
end_of_line = lf

3
.env Normal file
View File

@@ -0,0 +1,3 @@
VITE_TITLE='捷兑通 - 平台端'
VITE_PORT=4000

28
.env.development Normal file
View File

@@ -0,0 +1,28 @@
# 资源公共路径,需要以 /开头和结尾
VITE_PUBLIC_PATH='/'
# 是否启用MOCK
VITE_USE_MOCK=false
# 是否启用代理
VITE_USE_PROXY=true
# base api
VITE_BASE_API='/api'
VITE_BASE_API_1='/api1'
# VITE_BASE_API='https://test.wanzhuanyongcheng.cn/admin'
# VITE_BASE_API_1='https://api.gxwzwh.com/admin'
VITE_WS1_URL='game.wanzhuanyongcheng.cn/dice/home'
VITE_WS_URL='test.wanzhuanyongcheng.cn/admin/data'
VITE_MER_LOGIN_URL='//localhost:3100/login'
VITE_GAME_API='https://www.jdt168.com'
# 转盘相关
VITE_TRUN_WS_URL='test.wanzhuanyongcheng.cn/admin/turntable'
VITE_TRUN_WS1_URL='game2.wanzhuanyongcheng.cn/turntable/home'

26
.env.production Normal file
View File

@@ -0,0 +1,26 @@
# 资源公共路径,需要以 /开头和结尾
VITE_PUBLIC_PATH='/'
# 是否启用MOCK
VITE_USE_MOCK=false
# base api
VITE_BASE_API='//www.wanzhuanyongcheng.cn/admin'
VITE_BASE_API_1='//api.gxwzwh.com/admin'
# 是否启用压缩
VITE_USE_COMPRESS=true
# 压缩类型
VITE_COMPRESS_TYPE=gzip
VITE_WS1_URL='www.jdt168.com/dice/home'
VITE_WS_URL='www.wanzhuanyongcheng.cn/admin/data'
VITE_MER_LOGIN_URL='//jdt-prod-mer.wanzhuanyongcheng.cn/login'
# 转盘相关
VITE_TRUN_WS_URL='www.wanzhuanyongcheng.cn/admin/turntable'
VITE_TRUN_WS1_URL='turntable.jdt168.com/turntable/home'

29
.env.test Normal file
View File

@@ -0,0 +1,29 @@
# 资源公共路径,需要以 /开头和结尾
VITE_PUBLIC_PATH='/'
# 是否启用MOCK
VITE_USE_MOCK=false
# 是否启用代理
VITE_USE_PROXY=false
# 是否启用压缩
VITE_USE_COMPRESS=true
# 压缩类型
VITE_COMPRESS_TYPE=gzip
# base api
VITE_BASE_API='//test.wanzhuanyongcheng.cn/admin'
VITE_WS1_URL='game.wanzhuanyongcheng.cn/dice/home'
VITE_WS_URL='test.wanzhuanyongcheng.cn/admin/data'
VITE_GAME_API='https://game.wanzhuanyongcheng.cn'
VITE_MER_LOGIN_URL='//jdt-test-mer.wanzhuanyongcheng.cn/login'
# 转盘相关
VITE_TRUN_WS_URL='test.wanzhuanyongcheng.cn/admin/turntable'
VITE_TRUN_WS1_URL='game2.wanzhuanyongcheng.cn/turntable/home'

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

@@ -0,0 +1,4 @@
node_modules
dist
public
package.json

162
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,162 @@
name: CI Build & Deploy
on:
push:
branches:
- test
- main
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-admin-dev
mkdir -p jdt-admin-dev
tar -xzvf dist.tar -C /www/builder/jdt-admin-dev
rm -rf dist.tar
cd jdt-admin-dev
docker build -t jdt-admin-dev .
docker stop jdt-admin-dev || true
docker rm jdt-admin-dev || true
docker run -d -p 8085:80 --restart=always --name jdt-admin-dev jdt-admin-dev
cd ..
rm -rf jdt-admin-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 == 'main'
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-admin-prod
mkdir -p jdt-admin-prod
tar -xzvf dist.tar -C /www/builder/jdt-admin-prod
rm -rf dist.tar
cd jdt-admin-prod
docker build -t jdt-admin-prod .
docker stop jdt-admin-prod || true
docker rm jdt-admin-prod || true
docker run -d -p 8085:80 --restart=always --name jdt-admin-prod jdt-admin-prod
cd ..
rm -rf jdt-admin-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
View File

@@ -0,0 +1,4 @@
node_modules
dist
*.local
stats.html

4
.husky/commit-msg Normal file
View 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
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run lint:staged

57
.idea/codeStyles/Project.xml generated Normal file
View File

@@ -0,0 +1,57 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="80" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

78
.idea/workspace.xml generated Normal file
View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="686d4fd0-e8b8-40f8-9543-b02e49e6b7c2" name="更改" comment="">
<change afterPath="$PROJECT_DIR$/.idea/codeStyles/Project.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/codeStyles/codeStyleConfig.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/vcs.xml" afterDir="false" />
<change afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pnpm-lock.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/pnpm-lock.yaml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/App.vue" beforeDir="false" afterPath="$PROJECT_DIR$/src/App.vue" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="dev" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 7
}</component>
<component name="ProjectId" id="38Nllo7kKtQ32cVRkDYH1bjmPwD" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"git-widget-placeholder": "正在合并 test",
"javascript.preferred.runtime.type.id": "node",
"last_opened_file_path": "E:/XL/jdt-admin",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "pnpm",
"ts.external.directory.path": "D:\\program files\\JetBrains\\WebStorm 2025.2.5\\plugins\\javascript-plugin\\jsLanguageServicesImpl\\external",
"vue.rearranger.settings.migration": "true"
}
}]]></component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-WS-253.29346.242" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="默认任务">
<changelist id="686d4fd0-e8b8-40f8-9543-b02e49e6b7c2" name="更改" comment="" />
<created>1768648062880</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1768648062880</updated>
<workItem from="1768648067301" duration="726000" />
<workItem from="1768649210606" duration="513000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
</project>

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
/node_modules/**
/dist/*
/public/*

7
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"printWidth": 100,
"singleQuote": true,
"semi": false,
"endOfLine": "lf",
"htmlWhitespaceSensitivity": "ignore"
}

9
.vscode/extensions.json vendored Normal file
View 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
View 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
View 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"
},
"[dockerfile]": {
"editor.defaultFormatter": "ms-azuretools.vscode-docker"
}
}

5
Dockerfile Normal file
View 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

Binary file not shown.

24
build/constant.js Normal file
View File

@@ -0,0 +1,24 @@
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/, '/admin'),
},
/**
* @desc 备用接口代理
* @请求路径 http://localhost:3100/api1/login
* @转发路径 http://localhost:3001/api/login
*/
'/api1': {
target: 'https://api.gxwzwh.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api1/, '/admin'),
},
}

15
build/plugin/html.js Normal file
View 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
View 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
View 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();
`,
})
}

46
build/plugin/unplugin.js Normal file
View File

@@ -0,0 +1,46 @@
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'
/**
* * 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__',
}),
]

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

24
default.conf Normal file
View File

@@ -0,0 +1,24 @@
server {
# 监听ipv4
listen 80;
# 监听ipv6
listen [::]:80;
server_name localhost;
location / {
add_header Access-Control-Allow-Headers *;
add_header Access-Control-Allow-Methods *;
add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Allow-Origin *;
add_header Cache-Control no-cache;
add_header Access-Control-Max-Age 1728000;
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
View 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
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"~/*": ["./*"],
"@/*": ["src/*"]
},
"jsx": "preserve",
"allowJs": true
},
"exclude": ["node_modules", "dist"]
}

40
mock/api/auth.js Normal file
View File

@@ -0,0 +1,40 @@
import { resolveToken } from '../utils'
const token = {
admin: 'admin',
editor: 'editor',
}
export default [
{
url: '/api/auth/login',
method: 'post',
response: ({ body }) => {
if (['admin', 'editor'].includes(body?.name)) {
return {
code: 0,
data: {
token: token[body.name],
},
}
} else {
return {
code: -1,
message: '没有此用户',
}
}
},
},
{
url: '/api/auth/refreshToken',
method: 'post',
response: ({ headers }) => {
return {
code: 0,
data: {
token: resolveToken(headers?.authorization),
},
}
},
},
]

5
mock/api/index.js Normal file
View File

@@ -0,0 +1,5 @@
import auth from './auth'
import user from './user'
import post from './post'
export default [...auth, ...user, ...post]

138
mock/api/post.js Normal file
View File

@@ -0,0 +1,138 @@
const posts = [
{
title: '使用纯css优雅配置移动端rem布局',
author: '大脸怪',
category: 'Css',
description: '通常配置rem布局会使用js进行处理比如750的设计稿会这样...',
content: '通常配置rem布局会使用js进行处理比如750的设计稿会这样',
isRecommend: true,
isPublish: true,
createDate: '2021-11-04T04:03:36.000Z',
updateDate: '2021-11-04T04:03:36.000Z',
},
{
title: 'Vue2&Vue3项目风格指南',
author: 'Ronnie',
category: 'Vue',
description: '总结的Vue2和Vue3的项目风格',
content: '### 1. 命名风格\n\n> 文件夹如果是由多个单词组成,应该始终是横线连接 ',
isRecommend: true,
isPublish: true,
createDate: '2021-10-25T08:57:47.000Z',
updateDate: '2022-02-28T04:02:39.000Z',
},
{
title: '如何优雅的给图片添加水印',
author: '大脸怪',
category: 'JavaScript',
description: '优雅的给图片添加水印',
content: '我之前写过一篇文章记录了一次上传图片的优化史',
isRecommend: true,
isPublish: true,
createDate: '2021-06-24T18:46:19.000Z',
updateDate: '2021-09-23T07:51:22.000Z',
},
{
title: '前端缓存的理解',
author: '大脸怪',
category: 'Http',
description: '谈谈前端缓存的理解',
content:
'> 背景\n\n公司有个vue-cli3移动端web项目发版更新后发现部分用户手机在钉钉内置浏览器打开出现了缓存',
isRecommend: true,
isPublish: true,
createDate: '2021-06-10T18:51:19.000Z',
updateDate: '2021-09-17T09:33:24.000Z',
},
{
title: 'Promise的五个静态方法',
author: '大脸怪',
category: 'JavaScript',
description: '简单介绍下在 Promise 类中有5 种静态方法及它们的使用场景',
content:
'## 1. Promise.all\n\n并行执行多个 promise并等待所有 promise 都准备就绪。再对它们进行处理。',
isRecommend: true,
isPublish: true,
createDate: '2021-02-22T22:37:06.000Z',
updateDate: '2021-09-17T09:33:24.000Z',
},
]
export default [
{
url: '/api/posts',
method: 'get',
response: (data = {}) => {
const { title, pageNo, pageSize } = data.query
let pageData = []
let total = 60
const filterData = posts.filter(
(item) => item.title.includes(title) || (!title && title !== 0)
)
if (filterData.length) {
if (pageSize) {
while (pageData.length < pageSize) {
pageData.push(filterData[Math.round(Math.random() * (filterData.length - 1))])
}
} else {
pageData = filterData
}
pageData = pageData.map((item, index) => ({
id: pageSize * (pageNo - 1) + index + 1,
...item,
}))
} else {
total = 0
}
return {
code: 0,
message: 'ok',
data: {
pageData,
total,
pageNo,
pageSize,
},
}
},
},
{
url: '/api/post',
method: 'post',
response: ({ body }) => {
return {
code: 0,
message: 'ok',
data: body,
}
},
},
{
url: '/api/post/:id',
method: 'put',
response: ({ query, body }) => {
return {
code: 0,
message: 'ok',
data: {
id: query.id,
body,
},
}
},
},
{
url: '/api/post/:id',
method: 'delete',
response: ({ query }) => {
return {
code: 0,
message: 'ok',
data: {
id: query.id,
},
}
},
},
]

39
mock/api/user.js Normal file
View File

@@ -0,0 +1,39 @@
import { resolveToken } from '../utils'
const users = {
admin: {
id: 1,
name: '大脸怪(admin)',
avatar: 'https://assets.qszone.com/images/avatar.jpg',
email: 'Ronnie@123.com',
role: ['admin'],
},
editor: {
id: 2,
name: '大脸怪(editor)',
avatar: 'https://assets.qszone.com/images/avatar.jpg',
email: 'Ronnie@123.com',
role: ['editor'],
},
guest: {
id: 3,
name: '访客(guest)',
avatar: 'https://assets.qszone.com/images/avatar.jpg',
role: [],
},
}
export default [
{
url: '/api/user',
method: 'get',
response: ({ headers }) => {
const token = resolveToken(headers?.authorization)
return {
code: 0,
data: {
...(users[token] || users.guest),
},
}
},
},
]

6
mock/index.js Normal file
View File

@@ -0,0 +1,6 @@
import { createProdMockServer } from 'vite-plugin-mock/es/createProdMockServer'
import api from './api'
export function setupProdMockServer() {
createProdMockServer(api)
}

12
mock/utils.js Normal file
View File

@@ -0,0 +1,12 @@
export function resolveToken(authorization) {
/**
* * jwt token
* * Bearer + token
* ! 认证方案: Bearer
*/
const reqTokenSplit = authorization.split(' ')
if (reqTokenSplit.length === 2) {
return reqTokenSplit[1]
}
return ''
}

85
package.json Normal file
View File

@@ -0,0 +1,85 @@
{
"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}": [
"npx prettier --write --end-of-line lf .",
"eslint --ext .js,.vue ."
]
},
"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",
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
"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": "^13.3.0",
"naive-ui": "^2.43.1",
"rollup-plugin-visualizer": "^5.14.0",
"sass": "^1.93.2",
"unocss": "0.55.0",
"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-mock": "^2.9.8",
"vite-plugin-svg-icons": "^2.0.1"
},
"packageManager": "pnpm@9.1.4+sha512.9df9cf27c91715646c7d675d1c9c8e41f6fce88246f1318c1aa6a1ed1aeb3c4f032fcdf4ba63cc69c4fe6d634279176b5358727d8f2cc1e65b65f43ce2f8bfb0"
}

12864
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

2232
public/favicon.svg Normal file

File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 168 KiB

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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

1
settings/index.js Normal file
View File

@@ -0,0 +1 @@
export * from './theme.json'

37
settings/theme.json Normal file
View 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

Binary file not shown.

12
src/App.vue Normal file
View File

@@ -0,0 +1,12 @@
<template>
<AppProvider>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</AppProvider>
</template>
<script setup>
import AppProvider from '@/components/common/AppProvider.vue'
// App
</script>

13
src/api/index.js Normal file
View File

@@ -0,0 +1,13 @@
import { request } from '@/utils'
export default {
getUser: () => request.get('/user'),
refreshToken: () => request.post('/auth/refreshToken', null, { noNeedTip: true }),
// 上传
uploadImg: (data) =>
request.post('/upload', data, {
headers: {
'Content-Type': 'multipart/form-data',
},
}),
}

BIN
src/assets/images/404.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

1
src/assets/svg/logo.svg Normal file
View 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

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

97
src/components/Editor.vue Normal file
View File

@@ -0,0 +1,97 @@
<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: height + 'px', overflowY: '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: '',
},
height: {
type: Number,
default: 500,
},
})
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>

62
src/components/Upload.vue Normal file
View File

@@ -0,0 +1,62 @@
<template>
<n-upload
v-model:file-list="List"
:max="max"
list-type="image-card"
:custom-request="customRequest"
@before-upload="beforeUpload"
>
点击上传
</n-upload>
</template>
<script setup>
import api from '@/api'
const prop = defineProps({
list: {
type: Array,
default: () => [],
},
max: {
type: Number,
default: 1,
},
})
const emit = defineEmits(['update:list'])
const List = computed({
get() {
return prop.list
},
set(val) {
emit('update:list', val)
},
})
// 图片上传限制
const beforeUpload = ({ file }) => {
if (!(file.file?.type === 'image/png' || 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 (error) {
onError()
throw error
}
}
</script>
<style lang="scss" scoped></style>

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

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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1 @@
export { default as useCRUD } from './useCRUD'

103
src/composables/useCRUD.js Normal file
View 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,
}
}

43
src/hooks/useScript.js Normal file
View File

@@ -0,0 +1,43 @@
import { onMounted, onUnmounted, ref } from 'vue'
export function useScript(opts) {
const isLoading = ref(false)
const error = ref(false)
const success = ref(false)
let script
const promise = new Promise((resolve, reject) => {
onMounted(() => {
script = document.createElement('script')
script.type = 'text/javascript'
script.charset = 'utf-8'
script.onload = function () {
isLoading.value = false
success.value = true
error.value = false
resolve('')
}
script.onerror = function (err) {
isLoading.value = false
success.value = false
error.value = true
reject(err)
}
script.src = opts.src
document.head.appendChild(script)
})
})
onUnmounted(() => {
script && script.remove()
})
return {
isLoading,
error,
success,
toPromise: () => promise,
}
}

View File

@@ -0,0 +1,18 @@
<template>
<router-view v-slot="{ Component, route }">
<KeepAlive :include="keepAliveNames">
<div h-full w-full>
<component :is="Component" v-if="!tagStore.reloading" :key="route.fullPath" />
</div>
</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>

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

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

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

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,120 @@
<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 !== '无' && meta?.icon) return renderIcon(meta.icon, { size: 18 })
return null
}
function handleMenuSelect(_, 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>

View File

@@ -0,0 +1,9 @@
<script setup>
import SideLogo from './components/SideLogo.vue'
import SideMenu from './components/SideMenu.vue'
</script>
<template>
<SideLogo />
<SideMenu />
</template>

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

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

49
src/main.js Normal file
View File

@@ -0,0 +1,49 @@
/** 重置样式 */
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.directive('perms', {
mounted: (el, binding) => {
const { value } = binding
const permissions = JSON.parse(localStorage.getItem('roles'))
const all_permission = '*'
if (Array.isArray(value)) {
if (value.length > 0) {
const hasPermission = permissions.some((key) => {
return all_permission === key || value.includes(key)
})
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el)
}
}
} else {
throw new Error('like v-perms="[\'auth/menu/edit\']"')
}
},
})
app.mount('#app')
}
setupApp()

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

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

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

View File

@@ -0,0 +1,34 @@
import { getToken, 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 } }
}
/** 有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' }
}
}
return true
})
}

87
src/router/index.js Normal file
View File

@@ -0,0 +1,87 @@
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 { 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 addDynamicRoutes() {
const token = getToken()
// 没有token情况
if (isNullOrWhitespace(token)) {
router.addRoute(EMPTY_ROUTE)
return
}
// 有token的情况
try {
const permissionStore = usePermissionStore()
const accessRoutes = permissionStore.generateRoutes()
// 确保路由按正确顺序添加
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('workbench')) {
// const workbenchRoute = {
// name: 'workbench',
// path: '/',
// component: () => import('@/views/workbench/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)
// }
// console.log(router)
} 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.subMenu && route.subMenu.length) {
names.push(...route.subMenu.map((item) => getRouteName(item)).flat(1))
}
return names
}

View File

@@ -0,0 +1,31 @@
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: '登录页',
},
},
]
export const NOT_FOUND_ROUTE = {
name: 'NotFound',
path: '/:pathMatch(.*)*',
redirect: '/404',
isHidden: true,
}
export const EMPTY_ROUTE = {
name: 'Empty',
path: '/:pathMatch(.*)*',
component: null,
}

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