### 摘要

- 菜单表与接口增加终端维度 `client`(`pc` | `app`),与规范「PC 桌面 / APP 移动端」一致。不改已有授权逻辑。
- 管理端动态路由仅下发 `client=pc`;菜单管理页通过 Tab + 查询参数 `menu_client` 分端维护。
- 种子数据 `sys_menu.json` 为全量 PC 菜单补 `client: pc`,并内置 5 条 APP 顶级目录(首页/同事/打卡/消息/我的),图标使用项目 `src/assets/icons` 内已有名称。

### 后端
- `MenuModel` 增加 `client` 列(默认 `pc`)。
- Schema:`MenuCreateSchema` / `MenuOutSchema` 含 `client`;`MenuQueryParam` 增加 `menu_client` 过滤。
- `menu_request_validator` 校验 `client ∈ {pc,app}`;父子终端一致校验在 `MenuService` 创建/更新中执行。
- `get_current_user_info` 菜单树与角色菜单 ID 仅保留 PC 端,避免 APP 菜单进入 Vue Router。

### 前端
- 菜单管理:PC/APP Tab、`menu_client` 列表参数、表格/详情/表单「终端」、子级继承父级 `client`。
- API 类型与 `MenuClientEnum`;`permission.store` 路由 `meta.client`(可选扩展)。

### 数据库(已有库)
-postgres
```
ALTER TABLE sys_menu ADD COLUMN IF NOT EXISTS client VARCHAR(20) NOT NULL DEFAULT 'pc';
COMMENT ON COLUMN sys_menu.client IS '终端(pc:管理端桌面 app:移动端)';
```

-mysql
```
ALTER TABLE sys_menu ADD COLUMN client VARCHAR(20) NOT NULL DEFAULT 'pc' COMMENT '终端(pc/app)';

```

### 兼容与注意
- 角色授权抽屉仍调用**不带** `menu_client` 的菜单树,可同时勾选 PC 与 APP。不改已有逻辑。
- 仅改种子 JSON 不影响**已有数据**库;已建库需执行 DDL,APP 示例菜单需在库中已存在或通过初始化导入。
This commit is contained in:
luomgf
2026-04-27 14:09:58 +08:00
parent 842d1a5c98
commit d2a4e95357
10 changed files with 594 additions and 195 deletions
@@ -68,6 +68,13 @@ class MenuModel(ModelMixin):
nullable=False, nullable=False,
comment="是否固定标签页(True:是 False:否)", comment="是否固定标签页(True:是 False:否)",
) )
client: Mapped[str] = mapped_column(
String(20),
nullable=False,
default="pc",
server_default="pc",
comment="终端(pc:管理端桌面 app:移动端)",
)
# 树形结构 # 树形结构
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[int | None] = mapped_column(
@@ -32,6 +32,10 @@ class MenuCreateSchema(BaseModel):
parent_id: int | None = Field(default=None, ge=1, description="父菜单ID") parent_id: int | None = Field(default=None, ge=1, description="父菜单ID")
status: str = Field(default="0", description="是否启用(0:启用 1:禁用)") status: str = Field(default="0", description="是否启用(0:启用 1:禁用)")
description: str | None = Field(default=None, max_length=255, description="描述") description: str | None = Field(default=None, max_length=255, description="描述")
client: Literal["pc", "app"] = Field(
default="pc",
description="终端(pc:管理端桌面 app:移动端)",
)
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
@@ -53,6 +57,9 @@ class MenuCreateSchema(BaseModel):
values[k] = ( values[k] = (
values[k].strip() or None if values[k].strip() == "" else values[k].strip() values[k].strip() or None if values[k].strip() == "" else values[k].strip()
) )
if "client" in values and isinstance(values["client"], str):
cv = values["client"].strip()
values["client"] = cv if cv in ("pc", "app") else "pc"
# 父ID转整型 # 父ID转整型
if "parent_id" in values and isinstance(values["parent_id"], str): if "parent_id" in values and isinstance(values["parent_id"], str):
try: try:
@@ -124,6 +131,10 @@ class MenuQueryParam:
), ),
created_id: int | None = Query(None, description="创建人"), created_id: int | None = Query(None, description="创建人"),
updated_id: int | None = Query(None, description="更新人"), updated_id: int | None = Query(None, description="更新人"),
menu_client: Literal["pc", "app"] | None = Query(
None,
description="管理端 Tab:pc=桌面端菜单 app=移动端菜单;不传则不过滤终端",
),
) -> None: ) -> None:
# 模糊查询字段 # 模糊查询字段
self.name = (QueueEnum.like.value, name) self.name = (QueueEnum.like.value, name)
@@ -151,3 +162,6 @@ class MenuQueryParam:
self.created_id = (QueueEnum.eq.value, created_id) self.created_id = (QueueEnum.eq.value, created_id)
if updated_id: if updated_id:
self.updated_id = (QueueEnum.eq.value, updated_id) self.updated_id = (QueueEnum.eq.value, updated_id)
if menu_client in ("pc", "app"):
self.client = (QueueEnum.eq.value, menu_client)
@@ -50,6 +50,20 @@ class MenuService:
else: else:
raise CustomException(msg="菜单或链接类型下不允许新增子菜单") raise CustomException(msg="菜单或链接类型下不允许新增子菜单")
@classmethod
async def _validate_parent_child_client(
cls, auth: AuthSchema, parent_id: int | None, client: str
) -> None:
"""子菜单终端须与父菜单一致。"""
if parent_id is None:
return
parent = await MenuCRUD(auth).get_by_id_crud(id=parent_id)
if not parent:
return
p_client = getattr(parent, "client", None) or "pc"
if p_client != client:
raise CustomException(msg="子菜单终端须与父菜单一致(均为 pc 或均为 app)")
@classmethod @classmethod
async def get_menu_detail_service(cls, auth: AuthSchema, id: int) -> dict: async def get_menu_detail_service(cls, auth: AuthSchema, id: int) -> dict:
""" """
@@ -119,6 +133,7 @@ class MenuService:
raise CustomException(msg="创建失败,该菜单已存在") raise CustomException(msg="创建失败,该菜单已存在")
await cls._validate_parent_child_type(auth, data.parent_id, data.type) await cls._validate_parent_child_type(auth, data.parent_id, data.type)
await cls._validate_parent_child_client(auth, data.parent_id, data.client)
new_menu = await MenuCRUD(auth).create(data=data) new_menu = await MenuCRUD(auth).create(data=data)
new_menu_dict = MenuOutSchema.model_validate(new_menu).model_dump() new_menu_dict = MenuOutSchema.model_validate(new_menu).model_dump()
@@ -141,6 +156,7 @@ class MenuService:
if not menu: if not menu:
raise CustomException(msg="更新失败,该菜单不存在") raise CustomException(msg="更新失败,该菜单不存在")
await cls._validate_parent_child_type(auth, data.parent_id, data.type) await cls._validate_parent_child_type(auth, data.parent_id, data.type)
await cls._validate_parent_child_client(auth, data.parent_id, data.client)
search: dict[str, Any] = {"title": data.title} search: dict[str, Any] = {"title": data.title}
if data.parent_id is not None: if data.parent_id is not None:
search["parent_id"] = data.parent_id search["parent_id"] = data.parent_id
@@ -295,22 +295,25 @@ class UserService:
UserOutSchema.dept_name = user.dept.name UserOutSchema.dept_name = user.dept.name
user_dict = UserOutSchema.model_validate(user).model_dump() user_dict = UserOutSchema.model_validate(user).model_dump()
# 获取菜单权限 # 获取菜单权限(PC 管理端侧栏:仅 client=pc)
_pc_only = {"client": "pc"}
if auth.user and auth.user.is_superuser: if auth.user and auth.user.is_superuser:
# 使用树形结构查询,预加载children关系 # 使用树形结构查询,预加载children关系
menu_all = await MenuCRUD(auth).get_tree_list_crud( menu_all = await MenuCRUD(auth).get_tree_list_crud(
search={"type": ("in", [1, 2, 4]), "status": "0"}, search={"type": ("in", [1, 2, 4]), "status": "0", **_pc_only},
order_by=[{"order": "asc"}], order_by=[{"order": "asc"}],
) )
menus = [MenuOutSchema.model_validate(menu).model_dump() for menu in menu_all] menus = [MenuOutSchema.model_validate(menu).model_dump() for menu in menu_all]
else: else:
# 收集用户所有角色的菜单ID,使用列表推导式优化代码 # 收集用户所有角色的菜单 ID(仅 PC 端菜单参与动态路由)
menu_ids = { menu_ids = {
menu.id menu.id
for role in auth.user.roles or [] for role in auth.user.roles or []
for menu in role.menus for menu in role.menus
if menu.status == "0" and menu.type in [1, 2, 4] if menu.status == "0"
and menu.type in [1, 2, 4]
and getattr(menu, "client", "pc") == "pc"
} }
# 使用树形结构查询,预加载children关系 # 使用树形结构查询,预加载children关系
@@ -318,7 +321,7 @@ class UserService:
[ [
MenuOutSchema.model_validate(menu).model_dump() MenuOutSchema.model_validate(menu).model_dump()
for menu in await MenuCRUD(auth).get_tree_list_crud( for menu in await MenuCRUD(auth).get_tree_list_crud(
search={"id": ("in", list(menu_ids))}, search={"id": ("in", list(menu_ids)), **_pc_only},
order_by=[{"order": "asc"}], order_by=[{"order": "asc"}],
) )
] ]
+4
View File
@@ -257,6 +257,10 @@ def menu_request_validator(data: Any) -> Any:
if data.type == 2 and not data.component_path: if data.type == 2 and not data.component_path:
raise CustomException(code=RET.ERROR.code, msg="组件路径不能为空") raise CustomException(code=RET.ERROR.code, msg="组件路径不能为空")
c = getattr(data, "client", None) or "pc"
if c not in ("pc", "app"):
raise CustomException(code=RET.ERROR.code, msg="终端 client 仅允许 pc 或 app")
return data return data
File diff suppressed because it is too large Load Diff
+4
View File
@@ -58,6 +58,8 @@ export interface MenuPageQuery {
status?: string; status?: string;
created_time?: string[]; created_time?: string[];
updated_time?: string[]; updated_time?: string[];
/** 与后端 Query `menu_client` 一致:pc | app;菜单管理 Tab 切换时传入 */
menu_client?: "pc" | "app";
} }
export interface MenuTable extends BaseType { export interface MenuTable extends BaseType {
@@ -79,6 +81,7 @@ export interface MenuTable extends BaseType {
params?: { key: string; value: string }[]; params?: { key: string; value: string }[];
affix?: boolean; affix?: boolean;
children?: MenuTable[]; children?: MenuTable[];
client?: "pc" | "app";
} }
export interface MenuForm extends BaseFormType { export interface MenuForm extends BaseFormType {
@@ -98,6 +101,7 @@ export interface MenuForm extends BaseFormType {
title?: string; title?: string;
params?: KeyValue[]; params?: KeyValue[];
affix?: boolean; affix?: boolean;
client?: "pc" | "app";
} }
export interface KeyValue { export interface KeyValue {
+6
View File
@@ -5,3 +5,9 @@ export enum MenuTypeEnum {
BUTTON = 3, // 按钮 BUTTON = 3, // 按钮
EXTLINK = 4, // 外链 EXTLINK = 4, // 外链
} }
/** 菜单终端(与后端 sys_menu.client 一致) */
export enum MenuClientEnum {
PC = "pc",
APP = "app",
}
@@ -11,6 +11,8 @@ export interface Meta {
alwaysShow?: boolean; alwaysShow?: boolean;
/** 是否隐藏(true-是 false-否) */ /** 是否隐藏(true-是 false-否) */
hidden?: boolean; hidden?: boolean;
/** 菜单终端(后端 sys_menu.client,PC 动态路由仅下发 pc) */
client?: string;
/** ICON */ /** ICON */
icon?: string; icon?: string;
/** 【菜单】是否开启页面缓存 */ /** 【菜单】是否开启页面缓存 */
@@ -71,6 +73,7 @@ export const generator = (routers: MenuTable[]): RouteVO[] => {
meta: { meta: {
title: item.title, title: item.title,
icon: item.icon || undefined, icon: item.icon || undefined,
client: item.client,
keepAlive: item.keep_alive, keepAlive: item.keep_alive,
hidden: item.hidden, hidden: item.hidden,
order: item.order, order: item.order,
@@ -8,6 +8,15 @@
@reset-click="handleResetClick" @reset-click="handleResetClick"
/> />
<el-tabs
v-model="menuClientTab"
class="menu-client-tabs px-1 mb-2"
@tab-change="handleMenuClientTabChange"
>
<el-tab-pane label="PC 桌面菜单管理" name="pc" />
<el-tab-pane label="APP 移动端菜单管理" name="app" />
</el-tabs>
<PageContent ref="contentRef" :content-config="contentConfig"> <PageContent ref="contentRef" :content-config="contentConfig">
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }"> <template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
<CrudToolbarLeft <CrudToolbarLeft
@@ -70,6 +79,13 @@
<el-tag v-if="scope.row.type === MenuTypeEnum.EXTLINK" type="info">外链</el-tag> <el-tag v-if="scope.row.type === MenuTypeEnum.EXTLINK" type="info">外链</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="终端" prop="client" min-width="88" align="center">
<template #default="scope">
<el-tag v-if="scope.row.client === MenuClientEnum.PC" type="primary">PC</el-tag>
<el-tag v-else-if="scope.row.client === MenuClientEnum.APP" type="success">APP</el-tag>
<el-tag v-else type="info">{{ scope.row.client || "—" }}</el-tag>
</template>
</el-table-column>
<el-table-column label="排序" prop="order" min-width="80" /> <el-table-column label="排序" prop="order" min-width="80" />
<el-table-column <el-table-column
label="路由名称" label="路由名称"
@@ -219,6 +235,11 @@
<el-tag v-if="detailFormData.type === MenuTypeEnum.BUTTON" type="danger">按钮</el-tag> <el-tag v-if="detailFormData.type === MenuTypeEnum.BUTTON" type="danger">按钮</el-tag>
<el-tag v-if="detailFormData.type === MenuTypeEnum.EXTLINK" type="info">外链</el-tag> <el-tag v-if="detailFormData.type === MenuTypeEnum.EXTLINK" type="info">外链</el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="终端" :span="2">
<el-tag v-if="detailFormData.client === MenuClientEnum.PC" type="primary">PC</el-tag>
<el-tag v-else-if="detailFormData.client === MenuClientEnum.APP" type="success">APP</el-tag>
<el-tag v-else type="info">{{ detailFormData.client || "—" }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="图标" :span="2"> <el-descriptions-item label="图标" :span="2">
<template #default> <template #default>
<template v-if="detailFormData.icon && detailFormData.icon.startsWith('el-icon')"> <template v-if="detailFormData.icon && detailFormData.icon.startsWith('el-icon')">
@@ -367,6 +388,16 @@
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="终端" prop="client">
<el-radio-group v-model="formData.client" :disabled="createParentLocked">
<el-radio :value="MenuClientEnum.PC">PC 桌面</el-radio>
<el-radio :value="MenuClientEnum.APP">APP 移动</el-radio>
</el-radio-group>
<el-text v-if="createParentLocked" type="info" size="small" class="block mt-1">
子级终端与父菜单一致
</el-text>
</el-form-item>
<el-form-item v-if="formData.type == MenuTypeEnum.EXTLINK" label="外链地址" prop="path"> <el-form-item v-if="formData.type == MenuTypeEnum.EXTLINK" label="外链地址" prop="path">
<el-input v-model="formData.route_path" placeholder="请输入外链完整路径" /> <el-input v-model="formData.route_path" placeholder="请输入外链完整路径" />
</el-form-item> </el-form-item>
@@ -631,7 +662,7 @@ import { useUserStore } from "@/store/modules/user.store";
import { DeviceEnum } from "@/enums/settings/device.enum"; import { DeviceEnum } from "@/enums/settings/device.enum";
import MenuAPI, { MenuPageQuery, MenuForm, MenuTable } from "@/api/module_system/menu"; import MenuAPI, { MenuPageQuery, MenuForm, MenuTable } from "@/api/module_system/menu";
import { MenuTypeEnum } from "@/enums/system/menu.enum"; import { MenuClientEnum, MenuTypeEnum } from "@/enums/system/menu.enum";
import { formatTree } from "@/utils/common"; import { formatTree } from "@/utils/common";
import CrudToolbarLeft from "@/components/CURD/CrudToolbarLeft.vue"; import CrudToolbarLeft from "@/components/CURD/CrudToolbarLeft.vue";
import CrudToolbarRight from "@/components/CURD/CrudToolbarRight.vue"; import CrudToolbarRight from "@/components/CURD/CrudToolbarRight.vue";
@@ -645,6 +676,15 @@ const appStore = useAppStore();
const userStore = useUserStore(); const userStore = useUserStore();
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList(); const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
/** 菜单管理:PC / APP 分栏(与接口 menu_client 一致) */
const menuClientTab = ref<"pc" | "app">("pc");
function handleMenuClientTabChange(name: string | number) {
menuClientTab.value = name === "app" ? "app" : "pc";
refreshList();
}
const dataFormRef = ref(); const dataFormRef = ref();
const submitLoading = ref(false); const submitLoading = ref(false);
@@ -714,6 +754,7 @@ const formData = reactive<MenuForm>({
affix: false, affix: false,
status: "0", status: "0",
description: undefined, description: undefined,
client: MenuClientEnum.PC,
}); });
// 弹窗状态 // 弹窗状态
@@ -810,7 +851,10 @@ const contentConfig = reactive<IContentConfig<MenuPageQuery>>({
defaultToolbar: ["refresh", "filter"], defaultToolbar: ["refresh", "filter"],
pagination: false, pagination: false,
indexAction: async (params) => { indexAction: async (params) => {
const res = await MenuAPI.listMenu(params as MenuPageQuery); const res = await MenuAPI.listMenu({
...(params as MenuPageQuery),
menu_client: menuClientTab.value,
});
const tree = res.data.data || []; const tree = res.data.data || [];
fullMenuTree.value = tree; fullMenuTree.value = tree;
menuOptions.value = formatTree(filterMenuTypes(tree)); menuOptions.value = formatTree(filterMenuTypes(tree));
@@ -869,6 +913,7 @@ const rules = reactive({
hidden: [{ required: true, message: "请选择是否隐藏", trigger: "change" }], hidden: [{ required: true, message: "请选择是否隐藏", trigger: "change" }],
always_show: [{ required: true, message: "请选择始终显示", trigger: "change" }], always_show: [{ required: true, message: "请选择始终显示", trigger: "change" }],
status: [{ required: true, message: "请选择状态", trigger: "change" }], status: [{ required: true, message: "请选择状态", trigger: "change" }],
client: [{ required: true, message: "请选择终端", trigger: "change" }],
redirect: [ redirect: [
{ {
validator: (_rule: unknown, value: string | undefined, callback: (e?: Error) => void) => { validator: (_rule: unknown, value: string | undefined, callback: (e?: Error) => void) => {
@@ -909,6 +954,7 @@ const initialFormData: MenuForm = {
affix: false, affix: false,
status: "0", status: "0",
description: undefined, description: undefined,
client: MenuClientEnum.PC,
}; };
// 重置表单 // 重置表单
@@ -955,12 +1001,15 @@ async function handleOpenDialog(
Object.assign(formData, initialFormData); Object.assign(formData, initialFormData);
if (parentRow?.id != null) { if (parentRow?.id != null) {
formData.parent_id = parentRow.id; formData.parent_id = parentRow.id;
formData.client = (parentRow.client as MenuClientEnum) || menuClientTab.value;
if (parentRow.type === MenuTypeEnum.MENU) { if (parentRow.type === MenuTypeEnum.MENU) {
createParentLocked.value = true; createParentLocked.value = true;
formData.type = MenuTypeEnum.BUTTON; formData.type = MenuTypeEnum.BUTTON;
} else if (parentRow.type === MenuTypeEnum.CATALOG) { } else if (parentRow.type === MenuTypeEnum.CATALOG) {
formData.type = MenuTypeEnum.MENU; formData.type = MenuTypeEnum.MENU;
} }
} else {
formData.client = menuClientTab.value;
} }
} }
dialogVisible.visible = true; dialogVisible.visible = true;