refactor(layout): 重构应用布局和权限管理功能
- 更新AppLayout支持目录类型的菜单结构 - 实现菜单展开状态基于当前路径的自动管理 - 添加目录类型权限的过滤和渲染逻辑 - 优化页面布局的flexbox结构和滚动处理 - 修改权限管理页面支持目录、菜单、按钮三级结构 - 更新字典类型API支持分页查询参数 - 调整多个页面的表格布局和滚动配置 - 添加标准分页工具函数并统一使用 - 更新租户管理页面为卡片列表展示方式master
parent
1ae81909c2
commit
3f31ec0eb1
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ src
|
|||
> 阅读对应的后端controller了解接口
|
||||
### 核心原则
|
||||
|
||||
* 优先使用frontend/src/components/shared中的组件 如果是typeScript 需要修改为typeScript
|
||||
* 清晰的意图胜于技巧性的实现
|
||||
* 组件简单直观优于过度抽象
|
||||
* 奥卡姆剃刀:不必要的复杂度一律删除
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/* 行选中样式 */
|
||||
|
|
|
|||
|
|
@ -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" : "",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export interface SysUser extends BaseEntity {
|
|||
|
||||
export interface UserProfile {
|
||||
userId: number;
|
||||
tenantId: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
email?: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue