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