Merge pull request #325 from fastapiadmin/2.2.0

2.2.0
This commit is contained in:
fastapiadmin
2026-03-15 00:56:30 +08:00
committed by GitHub
6 changed files with 110 additions and 81 deletions
@@ -1,3 +1,5 @@
from typing import Any
from app.api.v1.module_system.auth.schema import AuthSchema from app.api.v1.module_system.auth.schema import AuthSchema
from app.core.base_schema import BatchSetAvailable from app.core.base_schema import BatchSetAvailable
from app.core.exceptions import CustomException from app.core.exceptions import CustomException
@@ -84,7 +86,10 @@ class MenuService:
返回: 返回:
- dict: 创建的菜单对象。 - dict: 创建的菜单对象。
""" """
menu = await MenuCRUD(auth).get(name=data.name) search: dict[str, Any] = {"title": data.title}
if data.parent_id is not None:
search["parent_id"] = data.parent_id
menu = await MenuCRUD(auth).get(**search)
if menu: if menu:
raise CustomException(msg="创建失败,该菜单已存在") raise CustomException(msg="创建失败,该菜单已存在")
@@ -108,15 +113,18 @@ class MenuService:
menu = await MenuCRUD(auth).get_by_id_crud(id=id) menu = await MenuCRUD(auth).get_by_id_crud(id=id)
if not menu: if not menu:
raise CustomException(msg="更新失败,该菜单不存在") raise CustomException(msg="更新失败,该菜单不存在")
exist_menu = await MenuCRUD(auth).get(name=data.name) search: dict[str, Any] = {"title": data.title}
if data.parent_id is not None:
search["parent_id"] = data.parent_id
exist_menu = await MenuCRUD(auth).get(**search)
if exist_menu and exist_menu.id != id: if exist_menu and exist_menu.id != id:
raise CustomException(msg="更新失败,菜单名称重复") raise CustomException(msg="更新失败,菜单标题重复")
if data.parent_id: if data.parent_id:
parent_menu = await MenuCRUD(auth).get_by_id_crud(id=data.parent_id) parent_menu = await MenuCRUD(auth).get_by_id_crud(id=data.parent_id)
if not parent_menu: if not parent_menu:
raise CustomException(msg="更新失败,父级菜单不存在") raise CustomException(msg="更新失败,父级菜单不存在")
data.parent_name = parent_menu.name data.parent_name = parent_menu.title
new_menu = await MenuCRUD(auth).update(id=id, data=data) new_menu = await MenuCRUD(auth).update(id=id, data=data)
await cls.set_menu_available_service( await cls.set_menu_available_service(
+6 -6
View File
@@ -7,9 +7,9 @@ services:
restart: always restart: always
environment: environment:
TZ: "Asia/Shanghai" TZ: "Asia/Shanghai"
MYSQL_ROOT_PASSWORD: "FastApi123abc" MYSQL_ROOT_PASSWORD: "fastapiadmin"
MYSQL_DATABASE: "fastapiadmin" MYSQL_DATABASE: "fastapiadmin"
MYSQL_PASSWORD: "FastApi123abc" MYSQL_PASSWORD: "fastapiadmin"
MYSQL_USER: "fastapiadmin" MYSQL_USER: "fastapiadmin"
MYSQL_TZINFO_TO_SYS_TABLES: 1 # 初始化MySQL时区表,解决时差问题 MYSQL_TZINFO_TO_SYS_TABLES: 1 # 初始化MySQL时区表,解决时差问题
ports: ports:
@@ -24,7 +24,7 @@ services:
- app_network - app_network
# 健康检查:检测MySQL是否真正就绪,避免后端启动早于MySQL # 健康检查:检测MySQL是否真正就绪,避免后端启动早于MySQL
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pFastApi123abc"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pfastapiadmin"]
interval: 5s # 每5秒检测一次 interval: 5s # 每5秒检测一次
timeout: 30s # 超时时间30秒 timeout: 30s # 超时时间30秒
retries: 10 # 重试10次,失败则认为容器未就绪 retries: 10 # 重试10次,失败则认为容器未就绪
@@ -42,7 +42,7 @@ services:
volumes: volumes:
# 挂载Redis配置文件,实现自定义配置 # 挂载Redis配置文件,实现自定义配置
- ./devops/redis/data:/data - ./devops/redis/data:/data
command: redis-server --requirepass FastApi123abc command: redis-server --requirepass fastapiadmin
networks: networks:
- app_network - app_network
@@ -57,9 +57,9 @@ services:
environment: environment:
TZ: "Asia/Shanghai" TZ: "Asia/Shanghai"
DATABASE_HOST: "mysql" DATABASE_HOST: "mysql"
DATABASE_PASSWORD: "FastApi123abc" DATABASE_PASSWORD: "fastapiadmin"
REDIS_HOST: "redis" REDIS_HOST: "redis"
REDIS_PASSWORD: "FastApi123abc" REDIS_PASSWORD: "fastapiadmin"
ports: ports:
- "8001:8001" - "8001:8001"
volumes: volumes:
@@ -18,14 +18,10 @@
? variables['menu-active-text'] ? variables['menu-active-text']
: undefined : undefined
" "
@select="handleMenuSelect" @select="handleTopMenuSelect"
> >
<el-menu-item v-for="menuItem in processedTopMenus" :key="menuItem.path" :index="menuItem.path"> <el-menu-item v-for="item in topMenuItems" :key="item.path" :index="item.path">
<MenuItemContent <MenuItemContent v-if="item.meta" :icon="item.meta.icon" :title="item.meta.title" />
v-if="menuItem.meta"
:icon="menuItem.meta.icon"
:title="menuItem.meta.title"
/>
</el-menu-item> </el-menu-item>
</el-menu> </el-menu>
</template> </template>
@@ -57,7 +53,7 @@ const sidebarColorScheme = computed(() => settingsStore.sidebarColorScheme);
const topMenus = ref<RouteRecordRaw[]>([]); const topMenus = ref<RouteRecordRaw[]>([]);
// 处理后的顶部菜单列表 - 智能显示唯一子菜单的标题 // 处理后的顶部菜单列表 - 智能显示唯一子菜单的标题
const processedTopMenus = computed(() => { const topMenuItems = computed(() => {
return topMenus.value.map((route) => { return topMenus.value.map((route) => {
// 如果路由设置了 alwaysShow=true,或者没有子菜单,直接返回原路由 // 如果路由设置了 alwaysShow=true,或者没有子菜单,直接返回原路由
if (route.meta?.alwaysShow || !route.children || route.children.length === 0) { if (route.meta?.alwaysShow || !route.children || route.children.length === 0) {
@@ -89,7 +85,7 @@ const processedTopMenus = computed(() => {
* 处理菜单点击事件,切换顶部菜单并加载对应的左侧菜单 * 处理菜单点击事件,切换顶部菜单并加载对应的左侧菜单
* @param routePath 点击的菜单路径 * @param routePath 点击的菜单路径
*/ */
const handleMenuSelect = (routePath: string) => { const handleTopMenuSelect = (routePath: string) => {
updateMenuState(routePath); updateMenuState(routePath);
}; };
@@ -274,7 +274,7 @@ function logout() {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 44px; /* 增加最小点击区域到44px,符合人机交互标准 */ min-width: 44px; /* 增加最小点击区域到44px,符合人机交互标准 */
height: 100%; height: 44px;
padding: 0 8px; padding: 0 8px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
@@ -315,7 +315,7 @@ function logout() {
} }
&:hover { &:hover {
background: rgba(0, 0, 0, 0.04); background: var(--el-fill-color-light);
:deep([class^="i-svg:"]) { :deep([class^="i-svg:"]) {
color: var(--el-color-primary); color: var(--el-color-primary);
@@ -368,20 +368,20 @@ function logout() {
.navbar-actions--white-text { .navbar-actions--white-text {
.navbar-actions__item { .navbar-actions__item {
:deep([class^="i-svg:"]) { :deep([class^="i-svg:"]) {
color: rgba(255, 255, 255, 0.85); color: color-mix(in srgb, var(--el-color-white) 85%, transparent);
} }
&:hover { &:hover {
background: rgba(255, 255, 255, 0.1); background: color-mix(in srgb, var(--el-color-white) 10%, transparent);
:deep([class^="i-svg:"]) { :deep([class^="i-svg:"]) {
color: #fff; color: var(--el-color-white);
} }
} }
} }
.user-profile__name { .user-profile__name {
color: rgba(255, 255, 255, 0.85); color: color-mix(in srgb, var(--el-color-white) 85%, transparent);
} }
} }
+80 -54
View File
@@ -3,14 +3,20 @@
<!-- 顶部菜单栏 --> <!-- 顶部菜单栏 -->
<div class="layout__header"> <div class="layout__header">
<div class="layout__header-left"> <div class="layout__header-left">
<!-- Logo --> <!-- Logo区域 -->
<AppLogo v-if="isShowLogo" :collapse="isLogoCollapsed" /> <div v-if="isShowLogo" class="layout__header-logo">
<!-- 菜单 --> <AppLogo :collapse="isLogoCollapsed" />
<BasicMenu :data="routes" menu-mode="horizontal" base-path="" /> </div>
</div>
<!-- 操作按钮 --> <!-- 顶部菜单区域 -->
<div class="layout__header-right"> <div class="layout__header-menu">
<NavbarActions /> <BasicMenu :data="topMenuItems" menu-mode="horizontal" base-path="" />
</div>
<!-- 右侧操作区域 -->
<div class="layout__header-right">
<NavbarActions />
</div>
</div> </div>
</div> </div>
@@ -26,7 +32,7 @@
import { computed } from "vue"; import { computed } from "vue";
import { useWindowSize } from "@vueuse/core"; import { useWindowSize } from "@vueuse/core";
import { useLayout } from "../composables/useLayout"; import { useLayout } from "../composables/useLayout";
import { useLayoutMenu } from "../composables/useLayoutMenu"; import { usePermissionStore } from "@/store";
import BaseLayout from "./BaseLayout.vue"; import BaseLayout from "./BaseLayout.vue";
import AppLogo from "../components/AppLogo/index.vue"; import AppLogo from "../components/AppLogo/index.vue";
import BasicMenu from "../components/Menu/BasicMenu.vue"; import BasicMenu from "../components/Menu/BasicMenu.vue";
@@ -38,7 +44,10 @@ import AppMain from "../components/AppMain/index.vue";
const { isShowTagsView, isShowLogo } = useLayout(); const { isShowTagsView, isShowLogo } = useLayout();
// 菜单相关 // 菜单相关
const { routes } = useLayoutMenu(); const permissionStore = usePermissionStore();
const topMenuItems = computed(() => {
return permissionStore.routes.filter((item) => !item.meta?.hidden);
});
// 响应式窗口尺寸 // 响应式窗口尺寸
const { width } = useWindowSize(); const { width } = useWindowSize();
@@ -64,63 +73,80 @@ const isLogoCollapsed = computed(() => width.value < 768);
display: flex; display: flex;
flex: 1; flex: 1;
align-items: center; align-items: center;
min-width: 0; // 允许flex收缩 min-width: 0;
height: 100%;
}
&-logo {
display: flex;
flex-shrink: 0;
align-items: center;
height: 100%; height: 100%;
// Logo样式由AppLogo组件的全局样式控制
:deep(.logo) { :deep(.logo) {
flex-shrink: 0; // 防止Logo被压缩
height: $navbar-height; height: $navbar-height;
} }
} }
&-menu {
display: flex;
flex: 1;
align-items: center;
min-width: 0;
height: 100%;
overflow: hidden;
:deep(.el-menu) {
height: 100%;
background-color: transparent;
border: none;
}
:deep(.el-menu--horizontal) {
flex: 1;
min-width: 0;
height: $navbar-height;
overflow: hidden;
line-height: $navbar-height;
background-color: transparent;
border: none;
.el-menu-item {
height: $navbar-height;
line-height: $navbar-height;
}
.el-sub-menu {
.el-sub-menu__title {
height: $navbar-height;
line-height: $navbar-height;
}
&.has-active-child {
.el-sub-menu__title {
color: var(--el-color-primary) !important;
border-bottom: 2px solid var(--el-color-primary) !important;
.menu-icon {
color: var(--el-color-primary) !important;
}
}
}
}
.el-menu--popup {
min-width: 160px;
}
}
}
&-right { &-right {
display: flex; display: flex;
flex-shrink: 0; // 防止操作按钮被压缩 flex-shrink: 0;
align-items: center; align-items: center;
height: 100%; height: 100%;
padding-left: 12px; padding-left: 12px;
} }
// 菜单样式
:deep(.el-menu--horizontal) {
flex: 1;
min-width: 0; // 允许菜单收缩
height: $navbar-height;
overflow: hidden; // 防止菜单溢出
line-height: $navbar-height;
background-color: transparent;
border: none;
.el-menu-item {
height: $navbar-height;
line-height: $navbar-height;
}
.el-sub-menu {
.el-sub-menu__title {
height: $navbar-height;
line-height: $navbar-height;
}
// 父菜单激活状态 - 水平布局专用
&.has-active-child {
.el-sub-menu__title {
color: var(--el-color-primary) !important;
border-bottom: 2px solid var(--el-color-primary) !important;
.menu-icon {
color: var(--el-color-primary) !important;
}
}
}
}
// 修复子菜单弹出位置
.el-menu--popup {
min-width: 160px;
}
}
} }
&__main { &__main {
@@ -457,10 +457,9 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password"> <el-form-item v-if="!formData.id" label="密码" prop="password">
<el-input <el-input
v-model="formData.password" v-model="formData.password"
:readonly="!!formData.id"
placeholder="请输入密码" placeholder="请输入密码"
type="password" type="password"
show-password show-password