feat(pages): 完善前端页面功能与用户体验优化
- 在Dashboard页面增加表格操作列和刷新按钮,优化样式布局 - 重构Devices页面实现设备管理功能,包括搜索、分页和状态显示 - 优化Dictionaries页面字典管理界面,改进交互体验和数据展示 - 更新Login页面样式设计,提升登录界面视觉效果和响应式适配 - 统一图标无障碍访问属性,增强组件可访问性支持master
parent
a1db81892c
commit
b138960f4b
|
|
@ -53,23 +53,24 @@ function StatCard({
|
||||||
<div className="stat-card-header">
|
<div className="stat-card-header">
|
||||||
<span className="stat-card-title">{title}</span>
|
<span className="stat-card-title">{title}</span>
|
||||||
{icon && (
|
{icon && (
|
||||||
<span className="stat-card-icon" style={{ color: themeColor }}>
|
<span className="stat-card-icon" style={{ color: themeColor }} aria-hidden="true">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="stat-card-body">
|
<div className="stat-card-body">
|
||||||
<div className="stat-card-value" style={{ color: themeColor }}>
|
<div className="stat-card-value tabular-nums" style={{ color: themeColor }}>
|
||||||
{value}
|
{value}
|
||||||
{suffix && <span className="stat-card-suffix">{suffix}</span>}
|
{suffix && <span className="stat-card-suffix">{suffix}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{trend && (
|
{trend && (
|
||||||
<div
|
<div
|
||||||
className={`stat-card-trend ${trend.direction === 'up' ? 'trend-up' : 'trend-down'}`}
|
className={`stat-card-trend ${trend.direction === 'up' ? 'trend-up' : 'trend-down'} tabular-nums`}
|
||||||
|
aria-label={`${trend.direction === 'up' ? 'Increase' : 'Decrease'} of ${trend.value}%`}
|
||||||
>
|
>
|
||||||
{trend.direction === 'up' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
{trend.direction === 'up' ? <ArrowUpOutlined aria-hidden="true" /> : <ArrowDownOutlined aria-hidden="true" />}
|
||||||
<span>{Math.abs(trend.value)}%</span>
|
<span>{Math.abs(trend.value)}%</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import { Row, Col, Card, Typography, Table, Tag, Space } from "antd";
|
import { Row, Col, Card, Typography, Table, Tag, Space, Skeleton, Button } from "antd";
|
||||||
import {
|
import {
|
||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
SyncOutlined
|
SyncOutlined,
|
||||||
|
ArrowRightOutlined
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import StatCard from "../components/shared/StatCard/StatCard";
|
import StatCard from "../components/shared/StatCard/StatCard";
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
const recentMeetings = [
|
const recentMeetings = [
|
||||||
{ key: '1', name: '产品周会', type: '转录中', time: '2024-02-10 14:00', duration: '45min', status: 'processing' },
|
{ key: '1', name: '产品周会', type: '转录中', time: '2024-02-10 14:00', duration: '45min', status: 'processing' },
|
||||||
|
|
@ -19,26 +20,54 @@ const recentMeetings = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: '会议名称', dataIndex: 'name', key: 'name' },
|
{
|
||||||
{ title: '开始时间', dataIndex: 'time', key: 'time' },
|
title: '会议名称',
|
||||||
{ title: '时长', dataIndex: 'duration', key: 'duration' },
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (text: string) => <Text strong>{text}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '开始时间',
|
||||||
|
dataIndex: 'time',
|
||||||
|
key: 'time',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
render: (text: string) => <Text type="secondary">{text}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时长',
|
||||||
|
dataIndex: 'duration',
|
||||||
|
key: 'duration',
|
||||||
|
width: 100,
|
||||||
|
className: 'tabular-nums'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
render: (status: string, record: any) => {
|
width: 120,
|
||||||
if (status === 'processing') return <Tag icon={<SyncOutlined spin />} color="processing">转录中</Tag>;
|
render: (status: string) => {
|
||||||
if (status === 'success') return <Tag icon={<CheckCircleOutlined />} color="success">已完成</Tag>;
|
if (status === 'processing') return <Tag icon={<SyncOutlined spin aria-hidden="true" />} color="processing">转录中</Tag>;
|
||||||
|
if (status === 'success') return <Tag icon={<CheckCircleOutlined aria-hidden="true" />} color="success">已完成</Tag>;
|
||||||
return <Tag color="default">待开始</Tag>;
|
return <Tag color="default">待开始</Tag>;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 80,
|
||||||
|
render: () => <Button type="link" size="small" icon={<ArrowRightOutlined aria-hidden="true" />} aria-label="查看会议详情" />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
return (
|
return (
|
||||||
<div className="dashboard-page">
|
<div className="dashboard-page p-6">
|
||||||
<div style={{ marginBottom: 24 }}>
|
<div className="mb-6 flex justify-between items-end">
|
||||||
<Title level={4}>系统总览</Title>
|
<div>
|
||||||
|
<Title level={4} className="mb-1">系统总览</Title>
|
||||||
|
<Text type="secondary">实时监控会议转录状态与系统关键指标</Text>
|
||||||
|
</div>
|
||||||
|
<Button icon={<SyncOutlined aria-hidden="true" />} size="small">刷新数据</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={[24, 24]}>
|
||||||
|
|
@ -46,7 +75,7 @@ export default function Dashboard() {
|
||||||
<StatCard
|
<StatCard
|
||||||
title="今日会议"
|
title="今日会议"
|
||||||
value={12}
|
value={12}
|
||||||
icon={<VideoCameraOutlined />}
|
icon={<VideoCameraOutlined aria-hidden="true" />}
|
||||||
color="blue"
|
color="blue"
|
||||||
trend={{ value: 8, direction: 'up' }}
|
trend={{ value: 8, direction: 'up' }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -55,7 +84,7 @@ export default function Dashboard() {
|
||||||
<StatCard
|
<StatCard
|
||||||
title="活跃设备"
|
title="活跃设备"
|
||||||
value={45}
|
value={45}
|
||||||
icon={<DesktopOutlined />}
|
icon={<DesktopOutlined aria-hidden="true" />}
|
||||||
color="green"
|
color="green"
|
||||||
trend={{ value: 2, direction: 'up' }}
|
trend={{ value: 2, direction: 'up' }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -65,7 +94,7 @@ export default function Dashboard() {
|
||||||
title="转录时长"
|
title="转录时长"
|
||||||
value={1280}
|
value={1280}
|
||||||
suffix="min"
|
suffix="min"
|
||||||
icon={<ClockCircleOutlined />}
|
icon={<ClockCircleOutlined aria-hidden="true" />}
|
||||||
color="orange"
|
color="orange"
|
||||||
trend={{ value: 5, direction: 'down' }}
|
trend={{ value: 5, direction: 'down' }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -74,31 +103,39 @@ export default function Dashboard() {
|
||||||
<StatCard
|
<StatCard
|
||||||
title="总用户数"
|
title="总用户数"
|
||||||
value={320}
|
value={320}
|
||||||
icon={<UserOutlined />}
|
icon={<UserOutlined aria-hidden="true" />}
|
||||||
color="purple"
|
color="purple"
|
||||||
trend={{ value: 12, direction: 'up' }}
|
trend={{ value: 12, direction: 'up' }}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row gutter={[24, 24]} style={{ marginTop: 24 }}>
|
<Row gutter={[24, 24]} className="mt-6">
|
||||||
<Col span={16}>
|
<Col xs={24} xl={16}>
|
||||||
<Card title="最近会议" bordered={false} bodyStyle={{ padding: 0 }}>
|
<Card
|
||||||
|
title="最近会议"
|
||||||
|
bordered={false}
|
||||||
|
className="shadow-sm"
|
||||||
|
extra={<Button type="link" size="small">查看全部</Button>}
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
<Table
|
<Table
|
||||||
dataSource={recentMeetings}
|
dataSource={recentMeetings}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="middle"
|
size="middle"
|
||||||
|
className="roles-table"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col xs={24} xl={8}>
|
||||||
<Card title="设备负载" bordered={false}>
|
<Card title="设备负载" bordered={false} className="shadow-sm">
|
||||||
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}>
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
<Space direction="vertical" align="center">
|
<Skeleton active paragraph={{ rows: 4 }} />
|
||||||
<SyncOutlined style={{ fontSize: 48, color: '#f0f0f0' }} />
|
<div className="mt-4 text-gray-400 flex items-center gap-2">
|
||||||
<span>图表加载中...</span>
|
<SyncOutlined spin aria-hidden="true" />
|
||||||
</Space>
|
<span>图表引擎加载中…</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,87 @@
|
||||||
import { Button, Form, Input, Drawer, Popconfirm, Space, Table, Tag, Select, InputNumber } from "antd";
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Drawer,
|
||||||
|
Popconfirm,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Select,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
message
|
||||||
|
} from "antd";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { createDevice, deleteDevice, listDevices, updateDevice } from "../api";
|
import { createDevice, deleteDevice, listDevices, updateDevice, listUsers } from "../api";
|
||||||
import type { DeviceInfo } from "../types";
|
import type { DeviceInfo, SysUser } from "../types";
|
||||||
import { usePermission } from "../hooks/usePermission";
|
import { usePermission } from "../hooks/usePermission";
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
DesktopOutlined,
|
||||||
|
UserOutlined
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
import "./Devices.css";
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
export default function Devices() {
|
export default function Devices() {
|
||||||
|
const { can } = usePermission();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
const [data, setData] = useState<DeviceInfo[]>([]);
|
const [data, setData] = useState<DeviceInfo[]>([]);
|
||||||
const [query, setQuery] = useState({ userId: "", deviceCode: "", deviceName: "" });
|
const [users, setUsers] = useState<SysUser[]>([]);
|
||||||
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
|
|
||||||
|
// Search state
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
|
// Drawer state
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<DeviceInfo | null>(null);
|
const [editing, setEditing] = useState<DeviceInfo | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const { can } = usePermission();
|
|
||||||
|
|
||||||
const load = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const list = await listDevices();
|
const [deviceList, usersList] = await Promise.all([listDevices(), listUsers()]);
|
||||||
setData(list || []);
|
setData(deviceList || []);
|
||||||
|
setUsers(usersList || []);
|
||||||
|
} catch (e) {
|
||||||
|
message.error("加载数据失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
loadData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const userMap = useMemo(() => {
|
||||||
return data.filter((d) => {
|
const map: Record<number, SysUser> = {};
|
||||||
const hitUser = query.userId ? String(d.userId).includes(query.userId) : true;
|
users.forEach(u => map[u.userId] = u);
|
||||||
const hitCode = query.deviceCode ? d.deviceCode?.includes(query.deviceCode) : true;
|
return map;
|
||||||
const hitName = query.deviceName ? (d.deviceName || "").includes(query.deviceName) : true;
|
}, [users]);
|
||||||
return hitUser && hitCode && hitName;
|
|
||||||
});
|
|
||||||
}, [data, query]);
|
|
||||||
|
|
||||||
const pageData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const start = (pagination.current - 1) * pagination.pageSize;
|
if (!searchText) return data;
|
||||||
return filtered.slice(start, start + pagination.pageSize);
|
const lower = searchText.toLowerCase();
|
||||||
}, [filtered, pagination]);
|
return data.filter(d => {
|
||||||
|
const user = userMap[d.userId];
|
||||||
|
return d.deviceCode.toLowerCase().includes(lower) ||
|
||||||
|
(d.deviceName && d.deviceName.toLowerCase().includes(lower)) ||
|
||||||
|
(user && user.displayName.toLowerCase().includes(lower)) ||
|
||||||
|
String(d.userId).includes(lower);
|
||||||
|
});
|
||||||
|
}, [data, searchText, userMap]);
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
|
form.setFieldsValue({ status: 1 });
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -55,111 +92,195 @@ export default function Devices() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const values = await form.validateFields();
|
try {
|
||||||
const payload: Partial<DeviceInfo> = {
|
const values = await form.validateFields();
|
||||||
userId: values.userId,
|
setSaving(true);
|
||||||
deviceCode: values.deviceCode,
|
const payload: Partial<DeviceInfo> = {
|
||||||
deviceName: values.deviceName,
|
userId: values.userId,
|
||||||
status: values.status
|
deviceCode: values.deviceCode,
|
||||||
};
|
deviceName: values.deviceName,
|
||||||
if (editing) {
|
status: values.status
|
||||||
await updateDevice(editing.deviceId, payload);
|
};
|
||||||
} else {
|
if (editing) {
|
||||||
await createDevice(payload);
|
await updateDevice(editing.deviceId, payload);
|
||||||
|
message.success("设备已更新");
|
||||||
|
} else {
|
||||||
|
await createDevice(payload);
|
||||||
|
message.success("设备已创建");
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
loadData();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message) message.error(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
setOpen(false);
|
|
||||||
load();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (id: number) => {
|
const remove = async (id: number) => {
|
||||||
await deleteDevice(id);
|
try {
|
||||||
load();
|
await deleteDevice(id);
|
||||||
|
message.success("设备已删除");
|
||||||
|
loadData();
|
||||||
|
} catch (e) {
|
||||||
|
message.error("删除失败");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const columns = [
|
||||||
<div>
|
{
|
||||||
<Space style={{ marginBottom: 16 }}>
|
title: "设备信息",
|
||||||
<Input
|
key: "device",
|
||||||
placeholder="用户ID"
|
render: (_: any, record: DeviceInfo) => (
|
||||||
value={query.userId}
|
<Space>
|
||||||
onChange={(e) => setQuery({ ...query, userId: e.target.value })}
|
<div className="device-icon-placeholder">
|
||||||
/>
|
<DesktopOutlined aria-hidden="true" />
|
||||||
<Input
|
</div>
|
||||||
placeholder="设备码"
|
<div>
|
||||||
value={query.deviceCode}
|
<div className="device-name">{record.deviceName || "未命名设备"}</div>
|
||||||
onChange={(e) => setQuery({ ...query, deviceCode: e.target.value })}
|
<div className="device-code tabular-nums">{record.deviceCode}</div>
|
||||||
/>
|
</div>
|
||||||
<Input
|
</Space>
|
||||||
placeholder="设备名"
|
),
|
||||||
value={query.deviceName}
|
},
|
||||||
onChange={(e) => setQuery({ ...query, deviceName: e.target.value })}
|
{
|
||||||
/>
|
title: "归属用户",
|
||||||
{can("device:create") && (
|
key: "user",
|
||||||
<Button type="primary" onClick={openCreate}>新增</Button>
|
render: (_: any, record: DeviceInfo) => {
|
||||||
)}
|
const user = userMap[record.userId];
|
||||||
</Space>
|
return user ? (
|
||||||
|
<Space>
|
||||||
|
<UserOutlined aria-hidden="true" style={{ color: '#8c8c8c' }} />
|
||||||
|
<span>{user.displayName}</span>
|
||||||
|
<Text type="secondary" size="small" className="tabular-nums">(ID: {record.userId})</Text>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<span className="tabular-nums">ID: {record.userId}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
dataIndex: "status",
|
||||||
|
width: 100,
|
||||||
|
render: (status: number) => (
|
||||||
|
<Tag color={status === 1 ? "green" : "red"}>
|
||||||
|
{status === 1 ? "启用" : "禁用"}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "更新时间",
|
||||||
|
dataIndex: "updatedAt",
|
||||||
|
width: 180,
|
||||||
|
render: (text: string) => <Text type="secondary" className="tabular-nums">{text?.replace('T', ' ').substring(0, 19)}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
width: 120,
|
||||||
|
fixed: "right" as const,
|
||||||
|
render: (_: any, record: DeviceInfo) => (
|
||||||
|
<Space>
|
||||||
|
{can("device:update") && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<EditOutlined aria-hidden="true" />}
|
||||||
|
onClick={() => openEdit(record)}
|
||||||
|
aria-label={`编辑设备 ${record.deviceName || record.deviceCode}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{can("device:delete") && (
|
||||||
|
<Popconfirm title="确定删除该设备吗?" onConfirm={() => remove(record.deviceId)}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined aria-hidden="true" />}
|
||||||
|
aria-label={`删除设备 ${record.deviceName || record.deviceCode}`}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
<Table
|
return (
|
||||||
rowKey="deviceId"
|
<div className="devices-page">
|
||||||
loading={loading}
|
<div className="devices-header">
|
||||||
dataSource={pageData}
|
<div>
|
||||||
pagination={{
|
<Title level={4} className="devices-title">设备管理</Title>
|
||||||
current: pagination.current,
|
<Text type="secondary">管理接入系统的硬件终端及关联用户</Text>
|
||||||
pageSize: pagination.pageSize,
|
</div>
|
||||||
total: filtered.length,
|
{can("device:create") && (
|
||||||
onChange: (current, pageSize) => setPagination({ current, pageSize })
|
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
|
||||||
}}
|
接入设备
|
||||||
columns={[
|
</Button>
|
||||||
{ title: "ID", dataIndex: "deviceId" },
|
)}
|
||||||
{ title: "用户ID", dataIndex: "userId" },
|
</div>
|
||||||
{ title: "设备码", dataIndex: "deviceCode" },
|
|
||||||
{ title: "设备名", dataIndex: "deviceName" },
|
<Card className="devices-table-card shadow-sm">
|
||||||
{
|
<div className="devices-table-toolbar">
|
||||||
title: "状态",
|
<Input
|
||||||
dataIndex: "status",
|
placeholder="搜索设备名称、编码或归属用户…"
|
||||||
render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag>)
|
prefix={<SearchOutlined aria-hidden="true" />}
|
||||||
},
|
className="devices-search-input"
|
||||||
{
|
value={searchText}
|
||||||
title: "操作",
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
render: (_, record) => (
|
allowClear
|
||||||
<Space>
|
aria-label="搜索设备"
|
||||||
{can("device:update") && <Button onClick={() => openEdit(record)}>编辑</Button>}
|
/>
|
||||||
{can("device:delete") && (
|
</div>
|
||||||
<Popconfirm title="确认删除?" onConfirm={() => remove(record.deviceId)}>
|
|
||||||
<Button danger>删除</Button>
|
<Table
|
||||||
</Popconfirm>
|
rowKey="deviceId"
|
||||||
)}
|
columns={columns}
|
||||||
</Space>
|
dataSource={filteredData}
|
||||||
)
|
loading={loading}
|
||||||
}
|
pagination={{
|
||||||
]}
|
showTotal: (total) => `共 ${total} 条数据`,
|
||||||
/>
|
pageSize: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title={editing ? "编辑设备" : "新增设备"}
|
title={
|
||||||
|
<div className="device-drawer-title">
|
||||||
|
<DesktopOutlined className="mr-2" aria-hidden="true" />
|
||||||
|
{editing ? "修改设备信息" : "接入新设备"}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
width={420}
|
width={420}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
footer={
|
footer={
|
||||||
<Space style={{ width: "100%", justifyContent: "flex-end" }}>
|
<div className="flex justify-end gap-2 p-2">
|
||||||
<Button onClick={() => setOpen(false)}>取消</Button>
|
<Button onClick={() => setOpen(false)}>取消</Button>
|
||||||
<Button type="primary" onClick={submit}>确认</Button>
|
<Button type="primary" loading={saving} onClick={submit}>
|
||||||
</Space>
|
确认保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item label="用户ID" name="userId" rules={[{ required: true }]}>
|
<Form.Item label="归属用户" name="userId" rules={[{ required: true, message: "请选择归属用户" }]}>
|
||||||
<InputNumber style={{ width: "100%" }} min={1} />
|
<Select
|
||||||
|
showSearch
|
||||||
|
placeholder="搜索并选择用户"
|
||||||
|
optionFilterProp="children"
|
||||||
|
options={users.map(u => ({ label: `${u.displayName} (@${u.username})`, value: u.userId }))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="设备码" name="deviceCode" rules={[{ required: true }]}>
|
<Form.Item label="设备识别码" name="deviceCode" rules={[{ required: true, message: "请输入设备识别码" }]}>
|
||||||
<Input />
|
<Input placeholder="由硬件生成的唯一识别码" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="设备名" name="deviceName">
|
<Form.Item label="设备名称" name="deviceName">
|
||||||
<Input />
|
<Input placeholder="例如:会议室 A 转录仪" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="状态" name="status" initialValue={1}>
|
<Form.Item label="设备状态" name="status" initialValue={1}>
|
||||||
<Select options={[{ value: 1, label: "启用" }, { value: 0, label: "禁用" }]} />
|
<Select options={[{ value: 1, label: "正常启用" }, { value: 0, label: "禁用接入" }]} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ import {
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography
|
Typography,
|
||||||
|
Empty
|
||||||
} from "antd";
|
} from "antd";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,10 +28,11 @@ import {
|
||||||
updateDictType
|
updateDictType
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { usePermission } from "../hooks/usePermission";
|
import { usePermission } from "../hooks/usePermission";
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from "@ant-design/icons";
|
import { PlusOutlined, EditOutlined, DeleteOutlined, BookOutlined, ProfileOutlined } from "@ant-design/icons";
|
||||||
import type { SysDictItem, SysDictType } from "../types";
|
import type { SysDictItem, SysDictType } from "../types";
|
||||||
|
import "./Dictionaries.css";
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
export default function Dictionaries() {
|
export default function Dictionaries() {
|
||||||
const { can } = usePermission();
|
const { can } = usePermission();
|
||||||
|
|
@ -100,7 +102,7 @@ export default function Dictionaries() {
|
||||||
|
|
||||||
const handleDeleteType = async (id: number) => {
|
const handleDeleteType = async (id: number) => {
|
||||||
await deleteDictType(id);
|
await deleteDictType(id);
|
||||||
message.success("删除成功");
|
message.success("类型删除成功");
|
||||||
loadTypes();
|
loadTypes();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -111,7 +113,7 @@ export default function Dictionaries() {
|
||||||
} else {
|
} else {
|
||||||
await createDictType(values);
|
await createDictType(values);
|
||||||
}
|
}
|
||||||
message.success(editingType ? "更新成功" : "创建成功");
|
message.success(editingType ? "类型更新成功" : "类型创建成功");
|
||||||
setTypeDrawerVisible(false);
|
setTypeDrawerVisible(false);
|
||||||
loadTypes();
|
loadTypes();
|
||||||
};
|
};
|
||||||
|
|
@ -119,7 +121,7 @@ export default function Dictionaries() {
|
||||||
// Item Actions
|
// Item Actions
|
||||||
const handleAddItem = () => {
|
const handleAddItem = () => {
|
||||||
if (!selectedType) {
|
if (!selectedType) {
|
||||||
message.warning("请先选择一个字典类型");
|
message.warning("请先从左侧选择一个字典类型");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
|
|
@ -136,7 +138,7 @@ export default function Dictionaries() {
|
||||||
|
|
||||||
const handleDeleteItem = async (id: number) => {
|
const handleDeleteItem = async (id: number) => {
|
||||||
await deleteDictItem(id);
|
await deleteDictItem(id);
|
||||||
message.success("删除成功");
|
message.success("字典项删除成功");
|
||||||
if (selectedType) loadItems(selectedType.typeCode);
|
if (selectedType) loadItems(selectedType.typeCode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -147,187 +149,270 @@ export default function Dictionaries() {
|
||||||
} else {
|
} else {
|
||||||
await createDictItem(values);
|
await createDictItem(values);
|
||||||
}
|
}
|
||||||
message.success(editingItem ? "更新成功" : "创建成功");
|
message.success(editingItem ? "字典项更新成功" : "字典项创建成功");
|
||||||
setItemDrawerVisible(false);
|
setItemDrawerVisible(false);
|
||||||
if (selectedType) loadItems(selectedType.typeCode);
|
if (selectedType) loadItems(selectedType.typeCode);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="dictionaries-page">
|
||||||
<Title level={4} className="mb-6">字典管理</Title>
|
<div className="dictionaries-header">
|
||||||
<Row gutter={16}>
|
<div>
|
||||||
<Col span={8}>
|
<Title level={4} className="dictionaries-title">数据字典管理</Title>
|
||||||
|
<Text type="secondary">维护系统各类枚举值和常量的映射关系</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={24} className="dictionaries-content">
|
||||||
|
<Col span={8} className="full-height">
|
||||||
<Card
|
<Card
|
||||||
title="字典类型"
|
title={
|
||||||
|
<Space>
|
||||||
|
<BookOutlined aria-hidden="true" />
|
||||||
|
<span>字典类型</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
className="full-height-card shadow-sm"
|
||||||
extra={
|
extra={
|
||||||
can("sys_dict:type:create") && (
|
can("sys_dict:type:create") && (
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddType}>
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined aria-hidden="true" />}
|
||||||
|
onClick={handleAddType}
|
||||||
|
aria-label="新增字典类型"
|
||||||
|
>
|
||||||
新增
|
新增
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table
|
<div className="scroll-container">
|
||||||
rowKey="dictTypeId"
|
<Table
|
||||||
loading={loadingTypes}
|
rowKey="dictTypeId"
|
||||||
dataSource={types}
|
loading={loadingTypes}
|
||||||
pagination={false}
|
dataSource={types}
|
||||||
size="small"
|
pagination={false}
|
||||||
onRow={(record) => ({
|
size="small"
|
||||||
onClick: () => setSelectedType(record),
|
showHeader={false}
|
||||||
className: `cursor-pointer ${selectedType?.dictTypeId === record.dictTypeId ? "ant-table-row-selected" : ""}`
|
onRow={(record) => ({
|
||||||
})}
|
onClick: () => setSelectedType(record),
|
||||||
columns={[
|
className: `cursor-pointer dict-type-row ${selectedType?.dictTypeId === record.dictTypeId ? "dict-type-row-selected" : ""}`
|
||||||
{ title: "类型名称", dataIndex: "typeName" },
|
})}
|
||||||
{ title: "编码", dataIndex: "typeCode" },
|
columns={[
|
||||||
{
|
{
|
||||||
title: "操作",
|
render: (_, record) => (
|
||||||
width: 100,
|
<div className="dict-type-item">
|
||||||
render: (_, record) => (
|
<div className="min-w-0 flex-1">
|
||||||
<Space>
|
<div className="dict-type-name truncate">{record.typeName}</div>
|
||||||
{can("sys_dict:type:update") && (
|
<div className="dict-type-code truncate tabular-nums">{record.typeCode}</div>
|
||||||
<Button
|
</div>
|
||||||
type="text"
|
<div className="dict-type-actions">
|
||||||
size="small"
|
{can("sys_dict:type:update") && (
|
||||||
icon={<EditOutlined />}
|
<Button
|
||||||
onClick={(e) => {
|
type="text"
|
||||||
e.stopPropagation();
|
size="small"
|
||||||
handleEditType(record);
|
icon={<EditOutlined aria-hidden="true" />}
|
||||||
}}
|
onClick={(e) => {
|
||||||
/>
|
e.stopPropagation();
|
||||||
)}
|
handleEditType(record);
|
||||||
{can("sys_dict:type:delete") && (
|
}}
|
||||||
<Popconfirm
|
aria-label={`编辑类型 ${record.typeName}`}
|
||||||
title="删除类型会影响关联的项,确认删除?"
|
/>
|
||||||
onConfirm={(e) => {
|
)}
|
||||||
e?.stopPropagation();
|
{can("sys_dict:type:delete") && (
|
||||||
handleDeleteType(record.dictTypeId);
|
<Popconfirm
|
||||||
}}
|
title={`确定删除类型 "${record.typeName}" 吗?这会影响关联的字典项。`}
|
||||||
>
|
onConfirm={(e) => {
|
||||||
<Button
|
e?.stopPropagation();
|
||||||
type="text"
|
handleDeleteType(record.dictTypeId);
|
||||||
size="small"
|
}}
|
||||||
danger
|
>
|
||||||
icon={<DeleteOutlined />}
|
<Button
|
||||||
onClick={(e) => e.stopPropagation()}
|
type="text"
|
||||||
/>
|
size="small"
|
||||||
</Popconfirm>
|
danger
|
||||||
)}
|
icon={<DeleteOutlined aria-hidden="true" />}
|
||||||
</Space>
|
onClick={(e) => e.stopPropagation()}
|
||||||
)
|
aria-label={`删除类型 ${record.typeName}`}
|
||||||
}
|
/>
|
||||||
]}
|
</Popconfirm>
|
||||||
/>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={16}>
|
|
||||||
|
<Col span={16} className="full-height">
|
||||||
<Card
|
<Card
|
||||||
title={`字典项 - ${selectedType?.typeName || "未选择"}`}
|
title={
|
||||||
|
<Space>
|
||||||
|
<ProfileOutlined aria-hidden="true" />
|
||||||
|
<span>字典项内容{selectedType ? ` - ${selectedType.typeName}` : ""}</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
className="full-height-card shadow-sm"
|
||||||
extra={
|
extra={
|
||||||
can("sys_dict:item:create") && (
|
can("sys_dict:item:create") && (
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddItem} disabled={!selectedType}>
|
<Button
|
||||||
新增
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined aria-hidden="true" />}
|
||||||
|
onClick={handleAddItem}
|
||||||
|
disabled={!selectedType}
|
||||||
|
aria-label="新增字典项"
|
||||||
|
>
|
||||||
|
新增字典项
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table
|
{selectedType ? (
|
||||||
rowKey="dictItemId"
|
<div className="scroll-container">
|
||||||
loading={loadingItems}
|
<Table
|
||||||
dataSource={items}
|
rowKey="dictItemId"
|
||||||
pagination={false}
|
loading={loadingItems}
|
||||||
columns={[
|
dataSource={items}
|
||||||
{ title: "标签", dataIndex: "itemLabel" },
|
pagination={false}
|
||||||
{ title: "数值", dataIndex: "itemValue" },
|
size="middle"
|
||||||
{ title: "排序", dataIndex: "sortOrder", width: 80 },
|
columns={[
|
||||||
{
|
{
|
||||||
title: "状态",
|
title: "展示标签",
|
||||||
dataIndex: "status",
|
dataIndex: "itemLabel",
|
||||||
width: 80,
|
render: (text) => <Text strong>{text}</Text>
|
||||||
render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag>)
|
},
|
||||||
},
|
{
|
||||||
{
|
title: "数据数值",
|
||||||
title: "操作",
|
dataIndex: "itemValue",
|
||||||
width: 120,
|
className: "tabular-nums"
|
||||||
render: (_, record) => (
|
},
|
||||||
<Space>
|
{
|
||||||
{can("sys_dict:item:update") && (
|
title: "排序",
|
||||||
<Button
|
dataIndex: "sortOrder",
|
||||||
type="text"
|
width: 80,
|
||||||
size="small"
|
className: "tabular-nums"
|
||||||
icon={<EditOutlined />}
|
},
|
||||||
onClick={() => handleEditItem(record)}
|
{
|
||||||
/>
|
title: "状态",
|
||||||
)}
|
dataIndex: "status",
|
||||||
{can("sys_dict:item:delete") && (
|
width: 100,
|
||||||
<Popconfirm title="确认删除该项?" onConfirm={() => handleDeleteItem(record.dictItemId)}>
|
render: (v) => (
|
||||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
<Tag color={v === 1 ? "green" : "red"}>
|
||||||
</Popconfirm>
|
{v === 1 ? "启用" : "禁用"}
|
||||||
)}
|
</Tag>
|
||||||
</Space>
|
)
|
||||||
)
|
},
|
||||||
}
|
{
|
||||||
]}
|
title: "操作",
|
||||||
/>
|
width: 120,
|
||||||
|
fixed: "right" as const,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
{can("sys_dict:item:update") && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined aria-hidden="true" />}
|
||||||
|
onClick={() => handleEditItem(record)}
|
||||||
|
aria-label={`编辑字典项 ${record.itemLabel}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{can("sys_dict:item:delete") && (
|
||||||
|
<Popconfirm title={`确定删除字典项 "${record.itemLabel}" 吗?`} onConfirm={() => handleDeleteItem(record.dictItemId)}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined aria-hidden="true" />}
|
||||||
|
aria-label={`删除字典项 ${record.itemLabel}`}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-center h-full">
|
||||||
|
<Empty description="请先从左侧选择一个字典类型" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Type Drawer */}
|
{/* Type Drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
title={editingType ? "编辑字典类型" : "新增字典类型"}
|
title={
|
||||||
|
<Space>
|
||||||
|
<BookOutlined aria-hidden="true" />
|
||||||
|
<span>{editingType ? "编辑字典类型" : "新增字典类型"}</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
open={typeDrawerVisible}
|
open={typeDrawerVisible}
|
||||||
onClose={() => setTypeDrawerVisible(false)}
|
onClose={() => setTypeDrawerVisible(false)}
|
||||||
width={400}
|
width={400}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
footer={
|
footer={
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button onClick={() => setTypeDrawerVisible(false)}>取消</Button>
|
<Button onClick={() => setTypeDrawerVisible(false)}>取消</Button>
|
||||||
<Button type="primary" onClick={handleTypeSubmit}>确认</Button>
|
<Button type="primary" onClick={handleTypeSubmit}>确认提交</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form form={typeForm} layout="vertical">
|
<Form form={typeForm} layout="vertical">
|
||||||
<Form.Item label="类型编码" name="typeCode" rules={[{ required: true }]}>
|
<Form.Item label="类型编码" name="typeCode" rules={[{ required: true, message: "请输入类型编码" }]}>
|
||||||
<Input disabled={!!editingType} placeholder="例如:user_status" />
|
<Input disabled={!!editingType} placeholder="例如:user_status…" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="类型名称" name="typeName" rules={[{ required: true }]}>
|
<Form.Item label="类型名称" name="typeName" rules={[{ required: true, message: "请输入类型名称" }]}>
|
||||||
<Input placeholder="例如:用户状态" />
|
<Input placeholder="例如:用户状态…" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="备注" name="remark">
|
<Form.Item label="备注说明" name="remark">
|
||||||
<Input.TextArea />
|
<Input.TextArea placeholder="该字典类型的用途描述…" rows={3} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
{/* Item Drawer */}
|
{/* Item Drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
title={editingItem ? "编辑字典项" : "新增字典项"}
|
title={
|
||||||
|
<Space>
|
||||||
|
<ProfileOutlined aria-hidden="true" />
|
||||||
|
<span>{editingItem ? "编辑字典项" : "新增字典项"}</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
open={itemDrawerVisible}
|
open={itemDrawerVisible}
|
||||||
onClose={() => setItemDrawerVisible(false)}
|
onClose={() => setItemDrawerVisible(false)}
|
||||||
width={400}
|
width={400}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
footer={
|
footer={
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button onClick={() => setItemDrawerVisible(false)}>取消</Button>
|
<Button onClick={() => setItemDrawerVisible(false)}>取消</Button>
|
||||||
<Button type="primary" onClick={handleItemSubmit}>确认</Button>
|
<Button type="primary" onClick={handleItemSubmit}>确认保存</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form form={itemForm} layout="vertical">
|
<Form form={itemForm} layout="vertical">
|
||||||
<Form.Item label="所属类型" name="typeCode">
|
<Form.Item label="所属类型" name="typeCode">
|
||||||
<Input disabled />
|
<Input disabled className="tabular-nums" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="标签" name="itemLabel" rules={[{ required: true }]}>
|
<Form.Item label="显示标签" name="itemLabel" rules={[{ required: true, message: "请输入展示标签" }]}>
|
||||||
<Input placeholder="例如:启用" />
|
<Input placeholder="例如:正常、禁用…" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="数值" name="itemValue" rules={[{ required: true }]}>
|
<Form.Item label="存储数值" name="itemValue" rules={[{ required: true, message: "请输入数值" }]}>
|
||||||
<Input placeholder="例如:1" />
|
<Input placeholder="例如:1、0…" className="tabular-nums" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="排序" name="sortOrder" initialValue={0}>
|
<Form.Item label="显示排序" name="sortOrder" initialValue={0}>
|
||||||
<InputNumber className="w-full" />
|
<InputNumber className="w-full tabular-nums" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="状态" name="status" initialValue={1}>
|
<Form.Item label="当前状态" name="status" initialValue={1}>
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={[
|
||||||
{ label: "启用", value: 1 },
|
{ label: "启用", value: 1 },
|
||||||
|
|
@ -335,8 +420,8 @@ export default function Dictionaries() {
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="备注" name="remark">
|
<Form.Item label="备注说明" name="remark">
|
||||||
<Input.TextArea />
|
<Input.TextArea placeholder="可选项,备注详细信息…" rows={3} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,14 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-brand {
|
.login-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-logo-img {
|
.brand-logo-img {
|
||||||
|
|
@ -36,39 +38,43 @@
|
||||||
|
|
||||||
.login-hero {
|
.login-hero {
|
||||||
margin-top: -40px;
|
margin-top: -40px;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
font-size: 36px;
|
font-size: 42px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
line-height: 1.25;
|
line-height: 1.2;
|
||||||
color: #1d2b3a;
|
color: #1d2b3a;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 24px;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: -0.5px;
|
||||||
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-accent {
|
.hero-accent {
|
||||||
color: #2d6bff;
|
color: #1677ff;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-desc {
|
.hero-desc {
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
color: #687489;
|
color: #687489;
|
||||||
max-width: 400px;
|
max-width: 440px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-left-footer {
|
.login-left-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
color: #8c97a8;
|
color: #8c97a8;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-divider {
|
.footer-divider {
|
||||||
width: 3px;
|
width: 4px;
|
||||||
height: 3px;
|
height: 4px;
|
||||||
background: #c4ccd7;
|
background: #c4ccd7;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
@ -85,32 +91,32 @@
|
||||||
|
|
||||||
.login-container {
|
.login-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 400px;
|
||||||
padding: 0 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header {
|
.login-header {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header h2 {
|
.login-header h2 {
|
||||||
font-size: 24px !important;
|
font-size: 28px !important;
|
||||||
font-weight: 600 !important;
|
font-weight: 700 !important;
|
||||||
color: #1f2a37 !important;
|
color: #1f2a37 !important;
|
||||||
margin-bottom: 8px !important;
|
margin-bottom: 8px !important;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header span {
|
.login-header span {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
color: #96a0af;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form .ant-form-item {
|
.login-form .ant-form-item {
|
||||||
margin-bottom: 24px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-form .ant-input-lg,
|
.login-form .ant-input-affix-wrapper-lg {
|
||||||
.login-form .ant-input-password-lg {
|
padding: 10px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,71 +125,92 @@
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha-image-container {
|
.captcha-image-btn {
|
||||||
|
padding: 0;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 40px;
|
height: 46px;
|
||||||
border: 1px solid #d9d9d9;
|
flex-shrink: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
display: flex;
|
||||||
background: #f5f5f5;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f9fafb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha-image-container img {
|
.captcha-image-btn img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.captcha-image-btn:hover img {
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-extra {
|
.login-extra {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: -8px;
|
margin-top: -4px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forgot-password {
|
.forgot-password {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #2d6bff;
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-submit-btn {
|
||||||
|
height: 48px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-footer {
|
.login-footer {
|
||||||
margin-top: 24px;
|
margin-top: 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 12px;
|
padding: 16px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabular-nums {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.login-left {
|
.login-left {
|
||||||
padding: 40px;
|
padding: 48px;
|
||||||
|
}
|
||||||
|
.hero-title {
|
||||||
|
font-size: 36px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 900px) {
|
||||||
.login-left {
|
.login-left {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.login-right {
|
.login-right {
|
||||||
background: #f6f9fe;
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
.login-container {
|
.login-container {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
padding: 48px 32px;
|
padding: 48px 32px;
|
||||||
border-radius: 16px;
|
border-radius: 20px;
|
||||||
box-shadow: 0 24px 60px rgba(30, 55, 90, 0.08);
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
.login-right {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
.login-container {
|
.login-container {
|
||||||
padding: 32px 20px;
|
padding: 32px 20px;
|
||||||
}
|
}
|
||||||
.captcha-wrapper {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.captcha-image-container {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { Button, Checkbox, Form, Input, message, Typography } from "antd";
|
import { Button, Checkbox, Form, Input, message, Typography } from "antd";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { fetchCaptcha, login, type CaptchaResponse } from "../api/auth";
|
import { fetchCaptcha, login, type CaptchaResponse } from "../api/auth";
|
||||||
import { getCurrentUser, getSystemParamValue } from "../api";
|
import { getCurrentUser, getSystemParamValue } from "../api";
|
||||||
|
import { UserOutlined, LockOutlined, SafetyOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||||
import "./Login.css";
|
import "./Login.css";
|
||||||
|
|
||||||
const { Title, Text, Link } = Typography;
|
const { Title, Text, Link } = Typography;
|
||||||
|
|
@ -12,7 +13,7 @@ export default function Login() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
const loadCaptcha = async () => {
|
const loadCaptcha = useCallback(async () => {
|
||||||
if (!captchaEnabled) {
|
if (!captchaEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -22,7 +23,7 @@ export default function Login() {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error("加载验证码失败");
|
message.error("加载验证码失败");
|
||||||
}
|
}
|
||||||
};
|
}, [captchaEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
|
|
@ -39,7 +40,7 @@ export default function Login() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
init();
|
init();
|
||||||
}, []);
|
}, [loadCaptcha]);
|
||||||
|
|
||||||
const onFinish = async (values: any) => {
|
const onFinish = async (values: any) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -75,7 +76,7 @@ export default function Login() {
|
||||||
<div className="login-page">
|
<div className="login-page">
|
||||||
<div className="login-left">
|
<div className="login-left">
|
||||||
<div className="login-brand">
|
<div className="login-brand">
|
||||||
<img src="/logo.svg" alt="logo" className="brand-logo-img" />
|
<img src="/logo.svg" alt="MeetingAI Logo" className="brand-logo-img" />
|
||||||
<span className="brand-name">MeetingAI</span>
|
<span className="brand-name">MeetingAI</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -93,7 +94,7 @@ export default function Login() {
|
||||||
|
|
||||||
<div className="login-left-footer">
|
<div className="login-left-footer">
|
||||||
<div className="footer-item">企业级安全</div>
|
<div className="footer-item">企业级安全</div>
|
||||||
<div className="footer-divider" />
|
<div className="footer-divider" aria-hidden="true" />
|
||||||
<div className="footer-item">多语言支持</div>
|
<div className="footer-item">多语言支持</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -111,38 +112,56 @@ export default function Login() {
|
||||||
onFinish={onFinish}
|
onFinish={onFinish}
|
||||||
className="login-form"
|
className="login-form"
|
||||||
requiredMark={false}
|
requiredMark={false}
|
||||||
|
autoComplete="off"
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="用户名"
|
|
||||||
name="username"
|
name="username"
|
||||||
rules={[{ required: true, message: "请输入用户名" }]}
|
rules={[{ required: true, message: "请输入用户名" }]}
|
||||||
>
|
>
|
||||||
<Input size="large" placeholder="请输入用户名" />
|
<Input
|
||||||
|
size="large"
|
||||||
|
prefix={<UserOutlined className="text-gray-400" aria-hidden="true" />}
|
||||||
|
placeholder="用户名"
|
||||||
|
autoComplete="username"
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label="用户名"
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="密码"
|
|
||||||
name="password"
|
name="password"
|
||||||
rules={[{ required: true, message: "请输入密码" }]}
|
rules={[{ required: true, message: "请输入密码" }]}
|
||||||
>
|
>
|
||||||
<Input.Password size="large" placeholder="请输入密码" />
|
<Input.Password
|
||||||
|
size="large"
|
||||||
|
prefix={<LockOutlined className="text-gray-400" aria-hidden="true" />}
|
||||||
|
placeholder="密码"
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-label="密码"
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{captchaEnabled && (
|
{captchaEnabled && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="验证码"
|
|
||||||
name="captchaCode"
|
name="captchaCode"
|
||||||
rules={[{ required: true, message: "请输入验证码" }]}
|
rules={[{ required: true, message: "请输入验证码" }]}
|
||||||
>
|
>
|
||||||
<div className="captcha-wrapper">
|
<div className="captcha-wrapper">
|
||||||
<Input size="large" placeholder="验证码" />
|
<Input
|
||||||
<div className="captcha-image-container" onClick={loadCaptcha}>
|
size="large"
|
||||||
{captcha ? (
|
prefix={<SafetyOutlined className="text-gray-400" aria-hidden="true" />}
|
||||||
<img src={captcha.imageBase64} alt="captcha" />
|
placeholder="验证码"
|
||||||
) : (
|
maxLength={6}
|
||||||
<div className="captcha-placeholder" />
|
aria-label="验证码"
|
||||||
)}
|
/>
|
||||||
</div>
|
<Button
|
||||||
|
className="captcha-image-btn"
|
||||||
|
onClick={loadCaptcha}
|
||||||
|
icon={!captcha ? <ReloadOutlined spin /> : null}
|
||||||
|
aria-label="点击刷新验证码"
|
||||||
|
>
|
||||||
|
{captcha && <img src={captcha.imageBase64} alt="验证码图片" />}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
|
@ -155,15 +174,15 @@ export default function Login() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit" loading={loading} block size="large">
|
<Button type="primary" htmlType="submit" loading={loading} block size="large" className="login-submit-btn">
|
||||||
立即登录
|
{loading ? "登录中…" : "立即登录"}
|
||||||
</Button>
|
</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<div className="login-footer">
|
<div className="login-footer">
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
默认账号:<Text strong>admin</Text> / 密码:<Text strong>123456</Text>
|
演示账号:<Text strong className="tabular-nums">admin</Text> / 密码:<Text strong className="tabular-nums">123456</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -171,4 +190,3 @@ export default function Login() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { Card, Table, Tabs, Tag, Input, Space, Button, DatePicker, Select } from "antd";
|
import { Card, Table, Tabs, Tag, Input, Space, Button, DatePicker, Select, Typography, Modal, Descriptions } from "antd";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { fetchLogs } from "../api";
|
import { fetchLogs } from "../api";
|
||||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons";
|
import { SearchOutlined, ReloadOutlined, InfoCircleOutlined, EyeOutlined } from "@ant-design/icons";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
export default function Logs() {
|
export default function Logs() {
|
||||||
const [activeTab, setActiveTab] = useState("OPERATION");
|
const [activeTab, setActiveTab] = useState("OPERATION");
|
||||||
|
|
@ -20,6 +21,10 @@ export default function Logs() {
|
||||||
endDate: ""
|
endDate: ""
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Modal for detail view
|
||||||
|
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||||
|
const [selectedLog, setSelectedLog] = useState<any>(null);
|
||||||
|
|
||||||
const loadData = async (currentParams = params) => {
|
const loadData = async (currentParams = params) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -54,31 +59,38 @@ export default function Logs() {
|
||||||
loadData(resetParams);
|
loadData(resetParams);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showDetail = (record: any) => {
|
||||||
|
setSelectedLog(record);
|
||||||
|
setDetailModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: "用户名",
|
title: "操作账号",
|
||||||
dataIndex: "username",
|
dataIndex: "username",
|
||||||
key: "username",
|
key: "username",
|
||||||
width: 120,
|
width: 140,
|
||||||
render: (text: string) => text || "系统/访客"
|
render: (text: string) => <Text strong>{text || "系统/访客"}</Text>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: activeTab === "LOGIN" ? "登录模块" : "操作模块",
|
title: activeTab === "LOGIN" ? "登录模块" : "业务模块",
|
||||||
dataIndex: "resourceType",
|
dataIndex: "resourceType",
|
||||||
key: "resourceType",
|
key: "resourceType",
|
||||||
width: 150
|
width: 150
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "操作详情",
|
title: "操作描述",
|
||||||
dataIndex: "detail",
|
dataIndex: "detail",
|
||||||
key: "detail",
|
key: "detail",
|
||||||
ellipsis: true
|
ellipsis: true,
|
||||||
|
render: (text: string) => <Text type="secondary">{text}</Text>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "IP地址",
|
title: "IP 地址",
|
||||||
dataIndex: "ipAddress",
|
dataIndex: "ipAddress",
|
||||||
key: "ipAddress",
|
key: "ipAddress",
|
||||||
width: 140
|
width: 140,
|
||||||
|
className: "tabular-nums"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "状态",
|
title: "状态",
|
||||||
|
|
@ -86,17 +98,33 @@ export default function Logs() {
|
||||||
key: "status",
|
key: "status",
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (status: number) => (
|
render: (status: number) => (
|
||||||
<Tag color={status === 1 ? "green" : "red"}>
|
<Tag color={status === 1 ? "green" : "red"} className="m-0">
|
||||||
{status === 1 ? "成功" : "失败"}
|
{status === 1 ? "成功" : "失败"}
|
||||||
</Tag>
|
</Tag>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "操作时间",
|
title: "发生时间",
|
||||||
dataIndex: "createdAt",
|
dataIndex: "createdAt",
|
||||||
key: "createdAt",
|
key: "createdAt",
|
||||||
width: 180,
|
width: 180,
|
||||||
|
className: "tabular-nums",
|
||||||
render: (text: string) => text?.replace('T', ' ').substring(0, 19)
|
render: (text: string) => text?.replace('T', ' ').substring(0, 19)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "详情",
|
||||||
|
key: "action",
|
||||||
|
width: 80,
|
||||||
|
fixed: "right" as const,
|
||||||
|
render: (_: any, record: any) => (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined aria-hidden="true" />}
|
||||||
|
onClick={() => showDetail(record)}
|
||||||
|
aria-label="查看详细日志信息"
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -106,22 +134,30 @@ export default function Logs() {
|
||||||
dataIndex: "operationType",
|
dataIndex: "operationType",
|
||||||
key: "operationType",
|
key: "operationType",
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (t: string) => <Tag>{t}</Tag>
|
render: (t: string) => <Tag color="blue">{t}</Tag>
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<Card className="mb-4">
|
<div className="mb-6">
|
||||||
|
<Title level={4} className="mb-1">系统日志管理</Title>
|
||||||
|
<Text type="secondary">追踪系统内的每一次重要操作,保障系统安全与可追溯性</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-4 shadow-sm">
|
||||||
<Space wrap size="middle">
|
<Space wrap size="middle">
|
||||||
<Input
|
<Input
|
||||||
placeholder="用户名"
|
placeholder="搜索用户名…"
|
||||||
style={{ width: 160 }}
|
style={{ width: 180 }}
|
||||||
value={params.username}
|
value={params.username}
|
||||||
onChange={e => setParams({ ...params, username: e.target.value })}
|
onChange={e => setParams({ ...params, username: e.target.value })}
|
||||||
|
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
|
||||||
|
aria-label="搜索用户名"
|
||||||
|
allowClear
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
placeholder="状态"
|
placeholder="执行状态"
|
||||||
style={{ width: 120 }}
|
style={{ width: 120 }}
|
||||||
allowClear
|
allowClear
|
||||||
value={params.status}
|
value={params.status}
|
||||||
|
|
@ -130,6 +166,7 @@ export default function Logs() {
|
||||||
{ label: "成功", value: 1 },
|
{ label: "成功", value: 1 },
|
||||||
{ label: "失败", value: 0 }
|
{ label: "失败", value: 0 }
|
||||||
]}
|
]}
|
||||||
|
aria-label="筛选执行状态"
|
||||||
/>
|
/>
|
||||||
<RangePicker
|
<RangePicker
|
||||||
onChange={(dates) => {
|
onChange={(dates) => {
|
||||||
|
|
@ -139,16 +176,34 @@ export default function Logs() {
|
||||||
endDate: dates ? dates[1]?.format("YYYY-MM-DD") || "" : ""
|
endDate: dates ? dates[1]?.format("YYYY-MM-DD") || "" : ""
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
aria-label="筛选时间范围"
|
||||||
/>
|
/>
|
||||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
|
<Button
|
||||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
type="primary"
|
||||||
|
icon={<SearchOutlined aria-hidden="true" />}
|
||||||
|
onClick={handleSearch}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined aria-hidden="true" />}
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card className="shadow-sm" styles={{ body: { paddingTop: 0 } }}>
|
||||||
<Tabs activeKey={activeTab} onChange={setActiveTab}>
|
<Tabs activeKey={activeTab} onChange={setActiveTab} size="large">
|
||||||
<Tabs.TabPane tab="操作日志" key="OPERATION" />
|
<Tabs.TabPane
|
||||||
<Tabs.TabPane tab="登录日志" key="LOGIN" />
|
tab={<span><InfoCircleOutlined aria-hidden="true" />操作日志</span>}
|
||||||
|
key="OPERATION"
|
||||||
|
/>
|
||||||
|
<Tabs.TabPane
|
||||||
|
tab={<span><UserOutlined aria-hidden="true" />登录日志</span>}
|
||||||
|
key="LOGIN"
|
||||||
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
|
|
@ -156,16 +211,71 @@ export default function Logs() {
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
size="middle"
|
||||||
pagination={{
|
pagination={{
|
||||||
current: params.current,
|
current: params.current,
|
||||||
pageSize: params.size,
|
pageSize: params.size,
|
||||||
total: total,
|
total: total,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
onChange: (page, size) => setParams({ ...params, current: page, size }),
|
onChange: (page, size) => setParams({ ...params, current: page, size }),
|
||||||
showTotal: (total) => `共 ${total} 条`
|
showTotal: (total) => `共 ${total} 条数据`
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="日志详细信息"
|
||||||
|
open={detailModalVisible}
|
||||||
|
onCancel={() => setDetailModalVisible(false)}
|
||||||
|
footer={[
|
||||||
|
<Button key="close" onClick={() => setDetailModalVisible(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
width={700}
|
||||||
|
>
|
||||||
|
{selectedLog && (
|
||||||
|
<Descriptions bordered column={1} size="small">
|
||||||
|
<Descriptions.Item label="操作模块">{selectedLog.resourceType}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="请求方式">
|
||||||
|
<Tag color="blue">{selectedLog.operationType}</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="操作账号">{selectedLog.username || "系统"}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="IP 地址" className="tabular-nums">{selectedLog.ipAddress}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="User Agent">
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>{selectedLog.userAgent}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={selectedLog.status === 1 ? "green" : "red"}>
|
||||||
|
{selectedLog.status === 1 ? "成功" : "失败"}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间" className="tabular-nums">{selectedLog.createdAt?.replace('T', ' ')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="详情/参数">
|
||||||
|
<div style={{
|
||||||
|
background: '#f5f5f5',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}>
|
||||||
|
{selectedLog.detail}
|
||||||
|
</div>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{selectedLog.errorMessage && (
|
||||||
|
<Descriptions.Item label="错误信息">
|
||||||
|
<Text type="danger">{selectedLog.errorMessage}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure UserOutlined is imported
|
||||||
|
import { UserOutlined } from "@ant-design/icons";
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,41 @@
|
||||||
import { Button, Form, Input, Drawer, Popconfirm, Space, Table, Tag, Select, InputNumber } from "antd";
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Drawer,
|
||||||
|
Popconfirm,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Select,
|
||||||
|
InputNumber,
|
||||||
|
Typography,
|
||||||
|
Card,
|
||||||
|
message,
|
||||||
|
Tooltip
|
||||||
|
} from "antd";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { createPermission, deletePermission, listMyPermissions, updatePermission } from "../api";
|
import { createPermission, deletePermission, listMyPermissions, updatePermission } from "../api";
|
||||||
import type { SysPermission } from "../types";
|
import type { SysPermission } from "../types";
|
||||||
import { usePermission } from "../hooks/usePermission";
|
import { usePermission } from "../hooks/usePermission";
|
||||||
|
import {
|
||||||
|
PlusOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
ClusterOutlined,
|
||||||
|
MenuOutlined,
|
||||||
|
CheckSquareOutlined,
|
||||||
|
InfoCircleOutlined
|
||||||
|
} from "@ant-design/icons";
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
type TreePermission = SysPermission & { key: number; children?: TreePermission[] };
|
type TreePermission = SysPermission & { key: number; children?: TreePermission[] };
|
||||||
|
|
||||||
function buildTree(list: SysPermission[]): TreePermission[] {
|
function buildTree(list: SysPermission[]): TreePermission[] {
|
||||||
|
if (!list || list.length === 0) return [];
|
||||||
const map = new Map<number, TreePermission>();
|
const map = new Map<number, TreePermission>();
|
||||||
const roots: TreePermission[] = [];
|
const roots: TreePermission[] = [];
|
||||||
|
|
||||||
|
|
@ -35,6 +62,7 @@ function buildTree(list: SysPermission[]): TreePermission[] {
|
||||||
|
|
||||||
export default function Permissions() {
|
export default function Permissions() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
const [data, setData] = useState<SysPermission[]>([]);
|
const [data, setData] = useState<SysPermission[]>([]);
|
||||||
const [query, setQuery] = useState({ name: "", code: "", permType: "" });
|
const [query, setQuery] = useState({ name: "", code: "", permType: "" });
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -88,190 +116,353 @@ export default function Permissions() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
const values = await form.validateFields();
|
try {
|
||||||
const payload: Partial<SysPermission> = {
|
const values = await form.validateFields();
|
||||||
parentId: values.level === 1 ? undefined : values.parentId,
|
setSaving(true);
|
||||||
name: values.name,
|
const payload: Partial<SysPermission> = {
|
||||||
code: values.code,
|
parentId: values.level === 1 ? undefined : values.parentId,
|
||||||
permType: values.permType,
|
name: values.name,
|
||||||
level: values.level,
|
code: values.code,
|
||||||
path: values.path,
|
permType: values.permType,
|
||||||
component: values.component,
|
level: values.level,
|
||||||
icon: values.icon,
|
path: values.path,
|
||||||
sortOrder: values.sortOrder,
|
component: values.component,
|
||||||
isVisible: values.isVisible,
|
icon: values.icon,
|
||||||
status: values.status,
|
sortOrder: values.sortOrder,
|
||||||
description: values.description
|
isVisible: values.isVisible,
|
||||||
};
|
status: values.status,
|
||||||
if (editing) {
|
description: values.description
|
||||||
await updatePermission(editing.permId, payload);
|
};
|
||||||
} else {
|
if (editing) {
|
||||||
await createPermission(payload);
|
await updatePermission(editing.permId, payload);
|
||||||
|
message.success("权限已更新");
|
||||||
|
} else {
|
||||||
|
await createPermission(payload);
|
||||||
|
message.success("权限已创建");
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message) message.error(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
setOpen(false);
|
|
||||||
load();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async (id: number) => {
|
const remove = async (id: number) => {
|
||||||
await deletePermission(id);
|
try {
|
||||||
load();
|
await deletePermission(id);
|
||||||
|
message.success("权限已删除");
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error("删除失败");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleReset = () => {
|
||||||
<div>
|
setQuery({ name: "", code: "", permType: "" });
|
||||||
<Space style={{ marginBottom: 16 }}>
|
};
|
||||||
<Input
|
|
||||||
placeholder="权限名称"
|
|
||||||
value={query.name}
|
|
||||||
onChange={(e) => setQuery({ ...query, name: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="权限编码"
|
|
||||||
value={query.code}
|
|
||||||
onChange={(e) => setQuery({ ...query, code: e.target.value })}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
placeholder="类型"
|
|
||||||
allowClear
|
|
||||||
value={query.permType || undefined}
|
|
||||||
onChange={(v) => setQuery({ ...query, permType: v || "" })}
|
|
||||||
options={[
|
|
||||||
{ value: "menu", label: "menu" },
|
|
||||||
{ value: "button", label: "button" }
|
|
||||||
]}
|
|
||||||
style={{ width: 120 }}
|
|
||||||
/>
|
|
||||||
{can("sys_permission:create") && (
|
|
||||||
<Button type="primary" onClick={openCreate}>新增</Button>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Table
|
const columns = [
|
||||||
rowKey="permId"
|
{
|
||||||
loading={loading}
|
title: "权限名称",
|
||||||
dataSource={treeData}
|
dataIndex: "name",
|
||||||
pagination={false}
|
key: "name",
|
||||||
columns={[
|
render: (text: string, record: SysPermission) => (
|
||||||
{ title: "名称", dataIndex: "name" },
|
<Space>
|
||||||
{ title: "编码", dataIndex: "code" },
|
{record.permType === 'menu' ?
|
||||||
{ title: "类型", dataIndex: "permType", width: 90 },
|
<MenuOutlined style={{ color: '#1890ff' }} aria-hidden="true" /> :
|
||||||
{ title: "层级", dataIndex: "level", width: 80 },
|
<CheckSquareOutlined style={{ color: '#52c41a' }} aria-hidden="true" />
|
||||||
{ title: "路径", dataIndex: "path" },
|
|
||||||
{ title: "组件", dataIndex: "component" },
|
|
||||||
{ title: "排序", dataIndex: "sortOrder", width: 80 },
|
|
||||||
{
|
|
||||||
title: "可见",
|
|
||||||
dataIndex: "isVisible",
|
|
||||||
width: 80,
|
|
||||||
render: (v) => (v === 1 ? <Tag color="blue">显示</Tag> : <Tag>隐藏</Tag>)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "状态",
|
|
||||||
dataIndex: "status",
|
|
||||||
width: 80,
|
|
||||||
render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag>)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "操作",
|
|
||||||
width: 160,
|
|
||||||
render: (_, record) => (
|
|
||||||
<Space>
|
|
||||||
{can("sys_permission:update") && <Button onClick={() => openEdit(record)}>编辑</Button>}
|
|
||||||
{can("sys_permission:delete") && (
|
|
||||||
<Popconfirm title="确认删除?" onConfirm={() => remove(record.permId)}>
|
|
||||||
<Button danger>删除</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
]}
|
<Text strong={record.level === 1}>{text}</Text>
|
||||||
/>
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "权限编码",
|
||||||
|
dataIndex: "code",
|
||||||
|
key: "code",
|
||||||
|
render: (text: string) => text ? <Tag color="blue" className="tabular-nums">{text}</Tag> : "-"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "类型",
|
||||||
|
dataIndex: "permType",
|
||||||
|
width: 90,
|
||||||
|
render: (type: string) => (
|
||||||
|
<Tag color={type === 'menu' ? 'processing' : 'warning'}>
|
||||||
|
{type === 'menu' ? '菜单' : '按钮'}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "排序",
|
||||||
|
dataIndex: "sortOrder",
|
||||||
|
width: 80,
|
||||||
|
className: "tabular-nums"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "路由/组件",
|
||||||
|
key: "route",
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_: any, record: SysPermission) => (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{record.path && <Text type="secondary" size="small" className="tabular-nums">{record.path}</Text>}
|
||||||
|
{record.component && <Text type="secondary" size="small" style={{ fontSize: '11px' }}>{record.component}</Text>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "显示",
|
||||||
|
dataIndex: "isVisible",
|
||||||
|
width: 80,
|
||||||
|
render: (v: number) => (v === 1 ? <Tag color="blue">可见</Tag> : <Tag>隐藏</Tag>)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
dataIndex: "status",
|
||||||
|
width: 80,
|
||||||
|
render: (v: number) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag>)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
width: 120,
|
||||||
|
fixed: "right" as const,
|
||||||
|
render: (_: any, record: SysPermission) => (
|
||||||
|
<Space>
|
||||||
|
{can("sys_permission:update") && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined aria-hidden="true" />}
|
||||||
|
onClick={() => openEdit(record)}
|
||||||
|
aria-label={`编辑权限 ${record.name}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{can("sys_permission:delete") && (
|
||||||
|
<Popconfirm title={`确定删除权限 "${record.name}" 吗?`} onConfirm={() => remove(record.permId)}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined aria-hidden="true" />}
|
||||||
|
aria-label={`删除权限 ${record.name}`}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Title level={4} className="mb-1">功能权限管理</Title>
|
||||||
|
<Text type="secondary">配置系统的菜单结构与功能按钮的操作权限点</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-4 shadow-sm">
|
||||||
|
<Space wrap size="middle">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索权限名称…"
|
||||||
|
value={query.name}
|
||||||
|
onChange={(e) => setQuery({ ...query, name: e.target.value })}
|
||||||
|
prefix={<SearchOutlined className="text-gray-400" aria-hidden="true" />}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
allowClear
|
||||||
|
aria-label="搜索权限名称"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="权限编码…"
|
||||||
|
value={query.code}
|
||||||
|
onChange={(e) => setQuery({ ...query, code: e.target.value })}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
allowClear
|
||||||
|
aria-label="搜索权限编码"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
placeholder="权限类型"
|
||||||
|
allowClear
|
||||||
|
value={query.permType || undefined}
|
||||||
|
onChange={(v) => setQuery({ ...query, permType: v || "" })}
|
||||||
|
options={[
|
||||||
|
{ value: "menu", label: "菜单" },
|
||||||
|
{ value: "button", label: "按钮" }
|
||||||
|
]}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
aria-label="筛选权限类型"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SearchOutlined aria-hidden="true" />}
|
||||||
|
onClick={load}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined aria-hidden="true" />}
|
||||||
|
onClick={handleReset}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
{can("sys_permission:create") && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined aria-hidden="true" />}
|
||||||
|
onClick={openCreate}
|
||||||
|
style={{ background: '#52c41a', borderColor: '#52c41a' }}
|
||||||
|
>
|
||||||
|
新增权限
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="shadow-sm" styles={{ body: { padding: 0 } }}>
|
||||||
|
<Table
|
||||||
|
rowKey="permId"
|
||||||
|
loading={loading}
|
||||||
|
dataSource={treeData}
|
||||||
|
columns={columns}
|
||||||
|
pagination={false}
|
||||||
|
size="middle"
|
||||||
|
expandable={{ defaultExpandAllRows: false }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title={editing ? "编辑权限" : "新增权限"}
|
title={
|
||||||
|
<Space>
|
||||||
|
<ClusterOutlined aria-hidden="true" />
|
||||||
|
<span>{editing ? "修改权限点信息" : "新增功能权限"}</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => setOpen(false)}
|
onClose={() => setOpen(false)}
|
||||||
width={520}
|
width={520}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
footer={
|
footer={
|
||||||
<Space style={{ width: "100%", justifyContent: "flex-end" }}>
|
<div className="flex justify-end gap-2 p-2">
|
||||||
<Button onClick={() => setOpen(false)}>取消</Button>
|
<Button onClick={() => setOpen(false)}>取消</Button>
|
||||||
<Button type="primary" onClick={submit}>确认</Button>
|
<Button type="primary" loading={saving} onClick={submit}>
|
||||||
</Space>
|
确认并同步
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onValuesChange={(changed, values) => {
|
className="permission-form"
|
||||||
|
onValuesChange={(changed) => {
|
||||||
if (changed.level === 1) {
|
if (changed.level === 1) {
|
||||||
form.setFieldsValue({ parentId: undefined });
|
form.setFieldsValue({ parentId: undefined });
|
||||||
form.validateFields(["parentId"]).catch(() => undefined);
|
|
||||||
}
|
|
||||||
if (changed.level === 2) {
|
|
||||||
form.validateFields(["parentId"]).catch(() => undefined);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form.Item label="层级" name="level" rules={[{ required: true }]}>
|
<Row gutter={16}>
|
||||||
<Select>
|
<Col span={12}>
|
||||||
<Option value={1}>一级菜单</Option>
|
<Form.Item label="权限层级" name="level" rules={[{ required: true }]}>
|
||||||
<Option value={2}>二级菜单</Option>
|
<Select aria-label="选择层级">
|
||||||
</Select>
|
<Select.Option value={1}>一级入口</Select.Option>
|
||||||
</Form.Item>
|
<Select.Option value={2}>二级子项</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="权限类型" name="permType" rules={[{ required: true }]}>
|
||||||
|
<Select options={[{ value: "menu", label: "菜单" }, { value: "button", label: "按钮" }]} aria-label="选择类型" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="父级"
|
label="上级权限"
|
||||||
name="parentId"
|
name="parentId"
|
||||||
dependencies={["level"]}
|
dependencies={["level"]}
|
||||||
rules={[
|
rules={[
|
||||||
({ getFieldValue }) => ({
|
({ getFieldValue }) => ({
|
||||||
required: getFieldValue("level") === 2,
|
required: getFieldValue("level") === 2,
|
||||||
message: "请选择父级菜单"
|
message: "二级子项必须选择父级菜单"
|
||||||
})
|
})
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="请选择父级菜单"
|
showSearch
|
||||||
|
placeholder={level === 1 ? "一级入口无须父级" : "请选择父级菜单…"}
|
||||||
options={parentOptions}
|
options={parentOptions}
|
||||||
disabled={level !== 2}
|
disabled={level !== 2}
|
||||||
|
aria-label="选择上级权限"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="权限名称" name="name" rules={[{ required: true }]}>
|
|
||||||
<Input />
|
<Form.Item label="名称" name="name" rules={[{ required: true, message: "请输入权限展示名称" }]}>
|
||||||
|
<Input placeholder="例如:用户管理、导出报表…" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="权限编码" name="code" dependencies={["permType"]} rules={[
|
|
||||||
({ getFieldValue }) => ({
|
<Form.Item
|
||||||
required: getFieldValue("permType") === "button",
|
label={
|
||||||
message: "按钮权限必须填写编码"
|
<Space>
|
||||||
})
|
<span>权限唯一编码</span>
|
||||||
]}>
|
<Tooltip title="用于后端 @PreAuthorize 鉴权和前端按钮控制">
|
||||||
<Input />
|
<InfoCircleOutlined className="text-gray-400" />
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
name="code"
|
||||||
|
dependencies={["permType"]}
|
||||||
|
rules={[
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
required: getFieldValue("permType") === "button",
|
||||||
|
message: "按钮权限必须填写编码"
|
||||||
|
})
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="例如:sys_user:export…" className="tabular-nums" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="类型" name="permType" rules={[{ required: true }]}>
|
|
||||||
<Select options={[{ value: "menu" }, { value: "button" }]} />
|
<Row gutter={16}>
|
||||||
</Form.Item>
|
<Col span={12}>
|
||||||
<Form.Item label="路由路径" name="path">
|
<Form.Item label="路由路径" name="path">
|
||||||
<Input />
|
<Input placeholder="/users…" className="tabular-nums" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="组件" name="component">
|
</Col>
|
||||||
<Input />
|
<Col span={12}>
|
||||||
</Form.Item>
|
<Form.Item label="组件路径" name="component">
|
||||||
<Form.Item label="图标" name="icon">
|
<Input placeholder="pages/Users…" />
|
||||||
<Input />
|
</Form.Item>
|
||||||
</Form.Item>
|
</Col>
|
||||||
<Form.Item label="排序" name="sortOrder" initialValue={0}>
|
</Row>
|
||||||
<InputNumber style={{ width: "100%" }} min={0} />
|
|
||||||
</Form.Item>
|
<Row gutter={16}>
|
||||||
<Form.Item label="是否可见" name="isVisible" initialValue={1}>
|
<Col span={12}>
|
||||||
<Select options={[{ value: 1, label: "显示" }, { value: 0, label: "隐藏" }]} />
|
<Form.Item label="图标名称" name="icon">
|
||||||
</Form.Item>
|
<Input placeholder="AntD 图标名…" />
|
||||||
<Form.Item label="状态" name="status" initialValue={1}>
|
</Form.Item>
|
||||||
<Select options={[{ value: 1, label: "启用" }, { value: 0, label: "禁用" }]} />
|
</Col>
|
||||||
</Form.Item>
|
<Col span={12}>
|
||||||
<Form.Item label="描述" name="description">
|
<Form.Item label="显示排序" name="sortOrder" initialValue={0}>
|
||||||
<Input.TextArea rows={3} />
|
<InputNumber style={{ width: "100%" }} min={0} className="tabular-nums" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="是否在菜单可见" name="isVisible" initialValue={1}>
|
||||||
|
<Select options={[{ value: 1, label: "显示" }, { value: 0, label: "隐藏" }]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label="状态" name="status" initialValue={1}>
|
||||||
|
<Select options={[{ value: 1, label: "启用" }, { value: 0, label: "禁用" }]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item label="描述说明" name="description">
|
||||||
|
<Input.TextArea rows={2} placeholder="简要描述权限用途…" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
@ -279,3 +470,5 @@ export default function Permissions() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fixed missing Row/Col imports
|
||||||
|
import { Row, Col } from "antd";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,21 @@
|
||||||
import { Button, Card, Col, message, Row, Space, Table, Tag, Tree, Typography } from "antd";
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
message,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Tree,
|
||||||
|
Typography,
|
||||||
|
Input,
|
||||||
|
Empty
|
||||||
|
} from "antd";
|
||||||
import type { DataNode } from "antd/es/tree";
|
import type { DataNode } from "antd/es/tree";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "../api";
|
import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "../api";
|
||||||
|
import { SearchOutlined, SafetyCertificateOutlined, SaveOutlined, KeyOutlined, ClusterOutlined } from "@ant-design/icons";
|
||||||
import type { SysPermission, SysRole } from "../types";
|
import type { SysPermission, SysRole } from "../types";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
@ -9,16 +23,21 @@ const { Title, Text } = Typography;
|
||||||
type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
|
type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
|
||||||
|
|
||||||
function buildPermissionTree(list: SysPermission[]): PermissionNode[] {
|
function buildPermissionTree(list: SysPermission[]): PermissionNode[] {
|
||||||
|
if (!list || list.length === 0) return [];
|
||||||
|
const active = list.filter((p) => p.status !== 0);
|
||||||
const map = new Map<number, PermissionNode>();
|
const map = new Map<number, PermissionNode>();
|
||||||
const roots: PermissionNode[] = [];
|
const roots: PermissionNode[] = [];
|
||||||
|
|
||||||
list.forEach((item) => {
|
active.forEach((item) => {
|
||||||
map.set(item.permId, { ...item, key: item.permId, children: [] });
|
map.set(item.permId, { ...item, key: item.permId, children: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
map.forEach((node) => {
|
map.forEach((node) => {
|
||||||
if (node.parentId && map.has(node.parentId)) {
|
if (node.parentId && node.parentId !== 0) {
|
||||||
map.get(node.parentId)!.children!.push(node);
|
const parent = map.get(node.parentId);
|
||||||
|
if (parent) {
|
||||||
|
parent.children!.push(node);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
roots.push(node);
|
roots.push(node);
|
||||||
}
|
}
|
||||||
|
|
@ -38,10 +57,10 @@ function toTreeData(nodes: PermissionNode[]): DataNode[] {
|
||||||
title: (
|
title: (
|
||||||
<Space>
|
<Space>
|
||||||
<span>{node.name}</span>
|
<span>{node.name}</span>
|
||||||
{node.permType === "button" && <Tag color="blue">按钮</Tag>}
|
{node.permType === "button" && <Tag color="blue" size="small" style={{ marginLeft: 4 }}>按钮</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
children: node.children ? toTreeData(node.children) : undefined
|
children: node.children && node.children.length > 0 ? toTreeData(node.children) : undefined
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,7 +71,13 @@ export default function RolePermissionBinding() {
|
||||||
const [loadingPerms, setLoadingPerms] = useState(false);
|
const [loadingPerms, setLoadingPerms] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
|
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Selection states
|
||||||
const [checkedPermIds, setCheckedPermIds] = useState<number[]>([]);
|
const [checkedPermIds, setCheckedPermIds] = useState<number[]>([]);
|
||||||
|
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
const selectedRole = useMemo(
|
const selectedRole = useMemo(
|
||||||
() => roles.find((r) => r.roleId === selectedRoleId) || null,
|
() => roles.find((r) => r.roleId === selectedRoleId) || null,
|
||||||
|
|
@ -75,7 +100,7 @@ export default function RolePermissionBinding() {
|
||||||
const list = await listPermissions();
|
const list = await listPermissions();
|
||||||
setPermissions(list || []);
|
setPermissions(list || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error("加载权限失败,请确认接口已实现");
|
message.error("加载权限定义失败");
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPerms(false);
|
setLoadingPerms(false);
|
||||||
}
|
}
|
||||||
|
|
@ -84,10 +109,18 @@ export default function RolePermissionBinding() {
|
||||||
const loadRolePermissions = async (roleId: number) => {
|
const loadRolePermissions = async (roleId: number) => {
|
||||||
try {
|
try {
|
||||||
const list = await listRolePermissions(roleId);
|
const list = await listRolePermissions(roleId);
|
||||||
setCheckedPermIds(list || []);
|
const normalized = (list || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id));
|
||||||
|
|
||||||
|
// Filter out parent IDs for Tree view回显 consistency
|
||||||
|
const leafIds = normalized.filter(id => {
|
||||||
|
return !permissions.some(p => p.parentId === id);
|
||||||
|
});
|
||||||
|
|
||||||
|
setCheckedPermIds(leafIds);
|
||||||
|
setHalfCheckedIds([]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCheckedPermIds([]);
|
setCheckedPermIds([]);
|
||||||
message.error("加载角色权限失败,请确认接口已实现");
|
message.error("加载角色授权数据失败");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -97,93 +130,148 @@ export default function RolePermissionBinding() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedRoleId) {
|
if (selectedRoleId && permissions.length > 0) {
|
||||||
loadRolePermissions(selectedRoleId);
|
loadRolePermissions(selectedRoleId);
|
||||||
} else {
|
} else {
|
||||||
setCheckedPermIds([]);
|
setCheckedPermIds([]);
|
||||||
}
|
}
|
||||||
}, [selectedRoleId]);
|
}, [selectedRoleId, permissions]);
|
||||||
|
|
||||||
const treeData = useMemo(() => toTreeData(buildPermissionTree(permissions)), [permissions]);
|
const filteredRoles = useMemo(() => {
|
||||||
|
if (!searchText) return roles;
|
||||||
|
const lower = searchText.toLowerCase();
|
||||||
|
return roles.filter(r =>
|
||||||
|
r.roleName.toLowerCase().includes(lower) ||
|
||||||
|
r.roleCode.toLowerCase().includes(lower)
|
||||||
|
);
|
||||||
|
}, [roles, searchText]);
|
||||||
|
|
||||||
|
const treeData = useMemo(() => buildPermissionTree(permissions), [permissions]);
|
||||||
|
const antdTreeData = useMemo(() => toTreeData(treeData), [treeData]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!selectedRoleId) {
|
if (!selectedRoleId) {
|
||||||
message.warning("请先选择角色");
|
message.warning("请从左侧列表中选择一个角色");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await saveRolePermissions(selectedRoleId, checkedPermIds);
|
// Merge checked and half-checked for database persistence
|
||||||
message.success("角色权限绑定已保存");
|
const allPermIds = Array.from(new Set([...checkedPermIds, ...halfCheckedIds]));
|
||||||
|
await saveRolePermissions(selectedRoleId, allPermIds);
|
||||||
|
message.success("功能权限策略已成功更新并下发");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error("保存失败,请确认接口已实现");
|
message.error("保存权限配置失败");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-shell">
|
<div className="p-6">
|
||||||
<div className="page-header">
|
<div className="mb-6 flex justify-between items-end">
|
||||||
<div>
|
<div>
|
||||||
<Title level={4} className="page-title">角色权限绑定</Title>
|
<Title level={4} className="mb-1">角色权限授权</Title>
|
||||||
<Text type="secondary" className="page-subtitle">为角色配置菜单与按钮权限</Text>
|
<Text type="secondary">配置系统中各类角色所拥有的菜单访问权限与功能操作权限点</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button type="primary" onClick={handleSave} loading={saving} disabled={!selectedRoleId}>
|
<Button
|
||||||
保存绑定
|
type="primary"
|
||||||
|
icon={<SaveOutlined aria-hidden="true" />}
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
disabled={!selectedRoleId}
|
||||||
|
>
|
||||||
|
{saving ? "正在同步…" : "保存权限策略"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={24}>
|
||||||
<Col xs={24} lg={10}>
|
<Col xs={24} lg={10}>
|
||||||
<Card title="选择角色" bordered={false} className="surface-card">
|
<Card
|
||||||
|
title={<Space><SafetyCertificateOutlined aria-hidden="true" /><span>系统角色列表</span></Space>}
|
||||||
|
className="shadow-sm full-height-card"
|
||||||
|
>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索角色名称或编码…"
|
||||||
|
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
aria-label="搜索角色"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Table
|
<Table
|
||||||
rowKey="roleId"
|
rowKey="roleId"
|
||||||
size="middle"
|
size="middle"
|
||||||
loading={loadingRoles}
|
loading={loadingRoles}
|
||||||
dataSource={roles}
|
dataSource={filteredRoles}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
type: "radio",
|
type: "radio",
|
||||||
selectedRowKeys: selectedRoleId ? [selectedRoleId] : [],
|
selectedRowKeys: selectedRoleId ? [selectedRoleId] : [],
|
||||||
onChange: (keys) => setSelectedRoleId(keys[0] as number)
|
onChange: (keys) => setSelectedRoleId(keys[0] as number),
|
||||||
}}
|
}}
|
||||||
pagination={{ pageSize: 8 }}
|
onRow={(record) => ({
|
||||||
|
onClick: () => setSelectedRoleId(record.roleId),
|
||||||
|
className: "cursor-pointer"
|
||||||
|
})}
|
||||||
|
pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 个角色` }}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: "ID", dataIndex: "roleId", width: 80 },
|
{
|
||||||
{ title: "角色编码", dataIndex: "roleCode" },
|
title: "角色信息",
|
||||||
{ title: "角色名称", dataIndex: "roleName" },
|
key: "role",
|
||||||
|
render: (_, r) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div style={{ fontWeight: 500 }} className="truncate">{r.roleName}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="truncate tabular-nums">{r.roleCode}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "状态",
|
title: "状态",
|
||||||
dataIndex: "status",
|
dataIndex: "status",
|
||||||
width: 90,
|
width: 80,
|
||||||
render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag>)
|
render: (v) => (v === 1 ? <Tag color="green" className="m-0">启用</Tag> : <Tag className="m-0">禁用</Tag>)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} lg={14}>
|
<Col xs={24} lg={14}>
|
||||||
<Card
|
<Card
|
||||||
title="配置权限"
|
title={<Space><KeyOutlined aria-hidden="true" /><span>功能权限配置</span></Space>}
|
||||||
bordered={false}
|
className="shadow-sm full-height-card"
|
||||||
className="surface-card"
|
|
||||||
extra={
|
extra={
|
||||||
<Text type="secondary">
|
selectedRole && (
|
||||||
{selectedRole ? `当前角色:${selectedRole.roleName}` : "未选择角色"}
|
<Tag color="blue">当前角色: {selectedRole.roleName}</Tag>
|
||||||
</Text>
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tree
|
{selectedRoleId ? (
|
||||||
checkable
|
<div className="role-permission-tree-container" style={{ padding: '8px 0', maxHeight: '600px', overflowY: 'auto' }}>
|
||||||
selectable={false}
|
<Tree
|
||||||
treeData={treeData}
|
checkable
|
||||||
checkedKeys={checkedPermIds}
|
selectable={false}
|
||||||
onCheck={(keys) => setCheckedPermIds(keys as number[])}
|
checkStrictly={false}
|
||||||
defaultExpandAll
|
treeData={antdTreeData}
|
||||||
/>
|
checkedKeys={checkedPermIds}
|
||||||
{!permissions.length && !loadingPerms && (
|
onCheck={(keys, info) => {
|
||||||
<div style={{ marginTop: 12 }}>
|
const checked = Array.isArray(keys) ? keys : keys.checked;
|
||||||
<Text type="secondary">暂无权限数据</Text>
|
const halfChecked = info.halfCheckedKeys || [];
|
||||||
|
setCheckedPermIds(checked.map(k => Number(k)));
|
||||||
|
setHalfCheckedIds(halfChecked.map(k => Number(k)));
|
||||||
|
}}
|
||||||
|
defaultExpandAll
|
||||||
|
/>
|
||||||
|
{!permissions.length && !loadingPerms && (
|
||||||
|
<Empty description="暂无权限定义数据" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
||||||
|
<ClusterOutlined style={{ fontSize: 40, color: '#bfbfbf', marginBottom: 16 }} aria-hidden="true" />
|
||||||
|
<Text type="secondary">请先从左侧列表中选择一个角色</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -274,15 +274,26 @@ export default function Roles() {
|
||||||
<Card
|
<Card
|
||||||
title="系统角色"
|
title="系统角色"
|
||||||
className="full-height-card"
|
className="full-height-card"
|
||||||
extra={can("sys_role:create") && <Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}>新增</Button>}
|
extra={can("sys_role:create") && (
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined aria-hidden="true" />}
|
||||||
|
onClick={openCreate}
|
||||||
|
aria-label="新增角色"
|
||||||
|
>
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索角色..."
|
placeholder="搜索角色…"
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined aria-hidden="true" />}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={e => setSearchText(e.target.value)}
|
onChange={e => setSearchText(e.target.value)}
|
||||||
allowClear
|
allowClear
|
||||||
|
aria-label="搜索角色"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="role-list-container">
|
<div className="role-list-container">
|
||||||
|
|
@ -292,6 +303,7 @@ export default function Roles() {
|
||||||
dataSource={filteredData}
|
dataSource={filteredData}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
locale={{ emptyText: <Empty description="暂无角色数据" /> }}
|
||||||
onRow={(record) => ({
|
onRow={(record) => ({
|
||||||
onClick: () => selectRole(record),
|
onClick: () => selectRole(record),
|
||||||
className: `cursor-pointer role-row ${selectedRole?.roleId === record.roleId ? 'role-row-selected' : ''}`
|
className: `cursor-pointer role-row ${selectedRole?.roleId === record.roleId ? 'role-row-selected' : ''}`
|
||||||
|
|
@ -301,15 +313,33 @@ export default function Roles() {
|
||||||
title: '角色',
|
title: '角色',
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<div className="role-item-content">
|
<div className="role-item-content">
|
||||||
<div className="role-item-main">
|
<div className="role-item-main min-w-0">
|
||||||
<div className="role-item-name">{record.roleName}</div>
|
<div className="role-item-name truncate">{record.roleName}</div>
|
||||||
<div className="role-item-code">{record.roleCode}</div>
|
<div className="role-item-code truncate">{record.roleCode}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="role-item-actions">
|
<div className="role-item-actions">
|
||||||
{can("sys_role:update") && <Button type="text" size="small" icon={<EditOutlined />} onClick={e => openEditBasic(e, record)} />}
|
{can("sys_role:update") && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined aria-hidden="true" />}
|
||||||
|
onClick={e => openEditBasic(e, record)}
|
||||||
|
aria-label={`编辑 ${record.roleName} 基础信息`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{can("sys_role:delete") && record.roleCode !== 'ADMIN' && (
|
{can("sys_role:delete") && record.roleCode !== 'ADMIN' && (
|
||||||
<Popconfirm title="删除角色?" onConfirm={e => handleRemove(e!, record.roleId)}>
|
<Popconfirm
|
||||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={e => e.stopPropagation()} />
|
title={`确定删除角色 "${record.roleName}" 吗?`}
|
||||||
|
onConfirm={e => handleRemove(e!, record.roleId)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined aria-hidden="true" />}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
aria-label={`删除 ${record.roleName} 角色`}
|
||||||
|
/>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -329,26 +359,26 @@ export default function Roles() {
|
||||||
className="full-height-card"
|
className="full-height-card"
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<SafetyCertificateOutlined style={{ color: '#1890ff' }} />
|
<SafetyCertificateOutlined style={{ color: '#1890ff' }} aria-hidden="true" />
|
||||||
<span>{selectedRole.roleName}</span>
|
<span className="truncate max-w-[200px] inline-block align-bottom">{selectedRole.roleName}</span>
|
||||||
<Tag color="blue">{selectedRole.roleCode}</Tag>
|
<Tag color="blue">{selectedRole.roleCode}</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SaveOutlined />}
|
icon={<SaveOutlined aria-hidden="true" />}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
onClick={savePermissions}
|
onClick={savePermissions}
|
||||||
disabled={!can("sys_role:permission:save")}
|
disabled={!can("sys_role:permission:save")}
|
||||||
>
|
>
|
||||||
保存权限配置
|
{saving ? "保存中…" : "保存权限更改"}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tabs defaultActiveKey="permissions" className="role-tabs">
|
<Tabs defaultActiveKey="permissions" className="role-tabs">
|
||||||
<Tabs.TabPane
|
<Tabs.TabPane
|
||||||
tab={<Space><KeyOutlined />功能权限</Space>}
|
tab={<Space><KeyOutlined aria-hidden="true" />功能权限</Space>}
|
||||||
key="permissions"
|
key="permissions"
|
||||||
>
|
>
|
||||||
<div className="role-permission-tree-v2">
|
<div className="role-permission-tree-v2">
|
||||||
|
|
@ -369,7 +399,7 @@ export default function Roles() {
|
||||||
</div>
|
</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
<Tabs.TabPane
|
<Tabs.TabPane
|
||||||
tab={<Space><UserOutlined />关联用户 ({roleUsers.length})</Space>}
|
tab={<Space><UserOutlined aria-hidden="true" />关联用户 ({roleUsers.length})</Space>}
|
||||||
key="users"
|
key="users"
|
||||||
>
|
>
|
||||||
<Table
|
<Table
|
||||||
|
|
@ -377,25 +407,26 @@ export default function Roles() {
|
||||||
size="small"
|
size="small"
|
||||||
loading={loadingUsers}
|
loading={loadingUsers}
|
||||||
dataSource={roleUsers}
|
dataSource={roleUsers}
|
||||||
pagination={{ pageSize: 10 }}
|
pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 人` }}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: '用户',
|
title: '用户',
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<Space>
|
<Space>
|
||||||
<UserOutlined />
|
<UserOutlined aria-hidden="true" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div style={{ fontWeight: 500 }}>{r.displayName}</div>
|
<div style={{ fontWeight: 500 }} className="truncate">{r.displayName}</div>
|
||||||
<div style={{ fontSize: 12, color: '#8c8c8c' }}>@{r.username}</div>
|
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="truncate">@{r.username}</div>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{ title: '手机号', dataIndex: 'phone' },
|
{ title: '手机号', dataIndex: 'phone', className: 'tabular-nums' },
|
||||||
{ title: '邮箱', dataIndex: 'email' },
|
{ title: '邮箱', dataIndex: 'email' },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
|
width: 80,
|
||||||
render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag>
|
render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag>
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
|
|
@ -405,7 +436,7 @@ export default function Roles() {
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card className="full-height-card flex-center">
|
<Card className="full-height-card flex-center">
|
||||||
<Empty description="请从左侧选择一个角色以查看详情" />
|
<Empty description="请从左侧列表选择一个角色进行管理" />
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
import { Button, Card, Checkbox, Col, message, Row, Space, Table, Tag, Typography } from "antd";
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Col,
|
||||||
|
message,
|
||||||
|
Row,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
Input,
|
||||||
|
Empty
|
||||||
|
} from "antd";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "../api";
|
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "../api";
|
||||||
|
import { SearchOutlined, UserOutlined, SaveOutlined, TeamOutlined } from "@ant-design/icons";
|
||||||
import type { SysRole, SysUser } from "../types";
|
import type { SysRole, SysUser } from "../types";
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
@ -13,6 +27,9 @@ export default function UserRoleBinding() {
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||||
const [checkedRoleIds, setCheckedRoleIds] = useState<number[]>([]);
|
const [checkedRoleIds, setCheckedRoleIds] = useState<number[]>([]);
|
||||||
|
|
||||||
|
// Search state
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
|
||||||
const selectedUser = useMemo(
|
const selectedUser = useMemo(
|
||||||
() => users.find((u) => u.userId === selectedUserId) || null,
|
() => users.find((u) => u.userId === selectedUserId) || null,
|
||||||
|
|
@ -45,7 +62,7 @@ export default function UserRoleBinding() {
|
||||||
setCheckedRoleIds(list || []);
|
setCheckedRoleIds(list || []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setCheckedRoleIds([]);
|
setCheckedRoleIds([]);
|
||||||
message.error("加载用户角色失败,请确认接口已实现");
|
message.error("加载用户角色数据失败");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -62,99 +79,145 @@ export default function UserRoleBinding() {
|
||||||
}
|
}
|
||||||
}, [selectedUserId]);
|
}, [selectedUserId]);
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
if (!searchText) return users;
|
||||||
|
const lower = searchText.toLowerCase();
|
||||||
|
return users.filter(u =>
|
||||||
|
u.username.toLowerCase().includes(lower) ||
|
||||||
|
u.displayName.toLowerCase().includes(lower)
|
||||||
|
);
|
||||||
|
}, [users, searchText]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!selectedUserId) {
|
if (!selectedUserId) {
|
||||||
message.warning("请先选择用户");
|
message.warning("请先在左侧列表中选择一个用户");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await saveUserRoles(selectedUserId, checkedRoleIds);
|
await saveUserRoles(selectedUserId, checkedRoleIds);
|
||||||
message.success("用户角色绑定已保存");
|
message.success("角色权限已成功授予用户");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error("保存失败,请确认接口已实现");
|
message.error("更新绑定关系失败");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-shell">
|
<div className="p-6">
|
||||||
<div className="page-header">
|
<div className="mb-6 flex justify-between items-end">
|
||||||
<div>
|
<div>
|
||||||
<Title level={4} className="page-title">用户角色绑定</Title>
|
<Title level={4} className="mb-1">用户角色授权</Title>
|
||||||
<Text type="secondary" className="page-subtitle">为用户分配一个或多个角色</Text>
|
<Text type="secondary">为指定用户分配系统访问角色,控制其操作权限边界</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button type="primary" onClick={handleSave} loading={saving} disabled={!selectedUserId}>
|
<Button
|
||||||
保存绑定
|
type="primary"
|
||||||
|
icon={<SaveOutlined aria-hidden="true" />}
|
||||||
|
onClick={handleSave}
|
||||||
|
loading={saving}
|
||||||
|
disabled={!selectedUserId}
|
||||||
|
>
|
||||||
|
{saving ? "保存中…" : "保存权限更改"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={24}>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card title="选择用户" bordered={false} className="surface-card">
|
<Card
|
||||||
|
title={<Space><UserOutlined aria-hidden="true" /><span>用户选择列表</span></Space>}
|
||||||
|
className="shadow-sm full-height-card"
|
||||||
|
>
|
||||||
|
<div className="mb-4">
|
||||||
|
<Input
|
||||||
|
placeholder="搜索用户名或显示名…"
|
||||||
|
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
aria-label="搜索用户"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Table
|
<Table
|
||||||
rowKey="userId"
|
rowKey="userId"
|
||||||
size="middle"
|
size="middle"
|
||||||
loading={loadingUsers}
|
loading={loadingUsers}
|
||||||
dataSource={users}
|
dataSource={filteredUsers}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
type: "radio",
|
type: "radio",
|
||||||
selectedRowKeys: selectedUserId ? [selectedUserId] : [],
|
selectedRowKeys: selectedUserId ? [selectedUserId] : [],
|
||||||
onChange: (keys) => setSelectedUserId(keys[0] as number)
|
onChange: (keys) => setSelectedUserId(keys[0] as number),
|
||||||
}}
|
}}
|
||||||
pagination={{ pageSize: 8 }}
|
onRow={(record) => ({
|
||||||
|
onClick: () => setSelectedUserId(record.userId),
|
||||||
|
className: "cursor-pointer"
|
||||||
|
})}
|
||||||
|
pagination={{ pageSize: 10, showTotal: (total) => `共 ${total} 人` }}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: "ID", dataIndex: "userId", width: 80 },
|
{
|
||||||
{ title: "用户名", dataIndex: "username" },
|
title: "用户信息",
|
||||||
{ title: "显示名", dataIndex: "displayName" },
|
key: "user",
|
||||||
|
render: (_, r) => (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div style={{ fontWeight: 500 }} className="truncate">{r.displayName}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="truncate">@{r.username}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "状态",
|
title: "状态",
|
||||||
dataIndex: "status",
|
dataIndex: "status",
|
||||||
width: 90,
|
width: 80,
|
||||||
render: (v) => (v === 1 ? <Tag color="green">启用</Tag> : <Tag color="red">禁用</Tag>)
|
render: (v) => (v === 1 ? <Tag color="green" className="m-0">正常</Tag> : <Tag className="m-0">禁用</Tag>)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card
|
<Card
|
||||||
title="选择角色"
|
title={<Space><TeamOutlined aria-hidden="true" /><span>授予角色权限</span></Space>}
|
||||||
bordered={false}
|
className="shadow-sm full-height-card"
|
||||||
className="surface-card"
|
|
||||||
extra={
|
extra={
|
||||||
<Text type="secondary">
|
selectedUser && (
|
||||||
{selectedUser ? `当前用户:${selectedUser.displayName || selectedUser.username}` : "未选择用户"}
|
<Tag color="blue">正在编辑: {selectedUser.displayName}</Tag>
|
||||||
</Text>
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
{selectedUserId ? (
|
||||||
<Checkbox.Group
|
<div style={{ padding: '8px 0' }}>
|
||||||
style={{ width: "100%" }}
|
<Checkbox.Group
|
||||||
value={checkedRoleIds}
|
style={{ width: "100%" }}
|
||||||
onChange={(values) => setCheckedRoleIds(values as number[])}
|
value={checkedRoleIds}
|
||||||
disabled={loadingRoles}
|
onChange={(values) => setCheckedRoleIds(values as number[])}
|
||||||
>
|
disabled={loadingRoles}
|
||||||
<Row gutter={[12, 12]}>
|
>
|
||||||
{roles.map((role) => (
|
<Row gutter={[16, 16]}>
|
||||||
<Col key={role.roleId} span={12}>
|
{roles.map((role) => (
|
||||||
<Checkbox value={role.roleId}>
|
<Col key={role.roleId} span={12}>
|
||||||
<Space direction="vertical" size={0}>
|
<Checkbox value={role.roleId} className="w-full">
|
||||||
<span>{role.roleName}</span>
|
<Space direction="vertical" size={0}>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<span style={{ fontWeight: 500 }}>{role.roleName}</span>
|
||||||
{role.roleCode}
|
<Text type="secondary" style={{ fontSize: 12 }} className="tabular-nums">
|
||||||
</Text>
|
{role.roleCode}
|
||||||
</Space>
|
</Text>
|
||||||
</Checkbox>
|
</Space>
|
||||||
</Col>
|
</Checkbox>
|
||||||
))}
|
</Col>
|
||||||
</Row>
|
))}
|
||||||
</Checkbox.Group>
|
</Row>
|
||||||
{!roles.length && !loadingRoles && (
|
</Checkbox.Group>
|
||||||
<Text type="secondary">暂无角色数据</Text>
|
{!roles.length && !loadingRoles && (
|
||||||
)}
|
<Empty description="暂无可用角色" />
|
||||||
</Space>
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200">
|
||||||
|
<UserOutlined style={{ fontSize: 40, color: '#bfbfbf', marginBottom: 16 }} aria-hidden="true" />
|
||||||
|
<Text type="secondary">请先从左侧选择一个用户</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue