feat: WWJCloud 企业级全栈框架 v0.3.5 完整更新

🚀 核心更新:
-  完善 NestJS 企业级架构设计
-  优化配置中心和基础设施层
-  增强第三方服务集成能力
-  完善多租户架构支持
- 🎯 对标 Java Spring Boot 和 PHP ThinkPHP

📦 新增文件:
- wwjcloud-nest 完整框架结构
- Docker 容器化配置
- 管理后台界面
- 数据库迁移脚本

🔑 Key: ebb38b43ec39f355f071294fd1cf9c42
This commit is contained in:
wanwu
2025-10-13 01:27:37 +08:00
parent 10bcd7f624
commit 1ed0085d15
1697 changed files with 260750 additions and 127 deletions

View File

@@ -0,0 +1,67 @@
<template>
<el-aside class="layout-aside dark w-auto">
<el-scrollbar>
<side class="hidden-xs-only slide" />
</el-scrollbar>
</el-aside>
</template>
<script lang="ts" setup>
import { watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import side from './side.vue'
import useSystemStore from '@/stores/modules/system'
const systemStore = useSystemStore()
const dark = computed(() => {
return systemStore.dark
})
const route = useRoute()
watch(route, () => {
systemStore.$patch(state => {
state.menuDrawer = false
})
})
</script>
<style lang="scss">
.layout-aside {
//--side-dark-color: #141414;
//background-color: var(--side-dark-color, var(--el-bg-color));
&.bright {
// background-color: #F5F7F9;
li {
// background-color: #F5F7F9;
&.is-active:not(.is-opened) {
position: relative;
color: #333;
// background-color: #fff;
&::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 2px;
//background-color: var(--el-menu-active-color);
}
}
}
}
.slide {
border-right: 1px solid var(--el-border-color-extra-light);
}
}
.aside-drawer {
.el-drawer__body {
padding: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<template v-if="meta.show">
<el-sub-menu v-if="meta.type == 0 && routes.children" :index="String(routes.name)">
<template #title>
<div class="flex h-full flex-col items-center justify-center" v-if="systemStore.menuIsCollapse && props.level == 1">
<icon v-if="meta.icon" :name="meta.icon" :size="'20px'" class="leading-none"/>
<div class="leading-none mt-[5px]">{{ meta.short_title || meta.title }}</div>
</div>
<template v-else>
<icon v-if="meta.icon && props.level != 1" :name="meta.icon" :size="props.level == 1 ? '20px' : '16px'" class="absolute" />
</template>
<span class="text-[14px]" :class="{ 'font-bold': props.level === 1 , ' ml-[20px]': (meta.icon && props.level != 1) }">{{ meta.title }}</span>
</template>
<menu-item v-for="(route, index) in routes.children" :routes="route" :key="index" :level="props.level + 1" />
</el-sub-menu>
<template v-else>
<el-menu-item :index="String(routes.name)" @click="router.push({ name: routes.name })" >
<div class="flex h-full flex-col items-center justify-center" v-if="systemStore.menuIsCollapse && props.level == 1">
<icon v-if="meta.icon" :name="meta.icon" :size="'20px'" class="leading-none"/>
<div class="leading-none mt-[5px]">{{ meta.short_title || meta.title }}</div>
</div>
<template v-else>
<icon v-if="meta.icon && props.level != 1" :name="meta.icon" :size="props.level == 1 ? '20px' : '16px'" class="absolute" />
</template>
<template #title>
<span class="text-[14px]" :class="{ 'font-bold': props.level === 1 , ' ml-[20px]': (meta.icon && props.level != 1) }">{{ meta.title }}</span>
</template>
</el-menu-item>
</template>
</template>
</template>
<script lang="ts" setup>
import { useRouter, useRoute } from 'vue-router'
import { ref, computed, watch } from 'vue'
import menuItem from './menu-item.vue'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const routers = useUserStore().routers
const props = defineProps({
routes: {
type: Object,
required: true
},
level: {
type: Number,
default: 1
}
})
const systemStore = useSystemStore()
const meta = computed(() => props.routes.meta)
const addons = computed(() => {
const addons:Record<string, any> = {}
userStore.siteInfo?.apps.forEach((item: any) => { addons[item.key] = item })
userStore.siteInfo?.site_addons.forEach((item: any) => { addons[item.key] = item })
return addons
})
const systemAddonKeys = computed(() => {
return userStore.siteInfo?.site_addons.map((item: any) => item.key)
})
const addonRouters: Record<string, any> = {}
routers.forEach(item => {
item.original_name = item.name
if (item.meta.addon) {
addonRouters[item.meta.addon] = item
}
})
const addonsMenus = ref(null)
watch(route, () => {
if (props.routes.name != 'addon_list') return
if (systemAddonKeys.value.includes(route.meta.addon) && addonRouters[route.meta.addon]) {
addonsMenus.value = addonRouters[route.meta.addon]
} else {
addonsMenus.value = null
}
}, { immediate: true })
</script>
<style lang="scss">
.el-sub-menu{
.el-icon{
// width: auto;
}
}
.el-menu {
.el-sub-menu__title,
.el-menu-item {
&:hover {
// background-color: #F1F5FF !important;
color: var(--el-color-primary);
}
}
.el-icon.el-sub-menu__icon-arrow{
font-size: 15px;
top: 50%;
}
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<el-container class="flex flex-col">
<el-main class="menu-wrap">
<el-scrollbar class="menu-scrollbar flex-1 h-0">
<el-menu :default-active="route.name" :default-openeds="defaultOpeneds" :router="true" :unique-opened="false" :collapse="systemStore.menuIsCollapse">
<menu-item v-for="(route, index) in menuData" :routes="route" :key="index" />
</el-menu>
<div class="h-[48px]"></div>
</el-scrollbar>
</el-main>
</el-container>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import useSystemStore from '@/stores/modules/system'
import useUserStore from '@/stores/modules/user'
import menuItem from './menu-item.vue'
import { findFirstValidRoute } from '@/router/routers'
const systemStore = useSystemStore()
const userStore = useUserStore()
const route = useRoute()
const siteInfo = userStore.siteInfo
const routers = userStore.routers
const addonIndexRoute = userStore.addonIndexRoute
const menuData = ref<Record<string, any>[]>([])
const addonRouters: Record<string, any> = {}
const website = computed(() => {
return systemStore.website
})
// 默认打开的菜单项路径数组
const defaultOpeneds = ref<string[]>([])
// 在组件挂载后,生成默认打开的菜单项路径数组
onMounted(() => {
})
routers.forEach(item => {
item.original_name = item.name
if (item.meta.addon == '') {
if (item.children && item.children.length) {
item.name = findFirstValidRoute(item.children)
}
menuData.value.push(item)
} else if (item.meta.addon != '' && siteInfo?.apps.length == 1 && siteInfo?.apps[0].key == item.meta.addon) {
if (item.children) {
item.children.forEach((citem: Record<string, any>) => {
citem.original_name = citem.name
if (citem.children && citem.children.length) {
citem.name = findFirstValidRoute(citem.children)
}
})
menuData.value.unshift(...item.children)
} else {
menuData.value.unshift(item)
}
} else {
addonRouters[item.meta.addon] = item
}
})
// 多应用时将应用插入菜单
if (siteInfo?.apps.length > 1) {
const routers:Record<string, any>[] = []
siteInfo?.apps.forEach((item: Record<string, any>) => {
if (addonRouters[item.key]) {
addonRouters[item.key].name = addonIndexRoute[item.key]
routers.push(addonRouters[item.key])
}
})
menuData.value.unshift(...routers)
}
defaultOpeneds.value = menuData.value.map(item => item.name)
</script>
<style lang="scss">
.logo-wrap {
// background: #1f2531;
background: #fff;
transition: transform getCssVar('transition-duration');
}
:root{
--aside-width: 200px;
}
.menu-wrap {
padding: 0!important;
// background: #1f2531;
// background: #fff;
display: flex;
flex-direction: column;
.el-menu {
border-right: 0!important;
&:not(.el-menu--collapse) {
width: var(--aside-width);
}
.el-menu-item, .el-sub-menu__title {
--el-menu-item-height: 40px;
}
.el-sub-menu .el-menu-item {
--el-menu-sub-item-height: 40px;
}
.el-menu-item.is-active {
background: var(--el-color-primary) !important;
color: #fff !important;
}
&.el-menu--inline {
// background: #282e3a;
// background: #fff;
}
.el-menu-item .el-menu-tooltip__trigger{
display: flex;
justify-content: center;
align-items: center;
}
&.el-menu--collapse {
.el-menu-item, .el-sub-menu__title {
--el-menu-item-height: 60px;
justify-content: center;
}
}
}
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<el-container class="h-[64px] layout-admin flex items-center justify-between px-[15px]" >
<!-- :class="['h-full px-[10px]',{'layout-header border-b border-color': !dark}]" -->
<div class="flex items-center">
<div class="max-w-[120px] flex justify-center items-center flex-shrink-0">
<div class="max-w-[120px] h-[40px] overflow-hidden">
<el-image class="w-full h-full" :src="img(website.icon)" fit="contain">
<template #error>
<div class="flex justify-center items-center w-full h-full"><img class="max-w-[120px]" src="@/app/assets/images/logo.default.png" alt="" object-fit="contain"></div>
</template>
</el-image>
</div>
</div>
<div class="left-panel flex items-center text-[14px] leading-[1]">
<div class="navbar-item flex items-center h-full cursor-pointer" @click="toggleMenuCollapse">
<icon name="element Expand" v-if="systemStore.menuIsCollapse" />
<icon name="element Fold" v-else />
</div>
<!-- 刷新当前页 -->
<div class="navbar-item flex items-center h-full cursor-pointer" @click="refreshRouter">
<icon name="element Refresh" />
</div>
<!-- 面包屑导航 -->
<div class="flex items-center h-full pl-[10px] hidden-xs-only">
<el-breadcrumb separator="/">
<el-breadcrumb-item v-for="(route, index) in breadcrumb" :key="index" :to="route.path" class="inter">{{route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
</div>
<div>
<div class="right-panel h-full flex items-center justify-end">
<div class="border-primary border-[1px] h-[30px] px-[15px] flex items-center rounded-[6px] mr-[10px] cursor-pointer" @click="toHome()">
<span class="iconfont iconguanliduan !text-primary mr-1"></span>
<span class="text-[14px] text-primary">客户端</span>
</div>
<div class="navbar-item flex items-center h-full cursor-pointer">
<message />
</div>
<div class="navbar-item flex items-center h-full cursor-pointer">
<layout-setting />
</div>
<!-- 用户信息 -->
<div class="navbar-item flex items-center h-full cursor-pointer">
<user-info />
</div>
</div>
</div>
<input type="hidden" v-model="comparisonToken">
<input type="hidden" v-model="comparisonSiteId">
<el-dialog v-model="detectionLoginDialog" :title="t('layout.detectionLoginTip')" width="30%" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<span>{{ t('layout.detectionLoginContent') }}</span>
<template #footer>
<span class="dialog-footer">
<el-button @click="detectionLoginFn">{{ t('layout.detectionLoginOperation') }}</el-button>
</span>
</template>
</el-dialog>
<el-dialog v-model="showDialog" :title="t('indexTemplate')" width="550px" :destroy-on-close="true" >
<div class="flex flex-wrap">
<div v-for="(items, index) in indexList" :key="index" v-if="index_path == ''">
<div @click="index_path = items.view_path" class="index-item py-[5px] px-[10px] mr-[10px] rounded-[3px] cursor-pointer" :class="items.is_use == 1 ? 'bg-primary text-[#fff]' : '' ">
<span >{{ items.name }}</span>
</div>
</div>
<div v-for="(itemTo, indexTo) in indexList" :key="indexTo" v-else>
<div @click="index_path = itemTo.view_path" class="index-item py-[5px] px-[10px] mr-[10px] rounded-[3px] cursor-pointer" :class="index_path == itemTo.view_path ? 'bg-primary text-[#fff]' : '' ">
<span >{{ itemTo.name }}</span>
</div>
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button type="primary" @click="submitIndex">{{ t('confirm') }}</el-button>
</span>
</template>
</el-dialog>
</el-container>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import useUserStore from '@/stores/modules/user'
import useAppStore from '@/stores/modules/app'
import { useRoute,useRouter } from 'vue-router'
import { t } from '@/lang'
import { img, getToken } from '@/utils/common'
import storage from '@/utils/storage'
import userInfo from './user-info.vue'
import layoutSetting from './layout-setting.vue'
import message from './message.vue'
import useSystemStore from "@/stores/modules/system";
const showDialog = ref<boolean>(false)
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const systemStore = useSystemStore()
const website = computed(() => {
return systemStore.website
})
// 检测登录 start
const detectionLoginDialog = ref(false)
const comparisonToken = ref('')
const comparisonSiteId = ref('')
if (storage.get('comparisonTokenStorage')) {
comparisonToken.value = storage.get('comparisonTokenStorage')
}
if (storage.get('comparisonSiteIdStorage')) {
comparisonSiteId.value = storage.get('comparisonSiteIdStorage')
}
// 监听标签页面切换
document.addEventListener('visibilitychange', e => {
if (document.visibilityState === 'visible' && (comparisonSiteId.value != storage.get('siteId') || comparisonToken.value != storage.get('token'))) {
detectionLoginDialog.value = true
}
})
systemStore.toggleMenuCollapse(storage.get('menuiscollapse') || false)
const detectionLoginFn = () => {
detectionLoginDialog.value = false
location.reload()
}
// 检测登录 end
// 刷新路由
const refreshRouter = () => {
if (!appStore.routeRefreshTag) return
appStore.refreshRouterView()
}
// 面包屑导航
const breadcrumb = computed(() => {
const matched = route.matched.filter(item => { return item.meta.title })
if (matched[0] && matched[0].path == '/') matched.splice(0, 1)
return matched
})
storage.set({ key: 'currHeadMenuName', data: "" })
const toggleMenuCollapse = () => {
systemStore.toggleMenuCollapse(!systemStore.menuIsCollapse)
}
const toHome = () => {
if (!window.localStorage.getItem('site.token')) {
window.localStorage.setItem('site.token', getToken())
window.localStorage.setItem('site.comparisonTokenStorage', getToken())
}
if (!window.localStorage.getItem('site.userinfo')) {
window.localStorage.setItem('site.userinfo', JSON.stringify(useUserStore().userInfo))
}
router.push({ path: '/home/index'})
}
</script>
<style lang="scss" scoped>
.layout-header{
position: relative;
z-index: 5;
border-bottom: 1px solid #e8e9eb;
}
.navbar-item {
padding: 0 8px;
}
.index-item {
border: 1px solid;
border-color: var(--el-color-primary);
&:hover {
color: #fff;
background-color: var(--el-color-primary);
}
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="flex">
<icon name="element Setting" @click="drawer = true" />
<el-drawer v-model="drawer" :title="t('layout.layoutSetting')" size="300px">
<el-scrollbar>
<!-- 黑暗模式 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.darkMode') }}</div>
<div>
<el-switch v-model="dark" :active-value="true" :inactive-value="false" />
</div>
</div>
<!-- 主题颜色 -->
<div class="setting-item flex items-center justify-between mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.themeColor') }}</div>
<div>
<el-color-picker v-model="theme" />
</div>
</div>
<!-- 布局风格 -->
<div class="setting-item mb-[10px]">
<div class="title text-base text-tx-secondary">{{ t('layout.layoutStyle') }}</div>
<div class="flex mt-[10px] layout-style flex-wrap">
<div class="relative w-[125px] h-[100px] border mr-[10px] mb-[10px] hover:border-primary"
:class="{ 'border-primary': currLayout == item.key }" v-for="(item, index) in layouts"
@click="handleSetLayout(item.key)">
<img :src="item.image" alt="" class="w-full h-full">
</div>
</div>
</div>
</el-scrollbar>
</el-drawer>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import useSystemStore from '@/stores/modules/system'
import { useDark, useToggle } from '@vueuse/core'
import { setThemeColor, img } from '@/utils/common'
import { t } from '@/lang'
import Storage from '@/utils/storage'
const layouts = ref([
{ key: 'admin', image: img('static/resource/images/system/layout_bussiness.png') },
{ key: 'admin_simplicity', image: img('static/resource/images/system/layout_darkside.png') }
])
const currLayout = ref(Storage.get('admin_layout') || 'admin')
const drawer = ref(false)
const systemStore = useSystemStore()
const isDark = useDark()
const toggleDark = useToggle(isDark)
const dark = computed({
get() {
return systemStore.dark
},
set(val) {
systemStore.setTheme('dark', val)
toggleDark(val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const sidebar = computed({
get() {
return systemStore.sidebar
},
set(val) {
systemStore.setTheme('sidebar', val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const theme = computed({
get() {
return systemStore.theme
},
set(val) {
systemStore.setTheme('theme', val)
setThemeColor(systemStore.theme, systemStore.dark ? 'dark' : 'light')
}
})
const handleSetLayout = (key: string) => {
Storage.set({ key: 'admin_layout', data: key })
location.reload()
}
</script>
<style lang="scss" scoped>
:deep(.el-drawer__header) {
margin-bottom: 0 !important;
}
.layout-style {
&>div:nth-child(2n+2) {
margin-right: 0;
}
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<el-popover class="box-item" :width="322">
<template #reference>
<div class="relative">
<icon name="iconfont iconFramec-1" />
<span v-if="showRedDot" class="absolute top-[-3px] right-[-5px] w-[12px] h-[12px] rounded-full bg-[#DA203E] text-white text-[12px] flex justify-center items-center">1</span>
</div>
</template>
<div class="flex items-center bg-[#F8FAFF] p-[10px] rounded-[8px]" v-if="showRedDot">
<div class="w-[36px] h-[36px] rounded-full flex justify-center items-center">
<img src="@/app/assets/images/index/cloud.png" alt="" class="w-[36px] h-[36px]" />
</div>
<div class="py-[3px] ml-[5px] flex-1">
<div class="text-[16px] font-bold text-[#1D1F3A] mb-[5px]">云编译</div>
<div class="text-[12px] text-[#4F516D] flex justify-between items-center">
<span>有正在执行的编译任务</span>
<span class="text-primary cursor-pointer ml-auto" @click="cloudBuildRef?.elNotificationClick()">点击查看</span>
</div>
</div>
</div>
<div class="flex items-center justify-center" v-else>
<img src="@/app/assets/images/index/message_empty.png" alt="">
</div>
</el-popover>
<!-- <i class="iconfont iconFramec-1 cursor-pointer" title="消息" v-else></i> -->
<cloud-build ref="cloudBuildRef"/>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import CloudBuild from "@/app/components/cloud-build/index.vue"
const cloudBuildRef = ref<any>(null)
const showRedDot = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
const startPolling = () => {
if (pollTimer) return
cloudBuildRef.value?.getCloudBuildTaskFn()
pollTimer = setInterval(() => {
const startTime = localStorage.getItem('cloud_build_task')
showRedDot.value = !!startTime
if (!startTime) {
stopPolling()
}
}, 1000)
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
startPolling()
</script>
<style lang="scss" scoped>
:deep(.box-item .el-popover.el-popper){
padding: 13px 16px !important;
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<el-dropdown @command="switchLang" :tabindex="1">
<icon name="iconfont iconfanyi" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-cn" :disabled="systemStore.lang == 'zh-cn'">简体中文</el-dropdown-item>
<el-dropdown-item command="en" :disabled="systemStore.lang == 'en'">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script lang="ts" setup>
import useSystemStore from '@/stores/modules/system'
import { language } from '@/lang'
import { useRoute } from 'vue-router'
import storage from '@/utils/storage'
const route = useRoute()
const systemStore = useSystemStore()
const switchLang = (command: string) => {
systemStore.$patch((state) => {
state.lang = command
storage.set({ key: 'lang', data: command })
})
language.loadLocaleMessages(route.path, systemStore.lang)
location.reload()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,159 @@
<template>
<div>
<el-dropdown @command="clickEvent" :tabindex="1">
<div class="userinfo flex h-full items-center">
<el-avatar v-if="userStore.userInfo.head_img" :size="25" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[25px] rounded-full" />
<div class="user-name pl-[8px]">{{ userStore.userInfo.username }}</div>
<icon name="element ArrowDown" class="ml-[5px]" />
</div>
<template #dropdown>
<div class="p-[10px]">
<div class="userinfo flex h-full items-center pb-[10px] border-b-[1px] border-solid border-[#e5e5e5]">
<el-avatar v-if="userStore.userInfo.head_img" :size="45" :icon="UserFilled" :src="img(userStore.userInfo.head_img)"/>
<img v-else src="@/app/assets/images/member_head.png" class="w-[45px] rounded-full" />
<div>
<div class="user-name pl-[8px] text-[14px]">{{ userStore.userInfo.username }}</div>
<div class="pl-[8px] text-[13px] text-[#9699B6]">个人中心</div>
</div>
</div>
<el-dropdown-menu>
<el-dropdown-item @click="getUserInfoFn" class="rounded-[4px]">
<!-- <router-link to="/user/center"> -->
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconshezhi1 ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">账号设置</span>
</div>
<!-- </router-link> -->
</el-dropdown-item>
<el-dropdown-item class="rounded-[4px]">
<router-link to="/tools/authorize">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconshouquanxinxi2 ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">授权信息</span>
</div>
</router-link>
</el-dropdown-item>
<el-dropdown-item @click="changePasswordDialog=true" class="rounded-[4px]">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont iconxiugai ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">修改密码</span>
</div>
</el-dropdown-item>
<el-dropdown-item @click="logout" class="rounded-[4px]">
<div class="flex items-center leading-[1] py-[5px]">
<span class="iconfont icontuichudenglu ml-[4px] !text-[14px] mr-[10px]"></span>
<span class="text-[14px]">退出登录</span>
</div>
</el-dropdown-item>
</el-dropdown-menu>
</div>
</template>
</el-dropdown>
<el-dialog v-model="changePasswordDialog" width="450px" title="修改密码">
<div>
<el-form :model="saveInfo" label-width="90px" ref="formRef" :rules="formRules" class="page-form">
<el-form-item :label="t('originalPassword')" prop="original_password">
<el-input v-model="saveInfo.original_password" type="password" :placeholder="t('originalPasswordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
<el-form-item :label="t('newPassword')" prop="password">
<el-input v-model="saveInfo.password" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
<div class="form-tip">{{t('passwordTip')}}</div>
</el-form-item>
<el-form-item :label="t('passwordCopy')" prop="password_copy">
<el-input v-model="saveInfo.password_copy" type="password" :placeholder="t('passwordPlaceholder')" clearable class="input-width" maxlength="40" />
</el-form-item>
</el-form>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="changePasswordDialog = false">{{t('cancel')}}</el-button>
<el-button type="primary" @click="submitForm(formRef)">{{t('save')}}</el-button>
</span>
</template>
</el-dialog>
<user-info-edit ref="userInfoEditRef" />
</div>
</template>
<script lang="ts" setup>
import { UserFilled } from '@element-plus/icons-vue'
import { reactive, ref } from 'vue'
import type { FormInstance, FormRules, ElNotification } from 'element-plus'
import useUserStore from '@/stores/modules/user'
import { setUserInfo } from '@/app/api/personal'
import { t } from '@/lang'
import { img } from '@/utils/common'
import userInfoEdit from '@/app/components/user-info-edit/index.vue'
const userStore = useUserStore()
const clickEvent = (command: string) => {
switch (command) {
case 'logout':
userStore.logout()
break
}
}
const logout = () => {
userStore.logout()
}
const userInfoEditRef = ref(null)
const getUserInfoFn = () => {
userInfoEditRef.value?.open()
}
// 修改密码 --- start
const changePasswordDialog = ref(false)
const formRef = ref<FormInstance>()
// 提交信息
const saveInfo = reactive({
original_password: '',
password: '',
password_copy: ''
})
// 表单验证规则
const formRules = reactive<FormRules>({
original_password: [
{ required: true, message: t('originalPasswordPlaceholder'), trigger: 'blur' }
],
password: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
],
password_copy: [
{ required: true, message: t('passwordPlaceholder'), trigger: 'blur' }
]
})
const submitForm = (formEl: FormInstance | undefined) => {
if (!formEl) return
formEl.validate((valid) => {
if (valid) {
let msg = ''
if (saveInfo.password && !saveInfo.original_password) msg = t('originalPasswordHint')
if (saveInfo.password && saveInfo.original_password && !saveInfo.password_copy) msg = t('newPasswordHint')
if (saveInfo.password && saveInfo.original_password && saveInfo.password_copy && saveInfo.password != saveInfo.password_copy) msg = t('doubleCipherHint')
if (msg) {
ElNotification({
type: 'error',
message: msg
})
return
}
setUserInfo(saveInfo).then((res: any) => {
changePasswordDialog.value = false
})
} else {
return false
}
})
}
</script>
<style lang="scss" scoped>
.el-popper .el-dropdown-menu{
width: 150px;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div class="tab-wrap w-full px-[16px]">
<el-tabs :closable="tabbarStore.tabLength > 1" :model-value="route.path" @tab-click="tabClick" @tab-remove="removeTab">
<el-tab-pane v-for="(tab, key, index) in tabbarStore.tabs" :name="tab.path" :key="index">
<template #label>
<el-dropdown trigger="contextmenu" placement="bottom-start">
<span :class="{ 'text-primary': route.path == tab.path }" class="tab-name">{{ tab.title }}</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item icon="Back" :disabled="index == 0" @click="closeLeft(tab.path)">{{t('tabs.closeLeft') }}</el-dropdown-item>
<el-dropdown-item icon="Right" :disabled="index == (tabbarStore.tabLength - 1)" @click="closeRight(tab.path)">{{t('tabs.closeRight') }}</el-dropdown-item>
<el-dropdown-item icon="Close" :disabled="tabbarStore.tabLength == 1" @click="closeOther(tab.path)">{{t('tabs.closeOther') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script lang="ts" setup>
import { watch, onMounted } from 'vue'
import useTabbarStore from '@/stores/modules/tabbar'
import { useRoute, useRouter } from 'vue-router'
import { t } from '@/lang'
const tabbarStore = useTabbarStore()
const route = useRoute()
const router = useRouter()
onMounted(() => {
tabbarStore.addTab(route)
})
watch(route, (nval: any) => {
tabbarStore.addTab(nval)
})
/**
* 添加tab
* @param content
*/
const tabClick = (content: any) => {
const tabRoute = tabbarStore.tabs[content.props.name]
router.push({ path: tabRoute.path, query: tabRoute.query })
}
/**
* 移除tab
* @param content
*/
const removeTab = (content: any) => {
if (route.path == content) {
const tabs = Object.keys(tabbarStore.tabs)
router.push({ path: tabs[tabs.indexOf(content) - 1] })
}
tabbarStore.removeTab(content)
}
/**
* 关闭左侧
* @param path
*/
const closeLeft = (path: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(path) - 1; i >= 0; i--) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ path })
}
/**
* 关闭右侧
* @param path
*/
const closeRight = (path: string) => {
const tabs = Object.keys(tabbarStore.tabs)
for (let i = tabs.indexOf(path) + 1; i < tabs.length; i++) {
delete tabbarStore.tabs[tabs[i]]
}
router.push({ path })
}
/**
* 关闭其他
* @param path
*/
const closeOther = (path: string) => {
const tabs = Object.keys(tabbarStore.tabs)
tabs.forEach((key: string) => { key != path && delete tabbarStore.tabs[key] })
router.push({ path })
}
</script>
<style lang="scss" scoped>
:deep(.el-tabs) {
.el-tabs--border-card {
border: none;
}
.el-tabs__header {
margin: 0;
}
.el-tabs__nav-wrap {
margin-bottom: 0;
&::after {
display: none;
}
}
.el-tabs__content {
display: none;
}
.el-tabs__item {
display: inline-flex !important;
padding: 0 20px !important;
align-items: center;
.tab-name:focus {
outline: none !important;
}
}
.el-tabs__active-bar {
display: none;
}
.el-tabs__item.is-active {
background-color: var(--el-color-primary-light-9);
}
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div class="flex w-full h-screen">
<el-container>
<!-- 顶部 -->
<el-header>
<layout-header></layout-header>
</el-header>
<!-- 顶部 end -->
<el-container :style="{height:'calc(100vh - 64px)'}">
<!-- 左侧边栏 -->
<layout-aside></layout-aside>
<!-- 左侧边栏 end -->
<!-- 主体 -->
<el-main class="h-full p-0 bg-page">
<el-scrollbar>
<div class="p-[15px]">
<router-view v-slot="{ Component, route }" v-if="appStore.routeRefreshTag">
<keep-alive :include="tabbarStore.tabNames">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</div>
</el-scrollbar>
</el-main>
<!-- 主体 end -->
</el-container>
</el-container>
</div>
</template>
<script lang="ts" setup>
import layoutHeader from './components/header/index.vue'
import layoutAside from './components/aside/index.vue'
import useAppStore from '@/stores/modules/app'
import useTabbarStore from '@/stores/modules/tabbar'
const appStore = useAppStore()
const tabbarStore = useTabbarStore()
</script>
<style lang="scss" scoped></style>