refactor(layout): 重构应用布局和权限管理功能

- 更新AppLayout支持目录类型的菜单结构
- 实现菜单展开状态基于当前路径的自动管理
- 添加目录类型权限的过滤和渲染逻辑
- 优化页面布局的flexbox结构和滚动处理
- 修改权限管理页面支持目录、菜单、按钮三级结构
- 更新字典类型API支持分页查询参数
- 调整多个页面的表格布局和滚动配置
- 添加标准分页工具函数并统一使用
- 更新租户管理页面为卡片列表展示方式
master
chenhao 2026-02-27 15:07:03 +08:00
parent 1ae81909c2
commit 3f31ec0eb1
17 changed files with 391 additions and 254 deletions

View File

@ -1,13 +1,13 @@
package com.imeeting.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.imeeting.common.ApiResponse;
import com.imeeting.entity.SysDictType;
import com.imeeting.service.SysDictTypeService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/dict-types")
public class DictTypeController {
@ -19,8 +19,21 @@ public class DictTypeController {
@GetMapping
@PreAuthorize("@ss.hasPermi('sys_dict:list')")
public ApiResponse<List<SysDictType>> list() {
return ApiResponse.ok(sysDictTypeService.list());
public ApiResponse<Page<SysDictType>> list(
@RequestParam(defaultValue = "1") Integer current,
@RequestParam(defaultValue = "10") Integer size,
@RequestParam(required = false) String typeCode,
@RequestParam(required = false) String typeName) {
Page<SysDictType> page = new Page<>(current, size);
LambdaQueryWrapper<SysDictType> queryWrapper = new LambdaQueryWrapper<>();
if (typeCode != null && !typeCode.isEmpty()) {
queryWrapper.like(SysDictType::getTypeCode, typeCode);
}
if (typeName != null && !typeName.isEmpty()) {
queryWrapper.like(SysDictType::getTypeName, typeName);
}
queryWrapper.orderByAsc(SysDictType::getTypeCode);
return ApiResponse.ok(sysDictTypeService.page(page, queryWrapper));
}
@GetMapping("/{id}")

View File

@ -7,9 +7,9 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_platform_config")
public class SysPlatformConfig extends BaseEntity {
public class SysPlatformConfig {
@TableId
private Long id;
private String projectName;
@ -20,6 +20,4 @@ public class SysPlatformConfig extends BaseEntity {
private String copyrightInfo;
private String systemDescription;
@TableField(exist = false)
private Long tenantId;
}

View File

@ -50,6 +50,7 @@ src
> 阅读对应的后端controller了解接口
### 核心原则
* 优先使用frontend/src/components/shared中的组件 如果是typeScript 需要修改为typeScript
* 清晰的意图胜于技巧性的实现
* 组件简单直观优于过度抽象
* 奥卡姆剃刀:不必要的复杂度一律删除

View File

@ -2,9 +2,9 @@ import http from "./http";
import { SysDictType, SysDictItem } from "../types";
// Dictionary Type APIs
export async function fetchDictTypes() {
const resp = await http.get("/api/dict-types");
return resp.data.data as SysDictType[];
export async function fetchDictTypes(params?: { current?: number; size?: number; typeCode?: string; typeName?: string }) {
const resp = await http.get("/api/dict-types", { params });
return resp.data.data;
}
export async function createDictType(data: Partial<SysDictType>) {

View File

@ -1,4 +1,4 @@
import { Button, Input, Space, Popover } from 'antd'
import { Button, Input, Space, Popover } from 'antd'
import { ReloadOutlined, FilterOutlined } from '@ant-design/icons'
import './ListActionBar.css'

View File

@ -1,13 +1,6 @@
/* 列表表格容器 */
.list-table-container {
background: var(--card-bg);
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
height: 626px;
overflow-y: auto;
width: 100%;
border: 1px solid var(--border-color);
}
/* 行选中样式 */

View File

@ -1,6 +1,8 @@
import React from "react";
import { Table } from "antd";
import type { TablePaginationConfig, TableProps } from "antd";
import "./ListTable.css";
import i18n from "../../../i18n";
export type ListTableProps<T extends Record<string, any>> = {
columns: TableProps<T>["columns"];
@ -13,11 +15,12 @@ export type ListTableProps<T extends Record<string, any>> = {
onSelectAllPages?: () => void;
onClearSelection?: () => void;
pagination?: TablePaginationConfig | false;
scroll?: { x?: number | true | string };
scroll?: { x?: number | true | string; y?: number | string };
onRowClick?: (record: T) => void;
selectedRow?: T | null;
loading?: boolean;
className?: string;
onChange?: TableProps<T>["onChange"];
};
function ListTable<T extends Record<string, any>>({
@ -40,6 +43,7 @@ function ListTable<T extends Record<string, any>>({
selectedRow,
loading = false,
className = "",
onChange,
}: ListTableProps<T>) {
const rowSelection: TableProps<T>["rowSelection"] = onSelectionChange
? {
@ -88,7 +92,9 @@ function ListTable<T extends Record<string, any>>({
)}
</>
) : (
<span className="selection-count"> 0 </span>
<span className="selection-count">
{i18n.t("common.total", { total: totalCount || total })}
</span>
)}
</div>
),
@ -105,6 +111,7 @@ function ListTable<T extends Record<string, any>>({
pagination={mergedPagination}
scroll={scroll}
loading={loading}
onChange={onChange}
onRow={(record) => ({
onClick: () => onRowClick?.(record),
className: selectedRow?.[rowKey] === record[rowKey] ? "row-selected" : "",

View File

@ -77,7 +77,7 @@ export default function AppLayout() {
// Filter visible menus and sort them
const filtered = data
.filter(p => p.permType === 'menu' && p.isVisible === 1 && p.status === 1)
.filter(p => (p.permType === 'menu' || p.permType === 'directory') && p.isVisible === 1 && p.status === 1)
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
setMenus(filtered);
} catch (e) {
@ -144,14 +144,17 @@ export default function AppLayout() {
nodes.map((m) => {
const key = m.path || m.code || String(m.permId);
const icon = m.icon ? (iconMap[m.icon] || <SettingOutlined />) : <SettingOutlined />;
if (m.children && m.children.length > 0) {
// Directory type or item with children should not have a link if it's a directory
if (m.permType === 'directory' || (m.children && m.children.length > 0)) {
return {
key,
icon,
label: m.name,
children: toMenuItems(m.children),
children: m.children && m.children.length > 0 ? toMenuItems(m.children) : undefined,
};
}
return {
key,
icon,
@ -161,6 +164,28 @@ export default function AppLayout() {
const menuItems = useMemo(() => toMenuItems(buildMenuTree(menus)), [menus, buildMenuTree, toMenuItems]);
// Calculate open keys based on current path
const [openKeys, setOpenKeys] = useState<string[]>([]);
useEffect(() => {
if (menus.length > 0) {
const findParentKeys = (nodes: any[], path: string, parents: string[] = []): string[] | null => {
for (const node of nodes) {
if (node.key === path) return parents;
if (node.children) {
const found = findParentKeys(node.children, path, [...parents, node.key]);
if (found) return found;
}
}
return null;
};
const keys = findParentKeys(menuItems, location.pathname);
if (keys) {
setOpenKeys(prev => Array.from(new Set([...prev, ...keys])));
}
}
}, [location.pathname, menuItems, menus]);
const userMenuItems: MenuProps["items"] = useMemo(() => {
const items: any[] = [
{
@ -241,11 +266,13 @@ export default function AppLayout() {
<Menu
mode="inline"
selectedKeys={[location.pathname]}
openKeys={openKeys}
onOpenChange={setOpenKeys}
items={menuItems}
style={{ borderRight: 0, marginTop: 16 }}
/>
</Sider>
<Layout>
<Layout style={{ height: "100vh", overflow: "hidden" }}>
<Header style={{
background: "#fff",
padding: '0 24px',
@ -253,7 +280,9 @@ export default function AppLayout() {
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: '0 1px 4px rgba(0,21,41,.08)',
zIndex: 9
zIndex: 9,
height: 64,
flexShrink: 0
}}>
<Button
type="text"
@ -295,10 +324,14 @@ export default function AppLayout() {
padding: 24,
background: '#fff',
borderRadius: '8px',
minHeight: 280,
boxShadow: '0 1px 2px rgba(0,0,0,0.03)'
boxShadow: '0 1px 2px rgba(0,0,0,0.03)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<Outlet />
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Outlet />
</div>
</Content>
</Layout>
</Layout>

View File

@ -27,6 +27,7 @@ import {
UserOutlined
} from "@ant-design/icons";
import PageHeader from "../components/shared/PageHeader";
import { getStandardPagination } from "../utils/pagination";
const { Title, Text } = Typography;
@ -215,7 +216,7 @@ export default function Devices() {
];
return (
<div className="devices-page p-6">
<div className="devices-page p-6 flex flex-col h-full overflow-hidden">
<PageHeader
title={t('devices.title')}
subtitle={t('devices.subtitle')}
@ -226,8 +227,8 @@ export default function Devices() {
)}
/>
<Card className="devices-table-card shadow-sm">
<div className="devices-table-toolbar mb-4">
<Card className="devices-table-card shadow-sm mb-4 flex-shrink-0" styles={{ body: { padding: '16px' } }}>
<div className="devices-table-toolbar">
<Input
placeholder={t('devices.searchPlaceholder')}
prefix={<SearchOutlined aria-hidden="true" />}
@ -238,17 +239,17 @@ export default function Devices() {
aria-label={t('common.search')}
/>
</div>
</Card>
<Card className="shadow-sm flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}>
<Table
rowKey="deviceId"
columns={columns}
dataSource={filteredData}
loading={loading}
size="middle"
pagination={{
showTotal: (total) => t('common.total', { total }),
pageSize: 10,
}}
scroll={{ y: 'calc(100vh - 350px)' }}
pagination={getStandardPagination(filteredData.length, 1, 1000)}
/>
</Card>

View File

@ -16,7 +16,7 @@ import {
Typography,
Empty
} from "antd";
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
createDictItem,
@ -29,10 +29,11 @@ import {
updateDictType
} from "../api";
import { usePermission } from "../hooks/usePermission";
import { PlusOutlined, EditOutlined, DeleteOutlined, BookOutlined, ProfileOutlined } from "@ant-design/icons";
import { PlusOutlined, EditOutlined, DeleteOutlined, BookOutlined, ProfileOutlined, SearchOutlined } from "@ant-design/icons";
import { useDict } from "../hooks/useDict";
import type { SysDictItem, SysDictType } from "../types";
import PageHeader from "../components/shared/PageHeader";
import { getStandardPagination } from "../utils/pagination";
import "./Dictionaries.css";
const { Title, Text } = Typography;
@ -45,6 +46,13 @@ export default function Dictionaries() {
const [selectedType, setSelectedType] = useState<SysDictType | null>(null);
const [loadingTypes, setLoadingTypes] = useState(false);
const [loadingItems, setLoadingItems] = useState(false);
const [typeTotal, setTypeTotal] = useState(0);
const [typeParams, setTypeParams] = useState({
current: 1,
size: 10,
typeCode: "",
typeName: ""
});
// Dictionaries
const { items: statusDict } = useDict("sys_common_status");
@ -59,18 +67,25 @@ export default function Dictionaries() {
const [editingItem, setEditingItem] = useState<SysDictItem | null>(null);
const [itemForm] = Form.useForm();
const loadTypes = async () => {
const loadTypes = useCallback(async (params = typeParams) => {
setLoadingTypes(true);
try {
const data = await fetchDictTypes();
setTypes(data || []);
if (data && data.length > 0 && !selectedType) {
setSelectedType(data[0]);
const data = await fetchDictTypes(params);
setTypes(data.records || []);
setTypeTotal(data.total || 0);
// If we have data and nothing is selected, select the first one
if (data.records && data.records.length > 0 && !selectedType) {
setSelectedType(data.records[0]);
} else if (selectedType) {
// Refresh selected type info if it still exists in the new list
const updatedSelected = data.records.find((t: SysDictType) => t.dictTypeId === selectedType.dictTypeId);
if (updatedSelected) setSelectedType(updatedSelected);
}
} finally {
setLoadingTypes(false);
}
};
}, [selectedType, typeParams]);
const loadItems = async (typeCode: string) => {
setLoadingItems(true);
@ -84,7 +99,7 @@ export default function Dictionaries() {
useEffect(() => {
loadTypes();
}, []);
}, [typeParams.current, typeParams.size]);
useEffect(() => {
if (selectedType) {
@ -125,6 +140,11 @@ export default function Dictionaries() {
loadTypes();
};
const handleTypeSearch = (val: string) => {
setTypeParams({ ...typeParams, current: 1, typeName: val });
loadTypes({ ...typeParams, current: 1, typeName: val });
};
// Item Actions
const handleAddItem = () => {
if (!selectedType) {
@ -162,7 +182,7 @@ export default function Dictionaries() {
};
return (
<div className="dictionaries-page p-6">
<div className="dictionaries-page p-6 flex flex-col h-full overflow-hidden">
<PageHeader
title={t('dicts.title')}
subtitle={t('dicts.subtitle')}
@ -177,8 +197,8 @@ export default function Dictionaries() {
)}
/>
<Row gutter={24} style={{ height: 'calc(100vh - 180px)' }}>
<Col span={8} style={{ height: '100%' }}>
<Row gutter={24} className="flex-1 min-h-0 overflow-hidden">
<Col span={8} className="h-full flex flex-col overflow-hidden">
<Card
title={
<Space>
@ -186,16 +206,31 @@ export default function Dictionaries() {
<span>{t('dicts.dictType')}</span>
</Space>
}
className="full-height-card shadow-sm"
className="flex-1 flex flex-col overflow-hidden shadow-sm"
styles={{ body: { padding: '12px', flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}
>
<div style={{ height: 'calc(100% - 10px)', overflowY: 'auto' }}>
<div style={{ marginBottom: 12 }} className="flex-shrink-0">
<Input.Search
placeholder="搜索类型名称"
allowClear
onSearch={handleTypeSearch}
enterButton
/>
</div>
<div className="flex-1 min-h-0">
<Table
rowKey="dictTypeId"
loading={loadingTypes}
dataSource={types}
pagination={false}
pagination={{
...getStandardPagination(typeTotal, typeParams.current, typeParams.size, (page, size) => setTypeParams({ ...typeParams, current: page, size })),
simple: true,
size: 'small',
position: ['bottomCenter']
}}
size="small"
showHeader={false}
scroll={{ y: 'calc(100vh - 480px)' }}
onRow={(record) => ({
onClick: () => setSelectedType(record),
className: `cursor-pointer dict-type-row ${selectedType?.dictTypeId === record.dictTypeId ? "dict-type-row-selected" : ""}`
@ -203,9 +238,9 @@ export default function Dictionaries() {
columns={[
{
render: (_, record) => (
<div className="dict-type-item flex justify-between items-center p-2">
<div className="dict-type-item flex justify-between items-center p-1">
<div className="min-w-0 flex-1">
<div className="dict-type-name font-medium truncate">{record.typeName}</div>
<div className="dict-type-name font-medium truncate" style={{ fontSize: '14px' }}>{record.typeName}</div>
<div className="dict-type-code text-xs text-gray-400 truncate tabular-nums">{record.typeCode}</div>
</div>
<div className="dict-type-actions flex gap-1">
@ -213,7 +248,7 @@ export default function Dictionaries() {
<Button
type="text"
size="small"
icon={<EditOutlined aria-hidden="true" />}
icon={<EditOutlined aria-hidden="true" style={{ fontSize: '12px' }} />}
onClick={(e) => {
e.stopPropagation();
handleEditType(record);
@ -232,7 +267,7 @@ export default function Dictionaries() {
type="text"
size="small"
danger
icon={<DeleteOutlined aria-hidden="true" />}
icon={<DeleteOutlined aria-hidden="true" style={{ fontSize: '12px' }} />}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
@ -247,7 +282,7 @@ export default function Dictionaries() {
</Card>
</Col>
<Col span={16} style={{ height: '100%' }}>
<Col span={16} className="h-full flex flex-col overflow-hidden">
<Card
title={
<Space>
@ -255,7 +290,8 @@ export default function Dictionaries() {
<span>{t('dicts.dictItem')}{selectedType ? ` - ${selectedType.typeName}` : ""}</span>
</Space>
}
className="full-height-card shadow-sm"
className="flex-1 flex flex-col overflow-hidden shadow-sm"
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}
extra={
can("sys_dict:item:create") && (
<Button
@ -271,13 +307,14 @@ export default function Dictionaries() {
}
>
{selectedType ? (
<div style={{ height: 'calc(100% - 10px)', overflowY: 'auto' }}>
<div className="flex-1 overflow-hidden">
<Table
rowKey="dictItemId"
loading={loadingItems}
dataSource={items}
pagination={false}
size="middle"
scroll={{ y: 'calc(100vh - 320px)' }}
columns={[
{
title: t('dicts.itemLabel'),

View File

@ -1,4 +1,4 @@
import { Card, Table, Tabs, Tag, Input, Space, Button, DatePicker, Select, Typography, Modal, Descriptions } from "antd";
import { Card, Tabs, Tag, Input, Space, Button, DatePicker, Select, Typography, Modal, Descriptions } from "antd";
import { useEffect, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { fetchLogs } from "../api";
@ -6,6 +6,8 @@ import { SearchOutlined, ReloadOutlined, InfoCircleOutlined, EyeOutlined, UserOu
import { SysLog, UserProfile } from "../types";
import { useDict } from "../hooks/useDict";
import PageHeader from "../components/shared/PageHeader";
import { getStandardPagination } from "../utils/pagination";
import ListTable from "../components/shared/ListTable/ListTable";
const { RangePicker } = DatePicker;
const { Text, Title } = Typography;
@ -18,7 +20,7 @@ export default function Logs() {
const [total, setTotal] = useState(0);
const [params, setParams] = useState({
current: 1,
size: 10,
size: 20,
username: "",
status: undefined,
startDate: "",
@ -65,6 +67,11 @@ export default function Logs() {
loadData();
}, [activeTab, params.current, params.size, params.sortField, params.sortOrder]);
const onTabChange = (key: string) => {
setActiveTab(key);
setParams(prev => ({ ...prev, current: 1 }));
};
const handleTableChange = (pagination: any, filters: any, sorter: any) => {
setParams({
...params,
@ -83,7 +90,7 @@ export default function Logs() {
const handleReset = () => {
const resetParams = {
current: 1,
size: 10,
size: 20,
username: "",
status: undefined,
startDate: "",
@ -204,13 +211,13 @@ export default function Logs() {
}
return (
<div className="p-6">
<div className="flex flex-col h-full overflow-hidden">
<PageHeader
title={t('logs.title')}
subtitle={t('logs.subtitle')}
/>
<Card className="mb-4 shadow-sm">
<Card className="mb-4 shadow-sm flex-shrink-0" styles={{ body: { padding: '16px' } }}>
<Space wrap size="middle">
<Input
placeholder={t('logs.searchPlaceholder')}
@ -255,8 +262,8 @@ export default function Logs() {
</Space>
</Card>
<Card className="shadow-sm" styles={{ body: { paddingTop: 0 } }}>
<Tabs activeKey={activeTab} onChange={setActiveTab} size="large">
<Card className="shadow-sm flex-1 flex flex-col overflow-hidden" styles={{ body: { paddingTop: 0, paddingBottom: 0, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}>
<Tabs activeKey={activeTab} onChange={onTabChange} size="large" className="flex-shrink-0">
{logTypeDict.length > 0 ? (
logTypeDict.map(item => (
<Tabs.TabPane
@ -283,21 +290,18 @@ export default function Logs() {
)}
</Tabs>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
size="middle"
onChange={handleTableChange}
pagination={{
current: params.current,
pageSize: params.size,
total: total,
showSizeChanger: true,
showTotal: (total) => t('common.total', { total })
}}
/>
<div className="flex-1 min-h-0 h-full">
<ListTable
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
onChange={handleTableChange}
totalCount={total}
scroll={{ y: 'calc(100vh - 520px)' }}
pagination={getStandardPagination(total, params.current, params.size)}
/>
</div>
</Card>
<Modal

View File

@ -238,7 +238,7 @@ export default function Orgs() {
];
return (
<div className="p-6">
<div className="p-6 flex flex-col h-full overflow-hidden">
<PageHeader
title={t('orgs.title')}
subtitle={t('orgs.subtitle')}
@ -250,7 +250,7 @@ export default function Orgs() {
/>
{isPlatformMode && (
<Card className="shadow-sm mb-4">
<Card className="shadow-sm mb-4 flex-shrink-0" styles={{ body: { padding: '16px' } }}>
<Space>
<Text strong>{t('users.tenant')}</Text>
<Select
@ -266,7 +266,7 @@ export default function Orgs() {
</Card>
)}
<Card className="shadow-sm" styles={{ body: { padding: 0 } }}>
<Card className="shadow-sm flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}>
{selectedTenantId !== undefined ? (
<Table
rowKey="id"
@ -275,6 +275,7 @@ export default function Orgs() {
loading={loading}
pagination={false}
size="middle"
scroll={{ y: 'calc(100vh - 350px)' }}
expandable={{ defaultExpandAllRows: true }}
/>
) : (

View File

@ -31,9 +31,11 @@ import {
ClusterOutlined,
MenuOutlined,
CheckSquareOutlined,
InfoCircleOutlined
InfoCircleOutlined,
FolderOutlined
} from "@ant-design/icons";
import PageHeader from "../components/shared/PageHeader";
import { getStandardPagination } from "../utils/pagination";
const { Title, Text } = Typography;
@ -75,7 +77,6 @@ export default function Permissions() {
const [editing, setEditing] = useState<SysPermission | null>(null);
const [form] = Form.useForm();
const { can } = usePermission();
const level = Form.useWatch("level", form);
// Dictionaries
const { items: statusDict } = useDict("sys_common_status");
@ -108,16 +109,23 @@ export default function Permissions() {
const treeData = useMemo(() => buildTree(filtered), [filtered]);
const currentPermType = Form.useWatch("permType", form);
const parentOptions = useMemo(() => {
return data
.filter((p) => p.permType === "menu")
.filter((p) => {
if (currentPermType === "button") {
return p.permType === "menu";
}
return p.permType === "directory";
})
.map((p) => ({ value: p.permId, label: p.name }));
}, [data]);
}, [data, currentPermType]);
const openCreate = () => {
setEditing(null);
form.resetFields();
form.setFieldsValue({ level: 1, permType: "menu", status: 1, isVisible: 1, sortOrder: 0 });
form.setFieldsValue({ level: 1, permType: "directory", status: 1, isVisible: 1, sortOrder: 0 });
setOpen(true);
};
@ -130,10 +138,20 @@ export default function Permissions() {
const openAddChild = (record: SysPermission) => {
setEditing(null);
form.resetFields();
// Auto-calculate level based on parent
const parentLevel = record.level || 1;
const newLevel = Math.min(parentLevel + 1, 3);
// Default type: Level 1 -> Directory/Menu, Level 2 -> Menu/Button, Level 3 -> Button
let defaultType = "menu";
if (newLevel === 3) defaultType = "button";
if (newLevel === 1) defaultType = "directory";
form.setFieldsValue({
parentId: record.permId,
level: Math.min((record.level || 1) + 1, 3),
permType: record.level === 1 ? "menu" : "button",
level: newLevel,
permType: defaultType,
status: 1,
isVisible: 1,
sortOrder: 0
@ -145,12 +163,22 @@ export default function Permissions() {
try {
const values = await form.validateFields();
setSaving(true);
// Auto calculate level
let calculatedLevel = 1;
if (values.parentId) {
const parent = data.find(p => p.permId === values.parentId);
if (parent) {
calculatedLevel = (parent.level || 1) + 1;
}
}
const payload: Partial<SysPermission> = {
parentId: values.level === 1 ? undefined : values.parentId,
parentId: values.parentId || 0, // Ensure 0 for roots if backend expects it
name: values.name,
code: values.code,
permType: values.permType,
level: values.level,
level: calculatedLevel,
path: values.path,
component: values.component,
icon: values.icon,
@ -194,15 +222,18 @@ export default function Permissions() {
title: t('permissions.permName'),
dataIndex: "name",
key: "name",
render: (text: string, record: SysPermission) => (
<Space>
{record.permType === 'menu' ?
<MenuOutlined style={{ color: '#1890ff' }} aria-hidden="true" /> :
<CheckSquareOutlined style={{ color: '#52c41a' }} aria-hidden="true" />
}
<Text strong={record.level === 1}>{text}</Text>
</Space>
)
render: (text: string, record: SysPermission) => {
let icon = <CheckSquareOutlined style={{ color: '#52c41a' }} aria-hidden="true" />;
if (record.permType === 'directory') icon = <FolderOutlined style={{ color: '#faad14' }} aria-hidden="true" />;
if (record.permType === 'menu') icon = <MenuOutlined style={{ color: '#1890ff' }} aria-hidden="true" />;
return (
<Space>
{icon}
<Text strong={record.level === 1}>{text}</Text>
</Space>
);
}
},
{
title: t('permissions.permCode'),
@ -216,8 +247,12 @@ export default function Permissions() {
width: 90,
render: (type: string) => {
const item = typeDict.find(i => i.itemValue === type);
let color = 'warning';
if (type === 'directory') color = 'default';
if (type === 'menu') color = 'processing';
return (
<Tag color={type === 'menu' ? 'processing' : 'warning'}>
<Tag color={color}>
{item ? item.itemLabel : type}
</Tag>
);
@ -264,7 +299,7 @@ export default function Permissions() {
fixed: "right" as const,
render: (_: any, record: SysPermission) => (
<Space>
{can("sys:permission:create") && record.permType === 'menu' && (
{can("sys:permission:create") && record.permType !== 'button' && (
<Tooltip title="添加子项">
<Button
type="text"
@ -300,7 +335,7 @@ export default function Permissions() {
];
return (
<div className="p-6">
<div className="p-6 flex flex-col h-full overflow-hidden">
<PageHeader
title={t('permissions.title')}
subtitle={t('permissions.subtitle')}
@ -315,7 +350,7 @@ export default function Permissions() {
)}
/>
<Card className="mb-4 shadow-sm">
<Card className="mb-4 shadow-sm flex-shrink-0" styles={{ body: { padding: '16px' } }}>
<Space wrap size="middle">
<Input
placeholder={t('permissions.permName')}
@ -359,7 +394,7 @@ export default function Permissions() {
</Space>
</Card>
<Card className="shadow-sm" styles={{ body: { padding: 0 } }}>
<Card className="shadow-sm flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}>
<Table
rowKey="permId"
loading={loading}
@ -367,7 +402,11 @@ export default function Permissions() {
columns={columns}
pagination={false}
size="middle"
expandable={{ defaultExpandAllRows: false }}
scroll={{ y: 'calc(100vh - 350px)' }}
expandable={{
defaultExpandAllRows: false,
rowExpandable: (record) => record.permType !== 'button' && !!record.children?.length
}}
/>
</Card>
@ -396,24 +435,13 @@ export default function Permissions() {
layout="vertical"
className="permission-form"
onValuesChange={(changed) => {
if (changed.level === 1) {
form.setFieldsValue({ parentId: undefined });
}
if (changed.level === 3) {
form.setFieldsValue({ permType: "button" });
if (changed.permType === 'button') {
form.setFieldsValue({ isVisible: 0 });
}
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item label={t('permissions.level')} name="level" rules={[{ required: true }]}>
<Select
options={levelDict.map(i => ({ value: Number(i.itemValue), label: i.itemLabel }))}
aria-label={t('permissions.level')}
/>
</Form.Item>
</Col>
<Col span={12}>
<Col span={24}>
<Form.Item label={t('permissions.permType')} name="permType" rules={[{ required: true }]}>
<Select
options={typeDict.map(i => ({ value: i.itemValue, label: i.itemLabel }))}
@ -426,20 +454,12 @@ export default function Permissions() {
<Form.Item
label={t('permissions.parentId')}
name="parentId"
dependencies={["level"]}
rules={[
({ getFieldValue }) => ({
required: getFieldValue("level") > 1,
message: "非一级入口必须选择父级"
})
]}
>
<Select
allowClear
showSearch
placeholder={level === 1 ? "一级入口无须父级" : "请选择父级菜单…"}
placeholder="顶级权限请留空"
options={parentOptions}
disabled={level === 1}
aria-label={t('permissions.parentId')}
/>
</Form.Item>

View File

@ -37,6 +37,7 @@ import {
} from "@ant-design/icons";
import type { SysParamVO, SysParamQuery } from "../types";
import PageHeader from "../components/shared/PageHeader";
import { getStandardPagination } from "../utils/pagination";
import "./SysParams.css";
const { Title, Text } = Typography;
@ -216,7 +217,7 @@ export default function SysParams() {
];
return (
<div className="sys-params-page p-6">
<div className="sys-params-page p-6 flex flex-col h-full overflow-hidden">
<PageHeader
title={t('sysParams.title')}
subtitle={t('sysParams.subtitle')}
@ -227,11 +228,10 @@ export default function SysParams() {
)}
/>
<Card className="sys-params-table-card shadow-sm mb-4">
<Card className="sys-params-table-card shadow-sm mb-4 flex-shrink-0" styles={{ body: { padding: '16px' } }}>
<Form
layout="inline"
onFinish={handleSearch}
className="mb-4"
>
<Form.Item name="paramKey">
<Input
@ -260,21 +260,17 @@ export default function SysParams() {
</Space>
</Form.Item>
</Form>
</Card>
<Card className="shadow-sm flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}>
<Table
rowKey="paramId"
columns={columns}
dataSource={data}
loading={loading}
size="middle"
pagination={{
current: queryParams.pageNum,
pageSize: queryParams.pageSize,
total: total,
showTotal: (tTotal) => t('common.total', { total: tTotal }),
onChange: handlePageChange,
showSizeChanger: true,
}}
scroll={{ y: 'calc(100vh - 350px)' }}
pagination={getStandardPagination(total, queryParams.pageNum || 1, queryParams.pageSize || 10, handlePageChange)}
/>
</Card>

View File

@ -7,13 +7,17 @@ import {
message,
Popconfirm,
Space,
Table,
Tag,
Typography,
DatePicker,
Row,
Col,
Select
Select,
List,
Avatar,
Tooltip,
Divider,
Empty
} from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@ -29,13 +33,15 @@ import {
ShopOutlined,
CalendarOutlined,
PhoneOutlined,
UserOutlined
UserOutlined,
ClockCircleOutlined
} from "@ant-design/icons";
import type { SysTenant } from "../types";
import PageHeader from "../components/shared/PageHeader";
import dayjs from "dayjs";
import { getStandardPagination } from "../utils/pagination";
const { Title, Text } = Typography;
const { Title, Text, Paragraph } = Typography;
export default function Tenants() {
const { t } = useTranslation();
@ -50,7 +56,7 @@ export default function Tenants() {
const [total, setTotal] = useState(0);
const [params, setParams] = useState({
current: 1,
size: 10,
size: 12,
name: "",
code: ""
});
@ -82,7 +88,7 @@ export default function Tenants() {
const handleReset = () => {
const resetParams = {
current: 1,
size: 10,
size: 12,
name: "",
code: ""
};
@ -141,132 +147,145 @@ export default function Tenants() {
}
};
const columns = [
{
title: t('tenants.tenantInfo'),
key: "tenant",
render: (_: any, record: SysTenant) => (
<Space>
<div style={{ width: 40, height: 40, background: '#f0f5ff', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#1890ff', fontSize: 20 }}>
<ShopOutlined aria-hidden="true" />
</div>
<div>
<div style={{ fontWeight: 600, color: '#262626' }}>{record.tenantName}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="tabular-nums">{record.tenantCode}</div>
</div>
</Space>
),
},
{
title: t('tenants.contact'),
key: "contact",
render: (_: any, record: SysTenant) => (
<div>
<div><UserOutlined style={{ marginRight: 4, color: '#8c8c8c' }} />{record.contactName || "-"}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="tabular-nums"><PhoneOutlined style={{ marginRight: 4 }} />{record.contactPhone || "-"}</div>
</div>
)
},
{
title: t('common.status'),
dataIndex: "status",
width: 100,
render: (status: number) => {
const item = statusDict.find(i => i.itemValue === String(status));
return (
<Tag color={status === 1 ? "green" : "red"}>
{item ? item.itemLabel : (status === 1 ? "正常" : "禁用")}
</Tag>
);
},
},
{
title: t('tenants.expireTime'),
dataIndex: "expireTime",
width: 180,
render: (text: string) => (
<Space>
<CalendarOutlined style={{ color: '#8c8c8c' }} />
<Text className="tabular-nums">{text ? text.substring(0, 10) : t('tenants.forever')}</Text>
</Space>
)
},
{
title: t('common.action'),
key: "action",
width: 120,
fixed: "right" as const,
render: (_: any, record: SysTenant) => (
<Space>
{can("sys_tenant:update") && (
<Button
type="text"
icon={<EditOutlined aria-hidden="true" />}
onClick={() => openEdit(record)}
aria-label={t('common.edit')}
const renderTenantCard = (item: SysTenant) => {
const statusItem = statusDict.find(i => i.itemValue === String(item.status));
const isExpired = item.expireTime && dayjs().isAfter(dayjs(item.expireTime));
return (
<List.Item>
<Card
hoverable
className="tenant-card shadow-sm border-0"
style={{ borderRadius: '12px', overflow: 'hidden' }}
actions={[
can("sys_tenant:update") && (
<Tooltip title={t('common.edit')} key="edit-tip">
<EditOutlined key="edit" onClick={() => openEdit(item)} style={{ color: '#1677ff' }} />
</Tooltip>
),
can("sys_tenant:delete") && (
<Popconfirm key="delete-pop" title={`确定删除租户 "${item.tenantName}" 吗?`} onConfirm={() => handleDelete(item.id)}>
<DeleteOutlined key="delete" style={{ color: '#ff4d4f' }} />
</Popconfirm>
)
].filter(Boolean) as React.ReactNode[]}
>
<div style={{ display: 'flex', alignItems: 'flex-start', marginBottom: 16 }}>
<Avatar
size={48}
icon={<ShopOutlined />}
style={{ backgroundColor: item.status === 1 ? '#e6f4ff' : '#fff1f0', color: item.status === 1 ? '#1677ff' : '#ff4d4f', marginRight: 12, borderRadius: '8px' }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={5} style={{ margin: 0, fontSize: '16px' }} ellipsis={{ tooltip: item.tenantName }}>
{item.tenantName}
</Title>
<Tag color={item.status === 1 ? "green" : "red"} style={{ margin: 0, borderRadius: '4px' }}>
{statusItem ? statusItem.itemLabel : (item.status === 1 ? "正常" : "禁用")}
</Tag>
</div>
<Text type="secondary" style={{ fontSize: '12px' }} className="tabular-nums">
CODE: {item.tenantCode}
</Text>
</div>
</div>
<div className="card-content" style={{ fontSize: '13px' }}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', color: '#595959' }}>
<UserOutlined style={{ marginRight: 8, color: '#bfbfbf' }} />
<Text ellipsis={{ tooltip: item.contactName || "-" }}>{item.contactName || "-"}</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center', color: '#595959' }}>
<PhoneOutlined style={{ marginRight: 8, color: '#bfbfbf' }} />
<Text className="tabular-nums">{item.contactPhone || "-"}</Text>
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>
<ClockCircleOutlined style={{ marginRight: 8, color: '#bfbfbf' }} />
<Text type={isExpired ? "danger" : "secondary"}>
{item.expireTime ? item.expireTime.substring(0, 10) : t('tenants.forever')}
{isExpired && <Tag color="error" style={{ marginLeft: 8, transform: 'scale(0.8)' }}></Tag>}
</Text>
</div>
</Space>
</div>
{item.remark && (
<>
<Divider style={{ margin: '12px 0' }} />
<Paragraph ellipsis={{ rows: 2, tooltip: item.remark }} style={{ margin: 0, fontSize: '12px', color: '#8c8c8c', height: '36px' }}>
{item.remark}
</Paragraph>
</>
)}
{can("sys_tenant:delete") && (
<Popconfirm title={`确定删除租户 "${record.tenantName}" 吗?`} onConfirm={() => handleDelete(record.id)}>
<Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={t('common.delete')} />
</Popconfirm>
)}
</Space>
),
},
];
</Card>
</List.Item>
);
};
return (
<div className="p-6">
<div className="p-6 flex flex-col h-full overflow-hidden">
<PageHeader
title={t('tenants.title')}
subtitle={t('tenants.subtitle')}
extra={can("sys_tenant:create") && (
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate} style={{ borderRadius: '6px' }}>
{t('tenants.drawerTitleCreate')}
</Button>
)}
/>
<Card className="shadow-sm mb-4">
<Card className="shadow-sm mb-6 border-0 flex-shrink-0" style={{ borderRadius: '12px' }} styles={{ body: { padding: '16px' } }}>
<Space wrap size="middle">
<Input
placeholder={t('tenants.tenantName')}
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
style={{ width: 200 }}
style={{ width: 220, borderRadius: '6px' }}
value={params.name}
onChange={e => setParams({ ...params, name: e.target.value })}
allowClear
/>
<Input
placeholder={t('tenants.tenantCode')}
style={{ width: 180 }}
style={{ width: 180, borderRadius: '6px' }}
value={params.code}
onChange={e => setParams({ ...params, code: e.target.value })}
allowClear
/>
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t('common.search')}</Button>
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={handleReset}>{t('common.reset')}</Button>
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch} style={{ borderRadius: '6px' }}>
{t('common.search')}
</Button>
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={handleReset} style={{ borderRadius: '6px' }}>
{t('common.reset')}
</Button>
</Space>
</Card>
<Card className="shadow-sm" styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
columns={columns}
dataSource={data}
<div className="flex-1 overflow-y-auto pr-2">
<List
grid={{
gutter: 24,
xs: 1,
sm: 2,
md: 2,
lg: 3,
xl: 4,
xxl: 4,
}}
loading={loading}
size="middle"
dataSource={data}
renderItem={renderTenantCard}
pagination={{
current: params.current,
pageSize: params.size,
total: total,
showSizeChanger: true,
onChange: (page, size) => setParams({ ...params, current: page, size }),
showTotal: (total) => t('common.total', { total })
...getStandardPagination(total, params.current, params.size, (page, size) => setParams({ ...params, current: page, size })),
align: 'center',
style: { marginTop: '24px', marginBottom: '24px' }
}}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无租户数据" />
}}
/>
</Card>
</div>
<Drawer
title={

View File

@ -43,6 +43,7 @@ import {
MinusCircleOutlined
} from "@ant-design/icons";
import PageHeader from "../components/shared/PageHeader";
import { getStandardPagination } from "../utils/pagination";
const { Title, Text } = Typography;
@ -124,6 +125,10 @@ export default function Users() {
const activeTenantId = useMemo(() => Number(localStorage.getItem("activeTenantId") || 0), []);
// Pagination state
const [current, setCurrent] = useState(1);
const [pageSize, setPageSize] = useState(10);
// Search state
const [searchText, setSearchText] = useState("");
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
@ -411,7 +416,7 @@ export default function Users() {
];
return (
<div className="users-page p-6">
<div className="users-page p-6 flex flex-col h-full overflow-hidden">
<PageHeader
title={t('users.title')}
subtitle={t('users.subtitle')}
@ -422,8 +427,8 @@ export default function Users() {
)}
/>
<Card className="users-table-card shadow-sm">
<div className="users-table-toolbar mb-4">
<Card className="users-table-card shadow-sm mb-4 flex-shrink-0" styles={{ body: { padding: '16px' } }}>
<div className="users-table-toolbar">
<Space size="middle" wrap>
{isPlatformMode && (
<Select
@ -442,25 +447,33 @@ export default function Users() {
className="users-search-input"
style={{ width: 300 }}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onChange={(e) => {
setSearchText(e.target.value);
setCurrent(1); // Reset to first page on search
}}
allowClear
aria-label={t('common.search')}
/>
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadUsersData}>{t('common.refresh')}</Button>
</Space>
</div>
</Card>
<Table
rowKey="userId"
columns={columns}
dataSource={filteredData}
loading={loading}
size="middle"
pagination={{
showTotal: (total) => t('common.total', { total }),
pageSize: 10,
}}
/>
<Card className="shadow-sm flex-1 flex flex-col overflow-hidden" styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}>
<div className="flex-1 min-h-0 h-full">
<Table
rowKey="userId"
columns={columns}
dataSource={filteredData}
loading={loading}
size="middle"
scroll={{ y: 'calc(100vh - 420px)' }}
pagination={getStandardPagination(filteredData.length, current, pageSize, (p, s) => {
setCurrent(p);
setPageSize(s);
})}
/>
</div>
</Card>
<Drawer

View File

@ -22,6 +22,7 @@ export interface SysUser extends BaseEntity {
export interface UserProfile {
userId: number;
tenantId: number;
username: string;
displayName: string;
email?: string;