🎨 更新用户版本

This commit is contained in:
2025-09-02 22:56:30 +08:00
parent 8276b3e87f
commit a1906d2a36
71 changed files with 4790 additions and 962 deletions

View File

@@ -8,7 +8,7 @@
content="Gin,Vue,Admin.Gin-Vue-Admin,GVA,gin-vue-admin,后台管理框架,vue后台管理框架,gin-vue-admin文档,gin-vue-admin首页,gin-vue-admin" content="Gin,Vue,Admin.Gin-Vue-Admin,GVA,gin-vue-admin,后台管理框架,vue后台管理框架,gin-vue-admin文档,gin-vue-admin首页,gin-vue-admin"
name="keywords" name="keywords"
/> />
<link rel="icon" href="favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<title></title> <title></title>
<style> <style>
.transition-colors { .transition-colors {

View File

@@ -1,6 +1,6 @@
// 运行项目前通过node执行此脚本 (此脚本与 node_modules 目录同级) // 运行项目前通过node执行此脚本 (此脚本与 node_modules 目录同级)
const fs = require('fs') import fs from 'fs'
const path = require('path') import path from 'path'
const wfPath = path.resolve(__dirname, './node_modules/.bin') const wfPath = path.resolve(__dirname, './node_modules/.bin')
fs.readdir(wfPath, (err, files) => { fs.readdir(wfPath, (err, files) => {

View File

@@ -1,6 +1,6 @@
{ {
"name": "gin-vue-admin", "name": "gin-vue-admin",
"version": "2.8.0", "version": "2.8.5",
"private": true, "private": true,
"scripts": { "scripts": {
"serve": "vite --host --mode development", "serve": "vite --host --mode development",
@@ -13,6 +13,7 @@
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"@form-create/designer": "^3.2.6", "@form-create/designer": "^3.2.6",
"@form-create/element-ui": "^3.2.10", "@form-create/element-ui": "^3.2.10",
"@unocss/transformer-directives": "^66.4.2",
"@vue-office/docx": "^1.6.2", "@vue-office/docx": "^1.6.2",
"@vue-office/excel": "^1.7.11", "@vue-office/excel": "^1.7.11",
"@vue-office/pdf": "^2.0.2", "@vue-office/pdf": "^2.0.2",
@@ -21,15 +22,17 @@
"@wangeditor/editor": "^5.1.23", "@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "^5.1.12", "@wangeditor/editor-for-vue": "^5.1.12",
"ace-builds": "^1.36.4", "ace-builds": "^1.36.4",
"axios": "^1.7.7", "axios": "1.8.2",
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"core-js": "^3.38.1", "core-js": "^3.38.1",
"echarts": "5.5.1", "echarts": "5.5.1",
"element-plus": "^2.8.5", "element-plus": "^2.10.2",
"highlight.js": "^11.10.0", "highlight.js": "^11.10.0",
"install": "^0.13.0",
"marked": "14.1.1", "marked": "14.1.1",
"marked-highlight": "^2.1.4", "marked-highlight": "^2.1.4",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"npm": "^11.3.0",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"path": "^0.12.7", "path": "^0.12.7",
"pinia": "^2.2.2", "pinia": "^2.2.2",
@@ -37,39 +40,45 @@
"screenfull": "^6.0.2", "screenfull": "^6.0.2",
"sortablejs": "^1.15.3", "sortablejs": "^1.15.3",
"spark-md5": "^3.0.2", "spark-md5": "^3.0.2",
"tailwindcss": "^3.4.10",
"universal-cookie": "^7", "universal-cookie": "^7",
"vform3-builds": "^3.0.10", "vform3-builds": "^3.0.10",
"vite-auto-import-svg": "^1.1.0", "vite-auto-import-svg": "^1.9.0",
"vue": "^3.5.7", "vue": "^3.5.7",
"vue-cropper": "^1.1.4", "vue-cropper": "^1.1.4",
"vue-echarts": "^7.0.3", "vue-echarts": "^7.0.3",
"vue-qr": "^4.0.9", "vue-qr": "^4.0.9",
"vue-router": "^4.4.3", "vue-router": "^4.4.3",
"vue3-ace-editor": "^2.2.4", "vue3-ace-editor": "^2.2.4",
"vue3-sfc-loader": "^0.9.5",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.25.1", "@babel/eslint-parser": "^7.25.1",
"@eslint/js": "^9.14.0", "@eslint/js": "^8.56.0",
"@vitejs/plugin-legacy": "^5.4.2", "@unocss/extractor-svelte": "^66.4.2",
"@vitejs/plugin-vue": "^5.1.4", "@unocss/preset-wind3": "^66.4.2",
"@unocss/vite": "^66.5.0",
"@vitejs/plugin-legacy": "^6.0.0",
"@vitejs/plugin-vue": "^5.0.3",
"@vue/cli-plugin-babel": "~5.0.8", "@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-plugin-eslint": "~5.0.8", "@vue/cli-plugin-eslint": "~5.0.8",
"@vue/cli-plugin-router": "~5.0.8", "@vue/cli-plugin-router": "~5.0.8",
"@vue/cli-plugin-vuex": "~5.0.8", "@vue/cli-plugin-vuex": "~5.0.8",
"@vue/cli-service": "~5.0.8", "@vue/cli-service": "~5.0.8",
"@vue/compiler-sfc": "^3.5.1", "@vue/compiler-sfc": "^3.5.1",
"autoprefixer": "^10.4.20",
"babel-plugin-import": "^1.13.8", "babel-plugin-import": "^1.13.8",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"eslint": "^9.14.0", "eslint": "^8.57.0",
"eslint-plugin-vue": "^9.30.0", "eslint-plugin-vue": "^9.19.2",
"globals": "^16.3.0",
"sass": "^1.78.0", "sass": "^1.78.0",
"terser": "^5.31.6", "terser": "^5.31.6",
"vite": "^5.4.3", "unocss": "^66.4.2",
"vite": "^6.2.3",
"vite-plugin-banner": "^0.8.0", "vite-plugin-banner": "^0.8.0",
"vite-plugin-importer": "^0.2.5", "vite-plugin-importer": "^0.2.5",
"vite-plugin-vue-devtools": "^7.4.4" "vite-plugin-vue-devtools": "^7.0.16"
} }
} }

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

View File

@@ -1,17 +1,17 @@
<template> <template>
<div <div id="app" class="bg-gray-50 text-slate-700 dark:text-slate-500 dark:bg-slate-800">
id="app"
class="bg-gray-50 text-slate-700 dark:text-slate-500 dark:bg-slate-800"
>
<el-config-provider :locale="zhCn"> <el-config-provider :locale="zhCn">
<router-view /> <router-view />
<Application />
</el-config-provider> </el-config-provider>
</div> </div>
</template> </template>
<script setup> <script setup>
import zhCn from 'element-plus/dist/locale/zh-cn.mjs' import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import Application from '@/components/application/index.vue'
import { useAppStore } from '@/pinia' import { useAppStore } from '@/pinia'
useAppStore() useAppStore()
defineOptions({ defineOptions({
name: 'App' name: 'App'
@@ -24,6 +24,7 @@
overflow: hidden; overflow: hidden;
font-weight: 400 !important; font-weight: 400 !important;
} }
.el-button { .el-button {
font-weight: 400 !important; font-weight: 400 !important;
} }
@@ -35,6 +36,7 @@
.gva-container { .gva-container {
height: calc(100% - 2.5rem); height: calc(100% - 2.5rem);
} }
.gva-container2 { .gva-container2 {
height: calc(100% - 4.5rem); height: calc(100% - 4.5rem);
} }

View File

@@ -173,6 +173,17 @@ export const eye = (data) => {
} }
export const createWeb = (data) => {
return service({
url: '/autoCode/llmAuto',
method: 'post',
data: { ...data, mode: 'painter' },
timeout: 1000 * 60 * 10
})
}
export const addFunc = (data) => { export const addFunc = (data) => {
return service({ return service({
url: '/autoCode/addFunc', url: '/autoCode/addFunc',
@@ -196,3 +207,29 @@ export const initAPI = (data) => {
data data
}) })
} }
export const mcp = (data) => {
return service({
url: '/autoCode/mcp',
method: 'post',
data
})
}
export const mcpList = (data) => {
return service({
url: '/autoCode/mcpList',
method: 'post',
data
})
}
export const mcpTest = (data) => {
return service({
url: '/autoCode/mcpTest',
method: 'post',
data
})
}

View File

@@ -42,7 +42,7 @@ export const getSystemState = () => {
} }
/** /**
* 重服务 * 重服务
* @param data * @param data
* @returns {*} * @returns {*}
*/ */

114
src/api/version.js Normal file
View File

@@ -0,0 +1,114 @@
import service from '@/utils/request'
// @Tags SysVersion
// @Summary 删除版本管理
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param data body model.SysVersion true "删除版本管理"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"删除成功"}"
// @Router /sysVersion/deleteSysVersion [delete]
export const deleteSysVersion = (params) => {
return service({
url: '/sysVersion/deleteSysVersion',
method: 'delete',
params
})
}
// @Tags SysVersion
// @Summary 批量删除版本管理
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param data body request.IdsReq true "批量删除版本管理"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"删除成功"}"
// @Router /sysVersion/deleteSysVersion [delete]
export const deleteSysVersionByIds = (params) => {
return service({
url: '/sysVersion/deleteSysVersionByIds',
method: 'delete',
params
})
}
// @Tags SysVersion
// @Summary 用id查询版本管理
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param data query model.SysVersion true "用id查询版本管理"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"查询成功"}"
// @Router /sysVersion/findSysVersion [get]
export const findSysVersion = (params) => {
return service({
url: '/sysVersion/findSysVersion',
method: 'get',
params
})
}
// @Tags SysVersion
// @Summary 分页获取版本管理列表
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param data query request.PageInfo true "分页获取版本管理列表"
// @Success 200 {string} string "{"success":true,"data":{},"msg":"获取成功"}"
// @Router /sysVersion/getSysVersionList [get]
export const getSysVersionList = (params) => {
return service({
url: '/sysVersion/getSysVersionList',
method: 'get',
params
})
}
// @Tags SysVersion
// @Summary 导出版本数据
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param data body object true "导出版本数据"
// @Success 200 {string} string "{\"success\":true,\"data\":{},\"msg\":\"导出成功\"}"
// @Router /sysVersion/exportVersion [post]
export const exportVersion = (data) => {
return service({
url: '/sysVersion/exportVersion',
method: 'post',
data
})
}
// @Tags SysVersion
// @Summary 下载版本JSON数据
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param ID query string true "版本ID"
// @Success 200 {string} string "{\"success\":true,\"data\":{},\"msg\":\"下载成功\"}"
// @Router /sysVersion/downloadVersionJson [get]
export const downloadVersionJson = (params) => {
return service({
url: '/sysVersion/downloadVersionJson',
method: 'get',
params,
responseType: 'blob'
})
}
// @Tags SysVersion
// @Summary 导入版本数据
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param data body object true "版本JSON数据"
// @Success 200 {string} string "{\"success\":true,\"data\":{},\"msg\":\"导入成功\"}"
// @Router /sysVersion/importVersion [post]
export const importVersion = (data) => {
return service({
url: '/sysVersion/importVersion',
method: 'post',
data
})
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><path fill="currentColor" d="M195.2 195.2a64 64 0 0 1 90.496 0L512 421.504L738.304 195.2a64 64 0 0 1 90.496 90.496L602.496 512L828.8 738.304a64 64 0 0 1-90.496 90.496L512 602.496L285.696 828.8a64 64 0 0 1-90.496-90.496L421.504 512L195.2 285.696a64 64 0 0 1 0-90.496"/></svg>

After

Width:  |  Height:  |  Size: 366 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6.09 14.999a6.9 6.9 0 0 1-.59-2.794C5.5 8.5 8.41 5.499 12 5.499s6.5 3.002 6.5 6.706a6.9 6.9 0 0 1-.59 2.794m-5.91-13v1m10 9h-1m-18 0H2m17.07-7.071l-.707.707m-12.726.001l-.707-.707m9.587 14.377c1.01-.327 1.416-1.252 1.53-2.182c.034-.278-.195-.509-.475-.509H8.477a.483.483 0 0 0-.488.534c.112.928.394 1.606 1.464 2.156m5.064 0H9.453m5.064 0c-.121 1.945-.683 2.716-2.51 2.694c-1.954.036-2.404-.916-2.554-2.693"/></svg>

After

Width:  |  Height:  |  Size: 609 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a5 5 0 0 1 5 5v3a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3v-6a3 3 0 0 1 3-3V7a5 5 0 0 1 5-5m0 12a2 2 0 0 0-1.995 1.85L10 16a2 2 0 1 0 2-2m0-10a3 3 0 0 0-3 3v3h6V7a3 3 0 0 0-3-3"/></svg>

After

Width:  |  Height:  |  Size: 307 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M12.5.5h-11a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1M7.5 3H11M1.5 5.5a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h11a1 1 0 0 0 1-1v-3a1 1 0 0 0-1-1"/><path d="M3.25 8.25a.25.25 0 0 1 0-.5m0 .5a.25.25 0 0 0 0-.5m0-4.5a.25.25 0 0 1 0-.5m0 .5a.25.25 0 0 0 0-.5M7.5 8H11m-4 2.5v3m-5 0h10"/></g></svg>

After

Width:  |  Height:  |  Size: 499 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><path fill="currentColor" d="M928.99 755.83L574.6 203.25c-12.89-20.16-36.76-32.58-62.6-32.58s-49.71 12.43-62.6 32.58L95.01 755.83c-12.91 20.12-12.9 44.91.01 65.03c12.92 20.12 36.78 32.51 62.59 32.49h708.78c25.82.01 49.68-12.37 62.59-32.49s12.92-44.91.01-65.03M554.67 768h-85.33v-85.33h85.33zm0-426.67v298.66h-85.33V341.32z"/></svg>

After

Width:  |  Height:  |  Size: 423 B

View File

@@ -0,0 +1,39 @@
<template>
<error-preview v-if="showError" :error-data="errorInfo" @close="handleClose" @confirm="handleConfirm" />
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
import { emitter } from '@/utils/bus'
import ErrorPreview from '@/components/errorPreview/index.vue'
const showError = ref(false)
const errorInfo = ref(null)
let cb = null
const showErrorDialog = (data) => {
// 这玩意同时只允许存在一个
if(showError.value) return
errorInfo.value = data
showError.value = true
cb = data?.fn || null
}
const handleClose = () => {
showError.value = false
errorInfo.value = null
cb = null
}
const handleConfirm = (code) => {
cb && cb(code)
handleClose()
}
emitter.on('show-error', showErrorDialog)
onUnmounted(() => {
emitter.off('show-error', showErrorDialog)
})
</script>

View File

@@ -11,12 +11,22 @@
<span> <span>
<a <a
class="font-bold text-active" class="font-bold text-active"
href="https://echol.cn" href="https://github.com/flipped-aurora/gin-vue-admin"
>Echo</a >Gin-Vue-Admin</a
>
</span>
</div>
<slot />
<div class="text-center">
<span class="mr-1">Copyright</span>
<span>
<a
class="font-bold text-active"
href="https://github.com/flipped-aurora"
>flipped-aurora团队</a
> >
</span> </span>
</div> </div>
</div> </div>
</template> </template>
@@ -25,4 +35,10 @@
name: 'BottomInfo' name: 'BottomInfo'
}) })
console.log(
`%c powered by %c flipped-aurorae %c`,
'background:#0081ff; padding: 1px; border-radius: 3px 0 0 3px; color: #fff',
'background:#354855; padding: 1px 5px; border-radius: 0 3px 3px 0; color: #fff; font-weight: bold;',
'background:transparent'
)
</script> </script>

View File

@@ -55,7 +55,6 @@
const deepMenus = (menus) => { const deepMenus = (menus) => {
const arr = [] const arr = []
menus?.forEach((menu) => { menus?.forEach((menu) => {
if (!menu?.children) return
if (menu.children && menu.children.length > 0) { if (menu.children && menu.children.length > 0) {
arr.push(...deepMenus(menu.children)) arr.push(...deepMenus(menu.children))
} else { } else {

View File

@@ -0,0 +1,126 @@
<template>
<div
class="fixed inset-0 bg-black/40 flex items-center justify-center z-[999]"
@click.self="closeModal"
>
<div class="bg-white rounded-xl shadow-dialog w-full max-w-md mx-4 transform transition-all duration-300 ease-in-out">
<!-- 弹窗头部 -->
<div class="p-5 border-b border-gray-100 flex justify-between items-center">
<h3 class="text-lg font-semibold text-gray-800">{{ displayData.title }}</h3>
<div class="text-gray-400 hover:text-gray-600 transition-colors cursor-pointer" @click="closeModal">
<close class="h-6 w-6" />
</div>
</div>
<!-- 弹窗内容 -->
<div class="p-6 pt-0">
<!-- 错误类型 -->
<div class="mb-4">
<div class="text-xs font-medium text-gray-500 uppercase mb-2">错误类型</div>
<div class="flex items-center gap-2">
<lock v-if="displayData.icon === 'lock'" class="text-red-500 w-5 h-5" />
<warn v-if="displayData.icon === 'warn'" class="text-red-500 w-5 h-5" />
<server v-if="displayData.icon === 'server'" class="text-red-500 w-5 h-5" />
<span class="font-medium text-gray-800">{{ displayData.type }}</span>
</div>
</div>
<!-- 具体错误 -->
<div class="mb-6">
<div class="text-xs font-medium text-gray-500 uppercase mb-2">具体错误</div>
<div class="bg-gray-100 rounded-lg p-3 text-sm text-gray-700 leading-relaxed">
{{ displayData.message }}
</div>
</div>
<!-- 提示信息 -->
<div v-if="displayData.tips">
<div class="text-xs font-medium text-gray-500 uppercase mb-2">提示</div>
<div class="flex items-center gap-2">
<idea class="text-blue-500 w-5 h-5" />
<p class="text-sm text-gray-600">{{ displayData.tips }}</p>
</div>
</div>
</div>
<!-- 弹窗底部 -->
<div class="py-2 px-4 border-t border-gray-100 flex justify-end">
<div class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm shadow-sm cursor-pointer" @click="handleConfirm">
确定
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, computed } from 'vue';
const props = defineProps({
errorData: {
type: Object,
required: true
}
});
const emits = defineEmits(['close', 'confirm']);
const presetErrors = {
500: {
title: '检测到接口错误',
type: '服务器发生内部错误',
icon: 'server',
color: 'text-red-500',
tips: '此类错误内容常见于后台panic请先查看后台日志如果影响您正常使用可强制登出清理缓存'
},
404: {
title: '资源未找到',
type: 'Not Found',
icon: 'warn',
color: 'text-orange-500',
tips: '此类错误多为接口未注册或未重启或者请求路径方法与api路径方法不符--如果为自动化代码请检查是否存在空格'
},
401: {
title: '身份认证失败',
type: '身份令牌无效',
icon: 'lock',
color: 'text-purple-500',
tips: '您的身份认证已过期或无效,请重新登录。'
},
'network': {
title: '网络错误',
type: 'Network Error',
icon: 'fa-wifi-slash',
color: 'text-gray-500',
tips: '无法连接到服务器,请检查您的网络连接。'
}
};
const displayData = computed(() => {
const preset = presetErrors[props.errorData.code];
if (preset) {
return {
...preset,
message: props.errorData.message || '没有提供额外信息。'
};
}
return {
title: '未知错误',
type: '检测到请求错误',
icon: 'fa-question-circle',
color: 'text-gray-400',
message: props.errorData.message || '发生了一个未知错误。',
tips: '请检查控制台获取更多信息。'
};
});
const closeModal = () => {
emits('close')
};
const handleConfirm = () => {
emits('confirm', props.errorData.code);
closeModal();
};
</script>

View File

@@ -9,6 +9,10 @@
import { exportExcel } from '@/api/exportTemplate' import { exportExcel } from '@/api/exportTemplate'
const props = defineProps({ const props = defineProps({
filterDeleted: {
type: Boolean,
default: true
},
templateId: { templateId: {
type: String, type: String,
required: true required: true
@@ -43,6 +47,11 @@ import { exportExcel } from '@/api/exportTemplate'
baseUrl = "" baseUrl = ""
} }
const paramsCopy = JSON.parse(JSON.stringify(props.condition)) const paramsCopy = JSON.parse(JSON.stringify(props.condition))
if (props.filterDeleted) {
paramsCopy.filterDeleted = 'true'
}
if (props.limit) { if (props.limit) {
paramsCopy.limit = props.limit paramsCopy.limit = props.limit
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="border border-solid border-gray-100 h-full"> <div class="border border-solid border-gray-100 h-full z-10">
<Toolbar <Toolbar
:editor="editorRef" :editor="editorRef"
:default-config="toolbarConfig" :default-config="toolbarConfig"
@@ -8,7 +8,7 @@
<Editor <Editor
v-model="valueHtml" v-model="valueHtml"
class="overflow-y-hidden mt-0.5" class="overflow-y-hidden mt-0.5"
style="min-height: 18rem" style="height: 18rem"
:default-config="editorConfig" :default-config="editorConfig"
mode="default" mode="default"
@onCreated="handleCreated" @onCreated="handleCreated"
@@ -27,7 +27,7 @@
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getUrl } from '@/utils/image' import { getUrl } from '@/utils/image'
import botLogo from '@/assets/bot_logo.png' import { useUserStore } from '@/pinia/modules/user'
const emits = defineEmits(['change', 'update:modelValue']) const emits = defineEmits(['change', 'update:modelValue'])
@@ -36,14 +36,11 @@
emits('update:modelValue', valueHtml.value) emits('update:modelValue', valueHtml.value)
} }
const userStore = useUserStore()
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: String, type: String,
default: '' default: ''
},
useWatermark: {
type: Boolean,
default: false
} }
}) })
@@ -53,65 +50,13 @@
const toolbarConfig = {} const toolbarConfig = {}
const editorConfig = { const editorConfig = {
placeholder: '请输入内容...', placeholder: '请输入内容...',
MENU_CONF: { MENU_CONF: {}
uploadImage: { }
server: basePath + '/fileUploadAndDownload/upload?noSave=1', editorConfig.MENU_CONF['uploadImage'] = {
fieldName: 'file', fieldName: 'file',
maxFileSize: 1024 * 1024 * 10, // 限制图片大小为10MB server: basePath + '/fileUploadAndDownload/upload?noSave=1',
maxNumberOfFiles: 1, headers: {
async customUpload(file, insertFn) { 'x-token': userStore.token,
// 未开启水印则直接上传原图
if (!props.useWatermark) {
const formData = new FormData()
formData.append('file', file)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', { method: 'POST', body: formData })
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
return
}
try {
const watermarkedBlob = await addBottomWatermark(file, {
stripRatio: 0.18, // 水印条高度占原图高度比例
background: 'rgba(255,255,255,0.96)',
text: '老陈机器人',
textColor: '#333',
fontFamily: 'PingFang SC, Microsoft YaHei, Arial',
logo: botLogo
})
const newFile = new File([watermarkedBlob], file.name, { type: watermarkedBlob.type || file.type })
const formData = new FormData()
formData.append('file', newFile)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', {
method: 'POST',
body: formData
})
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
} catch {
ElMessage.error('处理水印失败')
// 降级:直接走原图上传
const formData = new FormData()
formData.append('file', file)
const resp = await fetch(basePath + '/fileUploadAndDownload/upload?noSave=1', { method: 'POST', body: formData })
const res = await resp.json()
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
} else {
ElMessage.error(res.msg || '上传失败')
}
}
}, },
customInsert(res, insertFn) { customInsert(res, insertFn) {
if (res.code === 0) { if (res.code === 0) {
@@ -121,34 +66,6 @@
} }
ElMessage.error(res.msg) ElMessage.error(res.msg)
} }
},
uploadVideo: {
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
fieldName: 'file',
maxFileSize: 1024 * 1024 * 100, // 限制视频大小为100MB
maxNumberOfFiles: 1,
customInsert(res, insertFn) {
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
return
}
ElMessage.error(res.msg)
}
}
}
}
editorConfig.MENU_CONF['uploadImage','uploadVideo'] = {
fieldName: 'file',
server: basePath + '/fileUploadAndDownload/upload?noSave=1',
customInsert(res, insertFn) {
if (res.code === 0) {
const urlPath = getUrl(res.data.file.url)
insertFn(urlPath, res.data.file.name)
return
}
ElMessage.error(res.msg)
}
} }
// 组件销毁时,也及时销毁编辑器 // 组件销毁时,也及时销毁编辑器
@@ -169,94 +86,8 @@
valueHtml.value = props.modelValue valueHtml.value = props.modelValue
} }
) )
async function addBottomWatermark(file, options) {
const { stripRatio = 0.18, background = 'rgba(255,255,255,0.96)', text = '', textColor = '#333', fontFamily = 'Arial', logo } = options || {}
const img = await fileToImage(file)
const width = img.naturalWidth || img.width
const height = img.naturalHeight || img.height
const stripHeight = Math.max(60, Math.floor(height * stripRatio))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height + stripHeight
const ctx = canvas.getContext('2d')
// 原图
ctx.drawImage(img, 0, 0, width, height)
// 底部水印条背景
ctx.fillStyle = background
ctx.fillRect(0, height, width, stripHeight)
// 左侧 Logo可选
let logoSize = Math.floor(stripHeight * 0.6)
let logoPadding = Math.floor(stripHeight * 0.2)
if (logo) {
try {
const logoImg = await srcToImage(logo)
const ratio = logoImg.width / logoImg.height
const drawW = logoSize
const drawH = Math.floor(drawW / ratio)
const y = height + Math.floor((stripHeight - drawH) / 2)
ctx.drawImage(logoImg, logoPadding, y, drawW, drawH)
} catch { void 0 }
}
// 右侧文字
ctx.fillStyle = textColor
const fontSize = Math.floor(stripHeight * 0.38)
ctx.font = `${fontSize}px ${fontFamily}`
ctx.textBaseline = 'middle'
ctx.textAlign = 'right'
const textPadding = Math.floor(stripHeight * 0.25)
ctx.fillText(text, width - textPadding, height + Math.floor(stripHeight / 2))
const blob = await canvasToBlob(canvas, file.type || 'image/png')
return blob
}
function fileToImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = reader.result
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
function srcToImage(src) {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
function canvasToBlob(canvas, mime) {
return new Promise((resolve) => {
if (canvas.toBlob) {
canvas.toBlob((blob) => resolve(blob), mime, 0.92)
} else {
// 兼容处理
const dataURL = canvas.toDataURL(mime)
const arr = dataURL.split(',')
const bstr = atob(arr[1])
let n = bstr.length
const u8arr = new Uint8Array(n)
while (n--) u8arr[n] = bstr.charCodeAt(n)
resolve(new Blob([u8arr], { type: mime }))
}
})
}
</script> </script>
<style scoped lang="scss"></style> <style scoped lang="scss">
</style>

View File

@@ -47,7 +47,7 @@
const model = defineModel({ type: Array }) const model = defineModel({ type: Array })
const fileList = ref(model.value) const fileList = ref(model.value || [])
const emits = defineEmits(['on-success', 'on-error']) const emits = defineEmits(['on-success', 'on-error'])

View File

@@ -2,9 +2,27 @@
<div> <div>
<selectComponent :rounded="rounded" v-if="!props.multiple" :model="model" @chooseItem="openChooseImg" @deleteItem="openChooseImg" /> <selectComponent :rounded="rounded" v-if="!props.multiple" :model="model" @chooseItem="openChooseImg" @deleteItem="openChooseImg" />
<div v-else class="w-full gap-4 flex flex-wrap"> <div v-else class="w-full gap-4 flex flex-wrap">
<selectComponent :rounded="rounded" v-for="(item, index) in model" :key="index" :model="item" @chooseItem="openChooseImg" <draggable
v-model="model"
class="flex flex-wrap gap-4"
item-key="url"
ghost-class="ghost-item"
handle=".drag-handle"
animation="300"
@start="onDragStart"
@end="onDragEnd"
>
<template #item="{element, index}">
<div class="relative group">
<div class="drag-handle absolute left-2 top-2 w-8 h-8 flex items-center justify-center cursor-move z-10 opacity-0 group-hover:opacity-100 rounded-full">
<el-icon :size="18"><Menu /></el-icon>
</div>
<selectComponent :rounded="rounded" :model="element" @chooseItem="openChooseImg"
@deleteItem="deleteImg(index)" @deleteItem="deleteImg(index)"
/> />
</div>
</template>
</draggable>
<selectComponent :rounded="rounded" v-if="model?.length < props.maxUpdateCount || props.maxUpdateCount === 0" <selectComponent :rounded="rounded" v-if="model?.length < props.maxUpdateCount || props.maxUpdateCount === 0"
@chooseItem="openChooseImg" @deleteItem="openChooseImg" @chooseItem="openChooseImg" @deleteItem="openChooseImg"
/> />
@@ -140,6 +158,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
import { import {
ArrowLeftBold, ArrowLeftBold,
CloseBold, CloseBold,
Menu,
MoreFilled, MoreFilled,
Picture as IconPicture, Picture as IconPicture,
Plus, Plus,
@@ -149,6 +168,7 @@ import selectComponent from '@/components/selectImage/selectComponent.vue'
import { addCategory, deleteCategory, getCategoryList } from '@/api/attachmentCategory' import { addCategory, deleteCategory, getCategoryList } from '@/api/attachmentCategory'
import CropperImage from "@/components/upload/cropper.vue"; import CropperImage from "@/components/upload/cropper.vue";
import QRCodeUpload from "@/components/upload/QR-code.vue"; import QRCodeUpload from "@/components/upload/QR-code.vue";
import draggable from 'vuedraggable'
const imageUrl = ref('') const imageUrl = ref('')
const imageCommon = ref('') const imageCommon = ref('')
@@ -424,6 +444,20 @@ const useSelectedImages = () => {
selectedImages.value = [] selectedImages.value = []
} }
const onDragStart = () => {
// 拖拽开始时的处理
document.body.style.cursor = 'grabbing'
}
const onDragEnd = () => {
// 拖拽结束时的处理
document.body.style.cursor = 'default'
// 确保model是数组类型
if (!Array.isArray(model.value)) {
model.value = []
}
}
</script> </script>
<style scoped> <style scoped>
.selected { .selected {
@@ -450,4 +484,20 @@ const useSelectedImages = () => {
border-left-color: transparent; border-left-color: transparent;
transform: rotate(45deg); transform: rotate(45deg);
} }
.ghost-item {
opacity: 0.5;
background: #c8ebfb;
border: 1px dashed #409eff;
}
.drag-handle {
background-color: rgba(64, 158, 255, 0.1);
border-radius: 4px;
transition: opacity 0.3s;
}
.drag-handle:hover {
background-color: rgba(64, 158, 255, 0.2);
}
</style> </style>

View File

@@ -1,10 +1,12 @@
/** /**
* 网站配置文件 * 网站配置文件
*/ */
import packageInfo from '../../package.json'
const greenText = (text) => `\x1b[32m${text}\x1b[0m` const greenText = (text) => `\x1b[32m${text}\x1b[0m`
const config = { const config = {
appName: 'Lckt-Admin', appName: 'Gin-Vue-Admin',
appLogo: 'logo.png', appLogo: 'logo.png',
showViteLogo: true, showViteLogo: true,
logs: [] logs: []
@@ -12,20 +14,7 @@ const config = {
export const viteLogo = (env) => { export const viteLogo = (env) => {
if (config.showViteLogo) { if (config.showViteLogo) {
console.log( console.log(greenText(`> 当前版本:v${packageInfo.version}`))
greenText(
`> 欢迎使用Gin-Vue-Admin开源地址https://github.com/flipped-aurora/gin-vue-admin`
)
)
console.log(greenText(`> 当前版本:v2.8.0`))
console.log(greenText(`> 加群方式:微信shouzi_1994 QQ群470239250`))
console.log(
greenText(`> 项目地址https://github.com/flipped-aurora/gin-vue-admin`)
)
console.log(greenText(`> 插件市场:https://plugin.gin-vue-admin.com`))
console.log(
greenText(`> GVA讨论社区:https://support.qq.com/products/371961`)
)
console.log( console.log(
greenText( greenText(
`> 默认自动化文档地址:http://127.0.0.1:${env.VITE_SERVER_PORT}/swagger/index.html` `> 默认自动化文档地址:http://127.0.0.1:${env.VITE_SERVER_PORT}/swagger/index.html`
@@ -34,18 +23,6 @@ export const viteLogo = (env) => {
console.log( console.log(
greenText(`> 默认前端文件运行地址:http://127.0.0.1:${env.VITE_CLI_PORT}`) greenText(`> 默认前端文件运行地址:http://127.0.0.1:${env.VITE_CLI_PORT}`)
) )
console.log(
greenText(
`--------------------------------------版权声明--------------------------------------`
)
)
console.log(greenText(`** 版权所有方flipped-aurora开源团队 **`))
console.log(greenText(`** 版权持有公司:北京翻转极光科技有限责任公司 **`))
console.log(
greenText(
`** 剔除授权标识需购买商用授权https://gin-vue-admin.com/empower/index.html **`
)
)
console.log('\n') console.log('\n')
} }
} }

View File

@@ -4,24 +4,10 @@
* */ * */
// 加载网站配置文件夹 // 加载网站配置文件夹
import { register } from './global' import { register } from './global'
import packageInfo from '../../package.json'
export default { export default {
install: (app) => { install: (app) => {
register(app) register(app)
console.log(`
欢迎使用 Gin-Vue-Admin
当前版本:v2.8.0
加群方式:微信shouzi_1994 QQ群622360840
项目地址https://github.com/flipped-aurora/gin-vue-admin
插件市场:https://plugin.gin-vue-admin.com
GVA讨论社区:https://support.qq.com/products/371961
默认自动化文档地址:http://127.0.0.1:${import.meta.env.VITE_SERVER_PORT}/swagger/index.html
默认前端文件运行地址:http://127.0.0.1:${import.meta.env.VITE_CLI_PORT}
如果项目让您获得了收益,希望您能请团队喝杯可乐:https://www.gin-vue-admin.com/coffee/index.html
--------------------------------------版权声明--------------------------------------
** 版权所有方flipped-aurora开源团队 **
** 版权持有公司:北京翻转极光科技有限责任公司 **
** 剔除授权标识需购买商用授权https://gin-vue-admin.com/empower/index.html **
`)
} }
} }

View File

@@ -7,22 +7,7 @@ export default {
// 当被绑定的元素插入到 DOM 中时…… // 当被绑定的元素插入到 DOM 中时……
mounted: function (el, binding) { mounted: function (el, binding) {
const userInfo = userStore.userInfo const userInfo = userStore.userInfo
let type = '' if (!binding.value){
switch (Object.prototype.toString.call(binding.value)) {
case '[object Array]':
type = 'Array'
break
case '[object String]':
type = 'String'
break
case '[object Number]':
type = 'Number'
break
default:
type = ''
break
}
if (type === '') {
el.parentNode.removeChild(el) el.parentNode.removeChild(el)
return return
} }

View File

@@ -1,5 +1,6 @@
import './style/element_visiable.scss' import './style/element_visiable.scss'
import 'element-plus/theme-chalk/dark/css-vars.css' import 'element-plus/theme-chalk/dark/css-vars.css'
import 'uno.css';
import { createApp } from 'vue' import { createApp } from 'vue'
import ElementPlus from 'element-plus' import ElementPlus from 'element-plus'

View File

@@ -35,13 +35,21 @@
"/src/view/layout/aside/headMode.vue": "GvaAside", "/src/view/layout/aside/headMode.vue": "GvaAside",
"/src/view/layout/aside/index.vue": "Index", "/src/view/layout/aside/index.vue": "Index",
"/src/view/layout/aside/normalMode.vue": "GvaAside", "/src/view/layout/aside/normalMode.vue": "GvaAside",
"/src/view/layout/aside/sidebarMode.vue": "SidebarMode",
"/src/view/layout/header/index.vue": "Index", "/src/view/layout/header/index.vue": "Index",
"/src/view/layout/header/tools.vue": "Tools", "/src/view/layout/header/tools.vue": "Tools",
"/src/view/layout/iframe.vue": "GvaLayoutIframe", "/src/view/layout/iframe.vue": "GvaLayoutIframe",
"/src/view/layout/index.vue": "GvaLayout", "/src/view/layout/index.vue": "GvaLayout",
"/src/view/layout/screenfull/index.vue": "Screenfull", "/src/view/layout/screenfull/index.vue": "Screenfull",
"/src/view/layout/search/search.vue": "BtnBox", "/src/view/layout/search/search.vue": "BtnBox",
"/src/view/layout/setting/components/layoutModeCard.vue": "LayoutModeCard",
"/src/view/layout/setting/components/settingItem.vue": "SettingItem",
"/src/view/layout/setting/components/themeColorPicker.vue": "ThemeColorPicker",
"/src/view/layout/setting/components/themeModeSelector.vue": "ThemeModeSelector",
"/src/view/layout/setting/index.vue": "GvaSetting", "/src/view/layout/setting/index.vue": "GvaSetting",
"/src/view/layout/setting/modules/appearance/index.vue": "AppearanceSettings",
"/src/view/layout/setting/modules/general/index.vue": "GeneralSettings",
"/src/view/layout/setting/modules/layout/index.vue": "LayoutSettings",
"/src/view/layout/setting/title.vue": "layoutSettingTitle", "/src/view/layout/setting/title.vue": "layoutSettingTitle",
"/src/view/layout/tabs/index.vue": "HistoryComponent", "/src/view/layout/tabs/index.vue": "HistoryComponent",
"/src/view/login/index.vue": "Login", "/src/view/login/index.vue": "Login",
@@ -69,6 +77,9 @@
"/src/view/systemTools/autoCode/component/fieldDialog.vue": "FieldDialog", "/src/view/systemTools/autoCode/component/fieldDialog.vue": "FieldDialog",
"/src/view/systemTools/autoCode/component/previewCodeDialog.vue": "PreviewCodeDialog", "/src/view/systemTools/autoCode/component/previewCodeDialog.vue": "PreviewCodeDialog",
"/src/view/systemTools/autoCode/index.vue": "AutoCode", "/src/view/systemTools/autoCode/index.vue": "AutoCode",
"/src/view/systemTools/autoCode/mcp.vue": "MCP",
"/src/view/systemTools/autoCode/mcpTest.vue": "MCPTest",
"/src/view/systemTools/autoCode/picture.vue": "Picture",
"/src/view/systemTools/autoCodeAdmin/index.vue": "AutoCodeAdmin", "/src/view/systemTools/autoCodeAdmin/index.vue": "AutoCodeAdmin",
"/src/view/systemTools/autoPkg/autoPkg.vue": "AutoPkg", "/src/view/systemTools/autoPkg/autoPkg.vue": "AutoPkg",
"/src/view/systemTools/exportTemplate/exportTemplate.vue": "ExportTemplate", "/src/view/systemTools/exportTemplate/exportTemplate.vue": "ExportTemplate",
@@ -77,6 +88,7 @@
"/src/view/systemTools/installPlugin/index.vue": "Index", "/src/view/systemTools/installPlugin/index.vue": "Index",
"/src/view/systemTools/pubPlug/pubPlug.vue": "PubPlug", "/src/view/systemTools/pubPlug/pubPlug.vue": "PubPlug",
"/src/view/systemTools/system/system.vue": "Config", "/src/view/systemTools/system/system.vue": "Config",
"/src/view/systemTools/version/version.vue": "SysVersion",
"/src/view/user/index.vue": "UserManage", "/src/view/user/index.vue": "UserManage",
"/src/view/user/user/index.vue": "Index", "/src/view/user/user/index.vue": "Index",
"/src/view/user/user/teacherApply.vue": "TeacherApply", "/src/view/user/user/teacherApply.vue": "TeacherApply",

View File

@@ -115,7 +115,7 @@ router.beforeEach(async (to, from) => {
return { return {
name: 'Login', name: 'Login',
query: { redirect: to.href } query: { redirect: to.fullPath }
} }
} }
@@ -126,7 +126,7 @@ router.beforeEach(async (to, from) => {
return { return {
name: 'Login', name: 'Login',
query: { query: {
redirect: document.location.hash redirect: to.fullPath
} }
} }
}) })

View File

@@ -99,6 +99,27 @@ export const useAppStore = defineStore('app', () => {
config.transition_type = e config.transition_type = e
} }
const baseCoinfg = {
weakness: false,
grey: false,
primaryColor: '#3b82f6',
showTabs: true,
darkMode: 'auto',
layout_side_width: 256,
layout_side_collapsed_width: 80,
layout_side_item_height: 48,
show_watermark: true,
side_mode: 'normal',
// 页面过渡动画配置
transition_type: 'slide'
}
const resetConfig = () => {
for (let baseCoinfgKey in baseCoinfg) {
config[baseCoinfgKey] = baseCoinfg[baseCoinfgKey]
}
}
// 监听色弱模式和灰色模式 // 监听色弱模式和灰色模式
watchEffect(() => { watchEffect(() => {
document.documentElement.classList.toggle('html-weakenss', config.weakness) document.documentElement.classList.toggle('html-weakenss', config.weakness)
@@ -128,6 +149,7 @@ export const useAppStore = defineStore('app', () => {
toggleConfigSideItemHeight, toggleConfigSideItemHeight,
toggleConfigWatermark, toggleConfigWatermark,
toggleSideMode, toggleSideMode,
toggleTransition toggleTransition,
resetConfig
} }
}) })

View File

@@ -1,9 +1,10 @@
import { asyncRouterHandle } from '@/utils/asyncRouter' import { asyncRouterHandle } from '@/utils/asyncRouter'
import { emitter } from '@/utils/bus.js' import { emitter } from '@/utils/bus.js'
import { asyncMenu, getMenuList } from '@/api/menu' import { asyncMenu } from '@/api/menu'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, watchEffect } from 'vue' import { ref, watchEffect } from 'vue'
import pathInfo from '@/pathInfo.json' import pathInfo from '@/pathInfo.json'
import {useRoute} from "vue-router";
const notLayoutRouterArr = [] const notLayoutRouterArr = []
const keepAliveRoutersArr = [] const keepAliveRoutersArr = []
@@ -51,13 +52,33 @@ export const useRouterStore = defineStore('router', () => {
const asyncRouterFlag = ref(0) const asyncRouterFlag = ref(0)
const setKeepAliveRouters = (history) => { const setKeepAliveRouters = (history) => {
const keepArrTemp = [] const keepArrTemp = []
// 1. 首先添加原有的keepAlive配置
keepArrTemp.push(...keepAliveRoutersArr)
history.forEach((item) => { history.forEach((item) => {
// 2. 为所有history中的路由强制启用keep-alive
// 通过routeMap获取路由信息然后通过pathInfo获取组件名
const routeInfo = routeMap[item.name]
if (routeInfo && routeInfo.meta && routeInfo.meta.path) {
const componentName = pathInfo[routeInfo.meta.path]
if (componentName) {
keepArrTemp.push(componentName)
}
}
// 3. 如果子路由在tabs中打开父路由也需要keepAlive
if (nameMap[item.name]) { if (nameMap[item.name]) {
keepArrTemp.push(nameMap[item.name]) keepArrTemp.push(nameMap[item.name])
} }
}) })
keepAliveRouters.value = Array.from(new Set(keepArrTemp)) keepAliveRouters.value = Array.from(new Set(keepArrTemp))
} }
const route = useRoute()
emitter.on('setKeepAlive', setKeepAliveRouters) emitter.on('setKeepAlive', setKeepAliveRouters)
const asyncRouters = ref([]) const asyncRouters = ref([])
@@ -80,14 +101,32 @@ export const useRouterStore = defineStore('router', () => {
return menuMap[name]?.children return menuMap[name]?.children
} }
const findTopActive = (menuMap, routeName) => {
for (let topName in menuMap) {
const topItem = menuMap[topName];
if (topItem.children?.some(item => item.name === routeName)) {
return topName;
}
const foundName = findTopActive(topItem.children || {}, routeName);
if (foundName) {
return topName;
}
}
return null;
};
watchEffect(() => { watchEffect(() => {
let topActive = sessionStorage.getItem('topActive') let topActive = sessionStorage.getItem('topActive')
// 初始化菜单内容,防止重复添加
topMenu.value = [];
asyncRouters.value[0]?.children.forEach((item) => { asyncRouters.value[0]?.children.forEach((item) => {
if (item.hidden) return if (item.hidden) return
menuMap[item.name] = item menuMap[item.name] = item
topMenu.value.push({ ...item, children: [] }) topMenu.value.push({ ...item, children: [] })
}) })
if (!topActive || topActive === 'undefined' || topActive === 'null') {
topActive = findTopActive(menuMap, route.name);
}
setLeftMenu(topActive) setLeftMenu(topActive)
}) })
@@ -106,10 +145,8 @@ export const useRouterStore = defineStore('router', () => {
children: [] children: []
} }
] ]
// const asyncRouterRes = await asyncMenu() const asyncRouterRes = await asyncMenu()
const asyncRouterRes = await getMenuList() const asyncRouter = asyncRouterRes.data.menus
// const asyncRouter = asyncRouterRes.data.menus
const asyncRouter = asyncRouterRes.data
asyncRouter && asyncRouter &&
asyncRouter.push({ asyncRouter.push({
path: 'reload', path: 'reload',

View File

@@ -71,7 +71,6 @@ export const useUserStore = defineStore('user', () => {
const res = await login(loginInfo) const res = await login(loginInfo)
if (res.code !== 0) { if (res.code !== 0) {
ElMessage.error(res.message || '登录失败')
return false return false
} }
// 登陆成功,设置用户信息和权限相关信息 // 登陆成功,设置用户信息和权限相关信息
@@ -88,8 +87,13 @@ export const useUserStore = defineStore('user', () => {
router.addRoute(asyncRouter) router.addRoute(asyncRouter)
}) })
if(router.currentRoute.value.query.redirect) {
await router.replace(router.currentRoute.value.query.redirect)
return true
}
if (!router.hasRoute(userInfo.value.authority.defaultRouter)) { if (!router.hasRoute(userInfo.value.authority.defaultRouter)) {
ElMessage.error('请联系管理员进行授权') ElMessage.error('不存在可以登陆的首页,请联系管理员进行配置')
} else { } else {
await router.replace({ name: userInfo.value.authority.defaultRouter }) await router.replace({ name: userInfo.value.authority.defaultRouter })
} }
@@ -124,9 +128,12 @@ export const useUserStore = defineStore('user', () => {
/* 清理数据 */ /* 清理数据 */
const ClearStorage = async () => { const ClearStorage = async () => {
token.value = '' token.value = ''
xToken.value = '' // 使用remove方法正确删除cookie
xToken.remove()
sessionStorage.clear() sessionStorage.clear()
// 清理所有相关的localStorage项
localStorage.removeItem('originSetting') localStorage.removeItem('originSetting')
localStorage.removeItem('token')
} }
return { return {

View File

@@ -99,7 +99,7 @@
> >
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />
<el-table-column align="left" label="日期" prop="createdAt" width="180"> <el-table-column align="left" label="日期" prop="CreatedAt" width="180">
<template #default="scope"> <template #default="scope">
{{ formatDate(scope.row.CreatedAt) }} {{ formatDate(scope.row.CreatedAt) }}
</template> </template>

View File

@@ -31,30 +31,6 @@ const routes = [
}, },
component: () => import('@/view/error/index.vue') component: () => import('@/view/error/index.vue')
}, },
{
path: '/kefu/login',
name: 'ServiceLogin',
component: () => import('@/plugin/customerservice/view/login/index.vue'),
meta:{
client:true
}
},
{
path: '/kefu/main',
name: 'ServiceMain',
component: () => import('@/plugin/customerservice/view/chat/index.vue'),
meta:{
client:true
}
},
{
path: '/kefu/test',
name: 'ServiceUserTest',
component: () => import('@/plugin/customerservice/view/chat/test.vue'),
meta:{
client:true
}
},
] ]
const router = createRouter({ const router = createRouter({

View File

@@ -1,9 +1,6 @@
@use '@/style/main.scss'; @use '@/style/main.scss';
@use '@/style/reset'; @use '@/style/reset';
@tailwind base;
@tailwind components;
@tailwind utilities;
.el-button { .el-button {
font-weight: 400; font-weight: 400;
@@ -118,6 +115,10 @@
} }
} }
.el-menu-item.is-active{
color: var(--el-color-primary)!important;
}
.el-sub-menu__title.el-tooltip__trigger, .el-sub-menu__title.el-tooltip__trigger,
.el-menu-item .el-menu-tooltip__trigger { .el-menu-item .el-menu-tooltip__trigger {
justify-content: center; justify-content: center;

View File

@@ -17,7 +17,15 @@
} }
.gva-btn-list { .gva-btn-list {
@apply mb-3 flex items-center; @apply mb-3 flex items-center flex-wrap gap-2;
.el-button+.el-button{
@apply ml-0 !important;
}
.el-upload{
.el-button{
@apply ml-0 !important;
}
}
} }
#nprogress .bar { #nprogress .bar {
@@ -34,9 +42,7 @@
.gva-search-box { .gva-search-box {
@apply p-4 bg-white text-slate-700 dark:text-slate-400 dark:bg-slate-900 rounded my-2; @apply p-4 bg-white text-slate-700 dark:text-slate-400 dark:bg-slate-900 rounded my-2;
} }
.search-form{
@apply p-4 bg-white dark:text-slate-400 dark:bg-slate-900
}
.gva-form-box { .gva-form-box {
@apply p-4 bg-white text-slate-700 dark:text-slate-400 dark:bg-slate-900 rounded my-2; @apply p-4 bg-white text-slate-700 dark:text-slate-400 dark:bg-slate-900 rounded my-2;
} }
@@ -51,18 +57,3 @@
outline: none; outline: none;
} }
} }
.container-wrapper{
min-height: calc(100vh - 12rem);
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
}
.footer-box{
display: flex;
justify-content: center;
padding: 1rem 0;
position: sticky;
bottom: 0;
//border-top: 1px solid #ededed;
}

View File

@@ -393,7 +393,7 @@ fieldset,
table, table,
th, th,
td { td {
border: none; // border: none;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
'Microsoft YaHei', '微软雅黑', Arial, sans-serif; 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-size: 14px; font-size: 14px;

View File

@@ -1,8 +1,8 @@
import axios from 'axios' // 引入axios import axios from 'axios' // 引入axios
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/pinia/modules/user' import { useUserStore } from '@/pinia/modules/user'
import { ElLoading, ElMessage } from 'element-plus'
import { emitter } from '@/utils/bus'
import router from '@/router/index' import router from '@/router/index'
import { ElLoading } from 'element-plus'
const service = axios.create({ const service = axios.create({
baseURL: import.meta.env.VITE_BASE_API, baseURL: import.meta.env.VITE_BASE_API,
@@ -11,6 +11,9 @@ const service = axios.create({
let activeAxios = 0 let activeAxios = 0
let timer let timer
let loadingInstance let loadingInstance
let isLoadingVisible = false
let forceCloseTimer
const showLoading = ( const showLoading = (
option = { option = {
target: null target: null
@@ -18,13 +21,33 @@ const showLoading = (
) => { ) => {
const loadDom = document.getElementById('gva-base-load-dom') const loadDom = document.getElementById('gva-base-load-dom')
activeAxios++ activeAxios++
// 清除之前的定时器
if (timer) { if (timer) {
clearTimeout(timer) clearTimeout(timer)
} }
// 清除强制关闭定时器
if (forceCloseTimer) {
clearTimeout(forceCloseTimer)
}
timer = setTimeout(() => { timer = setTimeout(() => {
if (activeAxios > 0) { // 再次检查activeAxios状态,防止竞态条件
if (activeAxios > 0 && !isLoadingVisible) {
if (!option.target) option.target = loadDom if (!option.target) option.target = loadDom
loadingInstance = ElLoading.service(option) loadingInstance = ElLoading.service(option)
isLoadingVisible = true
// 设置强制关闭定时器防止loading永远不关闭30秒超时
forceCloseTimer = setTimeout(() => {
if (isLoadingVisible && loadingInstance) {
console.warn('Loading强制关闭超时30秒')
loadingInstance.close()
isLoadingVisible = false
activeAxios = 0 // 重置计数器
}
}, 30000)
} }
}, 400) }, 400)
} }
@@ -32,10 +55,47 @@ const showLoading = (
const closeLoading = () => { const closeLoading = () => {
activeAxios-- activeAxios--
if (activeAxios <= 0) { if (activeAxios <= 0) {
activeAxios = 0 // 确保不会变成负数
clearTimeout(timer) clearTimeout(timer)
loadingInstance && loadingInstance.close()
if (forceCloseTimer) {
clearTimeout(forceCloseTimer)
forceCloseTimer = null
}
if (isLoadingVisible && loadingInstance) {
loadingInstance.close()
isLoadingVisible = false
}
loadingInstance = null
} }
} }
// 全局重置loading状态的函数用于异常情况
const resetLoading = () => {
activeAxios = 0
isLoadingVisible = false
if (timer) {
clearTimeout(timer)
timer = null
}
if (forceCloseTimer) {
clearTimeout(forceCloseTimer)
forceCloseTimer = null
}
if (loadingInstance) {
try {
loadingInstance.close()
} catch (e) {
console.warn('关闭loading时出错:', e)
}
loadingInstance = null
}
}
// http request 拦截器 // http request 拦截器
service.interceptors.request.use( service.interceptors.request.use(
(config) => { (config) => {
@@ -55,15 +115,18 @@ service.interceptors.request.use(
if (!error.config.donNotShowLoading) { if (!error.config.donNotShowLoading) {
closeLoading() closeLoading()
} }
ElMessage({ emitter.emit('show-error', {
showClose: true, code: 'request',
message: error, message: error.message || '请求发送失败'
type: 'error'
}) })
return error return error
} }
) )
function getErrorMessage(error) {
return error.response?.data?.msg || '请求失败'
}
// http response 拦截器 // http response 拦截器
service.interceptors.response.use( service.interceptors.response.use(
(response) => { (response) => {
@@ -74,6 +137,9 @@ service.interceptors.response.use(
if (response.headers['new-token']) { if (response.headers['new-token']) {
userStore.setToken(response.headers['new-token']) userStore.setToken(response.headers['new-token'])
} }
if (typeof response.data.code === 'undefined') {
return response
}
if (response.data.code === 0 || response.headers.success === 'true') { if (response.data.code === 0 || response.headers.success === 'true') {
if (response.headers.msg) { if (response.headers.msg) {
response.data.msg = decodeURI(response.headers.msg) response.data.msg = decodeURI(response.headers.msg)
@@ -94,79 +160,43 @@ service.interceptors.response.use(
} }
if (!error.response) { if (!error.response) {
ElMessageBox.confirm( // 网络错误
` resetLoading()
<p>检测到请求错误</p> emitter.emit('show-error', {
<p>${error}</p> code: 'network',
`, message: getErrorMessage(error)
'请求报错', })
{ return Promise.reject(error)
dangerouslyUseHTMLString: true,
distinguishCancelAndClose: true,
confirmButtonText: '稍后重试',
cancelButtonText: '取消'
}
)
return
} }
switch (error.response.status) { // HTTP 状态码错误
case 500: if (error.response.status === 401) {
ElMessageBox.confirm( emitter.emit('show-error', {
` code: '401',
<p>检测到接口错误${error}</p> message: getErrorMessage(error),
<p>错误码<span style="color:red"> 500 </span>此类错误内容常见于后台panic请先查看后台日志如果影响您正常使用可强制登出清理缓存</p> fn: () => {
`,
'接口报错',
{
dangerouslyUseHTMLString: true,
distinguishCancelAndClose: true,
confirmButtonText: '清理缓存',
cancelButtonText: '取消'
}
).then(() => {
const userStore = useUserStore() const userStore = useUserStore()
userStore.ClearStorage() userStore.ClearStorage()
router.push({ name: 'Login', replace: true }) router.push({ name: 'Login', replace: true })
})
break
case 404:
ElMessageBox.confirm(
`
<p>检测到接口错误${error}</p>
<p>错误码<span style="color:red"> 404 </span>此类错误多为接口未注册或未重启或者请求路径方法与api路径方法不符--如果为自动化代码请检查是否存在空格</p>
`,
'接口报错',
{
dangerouslyUseHTMLString: true,
distinguishCancelAndClose: true,
confirmButtonText: '我知道了',
cancelButtonText: '取消'
} }
)
break
case 401:
ElMessageBox.confirm(
`
<p>无效的令牌</p>
<p>错误码:<span style="color:red"> 401 </span>错误信息:${error}</p>
`,
'身份信息',
{
dangerouslyUseHTMLString: true,
distinguishCancelAndClose: true,
confirmButtonText: '重新登录',
cancelButtonText: '取消'
}
).then(() => {
const userStore = useUserStore()
userStore.ClearStorage()
router.push({ name: 'Login', replace: true })
}) })
break return Promise.reject(error)
} }
return error emitter.emit('show-error', {
code: error.response.status,
message: getErrorMessage(error)
})
return Promise.reject(error)
} }
) )
// 监听页面卸载事件确保loading被正确清理
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', resetLoading)
window.addEventListener('unload', resetLoading)
}
// 导出service和resetLoading函数
export { resetLoading }
export default service export default service

View File

@@ -24,6 +24,7 @@
<script setup> <script setup>
import { useUserStore } from '@/pinia/modules/user' import { useUserStore } from '@/pinia/modules/user'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { emitter } from '@/utils/bus'
defineOptions({ defineOptions({
name: 'Error' name: 'Error'
@@ -32,6 +33,17 @@
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
const toDashboard = () => { const toDashboard = () => {
try {
router.push({ name: userStore.userInfo.authority.defaultRouter }) router.push({ name: userStore.userInfo.authority.defaultRouter })
} catch (error) {
emitter.emit('show-error', {
code: '401',
message: "检测到其他用户修改了路由权限,请重新登录",
fn: () => {
userStore.ClearStorage()
router.push({ name: 'Login', replace: true })
}
})
}
} }
</script> </script>

View File

@@ -136,7 +136,7 @@
// @ts-ignore // @ts-ignore
import { initDB } from '@/api/initdb' import { initDB } from '@/api/initdb'
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { ElLoading, ElMessage } from 'element-plus' import { ElLoading, ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
defineOptions({ defineOptions({
@@ -274,7 +274,25 @@
type: 'success', type: 'success',
message: res.msg message: res.msg
}) })
// 显示AI助手配置提示弹窗
ElMessageBox.confirm(
'已经完成基础数据库初始化建议先进行编辑器AI助手配置以获得更好的开发体验。',
'配置完成',
{
confirmButtonText: '查看AI配置文档',
cancelButtonText: '稍后配置',
type: 'success',
center: true
}
).then(() => {
// 点击确认按钮打开AI配置文档
window.open('https://www.gin-vue-admin.com/guide/server/mcp.html', '_blank')
router.push({ name: 'Login' }) router.push({ name: 'Login' })
}).catch(() => {
// 点击取消按钮或关闭弹窗,直接跳转到登录页
router.push({ name: 'Login' })
})
} }
loading.close() loading.close()
} catch (_) { } catch (_) {

View File

@@ -37,7 +37,7 @@
const menuComponent = computed(() => { const menuComponent = computed(() => {
if ( if (
props.routerInfo.children && props.routerInfo.children &&
props.routerInfo.children.filter((item) => !item.hidden).length props.routerInfo.children?.filter((item) => !item.hidden).length
) { ) {
return AsyncSubmenu return AsyncSubmenu
} else { } else {

View File

@@ -1,7 +1,6 @@
<template> <template>
<el-menu-item <el-menu-item
:index="routerInfo.name" :index="routerInfo.name"
class="dark:text-slate-300 overflow-hidden"
:style="{ :style="{
height: sideHeight height: sideHeight
}" }"
@@ -9,6 +8,9 @@
<el-icon v-if="routerInfo.meta.icon"> <el-icon v-if="routerInfo.meta.icon">
<component :is="routerInfo.meta.icon" /> <component :is="routerInfo.meta.icon" />
</el-icon> </el-icon>
<template v-else>
{{ isCollapse ? routerInfo.meta.title[0] : "" }}
</template>
<template #title> <template #title>
{{ routerInfo.meta.title }} {{ routerInfo.meta.title }}
</template> </template>
@@ -16,7 +18,7 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import {computed, inject} from 'vue'
import { useAppStore } from '@/pinia' import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
const appStore = useAppStore() const appStore = useAppStore()
@@ -35,6 +37,10 @@
} }
}) })
const isCollapse = inject('isCollapse', {
default: false
})
const sideHeight = computed(() => { const sideHeight = computed(() => {
return config.value.layout_side_item_height + 'px' return config.value.layout_side_item_height + 'px'
}) })

View File

@@ -7,7 +7,7 @@
<el-menu <el-menu
:default-active="routerStore.topActive" :default-active="routerStore.topActive"
mode="horizontal" mode="horizontal"
class="border-r-0 border-b-0 w-full flex gap-1 items-center box-border h-[calc(100%-1px)]" class="!border-r-0 border-b-0 w-full flex gap-1 items-center box-border h-[calc(100%-1px)]"
unique-opened unique-opened
@select="(index, _, ele) => selectMenuItem(index, _, ele, true)" @select="(index, _, ele) => selectMenuItem(index, _, ele, true)"
> >
@@ -34,7 +34,7 @@
:collapse="isCollapse" :collapse="isCollapse"
:collapse-transition="false" :collapse-transition="false"
:default-active="active" :default-active="active"
class="border-r-0 w-full" class="!border-r-0 w-full"
unique-opened unique-opened
@select="(index, _, ele) => selectMenuItem(index, _, ele, false)" @select="(index, _, ele) => selectMenuItem(index, _, ele, false)"
> >

View File

@@ -1,13 +1,16 @@
<template> <template>
<div <div
class="bg-white h-[calc(100%-4px)] text-slate-700 dark:text-slate-300 mx-2 dark:bg-slate-900 flex items-center w-[calc(100vw-600px)] overflow-auto" class="bg-white h-[calc(100%-4px)] text-slate-700 dark:text-slate-300 mx-2 dark:bg-slate-900 flex items-center w-[calc(100vw-600px)] overflow-auto"
ref="menuContainer"
> >
<el-menu <el-menu
:default-active="active" :default-active="active"
mode="horizontal" mode="horizontal"
class="border-r-0 w-full flex gap-1 items-center box-border h-[calc(100%-1px)]" class="!border-r-0 w-full flex gap-1 items-center box-border h-[calc(100%-1px)]"
unique-opened unique-opened
:ellipsis="shouldEllipsis"
@select="selectMenuItem" @select="selectMenuItem"
ref="menuRef"
> >
<template v-for="item in routerStore.asyncRouters[0].children"> <template v-for="item in routerStore.asyncRouters[0].children">
<aside-component <aside-component
@@ -23,7 +26,7 @@
<script setup> <script setup>
import AsideComponent from '@/view/layout/aside/asideComponent/index.vue' import AsideComponent from '@/view/layout/aside/asideComponent/index.vue'
import { ref, provide, watchEffect } from 'vue' import { ref, provide, watchEffect, onMounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useRouterStore } from '@/pinia/modules/router' import { useRouterStore } from '@/pinia/modules/router'
import { useAppStore } from '@/pinia' import { useAppStore } from '@/pinia'
@@ -39,6 +42,26 @@
const routerStore = useRouterStore() const routerStore = useRouterStore()
const isCollapse = ref(false) const isCollapse = ref(false)
const active = ref('') const active = ref('')
const menuRef = ref(null)
const menuContainer = ref(null)
const shouldEllipsis = ref(false)
// 计算是否需要启用省略功能
const calculateEllipsis = async () => {
await nextTick()
if (!menuRef.value || !menuContainer.value) return
const menuItems = menuRef.value.$el.querySelectorAll('.el-menu-item, .el-sub-menu')
let totalWidth = 0
menuItems.forEach(item => {
totalWidth += item.offsetWidth
})
const containerWidth = menuContainer.value.offsetWidth
shouldEllipsis.value = totalWidth > containerWidth
}
watchEffect(() => { watchEffect(() => {
if (route.name === 'Iframe') { if (route.name === 'Iframe') {
active.value = decodeURIComponent(route.query.url) active.value = decodeURIComponent(route.query.url)
@@ -53,10 +76,24 @@
} else { } else {
isCollapse.value = false isCollapse.value = false
} }
// 设备变化时重新计算
calculateEllipsis()
})
// 当路由变化时重新计算
watchEffect(() => {
if (route.name) {
nextTick(calculateEllipsis)
}
}) })
provide('isCollapse', isCollapse) provide('isCollapse', isCollapse)
onMounted(() => {
calculateEllipsis()
window.addEventListener('resize', calculateEllipsis)
})
const selectMenuItem = (index) => { const selectMenuItem = (index) => {
const query = {} const query = {}
const params = {} const params = {}
@@ -70,30 +107,26 @@
}) })
if (index === route.name) return if (index === route.name) return
if (index.indexOf('http://') > -1 || index.indexOf('https://') > -1) { if (index.indexOf('http://') > -1 || index.indexOf('https://') > -1) {
if (index === 'Iframe') {
query.url = decodeURIComponent(index)
router.push({
name: 'Iframe',
query,
params
})
return
} else {
window.open(index, '_blank') window.open(index, '_blank')
return return
} }
} else { if (index === 'Iframe') {
router.push({ name: index, query, params }) query.url = decodeURIComponent(index)
} }
router.push({ name: index, query, params })
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss">
.el-menu--horizontal.el-menu, .el-menu--horizontal.el-menu,
.el-menu--horizontal > .el-menu-item.is-active { .el-menu--horizontal > .el-menu-item.is-active {
border-bottom: none !important; border-bottom: none !important;
} }
.el-menu--horizontal>.el-sub-menu.is-active .el-sub-menu__title {
border-bottom: none !important;
}
.el-menu-item.is-active { .el-menu-item.is-active {
background-color: var(--el-color-primary-light-8) !important; background-color: var(--el-color-primary-light-8) !important;
} }

View File

@@ -4,7 +4,8 @@
v-if=" v-if="
config.side_mode === 'normal' || config.side_mode === 'normal' ||
(device === 'mobile' && config.side_mode == 'head') || (device === 'mobile' && config.side_mode == 'head') ||
(device === 'mobile' && config.side_mode == 'combination') (device === 'mobile' && config.side_mode == 'combination') ||
(device === 'mobile' && config.side_mode == 'sidebar')
" "
/> />
<head-mode v-if="config.side_mode === 'head' && device !== 'mobile'" /> <head-mode v-if="config.side_mode === 'head' && device !== 'mobile'" />
@@ -12,6 +13,9 @@
v-if="config.side_mode === 'combination' && device !== 'mobile'" v-if="config.side_mode === 'combination' && device !== 'mobile'"
:mode="mode" :mode="mode"
/> />
<sidebar-mode
v-if="config.side_mode === 'sidebar' && device !== 'mobile'"
/>
</div> </div>
</template> </template>
@@ -19,6 +23,7 @@
import NormalMode from './normalMode.vue' import NormalMode from './normalMode.vue'
import HeadMode from './headMode.vue' import HeadMode from './headMode.vue'
import CombinationMode from './combinationMode.vue' import CombinationMode from './combinationMode.vue'
import SidebarMode from './sidebarMode.vue'
defineProps({ defineProps({
mode: { mode: {

View File

@@ -11,7 +11,7 @@
:collapse="isCollapse" :collapse="isCollapse"
:collapse-transition="false" :collapse-transition="false"
:default-active="active" :default-active="active"
class="border-r-0 w-full" class="!border-r-0 w-full"
unique-opened unique-opened
@select="selectMenuItem" @select="selectMenuItem"
> >

View File

@@ -0,0 +1,300 @@
<template>
<div class="flex h-full">
<!-- 一级菜单常驻侧边栏 -->
<div
class="relative !h-full bg-white text-slate-700 dark:text-slate-300 dark:bg-slate-900 border-r shadow dark:shadow-gray-700"
:style="{
width: config.layout_side_collapsed_width + 'px'
}"
>
<el-scrollbar>
<el-menu
:collapse="true"
:collapse-transition="false"
:default-active="topActive"
class="!border-r-0 w-full"
unique-opened
@select="selectTopMenuItem"
>
<template v-for="item in routerStore.asyncRouters[0]?.children || []">
<el-menu-item
v-if="!item.hidden && (!item.children || item.children.length === 0)"
:key="item.name"
:index="item.name"
class="dark:text-slate-300 overflow-hidden"
:style="{
height: config.layout_side_item_height + 'px'
}"
>
<el-icon v-if="item.meta.icon">
<component :is="item.meta.icon" />
</el-icon>
<template v-else>
{{ item.meta.title[0] }}
</template>
<template #title>
{{ item.meta.title }}
</template>
</el-menu-item>
<template v-else-if="!item.hidden" >
<el-menu-item
:key="item.name"
:index="item.name"
:class="{'is-active': topActive === item.name}"
class="dark:text-slate-300 overflow-hidden"
:style="{
height: config.layout_side_item_height + 'px'
}"
>
<el-icon v-if="item.meta.icon">
<component :is="item.meta.icon" />
</el-icon>
<template v-else>
{{ item.meta.title[0] }}
</template>
<template #title>
{{ item.meta.title }}
</template>
</el-menu-item>
</template>
</template>
</el-menu>
</el-scrollbar>
</div>
<!-- 二级菜单并列显示 -->
<div
class="relative h-full bg-white text-slate-700 dark:text-slate-300 dark:bg-slate-900 border-r shadow dark:shadow-gray-700 px-2"
:style="{
width: layoutSideWidth + 'px'
}"
>
<el-scrollbar>
<el-menu
:collapse="isCollapse"
:collapse-transition="false"
:default-active="active"
class="!border-r-0 w-full"
unique-opened
@select="selectMenuItem"
>
<template v-for="item in secondLevelMenus">
<aside-component
v-if="!item.hidden"
:key="item.name"
:router-info="item"
/>
</template>
</el-menu>
</el-scrollbar>
<div
class="absolute bottom-8 right-2 w-8 h-8 bg-gray-50 dark:bg-slate-800 flex items-center justify-center rounded cursor-pointer"
:class="isCollapse ? 'right-0 left-0 mx-auto' : 'right-2'"
@click="toggleCollapse"
>
<el-icon v-if="!isCollapse">
<DArrowLeft />
</el-icon>
<el-icon v-else>
<DArrowRight />
</el-icon>
</div>
</div>
</div>
</template>
<script setup>
import AsideComponent from '@/view/layout/aside/asideComponent/index.vue'
import { ref, provide, watchEffect, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRouterStore } from '@/pinia/modules/router'
import { useAppStore } from '@/pinia'
import { storeToRefs } from 'pinia'
const appStore = useAppStore()
const { device, config } = storeToRefs(appStore)
defineOptions({
name: 'SidebarMode'
})
const route = useRoute()
const router = useRouter()
const routerStore = useRouterStore()
const isCollapse = ref(false)
const active = ref('')
const topActive = ref('')
const secondLevelMenus = ref([])
const layoutSideWidth = computed(() => {
if (!isCollapse.value) {
return config.value.layout_side_width
} else {
return config.value.layout_side_collapsed_width
}
})
provide('isCollapse', isCollapse)
// 更新二级菜单
const updateSecondLevelMenus = (menuName) => {
const menu = routerStore.asyncRouters[0]?.children.find(item => item.name === menuName)
if (menu && menu.children && menu.children.length > 0) {
secondLevelMenus.value = menu.children
}
}
// 选择一级菜单
const selectTopMenuItem = (index) => {
topActive.value = index
// 获取选中的菜单项
const menu = routerStore.asyncRouters[0]?.children.find(item => item.name === index)
// 只有当选中的菜单有子菜单时,才更新二级菜单区域
if (menu && menu.children && menu.children.length > 0) {
updateSecondLevelMenus(index)
// 导航到第一个可见的子菜单
const firstVisibleChild = menu.children.find(child => !child.hidden)
if (firstVisibleChild) {
navigateToMenuItem(firstVisibleChild.name)
}
} else {
// 如果没有子菜单,直接导航到该菜单,但不更新二级菜单区域
navigateToMenuItem(index)
}
}
// 选择二级或更深层级的菜单
const selectMenuItem = (index) => {
navigateToMenuItem(index)
}
// 导航到指定菜单
const navigateToMenuItem = (index) => {
const query = {}
const params = {}
routerStore.routeMap[index]?.parameters &&
routerStore.routeMap[index]?.parameters.forEach((item) => {
if (item.type === 'query') {
query[item.key] = item.value
} else {
params[item.key] = item.value
}
})
if (index === route.name) return
if (index.indexOf('http://') > -1 || index.indexOf('https://') > -1) {
if (index === 'Iframe') {
query.url = decodeURIComponent(index)
router.push({
name: 'Iframe',
query,
params
})
return
} else {
window.open(index, '_blank')
return
}
} else {
router.push({ name: index, query, params })
}
}
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value
}
watchEffect(() => {
if (route.name === 'Iframe') {
active.value = decodeURIComponent(route.query.url)
return
}
active.value = route.meta.activeName || route.name
// 找到当前路由所属的一级菜单
const findParentMenu = () => {
// 首先检查当前路由是否就是一级菜单
const isTopMenu = routerStore.asyncRouters[0]?.children.some(
item => !item.hidden && item.name === route.name
)
if (isTopMenu) {
return route.name
}
for (const topMenu of routerStore.asyncRouters[0]?.children || []) {
if (topMenu.hidden) continue
// 检查当前路由是否是这个一级菜单的子菜单
if (topMenu.children && topMenu.children.some(child => child.name === route.name)) {
return topMenu.name
}
// 递归检查更深层级
const checkChildren = (items) => {
for (const item of items || []) {
if (item.name === route.name) {
return true
}
if (item.children && checkChildren(item.children)) {
return true
}
}
return false
}
if (topMenu.children && checkChildren(topMenu.children)) {
return topMenu.name
}
}
return null
}
const parentMenu = findParentMenu()
if (parentMenu) {
topActive.value = parentMenu
// 只有当父菜单有子菜单时,才更新二级菜单区域
const menu = routerStore.asyncRouters[0]?.children.find(item => item.name === parentMenu)
if (menu && menu.children && menu.children.length > 0) {
updateSecondLevelMenus(parentMenu)
} else {
// 如果找到的父菜单没有子菜单,保持当前一级菜单高亮,但需要显示一些二级菜单
// 寻找第一个有子菜单的一级菜单来显示其子菜单
const firstMenuWithChildren = routerStore.asyncRouters[0].children.find(
item => !item.hidden && item.children && item.children.length > 0
)
if (firstMenuWithChildren) {
// 只更新二级菜单区域,但保持当前一级菜单的高亮状态
updateSecondLevelMenus(firstMenuWithChildren.name)
}
}
} else if (routerStore.asyncRouters[0]?.children?.length > 0) {
// 如果没有找到父菜单,保持当前路由名称作为高亮,但需要显示一些二级菜单
// 寻找第一个有子菜单的一级菜单来显示其子菜单
const firstMenuWithChildren = routerStore.asyncRouters[0].children.find(
item => !item.hidden && item.children && item.children.length > 0
)
if (firstMenuWithChildren) {
// 只更新二级菜单区域,高亮状态保持为当前路由
topActive.value = route.name
secondLevelMenus.value = firstMenuWithChildren.children
}
}
})
watchEffect(() => {
if (device.value === 'mobile') {
isCollapse.value = true
} else {
isCollapse.value = false
}
})
</script>

View File

@@ -8,7 +8,7 @@
<el-tooltip class="" effect="dark" content="视频教程" placement="bottom"> <el-tooltip class="" effect="dark" content="视频教程" placement="bottom">
<el-dropdown @command="toDoc"> <el-dropdown @command="toDoc">
<el-icon <el-icon
class="w-8 h-8 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid" class="w-8 h-8 p-2 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid"
> >
<Film /> <Film />
</el-icon> </el-icon>
@@ -29,7 +29,7 @@
<el-tooltip class="" effect="dark" content="搜索" placement="bottom"> <el-tooltip class="" effect="dark" content="搜索" placement="bottom">
<el-icon <el-icon
@click="handleCommand" @click="handleCommand"
class="w-8 h-8 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid" class="w-8 h-8 p-2 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid"
> >
<Search /> <Search />
</el-icon> </el-icon>
@@ -37,7 +37,7 @@
<el-tooltip class="" effect="dark" content="系统设置" placement="bottom"> <el-tooltip class="" effect="dark" content="系统设置" placement="bottom">
<el-icon <el-icon
class="w-8 h-8 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid" class="w-8 h-8 p-2 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid"
@click="toggleSetting" @click="toggleSetting"
> >
<Setting /> <Setting />
@@ -46,7 +46,7 @@
<el-tooltip class="" effect="dark" content="刷新" placement="bottom"> <el-tooltip class="" effect="dark" content="刷新" placement="bottom">
<el-icon <el-icon
class="w-8 h-8 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid" class="w-8 h-8 p-2 shadow rounded-full border border-gray-200 dark:border-gray-600 cursor-pointer border-solid"
:class="showRefreshAnmite ? 'animate-spin' : ''" :class="showRefreshAnmite ? 'animate-spin' : ''"
@click="toggleRefresh" @click="toggleRefresh"
> >
@@ -61,14 +61,14 @@
> >
<el-icon <el-icon
v-if="appStore.isDark" v-if="appStore.isDark"
class="w-8 h-8 shadow rounded-full border border-gray-600 cursor-pointer border-solid" class="w-8 h-8 p-2 shadow rounded-full border border-gray-600 cursor-pointer border-solid"
@click="appStore.toggleTheme(false)" @click="appStore.toggleTheme(false)"
> >
<Sunny /> <Sunny />
</el-icon> </el-icon>
<el-icon <el-icon
v-else v-else
class="w-8 h-8 shadow rounded-full border border-gray-200 cursor-pointer border-solid" class="w-8 h-8 p-2 shadow rounded-full border border-gray-200 cursor-pointer border-solid"
@click="appStore.toggleTheme(true)" @click="appStore.toggleTheme(true)"
> >
<Moon /> <Moon />

View File

@@ -11,10 +11,10 @@
:content="userStore.userInfo.nickName" :content="userStore.userInfo.nickName"
/> />
<gva-header /> <gva-header />
<div class="flex flex-row w-full gva-container pt-16 box-border h-full"> <div class="flex flex-row w-full gva-container pt-16 box-border !h-full">
<gva-aside <gva-aside
v-if=" v-if="
config.side_mode === 'normal' || config.side_mode === 'normal' || config.side_mode === 'sidebar' ||
(device === 'mobile' && config.side_mode == 'head') || (device === 'mobile' && config.side_mode == 'head') ||
(device === 'mobile' && config.side_mode == 'combination') (device === 'mobile' && config.side_mode == 'combination')
" "
@@ -34,7 +34,7 @@
id="gva-base-load-dom" id="gva-base-load-dom"
class="gva-body-h bg-gray-50 dark:bg-slate-800" class="gva-body-h bg-gray-50 dark:bg-slate-800"
> >
<transition mode="out-in" :name="config.transition_type"> <transition mode="out-in" :name="route.meta.transitionType || config.transition_type">
<keep-alive :include="routerStore.keepAliveRouters"> <keep-alive :include="routerStore.keepAliveRouters">
<component :is="Component" :key="route.fullPath" /> <component :is="Component" :key="route.fullPath" />
</keep-alive> </keep-alive>

View File

@@ -0,0 +1,219 @@
<template>
<div class="grid grid-cols-2 gap-6 font-inter px-6">
<div
v-for="layout in layoutModes"
:key="layout.value"
class="bg-white dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-600 rounded-xl p-6 cursor-pointer transition-all duration-150 ease-in-out hover:transform hover:-translate-y-1 hover:shadow-xl"
:class="{
'ring-2 ring-offset-2 ring-offset-gray-50 dark:ring-offset-gray-900 transform -translate-y-1 shadow-xl': modelValue === layout.value
}"
:style="modelValue === layout.value ? {
borderColor: primaryColor,
ringColor: primaryColor + '40'
} : {}"
@click="handleLayoutChange(layout.value)"
>
<div class="flex justify-center mb-5">
<div
class="w-28 h-20 bg-gray-50 dark:bg-gray-600 border border-gray-200 dark:border-gray-500 rounded-lg p-2 flex gap-1.5 shadow-inner"
:class="layout.containerClass"
>
<div
v-if="layout.showSidebar"
class="rounded-sm"
:class="[layout.sidebarClass]"
:style="getSidebarStyle(layout)"
></div>
<div class="flex-1 flex flex-col gap-1.5">
<div
v-if="layout.showHeader"
class="rounded-sm"
:class="layout.headerClass"
:style="getHeaderStyle(layout)"
></div>
<div
class="flex-1 rounded-sm"
:class="layout.contentClass"
:style="getContentStyle(layout)"
></div>
</div>
</div>
</div>
<div class="text-center">
<span class="block text-base font-semibold text-gray-900 dark:text-white mb-2" :class="{ 'text-current': modelValue === layout.value }" :style="modelValue === layout.value ? { color: primaryColor } : {}">{{ layout.label }}</span>
<span class="block text-sm text-gray-500 dark:text-gray-400">{{ layout.description }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/pinia'
defineOptions({
name: 'LayoutModeCard'
})
const props = defineProps({
modelValue: {
type: String,
default: 'normal'
}
})
const emit = defineEmits(['update:modelValue'])
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
const primaryColor = computed(() => config.value.primaryColor)
const lighterPrimaryColor = computed(() => {
const hex = config.value.primaryColor.replace('#', '')
const r = parseInt(hex.substr(0, 2), 16)
const g = parseInt(hex.substr(2, 2), 16)
const b = parseInt(hex.substr(4, 2), 16)
return `rgba(${r}, ${g}, ${b}, 0.7)`
})
const lightestPrimaryColor = computed(() => {
const hex = config.value.primaryColor.replace('#', '')
const r = parseInt(hex.substr(0, 2), 16)
const g = parseInt(hex.substr(2, 2), 16)
const b = parseInt(hex.substr(4, 2), 16)
return `rgba(${r}, ${g}, ${b}, 0.4)`
})
const layoutModes = [
{
value: 'normal',
label: '经典布局',
description: '左侧导航,顶部标题栏',
containerClass: '',
showSidebar: true,
sidebarClass: 'w-1/4',
showHeader: true,
headerClass: 'h-1/4',
contentClass: '',
showRightSidebar: false,
primaryElement: 'sidebar'
},
{
value: 'head',
label: '顶部导航',
description: '水平导航栏布局',
containerClass: 'flex-col',
showSidebar: false,
showHeader: true,
headerClass: 'h-1/3',
contentClass: '',
showRightSidebar: false,
primaryElement: 'header'
},
{
value: 'combination',
label: '混合布局',
description: '多级导航组合模式',
containerClass: '',
showSidebar: true,
sidebarClass: 'w-1/5',
showHeader: true,
headerClass: 'h-1/4',
contentClass: '',
showRightSidebar: true,
rightSidebarClass: 'w-1/5',
primaryElement: 'header',
secondaryElement: 'sidebar'
},
{
value: 'sidebar',
label: '侧栏常驻',
description: '二级菜单会始终打开',
containerClass: '',
showSidebar: true,
sidebarClass: 'w-1/3',
showHeader: true,
headerClass: 'h-1/4',
contentClass: '',
showRightSidebar: false,
primaryElement: 'sidebar'
}
]
const getSidebarStyle = (layout) => {
if (layout.primaryElement === 'sidebar') {
return { backgroundColor: primaryColor.value, opacity: '0.95' }
} else if (layout.secondaryElement === 'sidebar') {
return { backgroundColor: lighterPrimaryColor.value, opacity: '0.85' }
} else {
return { backgroundColor: lightestPrimaryColor.value, opacity: '0.6' }
}
}
const getHeaderStyle = (layout) => {
if (layout.primaryElement === 'header') {
return { backgroundColor: primaryColor.value, opacity: '0.95' }
} else if (layout.secondaryElement === 'header') {
return { backgroundColor: lighterPrimaryColor.value, opacity: '0.85' }
} else {
return { backgroundColor: lightestPrimaryColor.value, opacity: '0.6' }
}
}
const getContentStyle = (layout) => {
return { backgroundColor: lightestPrimaryColor.value, opacity: '0.5' }
}
const getRightSidebarStyle = (layout) => {
if (layout.primaryElement === 'rightSidebar') {
return { backgroundColor: primaryColor.value, opacity: '0.95' }
} else if (layout.secondaryElement === 'rightSidebar') {
return { backgroundColor: lighterPrimaryColor.value, opacity: '0.85' }
} else {
return { backgroundColor: lightestPrimaryColor.value, opacity: '0.6' }
}
}
const handleLayoutChange = (layout) => {
emit('update:modelValue', layout)
}
</script>
<style scoped>
.font-inter {
font-family: 'Inter', sans-serif;
}
.flex-col {
flex-direction: column;
}
.w-1\/4 {
width: 25%;
}
.w-1\/3 {
width: 33.333333%;
}
.w-1\/5 {
width: 20%;
}
.h-1\/4 {
height: 25%;
}
.h-1\/3 {
height: 33.333333%;
}
@media (max-width: 480px) {
.grid-cols-2 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<div class="flex items-center justify-between py-4 font-inter border-b border-gray-100 dark:border-gray-700 last:border-b-0">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ label }}</span>
<slot name="suffix"></slot>
</div>
<div class="flex items-center setting-controls">
<slot></slot>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/pinia'
defineOptions({
name: 'SettingItem'
})
defineProps({
label: {
type: String,
required: true
}
})
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
const primaryColor = computed(() => config.value.primaryColor)
const primaryColorWithOpacity = computed(() => config.value.primaryColor + '40')
</script>
<style scoped>
.font-inter {
font-family: 'Inter', sans-serif;
}
.setting-controls {
::v-deep(.el-switch) {
--el-switch-on-color: v-bind(primaryColor);
--el-switch-off-color: #d1d5db;
}
::v-deep(.el-select) {
.el-input__wrapper {
border: 1px solid #e5e7eb;
border-radius: 6px;
transition: all 150ms ease-in-out;
&:hover {
border-color: v-bind(primaryColor);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.is-focus {
border-color: v-bind(primaryColor);
box-shadow: 0 0 0 2px v-bind(primaryColorWithOpacity);
}
}
}
::v-deep(.el-input-number) {
.el-input__wrapper {
border: 1px solid #e5e7eb;
border-radius: 6px;
transition: all 150ms ease-in-out;
&:hover {
border-color: v-bind(primaryColor);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.is-focus {
border-color: v-bind(primaryColor);
box-shadow: 0 0 0 2px v-bind(primaryColorWithOpacity);
}
}
}
}
.dark .setting-controls {
::v-deep(.el-switch) {
--el-switch-off-color: #4b5563;
}
::v-deep(.el-select) {
.el-input__wrapper {
border-color: #4b5563;
background-color: #374151;
&:hover {
border-color: v-bind(primaryColor);
}
}
}
::v-deep(.el-input-number) {
.el-input__wrapper {
border-color: #4b5563;
background-color: #374151;
&:hover {
border-color: v-bind(primaryColor);
}
}
}
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<div class="font-inter">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-8 shadow-sm">
<div class="mb-8">
<p class="text-base font-semibold text-gray-700 dark:text-gray-300 mb-5">精选色彩</p>
<div class="grid grid-cols-3 gap-4">
<div
v-for="colorItem in presetColors"
:key="colorItem.color"
class="flex items-center gap-4 p-4 bg-white dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-600 rounded-xl cursor-pointer transition-all duration-150 ease-in-out hover:transform hover:-translate-y-1 hover:shadow-lg"
:class="{
'ring-2 ring-offset-2 ring-offset-gray-50 dark:ring-offset-gray-800 transform -translate-y-1 shadow-lg': modelValue === colorItem.color
}"
:style="modelValue === colorItem.color ? {
borderColor: colorItem.color,
ringColor: colorItem.color + '40'
} : {}"
@click="handleColorChange(colorItem.color)"
>
<div
class="relative w-10 h-10 rounded-lg border border-gray-300 dark:border-gray-500 flex-shrink-0 shadow-sm"
:style="{ backgroundColor: colorItem.color }"
>
<div
v-if="modelValue === colorItem.color"
class="absolute inset-0 flex items-center justify-center text-white text-base"
style="text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);"
>
<el-icon>
<Check />
</el-icon>
</div>
</div>
<div class="min-w-0 flex-1">
<span class="block text-sm font-semibold text-gray-900 dark:text-white">{{ colorItem.name }}</span>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between p-5 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl mb-6 shadow-sm">
<div class="flex-1">
<h4 class="text-base font-semibold text-gray-900 dark:text-white">自定义颜色</h4>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">选择任意颜色作为主题色</p>
</div>
<el-color-picker
v-model="customColor"
size="large"
:predefine="presetColors.map(item => item.color)"
@change="handleCustomColorChange"
class="custom-color-picker"
/>
</div>
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl p-5 shadow-sm">
<div class="flex items-center justify-between">
<span class="text-base font-semibold text-gray-700 dark:text-gray-300">当前主题色</span>
<div class="flex items-center gap-3">
<div
class="w-6 h-6 rounded-lg border border-gray-300 dark:border-gray-500 shadow-sm"
:style="{ backgroundColor: modelValue }"
></div>
<code class="text-sm font-mono bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-500">
{{ modelValue }}
</code>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { Check } from '@element-plus/icons-vue'
defineOptions({
name: 'ThemeColorPicker'
})
const props = defineProps({
modelValue: {
type: String,
default: '#3b82f6'
}
})
const emit = defineEmits(['update:modelValue'])
const customColor = ref(props.modelValue)
const presetColors = [
{ color: '#4E80EE', name: '默认' },
{ color: '#8bb5d1', name: '晨雾蓝' },
{ color: '#a8c8a8', name: '薄荷绿' },
{ color: '#d4a5a5', name: '玫瑰粉' },
{ color: '#c8a8d8', name: '薰衣草' },
{ color: '#f0c674', name: '暖阳黄' },
{ color: '#b8b8b8', name: '月光银' },
{ color: '#d8a8a8', name: '珊瑚橙' },
{ color: '#a8d8d8', name: '海雾青' },
{ color: '#c8c8a8', name: '橄榄绿' },
{ color: '#d8c8a8', name: '奶茶棕' },
{ color: '#a8a8d8', name: '梦幻紫' },
{ color: '#c8d8a8', name: '抹茶绿' }
]
const handleColorChange = (color) => {
customColor.value = color
emit('update:modelValue', color)
}
const handleCustomColorChange = (color) => {
if (color) {
emit('update:modelValue', color)
}
}
watch(() => props.modelValue, (newValue) => {
customColor.value = newValue
})
</script>
<style scoped>
.font-inter {
font-family: 'Inter', sans-serif;
}
.custom-color-picker {
::v-deep(.el-color-picker__trigger) {
border: 1px solid #e5e7eb;
border-radius: 6px;
transition: all 150ms ease-in-out;
&:hover {
border-color: #9ca3af;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
}
.dark .custom-color-picker {
::v-deep(.el-color-picker__trigger) {
border-color: #4b5563;
&:hover {
border-color: #6b7280;
}
}
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<div class="flex justify-center">
<div class="inline-flex bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-1 gap-1">
<div
v-for="mode in themeModes"
:key="mode.value"
class="flex flex-col items-center justify-center px-4 py-3 rounded-md cursor-pointer transition-all duration-150 ease-in-out min-w-[64px]"
:class="[
modelValue === mode.value
? 'text-white shadow-sm transform -translate-y-0.5'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
]"
:style="modelValue === mode.value ? { backgroundColor: primaryColor } : {}"
@click="handleModeChange(mode.value)"
>
<el-icon class="text-lg mb-1">
<component :is="mode.icon" />
</el-icon>
<span class="text-xs font-medium">{{ mode.label }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { Sunny, Moon, Monitor } from '@element-plus/icons-vue'
import { useAppStore } from '@/pinia'
defineOptions({
name: 'ThemeModeSelector'
})
const props = defineProps({
modelValue: {
type: String,
default: 'auto'
}
})
const emit = defineEmits(['update:modelValue'])
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
const primaryColor = computed(() => config.value.primaryColor)
const themeModes = [
{
value: 'light',
label: '浅色',
icon: Sunny
},
{
value: 'dark',
label: '深色',
icon: Moon
},
{
value: 'auto',
label: '跟随系统',
icon: Monitor
}
]
const handleModeChange = (mode) => {
emit('update:modelValue', mode)
}
</script>

View File

@@ -5,208 +5,175 @@
direction="rtl" direction="rtl"
:size="width" :size="width"
:show-close="false" :show-close="false"
class="theme-config-drawer"
> >
<template #header> <template #header>
<div class="flex justify-between items-center"> <div class="flex items-center justify-between w-full px-6 py-4 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<span class="text-lg">系统配置</span> <h2 class="text-xl font-semibold text-gray-900 dark:text-white font-inter">系统配置</h2>
<el-button type="primary" @click="saveConfig">保存配置</el-button> <el-button
type="primary"
size="small"
class="reset-btn"
:style="{ backgroundColor: config.primaryColor, borderColor: config.primaryColor }"
@click="resetConfig"
>
重置配置
</el-button>
</div> </div>
</template> </template>
<div class="flex flex-col">
<div class="mb-8"> <div class="bg-white dark:bg-gray-900">
<Title title="默认主题"></Title> <div class="px-8 pt-4 pb-6 border-b border-gray-200 dark:border-gray-700">
<div class="mt-2 text-sm p-2 flex items-center justify-center gap-2"> <div class="flex justify-center">
<el-segmented <div class="inline-flex bg-gray-100 dark:bg-gray-800 rounded-xl p-1.5 border border-gray-200 dark:border-gray-700 shadow-sm">
v-model="config.darkMode"
:options="options"
size="default"
@change="appStore.toggleDarkMode"
/>
</div>
</div>
<div class="mb-8">
<Title title="主题色"></Title>
<div class="mt-2 text-sm p-2 flex items-center gap-2 justify-center">
<div <div
v-for="item in colors" v-for="tab in tabs"
:key="item" :key="tab.key"
class="w-5 h-5 rounded cursor-pointer flex items-center justify-center" class="px-6 py-3 text-base text-center cursor-pointer font-medium rounded-lg transition-all duration-150 ease-in-out min-w-[80px]"
:style="`background:${item}`" :class="[
@click="appStore.togglePrimaryColor(item)" activeTab === tab.key
? 'text-white shadow-md transform -translate-y-0.5'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700'
]"
:style="activeTab === tab.key ? { backgroundColor: config.primaryColor } : {}"
@click="activeTab = tab.key"
> >
<el-icon v-if="config.primaryColor === item"> {{ tab.label }}
<Select />
</el-icon>
</div> </div>
<el-color-picker
v-model="customColor"
@change="appStore.togglePrimaryColor"
/>
</div>
</div>
<div class="mb-8">
<Title title="主题配置"></Title>
<div class="mt-2 text-md p-2 flex flex-col gap-2">
<div class="flex items-center justify-between">
<div>展示水印</div>
<el-switch
v-model="config.show_watermark"
@change="appStore.toggleConfigWatermark"
/>
</div>
<div class="flex items-center justify-between">
<div>灰色模式</div>
<el-switch v-model="config.grey" @change="appStore.toggleGrey" />
</div>
<div class="flex items-center justify-between">
<div>色弱模式</div>
<el-switch
v-model="config.weakness"
@change="appStore.toggleWeakness"
/>
</div>
<div class="flex items-center justify-between">
<div>菜单模式</div>
<el-segmented
v-model="config.side_mode"
:options="sideModes"
size="default"
@change="appStore.toggleSideMode"
/>
</div>
<div class="flex items-center justify-between">
<div>显示标签页</div>
<el-switch
v-model="config.showTabs"
@change="appStore.toggleTabs"
/>
</div>
<div class="flex items-center justify-between gap-2">
<div class="flex-shrink-0">页面切换动画</div>
<el-select
v-model="config.transition_type"
@change="appStore.toggleTransition"
class="w-40"
>
<el-option value="fade" label="淡入淡出" />
<el-option value="slide" label="滑动" />
<el-option value="zoom" label="缩放" />
<el-option value="none" label="无动画" />
</el-select>
</div> </div>
</div> </div>
</div> </div>
<div class="mb-8"> <div class="pb-8 h-full overflow-y-auto">
<Title title="layout 大小配置"></Title> <div class="transition-all duration-300 ease-in-out">
<div class="mt-2 text-md p-2 flex flex-col gap-2"> <AppearanceSettings v-if="activeTab === 'appearance'" />
<div class="flex items-center justify-between mb-2"> <LayoutSettings v-else-if="activeTab === 'layout'" />
<div>侧边栏展开宽度</div> <GeneralSettings v-else-if="activeTab === 'general'" />
<el-input-number
v-model="config.layout_side_width"
:min="150"
:max="400"
:step="10"
></el-input-number>
</div>
<div class="flex items-center justify-between mb-2">
<div>侧边栏收缩宽度</div>
<el-input-number
v-model="config.layout_side_collapsed_width"
:min="60"
:max="100"
></el-input-number>
</div>
<div class="flex items-center justify-between mb-2">
<div>侧边栏子项高度</div>
<el-input-number
v-model="config.layout_side_item_height"
:min="30"
:max="50"
></el-input-number>
</div> </div>
</div> </div>
</div> </div>
<!-- <el-alert type="warning" :closable="false">
请注意所有配置请保存到本地文件的
<el-tag>config.json</el-tag> 文件中否则刷新页面后会丢失配置
</el-alert>-->
</div>
</el-drawer> </el-drawer>
</template> </template>
<script setup> <script setup>
import { useAppStore } from '@/pinia' import { ref, computed, watch } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useAppStore } from '@/pinia'
import { setSelfSetting } from '@/api/user' import { setSelfSetting } from '@/api/user'
import Title from './title.vue' import AppearanceSettings from './modules/appearance/index.vue'
const appStore = useAppStore() import LayoutSettings from './modules/layout/index.vue'
const { config, device } = storeToRefs(appStore) import GeneralSettings from './modules/general/index.vue'
defineOptions({ defineOptions({
name: 'GvaSetting' name: 'GvaSetting'
}) })
const appStore = useAppStore()
const { config, device } = storeToRefs(appStore)
const activeTab = ref('appearance')
const tabs = [
{ key: 'appearance', label: '外观' },
{ key: 'layout', label: '布局' },
{ key: 'general', label: '通用' }
]
const width = computed(() => { const width = computed(() => {
return device.value === 'mobile' ? '100%' : '500px' return device.value === 'mobile' ? '100%' : '500px'
}) })
const colors = [
'#EB2F96',
'#3b82f6',
'#2FEB54',
'#EBEB2F',
'#EB2F2F',
'#2FEBEB'
]
const drawer = defineModel('drawer', { const drawer = defineModel('drawer', {
default: true, default: true,
type: Boolean type: Boolean
}) })
const options = ['dark', 'light', 'auto']
const sideModes = [
{
label: '正常模式',
value: 'normal'
},
{
label: '顶部菜单栏模式',
value: 'head'
},
{
label: '组合模式',
value: 'combination'
}
]
const saveConfig = async () => { const saveConfig = async () => {
/*const input = document.createElement("textarea");
input.value = JSON.stringify(config.value);
// 添加回车
input.value = input.value.replace(/,/g, ",\n");
document.body.appendChild(input);
input.select();
document.execCommand("copy");
document.body.removeChild(input);
ElMessage.success("复制成功, 请自行保存到本地文件中");*/
const res = await setSelfSetting(config.value) const res = await setSelfSetting(config.value)
if (res.code === 0) { if (res.code === 0) {
localStorage.setItem('originSetting', JSON.stringify(config.value)) localStorage.setItem('originSetting', JSON.stringify(config.value))
ElMessage.success('保存成功') ElMessage.success('保存成功')
drawer.value = false
} }
} }
const customColor = ref('') const resetConfig = () => {
appStore.resetConfig()
}
watch(config, async () => {
await saveConfig();
}, { deep: true });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.theme-config-drawer {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
::v-deep(.el-drawer) {
background: white;
}
::v-deep(.el-drawer__header) { ::v-deep(.el-drawer__header) {
@apply border-gray-400 dark:border-gray-600; padding: 0;
border: 0;
}
::v-deep(.el-drawer__body) {
padding: 0;
}
}
.dark .theme-config-drawer {
::v-deep(.el-drawer) {
background: #111827;
}
}
.font-inter {
font-family: 'Inter', sans-serif;
}
.reset-btn {
border-radius: 0.5rem;
font-weight: 500;
transition: all 150ms ease-in-out;
&:hover {
transform: translateY(-2px);
filter: brightness(0.9);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
/* Custom scrollbar for webkit browsers */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f3f4f6;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
&:hover {
background: #9ca3af;
}
}
.dark ::-webkit-scrollbar-track {
background: #1f2937;
}
.dark ::-webkit-scrollbar-thumb {
background: #4b5563;
&:hover {
background: #6b7280;
}
} }
</style> </style>

View File

@@ -0,0 +1,114 @@
<template>
<div class="font-inter">
<!-- Theme Mode Section -->
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">主题模式</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<ThemeModeSelector
v-model="config.darkMode"
@update:modelValue="appStore.toggleDarkMode"
/>
</div>
</div>
<!-- Theme Color Section -->
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">主题颜色</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<ThemeColorPicker
v-model="config.primaryColor"
@update:modelValue="appStore.togglePrimaryColor"
/>
</div>
</div>
<!-- Visual Accessibility Section -->
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">视觉辅助</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm">
<SettingItem label="灰色模式">
<template #suffix>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">降低色彩饱和度</span>
</template>
<el-switch
v-model="config.grey"
@change="appStore.toggleGrey"
/>
</SettingItem>
<SettingItem label="色弱模式">
<template #suffix>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">优化色彩对比度</span>
</template>
<el-switch
v-model="config.weakness"
@change="appStore.toggleWeakness"
/>
</SettingItem>
<SettingItem label="显示水印">
<template #suffix>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">在页面显示水印标识</span>
</template>
<el-switch
v-model="config.show_watermark"
@change="appStore.toggleConfigWatermark"
/>
</SettingItem>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/pinia'
import ThemeModeSelector from '../../components/themeModeSelector.vue'
import ThemeColorPicker from '../../components/themeColorPicker.vue'
import SettingItem from '../../components/settingItem.vue'
defineOptions({
name: 'AppearanceSettings'
})
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
</script>
<style scoped>
.font-inter {
font-family: 'Inter', sans-serif;
}
.section-content {
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<div class="font-inter">
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">系统信息</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm">
<div class="grid grid-cols-2 gap-4 text-sm">
<div class="flex justify-between items-center py-3 border-b border-gray-200 dark:border-gray-600">
<span class="text-gray-600 dark:text-gray-400 font-medium">版本</span>
<span class="font-mono text-gray-900 dark:text-white font-semibold">v2.7.4</span>
</div>
<div class="flex justify-between items-center py-3 border-b border-gray-200 dark:border-gray-600">
<span class="text-gray-600 dark:text-gray-400 font-medium">前端框架</span>
<span class="font-mono text-gray-900 dark:text-white font-semibold">Vue 3</span>
</div>
<div class="flex justify-between items-center py-3 border-b border-gray-200 dark:border-gray-600">
<span class="text-gray-600 dark:text-gray-400 font-medium">UI 组件库</span>
<span class="font-mono text-gray-900 dark:text-white font-semibold">Element Plus</span>
</div>
<div class="flex justify-between items-center py-3 border-b border-gray-200 dark:border-gray-600">
<span class="text-gray-600 dark:text-gray-400 font-medium">构建工具</span>
<span class="font-mono text-gray-900 dark:text-white font-semibold">Vite</span>
</div>
<div class="flex justify-between items-center py-3">
<span class="text-gray-600 dark:text-gray-400 font-medium">浏览器</span>
<span class="font-mono text-gray-900 dark:text-white font-semibold">{{ browserInfo }}</span>
</div>
<div class="flex justify-between items-center py-3">
<span class="text-gray-600 dark:text-gray-400 font-medium">屏幕分辨率</span>
<span class="font-mono text-gray-900 dark:text-white font-semibold">{{ screenResolution }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">配置管理</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm">
<div class="space-y-5">
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-5 flex items-center justify-between hover:shadow-md transition-all duration-150 ease-in-out hover:-translate-y-0.5">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl flex items-center justify-center text-red-600 dark:text-red-400 text-xl">
🔄
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">重置配置</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">将所有设置恢复为默认值</p>
</div>
</div>
<el-button
type="danger"
size="small"
class="rounded-lg font-medium transition-all duration-150 ease-in-out hover:-translate-y-0.5"
@click="handleResetConfig"
>
重置配置
</el-button>
</div>
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-5 flex items-center justify-between hover:shadow-md transition-all duration-150 ease-in-out hover:-translate-y-0.5">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl flex items-center justify-center text-blue-600 dark:text-blue-400 text-xl">
📤
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">导出配置</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">导出当前配置为 JSON 文件</p>
</div>
</div>
<el-button
type="primary"
size="small"
class="rounded-lg font-medium transition-all duration-150 ease-in-out hover:-translate-y-0.5"
:style="{ backgroundColor: config.primaryColor, borderColor: config.primaryColor }"
@click="handleExportConfig"
>
导出配置
</el-button>
</div>
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-5 flex items-center justify-between hover:shadow-md transition-all duration-150 ease-in-out hover:-translate-y-0.5">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-xl flex items-center justify-center text-green-600 dark:text-green-400 text-xl">
📥
</div>
<div>
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">导入配置</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"> JSON 文件导入配置</p>
</div>
</div>
<el-upload
ref="uploadRef"
:auto-upload="false"
:show-file-list="false"
accept=".json"
@change="handleImportConfig"
>
<el-button
type="success"
size="small"
class="rounded-lg font-medium transition-all duration-150 ease-in-out hover:-translate-y-0.5"
>
导入配置
</el-button>
</el-upload>
</div>
</div>
</div>
</div>
</div>
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">关于项目</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm">
<div class="flex items-start gap-5">
<div class="w-16 h-16 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-xl flex items-center justify-center flex-shrink-0 shadow-sm">
<img
src="/logo.png"
alt="Gin-Vue-Admin Logo"
class="w-10 h-10 object-contain"
@error="handleLogoError"
/>
</div>
<div class="flex-1">
<h4 class="text-xl font-semibold text-gray-900 dark:text-white mb-3">Gin-Vue-Admin</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-5 leading-relaxed">
基于 Vue3 + Gin 的全栈开发基础平台提供完整的后台管理解决方案
</p>
<div class="flex items-center gap-3 text-sm">
<a
href="https://github.com/flipped-aurora/gin-vue-admin"
target="_blank"
class="font-medium transition-colors duration-150 hover:underline"
:style="{ color: config.primaryColor }"
>
GitHub 仓库
</a>
<span class="text-gray-400 dark:text-gray-500">·</span>
<a
href="https://www.gin-vue-admin.com/"
target="_blank"
class="font-medium transition-colors duration-150 hover:underline"
:style="{ color: config.primaryColor }"
>
官方文档
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/pinia'
defineOptions({
name: 'GeneralSettings'
})
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
const uploadRef = ref()
const browserInfo = ref('')
const screenResolution = ref('')
const logoUrl = ref('')
onMounted(() => {
const userAgent = navigator.userAgent
if (userAgent.includes('Chrome')) {
browserInfo.value = 'Chrome'
} else if (userAgent.includes('Firefox')) {
browserInfo.value = 'Firefox'
} else if (userAgent.includes('Safari')) {
browserInfo.value = 'Safari'
} else if (userAgent.includes('Edge')) {
browserInfo.value = 'Edge'
} else {
browserInfo.value = 'Unknown'
}
screenResolution.value = `${screen.width}×${screen.height}`
})
const handleLogoError = () => {
logoUrl.value = ''
}
const handleResetConfig = async () => {
try {
await ElMessageBox.confirm(
'确定要重置所有配置吗?此操作不可撤销。',
'重置配置',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
appStore.resetConfig()
ElMessage.success('配置已重置')
} catch {
// User cancelled
}
}
const handleExportConfig = () => {
const configData = JSON.stringify(config.value, null, 2)
const blob = new Blob([configData], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `gin-vue-admin-config-${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('配置已导出')
}
const handleImportConfig = (file) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const importedConfig = JSON.parse(e.target.result)
Object.keys(importedConfig).forEach(key => {
if (key in config.value) {
config.value[key] = importedConfig[key]
}
})
ElMessage.success('配置已导入')
} catch (error) {
ElMessage.error('配置文件格式错误')
}
}
reader.readAsText(file.raw)
}
</script>
<style scoped>
.font-inter {
font-family: 'Inter', sans-serif;
}
.section-content {
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,164 @@
<template>
<div class="font-inter">
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">布局模式</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<LayoutModeCard
v-model="config.side_mode"
@update:modelValue="appStore.toggleSideMode"
/>
</div>
</div>
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">界面配置</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm">
<SettingItem label="显示标签页">
<template #suffix>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">页面标签导航</span>
</template>
<el-switch
v-model="config.showTabs"
@change="appStore.toggleTabs"
/>
</SettingItem>
<SettingItem label="页面切换动画">
<template #suffix>
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2">页面过渡效果</span>
</template>
<el-select
v-model="config.transition_type"
@change="appStore.toggleTransition"
class="w-32"
size="small"
>
<el-option value="fade" label="淡入淡出" />
<el-option value="slide" label="滑动" />
<el-option value="zoom" label="缩放" />
<el-option value="none" label="无动画" />
</el-select>
</SettingItem>
</div>
</div>
</div>
<div class="mb-10">
<div class="flex items-center justify-center mb-6">
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
<span class="px-6 text-lg font-semibold text-gray-700 dark:text-gray-300">尺寸配置</span>
<div class="h-px bg-gray-200 dark:bg-gray-700 flex-1"></div>
</div>
<div class="section-content">
<div class="bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 shadow-sm">
<div class="space-y-6">
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-5 hover:shadow-md transition-all duration-150 ease-in-out hover:-translate-y-0.5">
<div class="flex items-center justify-between">
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-white">侧边栏展开宽度</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">侧边栏完全展开时的宽度</p>
</div>
<div class="flex items-center gap-2">
<el-input-number
v-model="config.layout_side_width"
:min="150"
:max="400"
:step="10"
size="small"
class="w-24"
/>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">px</span>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-5 hover:shadow-md transition-all duration-150 ease-in-out hover:-translate-y-0.5">
<div class="flex items-center justify-between">
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-white">侧边栏收缩宽度</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">侧边栏收缩时的最小宽度</p>
</div>
<div class="flex items-center gap-2">
<el-input-number
v-model="config.layout_side_collapsed_width"
:min="60"
:max="100"
size="small"
class="w-24"
/>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">px</span>
</div>
</div>
</div>
<div class="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-5 hover:shadow-md transition-all duration-150 ease-in-out hover:-translate-y-0.5">
<div class="flex items-center justify-between">
<div>
<h4 class="text-sm font-medium text-gray-900 dark:text-white">菜单项高度</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">侧边栏菜单项的行高</p>
</div>
<div class="flex items-center gap-2">
<el-input-number
v-model="config.layout_side_item_height"
:min="30"
:max="50"
size="small"
class="w-24"
/>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">px</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useAppStore } from '@/pinia'
import LayoutModeCard from '../../components/layoutModeCard.vue'
import SettingItem from '../../components/settingItem.vue'
defineOptions({
name: 'LayoutSettings'
})
const appStore = useAppStore()
const { config } = storeToRefs(appStore)
</script>
<style scoped>
.font-inter {
font-family: 'Inter', sans-serif;
}
.section-content {
animation: fadeInUp 0.3s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -8,7 +8,7 @@
v-model="activeValue" v-model="activeValue"
:closable="!(historys.length === 1 && $route.name === defaultRouter)" :closable="!(historys.length === 1 && $route.name === defaultRouter)"
type="card" type="card"
class="bg-white text-slate-700 dark:text-slate-500 dark:bg-slate-900" class="bg-white text-slate-700 dark:text-slate-500 dark:bg-slate-900 pt-1"
@contextmenu.prevent="openContextMenu($event)" @contextmenu.prevent="openContextMenu($event)"
@tab-click="changeTab" @tab-click="changeTab"
@tab-remove="removeTab" @tab-remove="removeTab"

View File

@@ -531,7 +531,7 @@
if (res.code === 0) { if (res.code === 0) {
ElMessage({ ElMessage({
type: 'success', type: 'success',
message: '添加成功', message: '添加成功,请到角色管理页面分配权限',
showClose: true showClose: true
}) })
syncApiData.value.newApis = syncApiData.value.newApis.filter( syncApiData.value.newApis = syncApiData.value.newApis.filter(

View File

@@ -23,7 +23,7 @@
<template #default="{ node, data }"> <template #default="{ node, data }">
<span class="custom-tree-node"> <span class="custom-tree-node">
<span>{{ node.label }}</span> <span>{{ node.label }}</span>
<span v-if="node.checked"> <span v-if="node.checked && !data.name?.startsWith('http://') && !data.name?.startsWith('https://')">
<el-button <el-button
type="primary" type="primary"
link link
@@ -31,13 +31,13 @@
color: color:
row.defaultRouter === data.name ? '#E6A23C' : '#85ce61' row.defaultRouter === data.name ? '#E6A23C' : '#85ce61'
}" }"
@click="() => setDefault(data)" @click.stop="() => setDefault(data)"
> >
{{ row.defaultRouter === data.name ? '首页' : '设为首页' }} {{ row.defaultRouter === data.name ? '首页' : '设为首页' }}
</el-button> </el-button>
</span> </span>
<span v-if="data.menuBtn.length"> <span v-if="data.menuBtn.length">
<el-button type="primary" link @click="() => OpenBtn(data)"> <el-button type="primary" link @click.stop="() => OpenBtn(data)">
分配按钮 分配按钮
</el-button> </el-button>
</span> </span>
@@ -132,7 +132,7 @@
defaultRouter: data.name defaultRouter: data.name
}) })
if (res.code === 0) { if (res.code === 0) {
ElMessage({ type: 'success', message: '设置成功' }) relation()
emit('changeRow', 'defaultRouter', res.data.authority.defaultRouter) emit('changeRow', 'defaultRouter', res.data.authority.defaultRouter)
} }
} }

View File

@@ -119,6 +119,10 @@
</template> </template>
<warning-bar title="新增菜单,需要在角色管理内配置权限才可使用" /> <warning-bar title="新增菜单,需要在角色管理内配置权限才可使用" />
<!-- 基础信息区域 -->
<div class="border-b border-gray-200">
<h3 class="font-semibold text-gray-700 mb-4">基础信息</h3>
<el-form <el-form
v-if="dialogFormVisible" v-if="dialogFormVisible"
ref="menuForm" ref="menuForm"
@@ -128,31 +132,37 @@
label-position="top" label-position="top"
> >
<el-row class="w-full"> <el-row class="w-full">
<el-col :span="16"> <el-col :span="24">
<el-form-item label="文件路径" prop="component"> <el-form-item label="文件路径" prop="component">
<components-cascader <components-cascader
:component="form.component" :component="form.component"
@change="fmtComponent" @change="fmtComponent"
/> />
<span style="font-size: 12px; margin-right: 12px" <div class="form-tip">
>如果菜单包含子菜单请创建router-view二级路由页面或者</span <el-icon><InfoFilled /></el-icon>
> <span>如果菜单包含子菜单请创建router-view二级路由页面或者</span>
<el-button <el-button
style="margin-top: 4px" size="small"
type="text"
@click="form.component = 'view/routerHolder.vue'" @click="form.component = 'view/routerHolder.vue'"
> >
点我设置 点我设置
</el-button> </el-button>
</el-form-item> </div>
</el-col>
<el-col :span="8">
<el-form-item label="展示名称" prop="meta.title">
<el-input v-model="form.meta.title" autocomplete="off" />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row class="w-full"> <el-row class="w-full">
<el-col :span="8"> <el-col :span="12">
<el-form-item label="展示名称" prop="meta.title">
<el-input
v-model="form.meta.title"
autocomplete="off"
placeholder="请输入菜单展示名称"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="路由Name" prop="path"> <el-form-item label="路由Name" prop="path">
<el-input <el-input
v-model="form.name" v-model="form.name"
@@ -162,19 +172,52 @@
/> />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> </el-row>
</el-form>
</div>
<!-- 路由配置区域 -->
<div class="border-b border-gray-200">
<h3 class="font-semibold text-gray-700 mb-4">路由配置</h3>
<el-form
:inline="true"
:model="form"
:rules="rules"
label-position="top"
>
<el-row class="w-full">
<el-col :span="12">
<el-form-item label="父节点ID">
<el-cascader
v-model="form.parentId"
style="width: 100%"
:disabled="!isEdit"
:options="menuOption"
:props="{
checkStrictly: true,
label: 'title',
value: 'ID',
disabled: 'disabled',
emitPath: false
}"
:show-all-levels="false"
filterable
placeholder="请选择父节点"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="path"> <el-form-item prop="path">
<template #label> <template #label>
<span style="display: inline-flex; align-items: center"> <div class="inline-flex items-center h-4">
<span>路由Path</span> <span>路由Path</span>
<el-checkbox <el-checkbox
class="ml-2"
v-model="checkFlag" v-model="checkFlag"
style="margin-left: 12px; height: auto"
>添加参数</el-checkbox >添加参数</el-checkbox
> >
</span> </div>
</template> </template>
<el-input <el-input
v-model="form.path" v-model="form.path"
:disabled="!checkFlag" :disabled="!checkFlag"
@@ -183,6 +226,34 @@
/> />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row>
</el-form>
</div>
<!-- 显示设置区域 -->
<div class="border-b border-gray-200">
<h3 class="font-semibold text-gray-700 mb-4">显示设置</h3>
<el-form
:inline="true"
:model="form"
:rules="rules"
label-position="top"
>
<el-row class="w-full">
<el-col :span="8">
<el-form-item label="图标" prop="meta.icon">
<icon v-model="form.meta.icon" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="排序标记" prop="sort">
<el-input
v-model.number="form.sort"
autocomplete="off"
placeholder="请输入排序数字"
/>
</el-form-item>
</el-col>
<el-col :span="8"> <el-col :span="8">
<el-form-item label="是否隐藏"> <el-form-item label="是否隐藏">
<el-select <el-select
@@ -196,42 +267,23 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
</el-form>
</div>
<!-- 高级配置区域 -->
<div class="border-b border-gray-200">
<h3 class="font-semibold text-gray-700 mb-4">高级配置</h3>
<el-form
:inline="true"
:model="form"
:rules="rules"
label-position="top"
>
<el-row class="w-full"> <el-row class="w-full">
<el-col :span="8"> <el-col :span="12">
<el-form-item label="父节点ID">
<el-cascader
v-model="form.parentId"
style="width: 100%"
:disabled="!isEdit"
:options="menuOption"
:props="{
checkStrictly: true,
label: 'title',
value: 'ID',
disabled: 'disabled',
emitPath: false
}"
:show-all-levels="false"
filterable
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="图标" prop="meta.icon">
<icon v-model="form.meta.icon" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="排序标记" prop="sort">
<el-input v-model.number="form.sort" autocomplete="off" />
</el-form-item>
</el-col>
</el-row>
<el-row class="w-full">
<el-col :span="8">
<el-form-item prop="meta.activeName"> <el-form-item prop="meta.activeName">
<template #label> <template #label>
<div> <div class="label-with-tooltip">
<span>高亮菜单</span> <span>高亮菜单</span>
<el-tooltip <el-tooltip
content="注当到达此路由时候指定左侧菜单指定name会处于活跃状态亮起可为空为空则为本路由Name。" content="注当到达此路由时候指定左侧菜单指定name会处于活跃状态亮起可为空为空则为本路由Name。"
@@ -244,12 +296,12 @@
</template> </template>
<el-input <el-input
v-model="form.meta.activeName" v-model="form.meta.activeName"
:placeholder="form.name" :placeholder="form.name || '请输入高亮菜单名称'"
autocomplete="off" autocomplete="off"
/> />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8"> <el-col :span="12">
<el-form-item label="KeepAlive" prop="meta.keepAlive"> <el-form-item label="KeepAlive" prop="meta.keepAlive">
<el-select <el-select
v-model="form.meta.keepAlive" v-model="form.meta.keepAlive"
@@ -261,6 +313,8 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row>
<el-row class="w-full">
<el-col :span="8"> <el-col :span="8">
<el-form-item label="CloseTab" prop="meta.closeTab"> <el-form-item label="CloseTab" prop="meta.closeTab">
<el-select <el-select
@@ -273,12 +327,10 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row>
<el-row class="w-full">
<el-col :span="8"> <el-col :span="8">
<el-form-item> <el-form-item>
<template #label> <template #label>
<div> <div class="label-with-tooltip">
<span>是否为基础页面</span> <span>是否为基础页面</span>
<el-tooltip <el-tooltip
content="此项选择为是,则不会展示左侧菜单以及顶部信息。" content="此项选择为是,则不会展示左侧菜单以及顶部信息。"
@@ -289,7 +341,6 @@
</el-tooltip> </el-tooltip>
</div> </div>
</template> </template>
<el-select <el-select
v-model="form.meta.defaultMenu" v-model="form.meta.defaultMenu"
style="width: 100%" style="width: 100%"
@@ -300,102 +351,158 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="8">
<el-form-item>
<template #label>
<div class="label-with-tooltip">
<span>路由切换动画</span>
<el-tooltip
content="如果设置了路由切换动画,在本路由下的动画优先级高于全局动画切换优先级。"
placement="top"
effect="light"
>
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-select
v-model="form.meta.transitionType"
style="width: 100%"
placeholder="跟随全局"
clearable
>
<el-option value="fade" label="淡入淡出" />
<el-option value="slide" label="滑动" />
<el-option value="zoom" label="缩放" />
<el-option value="none" label="无动画" />
</el-select>
</el-form-item>
</el-col>
</el-row> </el-row>
</el-form> </el-form>
<div> </div>
<div class="flex items-center gap-2">
<el-button type="primary" icon="edit" @click="addParameter(form)"> <!-- 菜单参数配置区域 -->
<div class="border-b border-gray-200">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold text-gray-700">菜单参数配置</h3>
<el-button type="primary" size="small" @click="addParameter(form)">
新增菜单参数 新增菜单参数
</el-button> </el-button>
</div> </div>
<el-table :data="form.parameters" style="width: 100%; margin-top: 12px"> <el-table
:data="form.parameters"
style="width: 100%"
class="parameter-table"
>
<el-table-column <el-table-column
align="left" align="center"
prop="type" prop="type"
label="参数类型" label="参数类型"
width="180" width="150"
> >
<template #default="scope"> <template #default="scope">
<el-select v-model="scope.row.type" placeholder="请选择"> <el-select
v-model="scope.row.type"
placeholder="请选择"
size="small"
>
<el-option key="query" value="query" label="query" /> <el-option key="query" value="query" label="query" />
<el-option key="params" value="params" label="params" /> <el-option key="params" value="params" label="params" />
</el-select> </el-select>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column align="left" prop="key" label="参数key" width="180"> <el-table-column align="center" prop="key" label="参数key" width="150">
<template #default="scope"> <template #default="scope">
<div> <el-input
<el-input v-model="scope.row.key" /> v-model="scope.row.key"
</div> size="small"
placeholder="请输入参数key"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column align="left" prop="value" label="参数值"> <el-table-column align="center" prop="value" label="参数值">
<template #default="scope"> <template #default="scope">
<div> <el-input
<el-input v-model="scope.row.value" /> v-model="scope.row.value"
</div> size="small"
placeholder="请输入参数值"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column align="left"> <el-table-column align="center" label="操作" width="100">
<template #default="scope"> <template #default="scope">
<div>
<el-button <el-button
type="danger" type="danger"
icon="delete" size="small"
@click="deleteParameter(form.parameters, scope.$index)" @click="deleteParameter(form.parameters, scope.$index)"
> >
删除 <el-icon><Delete /></el-icon>
</el-button> </el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
</div>
<div class="flex items-center gap-2 mt-3"> <!-- 可控按钮配置区域 -->
<el-button type="primary" icon="edit" @click="addBtn(form)"> <div class="mb-2 mt-2">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold text-gray-700">可控按钮配置</h3>
<div class="flex items-center gap-2">
<el-button type="primary" size="small" @click="addBtn(form)">
新增可控按钮 新增可控按钮
</el-button> </el-button>
<el-tooltip
content="点击查看按钮权限配置文档"
placement="top"
effect="light"
>
<el-icon <el-icon
class="cursor-pointer" class="cursor-pointer text-blue-500 hover:text-blue-700"
@click=" @click="toDoc('https://www.gin-vue-admin.com/guide/web/button-auth.html')"
toDoc('https://www.gin-vue-admin.com/guide/web/button-auth.html')
"
> >
<QuestionFilled /> <QuestionFilled />
</el-icon> </el-icon>
</el-tooltip>
</div> </div>
</div>
<el-table :data="form.menuBtn" style="width: 100%; margin-top: 12px"> <el-table
:data="form.menuBtn"
style="width: 100%"
class="button-table"
>
<el-table-column <el-table-column
align="left" align="center"
prop="name" prop="name"
label="按钮名称" label="按钮名称"
width="180" width="150"
> >
<template #default="scope"> <template #default="scope">
<div> <el-input
<el-input v-model="scope.row.name" /> v-model="scope.row.name"
</div> size="small"
placeholder="请输入按钮名称"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column align="left" prop="name" label="备注" width="180"> <el-table-column align="center" prop="desc" label="备注">
<template #default="scope"> <template #default="scope">
<div> <el-input
<el-input v-model="scope.row.desc" /> v-model="scope.row.desc"
</div> size="small"
placeholder="请输入按钮备注"
/>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column align="left"> <el-table-column align="center" label="操作" width="100">
<template #default="scope"> <template #default="scope">
<div>
<el-button <el-button
type="danger" type="danger"
icon="delete" size="small"
@click="deleteBtn(form.menuBtn, scope.$index)" @click="deleteBtn(form.menuBtn, scope.$index)"
> >
删除 <el-icon><Delete /></el-icon>
</el-button> </el-button>
</div>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -417,7 +524,7 @@
import { canRemoveAuthorityBtnApi } from '@/api/authorityBtn' import { canRemoveAuthorityBtnApi } from '@/api/authorityBtn'
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue' import { QuestionFilled, InfoFilled, Delete } from '@element-plus/icons-vue'
import { toDoc } from '@/utils/doc' import { toDoc } from '@/utils/doc'
import { toLowerCase } from '@/utils/stringFun' import { toLowerCase } from '@/utils/stringFun'
import ComponentsCascader from '@/view/superAdmin/menu/components/components-cascader.vue' import ComponentsCascader from '@/view/superAdmin/menu/components/components-cascader.vue'
@@ -593,7 +700,7 @@
if (res.code === 0) { if (res.code === 0) {
ElMessage({ ElMessage({
type: 'success', type: 'success',
message: isEdit.value ? '编辑成功' : '添加成功!' message: isEdit.value ? '编辑成功' : '添加成功,请到角色管理页面分配权限'
}) })
getTableData() getTableData()
} }
@@ -677,4 +784,52 @@
margin-right: 8px; margin-right: 8px;
} }
} }
.form-tip {
margin-top: 8px;
font-size: 12px;
color: #909399;
display: flex;
align-items: center;
gap: 8px;
.el-icon {
color: #409eff;
}
}
.label-with-tooltip {
display: flex;
align-items: center;
gap: 6px;
.el-icon {
color: #909399;
cursor: help;
&:hover {
color: #409eff;
}
}
}
.parameter-table,
.button-table {
border: 1px solid #ebeef5;
border-radius: 6px;
:deep(.el-table__header) {
background-color: #fafafa;
}
:deep(.el-table__body) {
.el-table__row {
&:hover {
background-color: #f5f7fa;
}
}
}
}
</style> </style>

View File

@@ -268,16 +268,16 @@
> >
<el-descriptions :column="1" border> <el-descriptions :column="1" border>
<el-descriptions-item label="参数名称"> <el-descriptions-item label="参数名称">
{{ detailFrom.name }} {{ detailForm.name }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="参数键"> <el-descriptions-item label="参数键">
{{ detailFrom.key }} {{ detailForm.key }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="参数值"> <el-descriptions-item label="参数值">
{{ detailFrom.value }} {{ detailForm.value }}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="参数说明"> <el-descriptions-item label="参数说明">
{{ detailFrom.desc }} {{ detailForm.desc }}
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-drawer> </el-drawer>
@@ -574,7 +574,7 @@
}) })
} }
const detailFrom = ref({}) const detailForm = ref({})
// 查看详情控制标记 // 查看详情控制标记
const detailShow = ref(false) const detailShow = ref(false)
@@ -589,7 +589,7 @@
// 打开弹窗 // 打开弹窗
const res = await findSysParams({ ID: row.ID }) const res = await findSysParams({ ID: row.ID })
if (res.code === 0) { if (res.code === 0) {
detailFrom.value = res.data detailForm.value = res.data
openDetailShow() openDetailShow()
} }
} }
@@ -597,7 +597,7 @@
// 关闭详情弹窗 // 关闭详情弹窗
const closeDetailShow = () => { const closeDetailShow = () => {
detailShow.value = false detailShow.value = false
detailFrom.value = {} detailForm.value = {}
} }
</script> </script>

View File

@@ -143,6 +143,38 @@
/> />
</div> </div>
</div> </div>
<!-- 重置密码对话框 -->
<el-dialog
v-model="resetPwdDialog"
title="重置密码"
width="500px"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<el-form :model="resetPwdInfo" ref="resetPwdForm" label-width="100px">
<el-form-item label="用户账号">
<el-input v-model="resetPwdInfo.userName" disabled />
</el-form-item>
<el-form-item label="用户昵称">
<el-input v-model="resetPwdInfo.nickName" disabled />
</el-form-item>
<el-form-item label="新密码">
<div class="flex w-full">
<el-input class="flex-1" v-model="resetPwdInfo.password" placeholder="请输入新密码" show-password />
<el-button type="primary" @click="generateRandomPassword" style="margin-left: 10px">
生成随机密码
</el-button>
</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeResetPwdDialog">取 消</el-button>
<el-button type="primary" @click="confirmResetPassword">确 定</el-button>
</div>
</template>
</el-dialog>
<el-drawer <el-drawer
v-model="addUserDialog" v-model="addUserDialog"
:size="appStore.drawerSize" :size="appStore.drawerSize"
@@ -332,27 +364,80 @@
initPage() initPage()
const resetPasswordFunc = (row) => { // 重置密码对话框相关
ElMessageBox.confirm('是否将此用户密码重置为123456?', '警告', { const resetPwdDialog = ref(false)
confirmButtonText: '确定', const resetPwdForm = ref(null)
cancelButtonText: '取消', const resetPwdInfo = ref({
type: 'warning' ID: '',
}).then(async () => { userName: '',
const res = await resetPassword({ nickName: '',
ID: row.ID password: ''
}) })
// 生成随机密码
const generateRandomPassword = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'
let password = ''
for (let i = 0; i < 12; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length))
}
resetPwdInfo.value.password = password
// 复制到剪贴板
navigator.clipboard.writeText(password).then(() => {
ElMessage({
type: 'success',
message: '密码已复制到剪贴板'
})
}).catch(() => {
ElMessage({
type: 'error',
message: '复制失败,请手动复制'
})
})
}
// 打开重置密码对话框
const resetPasswordFunc = (row) => {
resetPwdInfo.value.ID = row.ID
resetPwdInfo.value.userName = row.userName
resetPwdInfo.value.nickName = row.nickName
resetPwdInfo.value.password = ''
resetPwdDialog.value = true
}
// 确认重置密码
const confirmResetPassword = async () => {
if (!resetPwdInfo.value.password) {
ElMessage({
type: 'warning',
message: '请输入或生成密码'
})
return
}
const res = await resetPassword({
ID: resetPwdInfo.value.ID,
password: resetPwdInfo.value.password
})
if (res.code === 0) { if (res.code === 0) {
ElMessage({ ElMessage({
type: 'success', type: 'success',
message: res.msg message: res.msg || '密码重置成功'
}) })
resetPwdDialog.value = false
} else { } else {
ElMessage({ ElMessage({
type: 'error', type: 'error',
message: res.msg message: res.msg || '密码重置失败'
}) })
} }
}) }
// 关闭重置密码对话框
const closeResetPwdDialog = () => {
resetPwdInfo.value.password = ''
resetPwdDialog.value = false
} }
const setAuthorityIds = () => { const setAuthorityIds = () => {
tableData.value && tableData.value &&

View File

@@ -206,7 +206,7 @@
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="6"> <el-col :span="6">
<el-form-item label="TableName" class="w-full"> <el-form-item label="abbreviation" prop="abbreviation" class="w-full">
<template #label> <template #label>
<el-tooltip <el-tooltip
content="简称会作为入参对象名和路由group" content="简称会作为入参对象名和路由group"
@@ -268,7 +268,7 @@
prop="package" prop="package"
class="w-full relative" class="w-full relative"
> >
<el-select v-model="form.package" class="w-full pr-12"> <el-select v-model="form.package" class="w-full pr-12" filterable>
<el-option <el-option
v-for="item in pkgs" v-for="item in pkgs"
:key="item.ID" :key="item.ID"
@@ -307,6 +307,7 @@
</template> </template>
<el-select <el-select
v-model="form.businessDB" v-model="form.businessDB"
clearable
placeholder="选择业务库" placeholder="选择业务库"
class="w-full" class="w-full"
> >
@@ -534,7 +535,7 @@
width="160" width="160"
> >
<template #default="{ row }"> <template #default="{ row }">
<el-input :disabled="row.disabled" v-model="row.fieldName" /> <el-input disabled v-model="row.fieldName" />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column <el-table-column
@@ -696,7 +697,7 @@
style="width: 100%" style="width: 100%"
placeholder="请选择字段查询条件" placeholder="请选择字段查询条件"
clearable clearable
:disabled="row.fieldType !== 'json' || row.disabled" :disabled="row.fieldType === 'json' || row.disabled"
> >
<el-option <el-option
v-for="item in typeSearchOptions" v-for="item in typeSearchOptions"
@@ -1541,15 +1542,6 @@
} }
init() init()
watch(
() => route.params.id,
() => {
if (route.name === 'autoCodeEdit') {
init()
}
}
)
watch(()=>form.value.generateServer,()=>{ watch(()=>form.value.generateServer,()=>{
if(!form.value.generateServer){ if(!form.value.generateServer){
form.value.autoCreateApiToSql = false form.value.autoCreateApiToSql = false
@@ -1566,6 +1558,7 @@
const catchData = () => { const catchData = () => {
window.sessionStorage.setItem('autoCode', JSON.stringify(form.value)) window.sessionStorage.setItem('autoCode', JSON.stringify(form.value))
ElMessage.success('暂存成功')
} }
const getCatch = () => { const getCatch = () => {
@@ -1619,6 +1612,8 @@
reader.onload = (e) => { reader.onload = (e) => {
try { try {
form.value = JSON.parse(e.target.result) form.value = JSON.parse(e.target.result)
form.value.generateServer = true
form.value.generateWeb = true
ElMessage.success('JSON 文件导入成功') ElMessage.success('JSON 文件导入成功')
} catch (_) { } catch (_) {
ElMessage.error('无效的 JSON 文件') ElMessage.error('无效的 JSON 文件')

View File

@@ -0,0 +1,151 @@
<template>
<div class="gva-form-box">
<el-form :model="form" ref="formRef" label-width="100px" :rules="rules">
<el-form-item label="工具名称" prop="name">
<el-input v-model="form.name" placeholder="例:CurrentTime" />
</el-form-item>
<el-form-item label="工具描述" prop="description">
<el-input type="textarea" v-model="form.description" placeholder="请输入工具描述" />
</el-form-item>
<el-form-item label="参数列表">
<el-table :data="form.params" style="width: 100%">
<el-table-column prop="name" label="参数名" width="120">
<template #default="scope">
<el-input v-model="scope.row.name" placeholder="参数名" />
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="180">
<template #default="scope">
<el-input v-model="scope.row.description" placeholder="描述" />
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="120">
<template #default="scope">
<el-select v-model="scope.row.type" placeholder="类型">
<el-option label="string" value="string" />
<el-option label="number" value="number" />
<el-option label="boolean" value="boolean" />
<el-option label="object" value="object" />
<el-option label="array" value="array" />
</el-select>
</template>
</el-table-column>
<el-table-column label="默认值" width="300">
<template #default="scope">
<el-input :disabled="scope.row.type === 'object'" v-model="scope.row.default" />
</template>
</el-table-column>
<el-table-column prop="required" label="必填" width="80">
<template #default="scope">
<el-checkbox v-model="scope.row.required" />
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="scope">
<el-button type="text" @click="removeParam(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
<div class="flex justify-end">
<el-button type="primary" icon="plus" @click="addParam" style="margin-top: 10px;">添加参数</el-button>
</div>
<el-form-item label="返回参数">
<el-table :data="form.response" style="width: 100%">
<el-table-column prop="type" label="类型" min-width="120">
<template #default="scope">
<el-select v-model="scope.row.type" placeholder="类型">
<el-option label="text" value="text" />
<el-option label="image" value="image" />
</el-select>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="scope">
<el-button type="text" @click="removeResponse(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
<div class="flex justify-end">
<el-button type="primary" icon="plus" @click="addResponse" style="margin-top: 10px;">添加返回参数</el-button>
</div>
<div class="flex justify-end mt-8">
<el-button type="primary" @click="submit">生成</el-button>
</div>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { mcp } from '@/api/autoCode'
defineOptions({
name: 'MCP'
})
const formRef = ref(null)
const form = reactive({
name: '',
description: '',
type: '',
params: [],
response: []
})
const rules = {
name: [{ required: true, message: '请输入工具名称', trigger: 'blur' }],
description: [{ required: true, message: '请输入工具描述', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }]
}
function addParam() {
form.params.push({
name: '',
description: '',
type: '',
required: false
})
}
function removeParam(index) {
form.params.splice(index, 1)
}
function addResponse() {
form.response.push({
type: ''
})
}
function removeResponse(index) {
form.response.splice(index, 1)
}
function submit() {
formRef.value.validate(async (valid) => {
if (!valid) return
// 简单校验参数
for (const p of form.params) {
if (!p.name || !p.description || !p.type) {
ElMessage.error('请完善所有参数信息')
return
}
}
// 校验返回参数
for (const r of form.response) {
if (!r.type) {
ElMessage.error('请完善所有返回参数类型')
return
}
}
const res = await mcp(form)
if (res.code === 0) {
ElMessage.success(res.msg)
}
})
}
</script>

View File

@@ -0,0 +1,261 @@
<template>
<div class="p-2">
<el-card class="mb-2">
<template #header>
<div class="flex justify-between items-center font-bold">
<span>MCP 服务器配置示例</span>
<el-tooltip content="复制配置" placement="top">
<el-button :icon="DocumentCopy" circle @click="copyMcpConfig" />
</el-tooltip>
</div>
</template>
<pre class="font-mono whitespace-pre-wrap break-words bg-gray-100 p-2.5 rounded text-gray-700">{{ mcpServerConfig }}</pre>
</el-card>
<el-row :gutter="8">
<el-col v-for="tool in mcpTools" :key="tool.name" :xs="24" :sm="12" :md="12" :lg="8">
<el-card class="mb-5 min-h-[150px] flex flex-col overflow-hidden">
<template #header>
<div class="flex justify-between items-center font-bold">
<span>{{ tool.name }}</span>
<el-tooltip content="测试工具" placement="top">
<el-button :icon="VideoPlay" circle @click="openTestDialog(tool)" />
</el-tooltip>
</div>
</template>
<div class="text-sm mb-1">{{ tool.description }}</div>
<div v-if="tool.inputSchema && tool.inputSchema.properties && Object.keys(tool.inputSchema.properties).length > 0" class="mt-1 text-xs overflow-y-auto max-h-[100px] p-2 border-t border-gray-200 bg-gray-50 rounded-b">
<p class="font-semibold mb-1 text-gray-700 flex items-center">
<span class="mr-1 my-2">参数列表</span>
<span class="text-xs text-gray-500">({{ Object.keys(tool.inputSchema.properties).length }})</span>
</p>
<div class="space-y-2">
<div v-for="(propDetails, propName) in tool.inputSchema.properties" :key="propName" class="flex flex-col p-1.5 bg-white rounded border border-gray-100 hover:border-gray-300 transition-colors">
<div class="flex items-center justify-between">
<div class="flex items-center">
<span class="font-medium text-gray-800">{{ propName }}</span>
<span v-if="tool.inputSchema.required && tool.inputSchema.required.includes(propName)" class="ml-1 text-red-500 text-xs">*</span>
</div>
<span class="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded">{{ propDetails.type }}</span>
</div>
<div class="text-gray-500 mt-0.5 text-xs line-clamp-2" :title="propDetails.description || '无描述'">
{{ propDetails.description || '无描述' }}
</div>
</div>
</div>
</div>
<div v-else class="mt-1 text-xs p-2 border-t border-gray-200 bg-gray-50 rounded-b flex items-center justify-center">
<span class="text-gray-500 italic py-3">无输入参数</span>
</div>
</el-card>
</el-col>
</el-row>
<el-dialog
v-model="testDialogVisible"
:title="currentTestingTool ? `${currentTestingTool.name} - 参数测试` : '参数测试'"
width="60%"
:before-close="handleCloseDialog"
>
<el-form v-if="currentTestingTool" :model="testParamsForm" ref="testParamsFormRef" label-width="120px" label-position="top" class="max-h-[calc(60vh-120px)] overflow-y-auto">
<el-form-item
v-for="(propDetails, propName) in currentTestingTool.inputSchema.properties"
:key="propName"
:label="propDetails.description || propName"
:prop="propName"
:rules="currentTestingTool.inputSchema.required && currentTestingTool.inputSchema.required.includes(propName) ? [{ required: true, message: '请输入 ' + (propDetails.description || propName), trigger: 'blur' }] : []"
>
<el-input
v-if="propDetails.type === 'string' && !propDetails.enum"
v-model="testParamsForm[propName]"
:placeholder="propDetails.description || '请输入' + propName"
/>
<el-input
v-else-if="propDetails.type === 'number'"
v-model.number="testParamsForm[propName]"
type="number"
:placeholder="propDetails.description || '请输入数字' + propName"
/>
<el-select
v-else-if="propDetails.type === 'boolean'"
v-model="testParamsForm[propName]"
:placeholder="propDetails.description || '请选择'"
>
<el-option label="True" :value="true" />
<el-option label="False" :value="false" />
</el-select>
<el-select
v-else-if="propDetails.type === 'string' && propDetails.enum"
v-model="testParamsForm[propName]"
:placeholder="propDetails.description || '请选择' + propName"
>
<el-option v-for="enumValue in propDetails.enum" :key="enumValue" :label="enumValue" :value="enumValue" />
</el-select>
<el-input
v-else
type="textarea"
v-model="testParamsForm[propName]"
:placeholder="(propDetails.description || propName) + ' (请输入JSON格式)'"
:autosize="{ minRows: 2, maxRows: 6 }"
/>
</el-form-item>
</el-form>
<div v-if="apiDialogResponse" class="mt-5 p-[15px] border border-gray-200 rounded bg-gray-50">
<h4 class="mt-0 mb-2.5 text-base">API 返回结果:</h4>
<div v-if="typeof apiDialogResponse === 'string'">
<pre class="bg-gray-100 p-2.5 rounded whitespace-pre-wrap break-words overflow-y-auto">{{ apiDialogResponse }}</pre>
</div>
<div v-else-if="apiDialogResponse.type === 'image' && apiDialogResponse.content">
<el-image
class="max-w-full max-h-[300px]"
:src="apiDialogResponse.content"
:preview-src-list="[apiDialogResponse.content]"
fit="contain"
/>
</div>
<div v-else-if="apiDialogResponse.type === 'text' && apiDialogResponse.content">
<pre class="bg-gray-100 p-2.5 rounded whitespace-pre-wrap break-words overflow-y-auto">{{ apiDialogResponse.content }}</pre>
</div>
<div v-else>
<pre class="bg-gray-100 p-2.5 rounded whitespace-pre-wrap break-words overflow-y-auto">{{ JSON.stringify(apiDialogResponse, null, 2) }}</pre>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="testDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleDialogTestTool">测试</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { VideoPlay, DocumentCopy } from '@element-plus/icons-vue' // Added DocumentCopy
import { mcpList, mcpTest } from '@/api/autoCode'
defineOptions({
name: 'MCPTest'
})
const mcpTools = ref([])
const testDialogVisible = ref(false)
const currentTestingTool = ref(null)
const testParamsForm = reactive({})
const testParamsFormRef = ref(null)
const apiDialogResponse = ref(null)
const mcpServerConfig = ref(JSON.stringify({
"mcpServers": {
"gva": {
"url": "https://127.0.0.1/sse"
}
}
}, null, 2))
const fetchMcpTools = async () => {
const res = await mcpList()
if (res.code === 0 && res.data && res.data.list.tools) {
mcpTools.value = res.data.list.tools
mcpServerConfig.value = JSON.stringify(res.data.mcpServerConfig, null, 2)
} else {
ElMessage.error(res.msg || '获取工具列表失败或数据格式不正确')
}
}
onMounted(() => {
fetchMcpTools()
})
const copyMcpConfig = async () => {
try {
await navigator.clipboard.writeText(mcpServerConfig.value)
ElMessage.success('配置已复制到剪贴板')
} catch (err) {
ElMessage.error('复制失败: ' + err)
}
}
const openTestDialog = (tool) => {
currentTestingTool.value = tool
apiDialogResponse.value = null // 清空之前的API响应
// 重置并初始化表单数据
for (const key in testParamsForm) {
delete testParamsForm[key]
}
if (tool.inputSchema && tool.inputSchema.properties) {
Object.keys(tool.inputSchema.properties).forEach(propName => {
const propDetails = tool.inputSchema.properties[propName]
// 设置默认值,优先使用 schema 中的 default否则根据类型给初始值
if (propDetails.default !== undefined) {
testParamsForm[propName] = propDetails.default
} else if (propDetails.type === 'boolean') {
testParamsForm[propName] = false
} else if (propDetails.type === 'number') {
testParamsForm[propName] = null // 或者 0
} else if (propDetails.type === 'object' || propDetails.type === 'array') {
testParamsForm[propName] = '' // 对象和数组类型默认为空字符串提示用户输入JSON
} else {
testParamsForm[propName] = ''
}
})
}
testDialogVisible.value = true
// 清除表单校验状态
if (testParamsFormRef.value) {
testParamsFormRef.value.clearValidate()
}
}
const handleCloseDialog = (done) => {
apiDialogResponse.value = null
done()
}
const handleDialogTestTool = async () => {
if (!currentTestingTool.value) {
ElMessage.warning('没有选中的测试工具')
return
}
if (testParamsFormRef.value) {
testParamsFormRef.value.validate(async (valid) => {
if (valid) {
const toolName = currentTestingTool.value.name
const payload = { ...testParamsForm }
if (currentTestingTool.value.inputSchema && currentTestingTool.value.inputSchema.properties) {
Object.keys(currentTestingTool.value.inputSchema.properties).forEach(propName => {
const propDetails = currentTestingTool.value.inputSchema.properties[propName]
if ((propDetails.type === 'object' || propDetails.type === 'array') && payload[propName] && typeof payload[propName] === 'string') {
try {
payload[propName] = JSON.parse(payload[propName])
} catch (e) {
ElMessage.error(`参数 ${propName} 的JSON格式无效: ${e.message}`)
throw new Error(`参数 ${propName} JSON无效`);
}
}
})
}
const res = await mcpTest({
name:toolName,
arguments:payload
})
apiDialogResponse.value = res.data
if (res.code === 0) {
ElMessage.success('API调用成功')
}
}
})
}
}
</script>

View File

@@ -0,0 +1,426 @@
<template>
<div>
<warning-bar
href="https://www.gin-vue-admin.com/empower/"
title="此功能只针对授权用户开放,点我【购买授权】"
/>
<div class="gva-search-box">
<div class="text-xl mb-2 text-gray-600">
AI前端工程师<a
class="text-blue-600 text-sm ml-4"
href="https://plugin.gin-vue-admin.com/#/layout/userInfo/center"
target="_blank"
>获取AiPath</a
>
</div>
<!-- 选项模式 -->
<div class="mb-4">
<div class="mb-3">
<div class="text-base font-medium mb-2">页面用途</div>
<el-radio-group v-model="pageType" class="mb-2" @change="handlePageTypeChange">
<el-radio label="企业官网">企业官网</el-radio>
<el-radio label="电商页面">电商页面</el-radio>
<el-radio label="个人博客">个人博客</el-radio>
<el-radio label="产品介绍">产品介绍</el-radio>
<el-radio label="活动落地页">活动落地页</el-radio>
<el-radio label="其他">其他</el-radio>
</el-radio-group>
<el-input v-if="pageType === '其他'" v-model="pageTypeCustom" placeholder="请输入页面用途" class="w-full" />
</div>
<div class="mb-3">
<div class="text-base font-medium mb-2">主要内容板块</div>
<el-checkbox-group v-model="contentBlocks" class="flex flex-wrap gap-2 mb-2">
<el-checkbox label="Banner轮播图">Banner轮播图</el-checkbox>
<el-checkbox label="产品/服务介绍">产品/服务介绍</el-checkbox>
<el-checkbox label="功能特点展示">功能特点展示</el-checkbox>
<el-checkbox label="客户案例">客户案例</el-checkbox>
<el-checkbox label="团队介绍">团队介绍</el-checkbox>
<el-checkbox label="联系表单">联系表单</el-checkbox>
<el-checkbox label="新闻/博客列表">新闻/博客列表</el-checkbox>
<el-checkbox label="价格表">价格表</el-checkbox>
<el-checkbox label="FAQ/常见问题">FAQ/常见问题</el-checkbox>
<el-checkbox label="用户评价">用户评价</el-checkbox>
<el-checkbox label="数据统计">数据统计</el-checkbox>
<el-checkbox label="商品列表">商品列表</el-checkbox>
<el-checkbox label="商品卡片">商品卡片</el-checkbox>
<el-checkbox label="购物车">购物车</el-checkbox>
<el-checkbox label="结算页面">结算页面</el-checkbox>
<el-checkbox label="订单跟踪">订单跟踪</el-checkbox>
<el-checkbox label="商品分类">商品分类</el-checkbox>
<el-checkbox label="热门推荐">热门推荐</el-checkbox>
<el-checkbox label="限时特惠">限时特惠</el-checkbox>
<el-checkbox label="其他">其他</el-checkbox>
</el-checkbox-group>
<el-input v-if="contentBlocks.includes('其他')" v-model="contentBlocksCustom" placeholder="请输入其他内容板块" class="w-full" />
</div>
<div class="mb-3">
<div class="text-base font-medium mb-2">风格偏好</div>
<el-radio-group v-model="stylePreference" class="mb-2">
<el-radio label="简约">简约</el-radio>
<el-radio label="科技感">科技感</el-radio>
<el-radio label="温馨">温馨</el-radio>
<el-radio label="专业">专业</el-radio>
<el-radio label="创意">创意</el-radio>
<el-radio label="复古">复古</el-radio>
<el-radio label="奢华">奢华</el-radio>
<el-radio label="其他">其他</el-radio>
</el-radio-group>
<el-input v-if="stylePreference === '其他'" v-model="stylePreferenceCustom" placeholder="请输入风格偏好" class="w-full" />
</div>
<div class="mb-3">
<div class="text-base font-medium mb-2">设计布局</div>
<el-radio-group v-model="layoutDesign" class="mb-2">
<el-radio label="单栏布局">单栏布局</el-radio>
<el-radio label="双栏布局">双栏布局</el-radio>
<el-radio label="三栏布局">三栏布局</el-radio>
<el-radio label="网格布局">网格布局</el-radio>
<el-radio label="画廊布局">画廊布局</el-radio>
<el-radio label="瀑布流">瀑布流</el-radio>
<el-radio label="卡片式">卡片式</el-radio>
<el-radio label="侧边栏+内容布局">侧边栏+内容布局</el-radio>
<el-radio label="分屏布局">分屏布局</el-radio>
<el-radio label="全屏滚动布局">全屏滚动布局</el-radio>
<el-radio label="混合布局">混合布局</el-radio>
<el-radio label="响应式">响应式</el-radio>
<el-radio label="其他">其他</el-radio>
</el-radio-group>
<el-input v-if="layoutDesign === '其他'" v-model="layoutDesignCustom" placeholder="请输入设计布局" class="w-full" />
</div>
<div class="mb-3">
<div class="text-base font-medium mb-2">配色方案</div>
<el-radio-group v-model="colorScheme" class="mb-2">
<el-radio label="蓝色系">蓝色系</el-radio>
<el-radio label="绿色系">绿色系</el-radio>
<el-radio label="红色系">红色系</el-radio>
<el-radio label="黑白灰">黑白灰</el-radio>
<el-radio label="纯黑白">纯黑白</el-radio>
<el-radio label="暖色调">暖色调</el-radio>
<el-radio label="冷色调">冷色调</el-radio>
<el-radio label="其他">其他</el-radio>
</el-radio-group>
<el-input v-if="colorScheme === '其他'" v-model="colorSchemeCustom" placeholder="请输入配色方案" class="w-full" />
</div>
</div>
<!-- 详细描述输入框 -->
<div class="relative">
<div class="text-base font-medium mb-2">详细描述可选</div>
<el-input
v-model="prompt"
:maxlength="2000"
:placeholder="placeholder"
:rows="5"
resize="none"
type="textarea"
@blur="handleBlur"
@focus="handleFocus"
/>
<div class="flex absolute right-2 bottom-2">
<el-tooltip effect="light">
<template #content>
<div>
此功能仅针对授权用户开放前往<a
class="text-blue-600"
href="https://www.gin-vue-admin.com/empower/"
target="_blank"
>购买授权</a
>
</div>
</template>
<el-button
type="primary"
@click="llmAutoFunc()"
>
<el-icon size="18">
<ai-gva/>
</el-icon>
生成
</el-button>
</el-tooltip>
</div>
</div>
</div>
<div>
<div v-if="!outPut">
<el-empty :image-size="200"/>
</div>
<div v-if="outPut && htmlFromLLM">
<el-tabs type="border-card">
<el-tab-pane label="页面预览">
<div class="h-[500px] overflow-auto bg-gray-50 p-4 rounded">
<div v-if="!loadedComponents" class="text-gray-500 text-center py-4">
组件加载中...
</div>
<component
v-else
:is="loadedComponents"
class="vue-component-container w-full"
/>
</div>
</el-tab-pane>
<el-tab-pane label="源代码">
<div class="relative h-[500px] overflow-auto bg-gray-50 p-4 rounded">
<el-button
type="primary"
:icon="DocumentCopy"
class="absolute top-2 right-2 px-2 py-1"
@click="copySnippet(htmlFromLLM)"
plain
>
复制
</el-button>
<pre class="mt-10 whitespace-pre-wrap">{{ htmlFromLLM }}</pre>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
</template>
<script setup>
import { createWeb } from '@/api/autoCode'
import { ref, reactive, markRaw } from 'vue'
import * as Vue from "vue";
import WarningBar from '@/components/warningBar/warningBar.vue'
import { ElMessage } from 'element-plus'
import { defineAsyncComponent } from 'vue'
import { DocumentCopy } from '@element-plus/icons-vue'
import { loadModule } from "vue3-sfc-loader";
defineOptions({
name: 'Picture'
})
const handleFocus = () => {
document.addEventListener('keydown', handleKeydown);
}
const handleBlur = () => {
document.removeEventListener('keydown', handleKeydown);
}
const handleKeydown = (event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
llmAutoFunc()
}
}
// 复制方法:把某个字符串写进剪贴板
const copySnippet = (vueString) => {
navigator.clipboard.writeText(vueString)
.then(() => {
ElMessage({
message: '复制成功',
type: 'success',
})
})
.catch(err => {
ElMessage({
message: '复制失败',
type: 'warning',
})
})
}
// 选项模式相关变量
const pageType = ref('企业官网')
const pageTypeCustom = ref('')
const contentBlocks = ref(['Banner轮播图', '产品/服务介绍'])
const contentBlocksCustom = ref('')
const stylePreference = ref('简约')
const stylePreferenceCustom = ref('')
const layoutDesign = ref('响应式')
const layoutDesignCustom = ref('')
const colorScheme = ref('蓝色系')
const colorSchemeCustom = ref('')
// 页面用途与内容板块的推荐映射关系
const pageTypeContentMap = {
'企业官网': ['Banner轮播图', '产品/服务介绍', '功能特点展示', '客户案例', '联系表单'],
'电商页面': ['Banner轮播图', '商品列表', '商品卡片', '购物车', '商品分类', '热门推荐', '限时特惠', '结算页面', '用户评价'],
'个人博客': ['Banner轮播图', '新闻/博客列表', '用户评价', '联系表单'],
'产品介绍': ['Banner轮播图', '产品/服务介绍', '功能特点展示', '价格表', 'FAQ/常见问题'],
'活动落地页': ['Banner轮播图', '功能特点展示', '联系表单', '数据统计']
}
const prompt = ref('')
// 判断是否返回的标志
const outPut = ref(false)
// 容纳llm返回的vue组件代码
const htmlFromLLM = ref("")
// 存储加载的组件
const loadedComponents = ref(null)
const loadVueComponent = async (vueCode) => {
try {
// 使用内存中的虚拟路径
const fakePath = `virtual:component-0.vue`
const component = defineAsyncComponent({
loader: async () => {
try {
const options = {
moduleCache: {
vue: Vue,
},
getFile(url) {
// 处理所有可能的URL格式包括相对路径、绝对路径等
// 提取路径的最后部分,忽略查询参数
const fileName = url.split('/').pop().split('?')[0]
const componentFileName = fakePath.split('/').pop()
// 如果文件名包含我们的组件名称或者url完全匹配fakePath
if (fileName === componentFileName || url === fakePath ||
url === `./component/0.vue`) {
return Promise.resolve({
type: '.vue',
getContentData: () => vueCode
})
}
console.warn('请求未知文件:', url)
return Promise.reject(new Error(`找不到文件: ${url}`))
},
addStyle(textContent) {
// 不再将样式添加到document.head而是返回样式内容
// 稍后会将样式添加到Shadow DOM中
return textContent
},
handleModule(type, source, path, options) {
// 默认处理器
return undefined
},
log(type, ...args) {
console.log(`[vue3-sfc-loader] [${type}]`, ...args)
}
}
// 尝试加载组件
const comp = await loadModule(fakePath, options)
return comp.default || comp
} catch (error) {
console.error('组件加载详细错误:', error)
throw error
}
},
loadingComponent: {
template: '<div>加载中...</div>'
},
errorComponent: {
props: ['error'],
template: '<div>组件加载失败: {{ error && error.message }}</div>',
setup(props) {
console.error('错误组件收到的错误:', props.error)
return {}
}
},
// 添加超时和重试选项
timeout: 30000,
delay: 200,
suspensible: false,
onError(error, retry, fail) {
console.error('加载错误,细节:', error)
fail()
}
})
// 创建一个包装组件使用Shadow DOM隔离样式
const ShadowWrapper = {
name: 'ShadowWrapper',
setup() {
return {}
},
render() {
return Vue.h('div', { class: 'shadow-wrapper' })
},
mounted() {
// 创建Shadow DOM
const shadowRoot = this.$el.attachShadow({ mode: 'open' })
// 创建一个容器元素
const container = document.createElement('div')
container.className = 'shadow-container'
shadowRoot.appendChild(container)
// 提取组件中的样式
const styleContent = vueCode.match(/<style[^>]*>([\s\S]*?)<\/style>/i)?.[1] || ''
// 创建样式元素并添加到Shadow DOM
if (styleContent) {
const style = document.createElement('style')
style.textContent = styleContent
shadowRoot.appendChild(style)
}
// 创建Vue应用并挂载到Shadow DOM容器中
const app = Vue.createApp({
render: () => Vue.h(component)
})
app.mount(container)
}
}
loadedComponents.value = markRaw(ShadowWrapper)
return ShadowWrapper
} catch (error) {
console.error('组件创建总错误:', error)
return null
}
}
// 当页面用途改变时,更新内容板块的选择
const handlePageTypeChange = (value) => {
if (value !== '其他' && pageTypeContentMap[value]) {
contentBlocks.value = [...pageTypeContentMap[value]]
}
}
const llmAutoFunc = async () => {
// 构建完整的描述,包含选项模式的选择
let fullPrompt = ''
// 添加页面用途
fullPrompt += `页面用途: ${pageType.value === '其他' ? pageTypeCustom.value : pageType.value}\n`
// 添加内容板块
fullPrompt += '主要内容板块: '
const blocks = contentBlocks.value.filter(block => block !== '其他')
if (contentBlocksCustom.value) {
blocks.push(contentBlocksCustom.value)
}
fullPrompt += blocks.join(', ') + '\n'
// 添加风格偏好
fullPrompt += `风格偏好: ${stylePreference.value === '其他' ? stylePreferenceCustom.value : stylePreference.value}\n`
// 添加设计布局
fullPrompt += `设计布局: ${layoutDesign.value === '其他' ? layoutDesignCustom.value : layoutDesign.value}\n`
// 添加配色方案
fullPrompt += `配色方案: ${colorScheme.value === '其他' ? colorSchemeCustom.value : colorScheme.value}\n`
// 添加用户的详细描述
if (prompt.value) {
fullPrompt += `\n详细描述: ${prompt.value}`
}
const res = await createWeb({web: fullPrompt, command: 'createWeb'})
if (res.code === 0) {
outPut.value = true
// 添加返回的Vue组件代码到数组
htmlFromLLM.value = res.data
// 加载新生成的组件
await loadVueComponent(res.data)
}
}
const placeholder = ref(`补充您对页面的其他要求或特殊需求,例如:特别强调的元素、参考网站、交互效果等。`)
</script>

View File

@@ -123,6 +123,8 @@
callback(new Error('不能为中文')) callback(new Error('不能为中文'))
} else if (/^\d+$/.test(value[0])) { } else if (/^\d+$/.test(value[0])) {
callback(new Error('不能够以数字开头')) callback(new Error('不能够以数字开头'))
} else if (!/^[a-zA-Z0-9_]+$/.test(value)) {
callback(new Error('只能包含英文字母、数字和下划线'))
} else { } else {
callback() callback()
} }

View File

@@ -509,7 +509,15 @@ JOINS模式下不支持导入
{ {
label: 'NOT BETWEEN', label: 'NOT BETWEEN',
value: 'NOT BETWEEN' value: 'NOT BETWEEN'
} },
{
label: 'IN',
value: 'IN'
},
{
label: 'NOT IN',
value: 'NOT IN'
},
]) ])
const addCondition = () => { const addCondition = () => {

View File

@@ -202,6 +202,9 @@
<el-form-item label="是否为ssl"> <el-form-item label="是否为ssl">
<el-switch v-model="config.email['is-ssl']" /> <el-switch v-model="config.email['is-ssl']" />
</el-form-item> </el-form-item>
<el-form-item label="是否LoginAuth认证">
<el-switch v-model="config.email['is-loginauth']" />
</el-form-item>
<el-form-item label="secret"> <el-form-item label="secret">
<el-input <el-input
v-model.trim="config.email.secret" v-model.trim="config.email.secret"
@@ -938,7 +941,7 @@
</el-form> </el-form>
<div class="mt-4"> <div class="mt-4">
<el-button type="primary" @click="update">立即更新 </el-button> <el-button type="primary" @click="update">立即更新 </el-button>
<el-button type="primary" @click="reload">服务 </el-button> <el-button type="primary" @click="reload">服务 </el-button>
</div> </div>
</div> </div>
</template> </template>
@@ -1010,7 +1013,7 @@
} }
initForm() initForm()
const reload = () => { const reload = () => {
ElMessageBox.confirm('确定要重服务?', '警告', { ElMessageBox.confirm('确定要重服务?', '警告', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning'

View File

@@ -0,0 +1,991 @@
<template>
<div>
<div class="gva-search-box">
<el-form ref="elSearchFormRef" :inline="true" :model="searchInfo" class="demo-form-inline"
@keyup.enter="onSubmit">
<el-form-item label="创建日期" prop="createdAtRange">
<template #label>
<span>
创建日期
<el-tooltip content="搜索范围是开始日期(包含)至结束日期(不包含)">
<el-icon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</span>
</template>
<el-date-picker v-model="searchInfo.createdAtRange" class="w-[380px]" type="datetimerange" range-separator="至"
start-placeholder="开始时间" end-placeholder="结束时间" />
</el-form-item>
<el-form-item label="版本名称" prop="versionName">
<el-input v-model="searchInfo.versionName" placeholder="搜索条件" />
</el-form-item>
<el-form-item label="版本号" prop="versionCode">
<el-input v-model="searchInfo.versionCode" placeholder="搜索条件" />
</el-form-item>
<template v-if="showAllQuery">
<!-- 将需要控制显示状态的查询条件添加到此范围内 -->
</template>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit">查询</el-button>
<el-button icon="refresh" @click="onReset">重置</el-button>
<el-button link type="primary" icon="arrow-down" @click="showAllQuery = true"
v-if="!showAllQuery">展开</el-button>
<el-button link type="primary" icon="arrow-up" @click="showAllQuery = false" v-else>收起</el-button>
</el-form-item>
</el-form>
</div>
<div class="gva-table-box">
<div class="gva-btn-list">
<el-button type="success" icon="download" @click="openExportDialog">创建发版</el-button>
<el-button type="warning" icon="upload" @click="openImportDialog">导入版本</el-button>
<el-button icon="delete" style="margin-left: 10px;" :disabled="!multipleSelection.length"
@click="onDelete">删除</el-button>
</div>
<el-table ref="multipleTable" style="width: 100%" tooltip-effect="dark" :data="tableData" row-key="ID"
@selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" />
<el-table-column sortable align="left" label="日期" prop="CreatedAt" width="180">
<template #default="scope">{{ formatDate(scope.row.CreatedAt) }}</template>
</el-table-column>
<el-table-column align="left" label="版本名称" prop="versionName" width="120" />
<el-table-column align="left" label="版本号" prop="versionCode" width="120" />
<el-table-column align="left" label="操作" fixed="right" min-width="320">
<template #default="scope">
<el-button type="primary" link class="table-button" @click="getDetails(scope.row)"><el-icon
style="margin-right: 5px">
<InfoFilled />
</el-icon>查看</el-button>
<el-button type="success" link icon="download" class="table-button"
@click="downloadJson(scope.row)">下载发版包</el-button>
<el-button type="primary" link icon="delete" @click="deleteRow(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="gva-pagination">
<el-pagination layout="total, sizes, prev, pager, next, jumper" :current-page="page" :page-size="pageSize"
:page-sizes="[10, 30, 50, 100]" :total="total" @current-change="handleCurrentChange"
@size-change="handleSizeChange" />
</div>
</div>
<el-drawer destroy-on-close :size="appStore.drawerSize" v-model="detailShow" :show-close="true"
:before-close="closeDetailShow" title="查看">
<el-descriptions :column="1" border>
<el-descriptions-item label="版本名称">
{{ detailForm.versionName }}
</el-descriptions-item>
<el-descriptions-item label="版本号">
{{ detailForm.versionCode }}
</el-descriptions-item>
<el-descriptions-item label="版本描述">
{{ detailForm.description }}
</el-descriptions-item>
</el-descriptions>
</el-drawer>
<!-- 导出版本抽屉 -->
<el-drawer v-model="exportDialogVisible" title="创建发版" direction="rtl" size="80%" :before-close="closeExportDialog" :show-close="false">
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">创建发版</span>
<div>
<el-button @click="closeExportDialog">取消</el-button>
<el-button type="primary" @click="handleExport" :loading="exportLoading">创建发版</el-button>
</div>
</div>
</template>
<el-form :model="exportForm" label-width="100px">
<el-form-item label="版本名称" required>
<el-input v-model="exportForm.versionName" placeholder="请输入版本名称" />
</el-form-item>
<el-form-item label="版本号" required>
<el-input v-model="exportForm.versionCode" placeholder="请输入版本号" />
</el-form-item>
<el-form-item label="版本描述">
<el-input v-model="exportForm.description" type="textarea" placeholder="请输入版本描述" />
</el-form-item>
<el-form-item label="发版信息">
<div class="flex gap-3 w-full">
<!-- 菜单选择 -->
<div class="flex flex-col border border-gray-300 rounded overflow-hidden h-full flex-1 w-1/3">
<div class="flex justify-between items-center px-4 py-3 bg-gray-50 border-b border-gray-300">
<span class="m-0 text-gray-800 text-base font-medium">选择菜单</span>
</div>
<div class="px-4 py-3 border-b border-gray-300 bg-gray-50">
<el-input v-model="menuFilterText" placeholder="输入关键字进行过滤" clearable size="small" />
</div>
<div class="flex-1 p-2 min-h-[300px] max-h-[400px] overflow-y-auto">
<el-tree ref="menuTreeRef" :data="menuTreeData" :default-checked-keys="selectedMenuIds"
:props="menuTreeProps" default-expand-all highlight-current node-key="ID" show-checkbox
:filter-node-method="filterMenuNode" @check="onMenuCheck" class="menu-tree">
<template #default="{ node }">
<span class="flex-1 flex items-center justify-between text-sm pr-2">
<span>{{ node.label }}</span>
</span>
</template>
</el-tree>
</div>
</div>
<!-- API选择 -->
<div class="flex flex-col border border-gray-300 rounded overflow-hidden h-full flex-1 w-1/3">
<div class="flex justify-between items-center px-4 py-3 bg-gray-50 border-b border-gray-300">
<span class="m-0 text-gray-800 text-base font-medium">选择API</span>
</div>
<div class="px-4 py-3 border-b border-gray-300 bg-gray-50">
<el-input v-model="apiFilterTextName" placeholder="按名称过滤" clearable size="small"
style="margin-bottom: 8px" />
<el-input v-model="apiFilterTextPath" placeholder="按路径过滤" clearable size="small" />
</div>
<div class="flex-1 p-2 min-h-[300px] max-h-[400px] overflow-y-auto">
<el-tree ref="apiTreeRef" :data="apiTreeData" :default-checked-keys="selectedApiIds"
:props="apiTreeProps" default-expand-all highlight-current node-key="onlyId" show-checkbox
:filter-node-method="filterApiNode" @check="onApiCheck" class="api-tree">
<template #default="{ data }">
<div class="flex items-center justify-between w-full pr-1">
<span>{{ data.description }}</span>
<el-tooltip :content="data.path">
<span class="max-w-[240px] break-all overflow-ellipsis overflow-hidden">
{{ data.path }}
</span>
</el-tooltip>
</div>
</template>
</el-tree>
</div>
</div>
<!-- 字典选择 -->
<div class="flex flex-col border border-gray-300 rounded overflow-hidden h-full flex-1 w-1/3">
<div class="flex justify-between items-center px-4 py-3 bg-gray-50 border-b border-gray-300">
<span class="m-0 text-gray-800 text-base font-medium">选择字典</span>
</div>
<div class="px-4 py-3 border-b border-gray-300 bg-gray-50">
<el-input v-model="dictFilterText" placeholder="输入关键字进行过滤" clearable size="small" />
</div>
<div class="flex-1 p-2 min-h-[300px] max-h-[400px] overflow-y-auto">
<el-tree ref="dictTreeRef" :data="dictTreeData" :default-checked-keys="selectedDictIds"
:props="dictTreeProps" default-expand-all highlight-current node-key="ID" show-checkbox
:filter-node-method="filterDictNode" @check="onDictCheck" class="dict-tree">
<template #default="{ data }">
<div class="flex items-center justify-between w-full pr-1">
<span>{{ data.name || data.label }}</span>
<el-tooltip :content="data.desc || (data.value ? `值: ${data.value}` : '')">
<span class="text-gray-500 text-xs ml-2">
{{ data.type || (data.value ? `值: ${data.value}` : '') }}
</span>
</el-tooltip>
</div>
</template>
</el-tree>
</div>
</div>
</div>
</el-form-item>
</el-form>
</el-drawer>
<!-- 导入版本抽屉 -->
<el-drawer v-model="importDialogVisible" title="导入版本" direction="rtl" size="80%" :before-close="closeImportDialog" :show-close="false">
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">导入版本</span>
<div>
<el-button @click="closeImportDialog">取消</el-button>
<el-button type="primary" @click="handleImport" :loading="importLoading"
:disabled="!importJsonContent.trim()">导入</el-button>
</div>
</div>
</template>
<el-form label-width="100px">
<el-form-item label="上传文件">
<el-upload
ref="uploadRef"
:auto-upload="false"
:show-file-list="true"
:limit="1"
accept=".json"
:on-change="handleFileChange"
:on-remove="handleFileRemove"
drag
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将JSON文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
只能上传JSON文件
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="版本JSON">
<el-input v-model="importJsonContent" type="textarea" :rows="20" placeholder="请粘贴版本JSON"
@input="handleJsonContentChange" />
</el-form-item>
<el-form-item label="预览内容" v-if="importPreviewData">
<div class="flex flex-col flex-1 gap-4 border border-gray-300 rounded p-4 bg-gray-50">
<div class="flex gap-3 w-full">
<div class="border border-gray-300 rounded overflow-hidden flex-1 w-1/3">
<div class="flex flex-col border border-gray-300 rounded overflow-hidden h-full">
<div class="flex justify-between items-center px-4 py-3 bg-gray-50 border-b border-gray-300">
<h3 class="m-0 text-gray-800 text-base font-medium">菜单 ({{ getTotalMenuCount() }})</h3>
</div>
<div class="flex-1 p-2 min-h-[300px] max-h-[400px] overflow-y-auto">
<el-tree
:data="previewMenuTreeData"
:props="menuTreeProps"
node-key="name"
:expand-on-click-node="false"
:check-on-click-node="false"
:show-checkbox="false"
default-expand-all
>
<template #default="{ data }">
<div class="flex-1 flex items-center justify-between text-sm pr-2">
<span>{{ data.meta?.title || data.title }}</span>
<span class="text-gray-500 text-xs ml-2">{{ data.path }}</span>
</div>
</template>
</el-tree>
</div>
</div>
</div>
<div class="border border-gray-300 rounded overflow-hidden flex-1 w-1/3">
<div class="flex flex-col border border-gray-300 rounded overflow-hidden h-full">
<div class="flex justify-between items-center px-4 py-3 bg-gray-50 border-b border-gray-300">
<h3 class="m-0 text-gray-800 text-base font-medium">API ({{ importPreviewData.apis?.length || 0 }})</h3>
</div>
<div class="flex-1 p-2 min-h-[300px] max-h-[400px] overflow-y-auto">
<el-tree
:data="previewApiTreeData"
:props="apiTreeProps"
node-key="ID"
:expand-on-click-node="false"
:check-on-click-node="false"
:show-checkbox="false"
default-expand-all
>
<template #default="{ data }">
<div class="flex-1 flex items-center justify-between text-sm pr-2">
<span>{{ data.description }}</span>
<span class="text-gray-500 text-xs ml-2">{{ data.path }} [{{ data.method }}]</span>
</div>
</template>
</el-tree>
</div>
</div>
</div>
<div class="border border-gray-300 rounded overflow-hidden flex-1 w-1/3">
<div class="flex flex-col border border-gray-300 rounded overflow-hidden h-full">
<div class="flex justify-between items-center px-4 py-3 bg-gray-50 border-b border-gray-300">
<h3 class="m-0 text-gray-800 text-base font-medium">字典 ({{ importPreviewData.dictionaries?.length || 0 }})</h3>
</div>
<div class="flex-1 p-2 min-h-[300px] max-h-[400px] overflow-y-auto">
<el-tree
:data="previewDictTreeData"
:props="dictTreeProps"
node-key="ID"
:expand-on-click-node="false"
:check-on-click-node="false"
:show-checkbox="false"
default-expand-all
>
<template #default="{ data }">
<div class="flex-1 flex items-center justify-between text-sm pr-2">
<span>{{ data.name || data.label }}</span>
<span class="text-gray-500 text-xs ml-2">
{{ data.type || (data.value ? `值: ${data.value}` : '') }}
</span>
</div>
</template>
</el-tree>
</div>
</div>
</div>
</div>
</div>
</el-form-item>
</el-form>
</el-drawer>
</div>
</template>
<script setup>
import {
deleteSysVersion,
deleteSysVersionByIds,
findSysVersion,
getSysVersionList,
exportVersion,
importVersion,
downloadVersionJson
} from '@/api/version'
// 导入菜单和API相关接口
import { getMenuList } from '@/api/menu'
import { getApiList } from '@/api/api'
import { getSysDictionaryList } from '@/api/sysDictionary'
// 全量引入格式化工具 请按需保留
import { formatDate } from '@/utils/format'
import { ElMessage, ElMessageBox } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import { ref, watch } from 'vue'
import { useAppStore } from "@/pinia"
defineOptions({
name: 'SysVersion'
})
const appStore = useAppStore()
// 控制更多查询条件显示/隐藏状态
const showAllQuery = ref(false)
// 导出相关数据
const exportDialogVisible = ref(false)
const exportLoading = ref(false)
const exportForm = ref({
versionName: '',
versionCode: '',
description: '',
menuIds: [],
apiIds: [],
dictIds: []
})
// 树形结构相关数据
const menuTreeData = ref([])
const apiTreeData = ref([])
const dictTreeData = ref([])
const selectedMenuIds = ref([])
const selectedApiIds = ref([])
const selectedDictIds = ref([])
const menuFilterText = ref('')
const apiFilterTextName = ref('')
const apiFilterTextPath = ref('')
const dictFilterText = ref('')
// 树形组件引用
const menuTreeRef = ref(null)
const apiTreeRef = ref(null)
const dictTreeRef = ref(null)
// 树形属性配置
const menuTreeProps = ref({
children: 'children',
label: function (data) {
return data.meta?.title || data.title
}
})
const apiTreeProps = ref({
children: 'children',
label: 'description'
})
const dictTreeProps = ref({
children: 'sysDictionaryDetails',
label: function (data) {
// 如果是字典主项,显示字典名称
if (data.name) {
return data.name
}
// 如果是字典详情项,显示标签
if (data.label) {
return data.label
}
return '未知项'
}
})
// 导入相关数据
const importDialogVisible = ref(false)
const importLoading = ref(false)
const importJsonContent = ref('')
const importPreviewData = ref(null)
const uploadRef = ref(null)
const previewMenuTreeData = ref([])
const previewApiTreeData = ref([])
const previewDictTreeData = ref([])
const elSearchFormRef = ref()
// =========== 表格控制部分 ===========
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
const searchInfo = ref({})
// 重置
const onReset = () => {
searchInfo.value = {}
getTableData()
}
// 搜索
const onSubmit = () => {
elSearchFormRef.value?.validate(async (valid) => {
if (!valid) return
page.value = 1
getTableData()
})
}
// 分页
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
// 修改页面容量
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
// 查询
const getTableData = async () => {
const table = await getSysVersionList({ page: page.value, pageSize: pageSize.value, ...searchInfo.value })
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
getTableData()
// ============== 表格控制部分结束 ===============
// 多选数据
const multipleSelection = ref([])
// 多选
const handleSelectionChange = (val) => {
multipleSelection.value = val
}
// 删除行
const deleteRow = (row) => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteSysVersionFunc(row)
})
}
// 多选删除
const onDelete = async () => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const IDs = []
if (multipleSelection.value.length === 0) {
ElMessage({
type: 'warning',
message: '请选择要删除的数据'
})
return
}
multipleSelection.value &&
multipleSelection.value.map(item => {
IDs.push(item.ID)
})
const res = await deleteSysVersionByIds({ IDs })
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功'
})
if (tableData.value.length === IDs.length && page.value > 1) {
page.value--
}
getTableData()
}
})
}
// 删除行
const deleteSysVersionFunc = async (row) => {
const res = await deleteSysVersion({ ID: row.ID })
if (res.code === 0) {
ElMessage({
type: 'success',
message: '删除成功'
})
if (tableData.value.length === 1 && page.value > 1) {
page.value--
}
getTableData()
}
}
const detailForm = ref({})
// 查看详情控制标记
const detailShow = ref(false)
// 打开详情弹窗
const openDetailShow = () => {
detailShow.value = true
}
// 打开详情
const getDetails = async (row) => {
// 打开弹窗
const res = await findSysVersion({ ID: row.ID })
if (res.code === 0) {
detailForm.value = res.data
openDetailShow()
}
}
// 关闭详情弹窗
const closeDetailShow = () => {
detailShow.value = false
detailForm.value = {}
}
// 获取菜单和API列表
const getMenuAndApiList = async () => {
try {
// 获取菜单列表
const menuRes = await getMenuList()
if (menuRes.code === 0) {
menuTreeData.value = menuRes.data || []
}
// 获取API列表
const apiRes = await getApiList({ page: 1, pageSize: 9999 })
if (apiRes.code === 0) {
console.log('原始API数据:', apiRes.data)
const apis = apiRes.data.list || []
apiTreeData.value = buildApiTree(apis)
}
} catch (error) {
console.error('获取数据失败:', error)
ElMessage.error('获取菜单或API数据失败')
}
}
// 获取字典列表
const getDictList = async () => {
try {
const dictRes = await getSysDictionaryList({ page: 1, pageSize: 9999 })
if (dictRes.code === 0) {
dictTreeData.value = dictRes.data || []
}
} catch (error) {
console.error('获取字典数据失败:', error)
ElMessage.error('获取字典数据失败')
}
}
// 构建API树形结构
const buildApiTree = (apis) => {
const apiObj = {}
apis.forEach((item) => {
item.onlyId = 'p:' + item.path + 'm:' + item.method
if (Object.prototype.hasOwnProperty.call(apiObj, item.apiGroup)) {
apiObj[item.apiGroup].push(item)
} else {
Object.assign(apiObj, { [item.apiGroup]: [item] })
}
})
const apiTree = []
for (const key in apiObj) {
const treeNode = {
ID: key,
description: key + '组',
children: apiObj[key]
}
apiTree.push(treeNode)
}
return apiTree
}
// 树形组件事件处理方法
const filterMenuNode = (value, data) => {
if (!value) return true
const title = data.meta?.title || data.title || ''
return title.indexOf(value) !== -1
}
const filterApiNode = (value, data) => {
if (!apiFilterTextName.value && !apiFilterTextPath.value) return true
let matchesName, matchesPath
if (!apiFilterTextName.value) {
matchesName = true
} else {
matchesName = data.description && data.description.includes(apiFilterTextName.value)
}
if (!apiFilterTextPath.value) {
matchesPath = true
} else {
matchesPath = data.path && data.path.includes(apiFilterTextPath.value)
}
return matchesName && matchesPath
}
const filterDictNode = (value, data) => {
if (!value) return true
const name = data.name || ''
const type = data.type || ''
const desc = data.desc || ''
const label = data.label || ''
const dataValue = data.value || ''
return name.indexOf(value) !== -1 ||
type.indexOf(value) !== -1 ||
desc.indexOf(value) !== -1 ||
label.indexOf(value) !== -1 ||
dataValue.indexOf(value) !== -1
}
const onMenuCheck = (data, checked) => {
if (checked.checkedKeys) {
selectedMenuIds.value = checked.checkedKeys
}
}
const onApiCheck = (data, checked) => {
if (checked.checkedKeys) {
selectedApiIds.value = checked.checkedKeys
}
}
const onDictCheck = (data, checked) => {
if (checked.checkedKeys) {
selectedDictIds.value = checked.checkedKeys
}
}
// 监听过滤文本变化
watch(menuFilterText, (val) => {
if (menuTreeRef.value) {
menuTreeRef.value.filter(val)
}
})
watch([apiFilterTextName, apiFilterTextPath], () => {
if (apiTreeRef.value) {
apiTreeRef.value.filter('')
}
})
watch(dictFilterText, (val) => {
if (dictTreeRef.value) {
dictTreeRef.value.filter(val)
}
})
// 导出相关方法
const openExportDialog = async () => {
exportDialogVisible.value = true
await getMenuAndApiList()
await getDictList()
}
const closeExportDialog = () => {
exportDialogVisible.value = false
exportForm.value = {
versionName: '',
versionCode: '',
description: '',
menuIds: [],
apiIds: [],
dictIds: []
}
selectedMenuIds.value = []
selectedApiIds.value = []
selectedDictIds.value = []
menuFilterText.value = ''
apiFilterTextName.value = ''
apiFilterTextPath.value = ''
dictFilterText.value = ''
}
const handleExport = async () => {
if (!exportForm.value.versionName || !exportForm.value.versionCode) {
ElMessage.warning('请填写版本名称和版本号')
return
}
exportLoading.value = true
try {
// 获取选中的菜单、API和字典
const checkedMenus = menuTreeRef.value ? menuTreeRef.value.getCheckedNodes(false, true) : []
const checkedApis = apiTreeRef.value ? apiTreeRef.value.getCheckedNodes(true) : []
const checkedDicts = dictTreeRef.value ? dictTreeRef.value.getCheckedNodes(true) : []
const menuIds = checkedMenus.map(menu => menu.ID)
const apiIds = checkedApis.map(api => api.ID)
const dictIds = checkedDicts.map(dict => dict.ID)
exportForm.value.menuIds = menuIds
exportForm.value.apiIds = apiIds
exportForm.value.dictIds = dictIds
const res = await exportVersion(exportForm.value)
if (res.code !== 0) {
ElMessage.error(res.msg || '创建发版失败')
return
}
ElMessage.success('创建发版成功')
closeExportDialog()
getTableData() // 刷新表格数据
} catch (error) {
console.error('创建发版失败:', error)
ElMessage.error('创建发版失败')
} finally {
exportLoading.value = false
}
}
// 导入相关方法
const openImportDialog = () => {
importDialogVisible.value = true
}
const closeImportDialog = () => {
importDialogVisible.value = false
importJsonContent.value = ''
importPreviewData.value = null
previewMenuTreeData.value = []
previewApiTreeData.value = []
// 清理上传文件
if (uploadRef.value) {
uploadRef.value.clearFiles()
}
}
// 文件上传处理函数
const handleFileChange = (file) => {
if (!file.raw) return
// 验证文件类型
if (!file.name.toLowerCase().endsWith('.json')) {
ElMessage.error('只能上传JSON文件')
uploadRef.value.clearFiles()
return
}
// 读取文件内容
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target.result
// 验证JSON格式
JSON.parse(content)
importJsonContent.value = content
handleJsonContentChange()
ElMessage.success('文件上传成功')
} catch (error) {
ElMessage.error('JSON文件格式错误')
uploadRef.value.clearFiles()
}
}
reader.readAsText(file.raw)
}
const handleFileRemove = () => {
importJsonContent.value = ''
importPreviewData.value = null
previewMenuTreeData.value = []
previewApiTreeData.value = []
}
// 计算菜单总数(递归计算所有菜单项)
const getTotalMenuCount = () => {
if (!importPreviewData.value?.menus) return 0
const countMenus = (menus) => {
let count = 0
menus.forEach(menu => {
count += 1 // 当前菜单
if (menu.children && menu.children.length > 0) {
count += countMenus(menu.children) // 递归计算子菜单
}
})
return count
}
return countMenus(importPreviewData.value.menus)
}
const handleJsonContentChange = () => {
if (!importJsonContent.value.trim()) {
importPreviewData.value = null
previewMenuTreeData.value = []
previewApiTreeData.value = []
previewDictTreeData.value = []
return
}
try {
const data = JSON.parse(importJsonContent.value)
// 构建预览数据
importPreviewData.value = {
menus: data.menus || [],
apis: data.apis || [],
dictionaries: data.dictionaries || []
}
// 直接使用菜单数据因为它已经是树形结构包含children字段
if (data.menus && data.menus.length > 0) {
previewMenuTreeData.value = data.menus
} else {
previewMenuTreeData.value = []
}
// 构建API树形数据按分组组织
if (data.apis && data.apis.length > 0) {
const apiGroups = {}
data.apis.forEach(api => {
const group = api.apiGroup || '未分组'
if (!apiGroups[group]) {
apiGroups[group] = {
ID: `group_${group}`,
description: group,
path: '',
method: '',
children: []
}
}
apiGroups[group].children.push(api)
})
previewApiTreeData.value = Object.values(apiGroups)
} else {
previewApiTreeData.value = []
}
// 处理字典数据
if (data.dictionaries && data.dictionaries.length > 0) {
previewDictTreeData.value = data.dictionaries
} else {
previewDictTreeData.value = []
}
} catch (error) {
console.error('JSON解析失败:', error)
importPreviewData.value = null
previewMenuTreeData.value = []
previewApiTreeData.value = []
previewDictTreeData.value = []
}
}
const handleImport = async () => {
if (!importJsonContent.value.trim()) {
ElMessage.warning('请输入版本JSON')
return
}
try {
JSON.parse(importJsonContent.value)
} catch (error) {
ElMessage.error('JSON格式错误请检查输入内容')
return
}
importLoading.value = true
try {
const data = JSON.parse(importJsonContent.value)
const res = await importVersion(data)
if (res.code === 0) {
ElMessage.success('导入成功')
closeImportDialog()
getTableData() // 刷新表格数据
} else {
ElMessage.error(res.msg || '导入失败')
}
} catch (error) {
console.error('导入失败:', error)
ElMessage.error('导入失败')
} finally {
importLoading.value = false
}
}
// 下载版本JSON
const downloadJson = async (row) => {
try {
const res = await downloadVersionJson({ ID: row.ID })
// 处理axios响应获取实际的blob数据
// 当responseType为blob时axios拦截器会返回完整的response对象
let blob
if (res instanceof Blob) {
blob = res
} else if (res.data instanceof Blob) {
blob = res.data
} else {
// 如果不是blob可能是错误响应尝试从response中获取
blob = res
}
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${row.versionName}_${row.versionCode}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('下载成功')
} catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败')
}
}
</script>
<style scoped>
/* Element Plus 树形组件样式优化 */
:deep(.el-tree) {
background-color: transparent;
}
:deep(.el-tree-node__content) {
height: 32px;
line-height: 32px;
}
:deep(.el-tree-node__label) {
font-size: 14px;
}
:deep(.el-scrollbar__view) {
padding: 0;
}
</style>

26
uno.config.js Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from '@unocss/vite';
import presetWind3 from '@unocss/preset-wind3';
import transformerDirectives from '@unocss/transformer-directives'
export default defineConfig({
theme: {
backgroundColor: {
main: '#F5F5F5'
},
textColor: {
active: 'var(--el-color-primary)'
},
boxShadowColor: {
active: 'var(--el-color-primary)'
},
borderColor: {
'table-border': 'var(--el-border-color-lighter)'
}
},
presets: [
presetWind3({ dark: 'class' })
],
transformers: [
transformerDirectives(),
],
})

View File

@@ -1,4 +1,5 @@
import legacyPlugin from '@vitejs/plugin-legacy' import legacyPlugin from '@vitejs/plugin-legacy'
import { viteLogo } from './src/core/config'
import Banner from 'vite-plugin-banner' import Banner from 'vite-plugin-banner'
import * as path from 'path' import * as path from 'path'
import * as dotenv from 'dotenv' import * as dotenv from 'dotenv'
@@ -8,6 +9,8 @@ import vueDevTools from 'vite-plugin-vue-devtools'
import VueFilePathPlugin from './vitePlugin/componentName/index.js' import VueFilePathPlugin from './vitePlugin/componentName/index.js'
import { svgBuilder } from 'vite-auto-import-svg' import { svgBuilder } from 'vite-auto-import-svg'
import { AddSecret } from './vitePlugin/secret' import { AddSecret } from './vitePlugin/secret'
import UnoCSS from '@unocss/vite'
// @see https://cn.vitejs.dev/config/ // @see https://cn.vitejs.dev/config/
export default ({ mode }) => { export default ({ mode }) => {
AddSecret('') AddSecret('')
@@ -20,6 +23,8 @@ export default ({ mode }) => {
} }
} }
viteLogo(process.env)
const timestamp = Date.parse(new Date()) const timestamp = Date.parse(new Date())
const optimizeDeps = {} const optimizeDeps = {}
@@ -39,9 +44,13 @@ export default ({ mode }) => {
} }
} }
const base = "/"
const root = "./"
const outDir = "dist"
const config = { const config = {
base: '/', // 编译后js导入的资源路径 base: base, // 编译后js导入的资源路径
root: './', // index.html文件所在位置 root: root, // index.html文件所在位置
publicDir: 'public', // 静态资源文件夹 publicDir: 'public', // 静态资源文件夹
resolve: { resolve: {
alias alias
@@ -70,14 +79,13 @@ export default ({ mode }) => {
rewrite: (path) => rewrite: (path) =>
path.replace(new RegExp('^' + process.env.VITE_BASE_API), '') path.replace(new RegExp('^' + process.env.VITE_BASE_API), '')
} }
}, }
allowedHosts: ['all','lckt.echol.top','lckt.hnlc5588.cn'],
}, },
build: { build: {
minify: 'terser', // 是否进行压缩,boolean | 'terser' | 'esbuild',默认使用terser minify: 'terser', // 是否进行压缩,boolean | 'terser' | 'esbuild',默认使用terser
manifest: false, // 是否产出manifest.json manifest: false, // 是否产出manifest.json
sourcemap: false, // 是否产出sourcemap.json sourcemap: false, // 是否产出sourcemap.json
outDir: 'dist', // 产出目录 outDir: outDir, // 产出目录
terserOptions: { terserOptions: {
compress: { compress: {
//生产环境时移除console //生产环境时移除console
@@ -103,10 +111,10 @@ export default ({ mode }) => {
] ]
}), }),
vuePlugin(), vuePlugin(),
svgBuilder('./src/assets/icons/'), svgBuilder(['./src/plugin/','./src/assets/icons/'],base, outDir,'assets', NODE_ENV),
svgBuilder('./src/plugin/'),
[Banner(`\n Build based on gin-vue-admin \n Time : ${timestamp}`)], [Banner(`\n Build based on gin-vue-admin \n Time : ${timestamp}`)],
VueFilePathPlugin('./src/pathInfo.json') VueFilePathPlugin('./src/pathInfo.json'),
UnoCSS()
] ]
} }
return config return config