feat(pages): 完善前端页面功能与用户体验优化

- 在Dashboard页面增加表格操作列和刷新按钮,优化样式布局
- 重构Devices页面实现设备管理功能,包括搜索、分页和状态显示
- 优化Dictionaries页面字典管理界面,改进交互体验和数据展示
- 更新Login页面样式设计,提升登录界面视觉效果和响应式适配
- 统一图标无障碍访问属性,增强组件可访问性支持
master
chenhao 2026-02-12 13:43:59 +08:00
parent a1db81892c
commit b138960f4b
11 changed files with 1397 additions and 623 deletions

View File

@ -53,23 +53,24 @@ function StatCard({
<div className="stat-card-header"> <div className="stat-card-header">
<span className="stat-card-title">{title}</span> <span className="stat-card-title">{title}</span>
{icon && ( {icon && (
<span className="stat-card-icon" style={{ color: themeColor }}> <span className="stat-card-icon" style={{ color: themeColor }} aria-hidden="true">
{icon} {icon}
</span> </span>
)} )}
</div> </div>
<div className="stat-card-body"> <div className="stat-card-body">
<div className="stat-card-value" style={{ color: themeColor }}> <div className="stat-card-value tabular-nums" style={{ color: themeColor }}>
{value} {value}
{suffix && <span className="stat-card-suffix">{suffix}</span>} {suffix && <span className="stat-card-suffix">{suffix}</span>}
</div> </div>
{trend && ( {trend && (
<div <div
className={`stat-card-trend ${trend.direction === 'up' ? 'trend-up' : 'trend-down'}`} className={`stat-card-trend ${trend.direction === 'up' ? 'trend-up' : 'trend-down'} tabular-nums`}
aria-label={`${trend.direction === 'up' ? 'Increase' : 'Decrease'} of ${trend.value}%`}
> >
{trend.direction === 'up' ? <ArrowUpOutlined /> : <ArrowDownOutlined />} {trend.direction === 'up' ? <ArrowUpOutlined aria-hidden="true" /> : <ArrowDownOutlined aria-hidden="true" />}
<span>{Math.abs(trend.value)}%</span> <span>{Math.abs(trend.value)}%</span>
</div> </div>
)} )}

View File

@ -1,15 +1,16 @@
import { Row, Col, Card, Typography, Table, Tag, Space } from "antd"; import { Row, Col, Card, Typography, Table, Tag, Space, Skeleton, Button } from "antd";
import { import {
VideoCameraOutlined, VideoCameraOutlined,
DesktopOutlined, DesktopOutlined,
UserOutlined, UserOutlined,
ClockCircleOutlined, ClockCircleOutlined,
CheckCircleOutlined, CheckCircleOutlined,
SyncOutlined SyncOutlined,
ArrowRightOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import StatCard from "../components/shared/StatCard/StatCard"; import StatCard from "../components/shared/StatCard/StatCard";
const { Title } = Typography; const { Title, Text } = Typography;
const recentMeetings = [ const recentMeetings = [
{ key: '1', name: '产品周会', type: '转录中', time: '2024-02-10 14:00', duration: '45min', status: 'processing' }, { key: '1', name: '产品周会', type: '转录中', time: '2024-02-10 14:00', duration: '45min', status: 'processing' },
@ -19,26 +20,54 @@ const recentMeetings = [
]; ];
const columns = [ const columns = [
{ title: '会议名称', dataIndex: 'name', key: 'name' }, {
{ title: '开始时间', dataIndex: 'time', key: 'time' }, title: '会议名称',
{ title: '时长', dataIndex: 'duration', key: 'duration' }, dataIndex: 'name',
key: 'name',
render: (text: string) => <Text strong>{text}</Text>
},
{
title: '开始时间',
dataIndex: 'time',
key: 'time',
className: 'tabular-nums',
render: (text: string) => <Text type="secondary">{text}</Text>
},
{
title: '时长',
dataIndex: 'duration',
key: 'duration',
width: 100,
className: 'tabular-nums'
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
key: 'status', key: 'status',
render: (status: string, record: any) => { width: 120,
if (status === 'processing') return <Tag icon={<SyncOutlined spin />} color="processing"></Tag>; render: (status: string) => {
if (status === 'success') return <Tag icon={<CheckCircleOutlined />} color="success"></Tag>; if (status === 'processing') return <Tag icon={<SyncOutlined spin aria-hidden="true" />} color="processing"></Tag>;
if (status === 'success') return <Tag icon={<CheckCircleOutlined aria-hidden="true" />} color="success"></Tag>;
return <Tag color="default"></Tag>; return <Tag color="default"></Tag>;
} }
},
{
title: '操作',
key: 'action',
width: 80,
render: () => <Button type="link" size="small" icon={<ArrowRightOutlined aria-hidden="true" />} aria-label="查看会议详情" />
} }
]; ];
export default function Dashboard() { export default function Dashboard() {
return ( return (
<div className="dashboard-page"> <div className="dashboard-page p-6">
<div style={{ marginBottom: 24 }}> <div className="mb-6 flex justify-between items-end">
<Title level={4}></Title> <div>
<Title level={4} className="mb-1"></Title>
<Text type="secondary"></Text>
</div>
<Button icon={<SyncOutlined aria-hidden="true" />} size="small"></Button>
</div> </div>
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
@ -46,7 +75,7 @@ export default function Dashboard() {
<StatCard <StatCard
title="今日会议" title="今日会议"
value={12} value={12}
icon={<VideoCameraOutlined />} icon={<VideoCameraOutlined aria-hidden="true" />}
color="blue" color="blue"
trend={{ value: 8, direction: 'up' }} trend={{ value: 8, direction: 'up' }}
/> />
@ -55,7 +84,7 @@ export default function Dashboard() {
<StatCard <StatCard
title="活跃设备" title="活跃设备"
value={45} value={45}
icon={<DesktopOutlined />} icon={<DesktopOutlined aria-hidden="true" />}
color="green" color="green"
trend={{ value: 2, direction: 'up' }} trend={{ value: 2, direction: 'up' }}
/> />
@ -65,7 +94,7 @@ export default function Dashboard() {
title="转录时长" title="转录时长"
value={1280} value={1280}
suffix="min" suffix="min"
icon={<ClockCircleOutlined />} icon={<ClockCircleOutlined aria-hidden="true" />}
color="orange" color="orange"
trend={{ value: 5, direction: 'down' }} trend={{ value: 5, direction: 'down' }}
/> />
@ -74,31 +103,39 @@ export default function Dashboard() {
<StatCard <StatCard
title="总用户数" title="总用户数"
value={320} value={320}
icon={<UserOutlined />} icon={<UserOutlined aria-hidden="true" />}
color="purple" color="purple"
trend={{ value: 12, direction: 'up' }} trend={{ value: 12, direction: 'up' }}
/> />
</Col> </Col>
</Row> </Row>
<Row gutter={[24, 24]} style={{ marginTop: 24 }}> <Row gutter={[24, 24]} className="mt-6">
<Col span={16}> <Col xs={24} xl={16}>
<Card title="最近会议" bordered={false} bodyStyle={{ padding: 0 }}> <Card
title="最近会议"
bordered={false}
className="shadow-sm"
extra={<Button type="link" size="small"></Button>}
styles={{ body: { padding: 0 } }}
>
<Table <Table
dataSource={recentMeetings} dataSource={recentMeetings}
columns={columns} columns={columns}
pagination={false} pagination={false}
size="middle" size="middle"
className="roles-table"
/> />
</Card> </Card>
</Col> </Col>
<Col span={8}> <Col xs={24} xl={8}>
<Card title="设备负载" bordered={false}> <Card title="设备负载" bordered={false} className="shadow-sm">
<div style={{ height: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#999' }}> <div className="flex flex-col items-center justify-center py-12">
<Space direction="vertical" align="center"> <Skeleton active paragraph={{ rows: 4 }} />
<SyncOutlined style={{ fontSize: 48, color: '#f0f0f0' }} /> <div className="mt-4 text-gray-400 flex items-center gap-2">
<span>...</span> <SyncOutlined spin aria-hidden="true" />
</Space> <span></span>
</div>
</div> </div>
</Card> </Card>
</Col> </Col>

View File

@ -1,50 +1,87 @@
import { Button, Form, Input, Drawer, Popconfirm, Space, Table, Tag, Select, InputNumber } from "antd"; import {
Button,
Form,
Input,
Drawer,
Popconfirm,
Space,
Table,
Tag,
Select,
Typography,
Card,
message
} from "antd";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { createDevice, deleteDevice, listDevices, updateDevice } from "../api"; import { createDevice, deleteDevice, listDevices, updateDevice, listUsers } from "../api";
import type { DeviceInfo } from "../types"; import type { DeviceInfo, SysUser } from "../types";
import { usePermission } from "../hooks/usePermission"; import { usePermission } from "../hooks/usePermission";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
DesktopOutlined,
UserOutlined
} from "@ant-design/icons";
import "./Devices.css";
const { Title, Text } = Typography;
export default function Devices() { export default function Devices() {
const { can } = usePermission();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [data, setData] = useState<DeviceInfo[]>([]); const [data, setData] = useState<DeviceInfo[]>([]);
const [query, setQuery] = useState({ userId: "", deviceCode: "", deviceName: "" }); const [users, setUsers] = useState<SysUser[]>([]);
const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
// Search state
const [searchText, setSearchText] = useState("");
// Drawer state
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<DeviceInfo | null>(null); const [editing, setEditing] = useState<DeviceInfo | null>(null);
const [form] = Form.useForm(); const [form] = Form.useForm();
const { can } = usePermission();
const load = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
try { try {
const list = await listDevices(); const [deviceList, usersList] = await Promise.all([listDevices(), listUsers()]);
setData(list || []); setData(deviceList || []);
setUsers(usersList || []);
} catch (e) {
message.error("加载数据失败");
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
load(); loadData();
}, []); }, []);
const filtered = useMemo(() => { const userMap = useMemo(() => {
return data.filter((d) => { const map: Record<number, SysUser> = {};
const hitUser = query.userId ? String(d.userId).includes(query.userId) : true; users.forEach(u => map[u.userId] = u);
const hitCode = query.deviceCode ? d.deviceCode?.includes(query.deviceCode) : true; return map;
const hitName = query.deviceName ? (d.deviceName || "").includes(query.deviceName) : true; }, [users]);
return hitUser && hitCode && hitName;
});
}, [data, query]);
const pageData = useMemo(() => { const filteredData = useMemo(() => {
const start = (pagination.current - 1) * pagination.pageSize; if (!searchText) return data;
return filtered.slice(start, start + pagination.pageSize); const lower = searchText.toLowerCase();
}, [filtered, pagination]); return data.filter(d => {
const user = userMap[d.userId];
return d.deviceCode.toLowerCase().includes(lower) ||
(d.deviceName && d.deviceName.toLowerCase().includes(lower)) ||
(user && user.displayName.toLowerCase().includes(lower)) ||
String(d.userId).includes(lower);
});
}, [data, searchText, userMap]);
const openCreate = () => { const openCreate = () => {
setEditing(null); setEditing(null);
form.resetFields(); form.resetFields();
form.setFieldsValue({ status: 1 });
setOpen(true); setOpen(true);
}; };
@ -55,7 +92,9 @@ export default function Devices() {
}; };
const submit = async () => { const submit = async () => {
try {
const values = await form.validateFields(); const values = await form.validateFields();
setSaving(true);
const payload: Partial<DeviceInfo> = { const payload: Partial<DeviceInfo> = {
userId: values.userId, userId: values.userId,
deviceCode: values.deviceCode, deviceCode: values.deviceCode,
@ -64,102 +103,184 @@ export default function Devices() {
}; };
if (editing) { if (editing) {
await updateDevice(editing.deviceId, payload); await updateDevice(editing.deviceId, payload);
message.success("设备已更新");
} else { } else {
await createDevice(payload); await createDevice(payload);
message.success("设备已创建");
} }
setOpen(false); setOpen(false);
load(); loadData();
} catch (e) {
if (e instanceof Error && e.message) message.error(e.message);
} finally {
setSaving(false);
}
}; };
const remove = async (id: number) => { const remove = async (id: number) => {
try {
await deleteDevice(id); await deleteDevice(id);
load(); message.success("设备已删除");
loadData();
} catch (e) {
message.error("删除失败");
}
}; };
return ( const columns = [
{
title: "设备信息",
key: "device",
render: (_: any, record: DeviceInfo) => (
<Space>
<div className="device-icon-placeholder">
<DesktopOutlined aria-hidden="true" />
</div>
<div> <div>
<Space style={{ marginBottom: 16 }}> <div className="device-name">{record.deviceName || "未命名设备"}</div>
<Input <div className="device-code tabular-nums">{record.deviceCode}</div>
placeholder="用户ID" </div>
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> </Space>
),
<Table },
rowKey="deviceId" {
loading={loading} title: "归属用户",
dataSource={pageData} key: "user",
pagination={{ render: (_: any, record: DeviceInfo) => {
current: pagination.current, const user = userMap[record.userId];
pageSize: pagination.pageSize, return user ? (
total: filtered.length, <Space>
onChange: (current, pageSize) => setPagination({ current, pageSize }) <UserOutlined aria-hidden="true" style={{ color: '#8c8c8c' }} />
}} <span>{user.displayName}</span>
columns={[ <Text type="secondary" size="small" className="tabular-nums">(ID: {record.userId})</Text>
{ title: "ID", dataIndex: "deviceId" }, </Space>
{ title: "用户ID", dataIndex: "userId" }, ) : (
{ title: "设备码", dataIndex: "deviceCode" }, <span className="tabular-nums">ID: {record.userId}</span>
{ title: "设备名", dataIndex: "deviceName" }, );
}
},
{ {
title: "状态", title: "状态",
dataIndex: "status", dataIndex: "status",
render: (v) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>) 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: "操作", title: "操作",
render: (_, record) => ( key: "action",
width: 120,
fixed: "right" as const,
render: (_: any, record: DeviceInfo) => (
<Space> <Space>
{can("device:update") && <Button onClick={() => openEdit(record)}></Button>} {can("device:update") && (
<Button
type="text"
icon={<EditOutlined aria-hidden="true" />}
onClick={() => openEdit(record)}
aria-label={`编辑设备 ${record.deviceName || record.deviceCode}`}
/>
)}
{can("device:delete") && ( {can("device:delete") && (
<Popconfirm title="确认删除?" onConfirm={() => remove(record.deviceId)}> <Popconfirm title="确定删除该设备吗?" onConfirm={() => remove(record.deviceId)}>
<Button danger></Button> <Button
type="text"
danger
icon={<DeleteOutlined aria-hidden="true" />}
aria-label={`删除设备 ${record.deviceName || record.deviceCode}`}
/>
</Popconfirm> </Popconfirm>
)} )}
</Space> </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 <Drawer
title={editing ? "编辑设备" : "新增设备"} title={
<div className="device-drawer-title">
<DesktopOutlined className="mr-2" aria-hidden="true" />
{editing ? "修改设备信息" : "接入新设备"}
</div>
}
open={open} open={open}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
width={420} width={420}
destroyOnClose destroyOnClose
footer={ footer={
<Space style={{ width: "100%", justifyContent: "flex-end" }}> <div className="flex justify-end gap-2 p-2">
<Button onClick={() => setOpen(false)}></Button> <Button onClick={() => setOpen(false)}></Button>
<Button type="primary" onClick={submit}></Button> <Button type="primary" loading={saving} onClick={submit}>
</Space>
</Button>
</div>
} }
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item label="用户ID" name="userId" rules={[{ required: true }]}> <Form.Item label="归属用户" name="userId" rules={[{ required: true, message: "请选择归属用户" }]}>
<InputNumber style={{ width: "100%" }} min={1} /> <Select
showSearch
placeholder="搜索并选择用户"
optionFilterProp="children"
options={users.map(u => ({ label: `${u.displayName} (@${u.username})`, value: u.userId }))}
/>
</Form.Item> </Form.Item>
<Form.Item label="设备码" name="deviceCode" rules={[{ required: true }]}> <Form.Item label="设备识别码" name="deviceCode" rules={[{ required: true, message: "请输入设备识别码" }]}>
<Input /> <Input placeholder="由硬件生成的唯一识别码" />
</Form.Item> </Form.Item>
<Form.Item label="设备名" name="deviceName"> <Form.Item label="设备名" name="deviceName">
<Input /> <Input placeholder="例如:会议室 A 转录仪" />
</Form.Item> </Form.Item>
<Form.Item label="状态" name="status" initialValue={1}> <Form.Item label="设备状态" name="status" initialValue={1}>
<Select options={[{ value: 1, label: "启用" }, { value: 0, label: "禁用" }]} /> <Select options={[{ value: 1, label: "正常启用" }, { value: 0, label: "禁用接入" }]} />
</Form.Item> </Form.Item>
</Form> </Form>
</Drawer> </Drawer>

View File

@ -13,7 +13,8 @@ import {
Space, Space,
Table, Table,
Tag, Tag,
Typography Typography,
Empty
} from "antd"; } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@ -27,10 +28,11 @@ import {
updateDictType updateDictType
} from "../api"; } from "../api";
import { usePermission } from "../hooks/usePermission"; import { usePermission } from "../hooks/usePermission";
import { PlusOutlined, EditOutlined, DeleteOutlined } from "@ant-design/icons"; import { PlusOutlined, EditOutlined, DeleteOutlined, BookOutlined, ProfileOutlined } from "@ant-design/icons";
import type { SysDictItem, SysDictType } from "../types"; import type { SysDictItem, SysDictType } from "../types";
import "./Dictionaries.css";
const { Title } = Typography; const { Title, Text } = Typography;
export default function Dictionaries() { export default function Dictionaries() {
const { can } = usePermission(); const { can } = usePermission();
@ -100,7 +102,7 @@ export default function Dictionaries() {
const handleDeleteType = async (id: number) => { const handleDeleteType = async (id: number) => {
await deleteDictType(id); await deleteDictType(id);
message.success("删除成功"); message.success("类型删除成功");
loadTypes(); loadTypes();
}; };
@ -111,7 +113,7 @@ export default function Dictionaries() {
} else { } else {
await createDictType(values); await createDictType(values);
} }
message.success(editingType ? "更新成功" : "创建成功"); message.success(editingType ? "类型更新成功" : "类型创建成功");
setTypeDrawerVisible(false); setTypeDrawerVisible(false);
loadTypes(); loadTypes();
}; };
@ -119,7 +121,7 @@ export default function Dictionaries() {
// Item Actions // Item Actions
const handleAddItem = () => { const handleAddItem = () => {
if (!selectedType) { if (!selectedType) {
message.warning("请先选择一个字典类型"); message.warning("请先从左侧选择一个字典类型");
return; return;
} }
setEditingItem(null); setEditingItem(null);
@ -136,7 +138,7 @@ export default function Dictionaries() {
const handleDeleteItem = async (id: number) => { const handleDeleteItem = async (id: number) => {
await deleteDictItem(id); await deleteDictItem(id);
message.success("删除成功"); message.success("字典项删除成功");
if (selectedType) loadItems(selectedType.typeCode); if (selectedType) loadItems(selectedType.typeCode);
}; };
@ -147,58 +149,80 @@ export default function Dictionaries() {
} else { } else {
await createDictItem(values); await createDictItem(values);
} }
message.success(editingItem ? "更新成功" : "创建成功"); message.success(editingItem ? "字典项更新成功" : "字典项创建成功");
setItemDrawerVisible(false); setItemDrawerVisible(false);
if (selectedType) loadItems(selectedType.typeCode); if (selectedType) loadItems(selectedType.typeCode);
}; };
return ( return (
<div className="p-6"> <div className="dictionaries-page">
<Title level={4} className="mb-6"></Title> <div className="dictionaries-header">
<Row gutter={16}> <div>
<Col span={8}> <Title level={4} className="dictionaries-title"></Title>
<Text type="secondary"></Text>
</div>
</div>
<Row gutter={24} className="dictionaries-content">
<Col span={8} className="full-height">
<Card <Card
title="字典类型" title={
<Space>
<BookOutlined aria-hidden="true" />
<span></span>
</Space>
}
className="full-height-card shadow-sm"
extra={ extra={
can("sys_dict:type:create") && ( can("sys_dict:type:create") && (
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddType}> <Button
type="primary"
size="small"
icon={<PlusOutlined aria-hidden="true" />}
onClick={handleAddType}
aria-label="新增字典类型"
>
</Button> </Button>
) )
} }
> >
<div className="scroll-container">
<Table <Table
rowKey="dictTypeId" rowKey="dictTypeId"
loading={loadingTypes} loading={loadingTypes}
dataSource={types} dataSource={types}
pagination={false} pagination={false}
size="small" size="small"
showHeader={false}
onRow={(record) => ({ onRow={(record) => ({
onClick: () => setSelectedType(record), onClick: () => setSelectedType(record),
className: `cursor-pointer ${selectedType?.dictTypeId === record.dictTypeId ? "ant-table-row-selected" : ""}` className: `cursor-pointer dict-type-row ${selectedType?.dictTypeId === record.dictTypeId ? "dict-type-row-selected" : ""}`
})} })}
columns={[ columns={[
{ title: "类型名称", dataIndex: "typeName" },
{ title: "编码", dataIndex: "typeCode" },
{ {
title: "操作",
width: 100,
render: (_, record) => ( render: (_, record) => (
<Space> <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") && ( {can("sys_dict:type:update") && (
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<EditOutlined />} icon={<EditOutlined aria-hidden="true" />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleEditType(record); handleEditType(record);
}} }}
aria-label={`编辑类型 ${record.typeName}`}
/> />
)} )}
{can("sys_dict:type:delete") && ( {can("sys_dict:type:delete") && (
<Popconfirm <Popconfirm
title="删除类型会影响关联的项,确认删除?" title={`确定删除类型 "${record.typeName}" 吗?这会影响关联的字典项。`}
onConfirm={(e) => { onConfirm={(e) => {
e?.stopPropagation(); e?.stopPropagation();
handleDeleteType(record.dictTypeId); handleDeleteType(record.dictTypeId);
@ -208,60 +232,105 @@ export default function Dictionaries() {
type="text" type="text"
size="small" size="small"
danger danger
icon={<DeleteOutlined />} icon={<DeleteOutlined aria-hidden="true" />}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
aria-label={`删除类型 ${record.typeName}`}
/> />
</Popconfirm> </Popconfirm>
)} )}
</Space> </div>
</div>
) )
} }
]} ]}
/> />
</div>
</Card> </Card>
</Col> </Col>
<Col span={16}>
<Col span={16} className="full-height">
<Card <Card
title={`字典项 - ${selectedType?.typeName || "未选择"}`} title={
<Space>
<ProfileOutlined aria-hidden="true" />
<span>{selectedType ? ` - ${selectedType.typeName}` : ""}</span>
</Space>
}
className="full-height-card shadow-sm"
extra={ extra={
can("sys_dict:item:create") && ( can("sys_dict:item:create") && (
<Button type="primary" icon={<PlusOutlined />} onClick={handleAddItem} disabled={!selectedType}> <Button
type="primary"
size="small"
icon={<PlusOutlined aria-hidden="true" />}
onClick={handleAddItem}
disabled={!selectedType}
aria-label="新增字典项"
>
</Button> </Button>
) )
} }
> >
{selectedType ? (
<div className="scroll-container">
<Table <Table
rowKey="dictItemId" rowKey="dictItemId"
loading={loadingItems} loading={loadingItems}
dataSource={items} dataSource={items}
pagination={false} pagination={false}
size="middle"
columns={[ columns={[
{ title: "标签", dataIndex: "itemLabel" }, {
{ title: "数值", dataIndex: "itemValue" }, title: "展示标签",
{ title: "排序", dataIndex: "sortOrder", width: 80 }, dataIndex: "itemLabel",
render: (text) => <Text strong>{text}</Text>
},
{
title: "数据数值",
dataIndex: "itemValue",
className: "tabular-nums"
},
{
title: "排序",
dataIndex: "sortOrder",
width: 80,
className: "tabular-nums"
},
{ {
title: "状态", title: "状态",
dataIndex: "status", dataIndex: "status",
width: 80, width: 100,
render: (v) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>) render: (v) => (
<Tag color={v === 1 ? "green" : "red"}>
{v === 1 ? "启用" : "禁用"}
</Tag>
)
}, },
{ {
title: "操作", title: "操作",
width: 120, width: 120,
fixed: "right" as const,
render: (_, record) => ( render: (_, record) => (
<Space> <Space>
{can("sys_dict:item:update") && ( {can("sys_dict:item:update") && (
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<EditOutlined />} icon={<EditOutlined aria-hidden="true" />}
onClick={() => handleEditItem(record)} onClick={() => handleEditItem(record)}
aria-label={`编辑字典项 ${record.itemLabel}`}
/> />
)} )}
{can("sys_dict:item:delete") && ( {can("sys_dict:item:delete") && (
<Popconfirm title="确认删除该项?" onConfirm={() => handleDeleteItem(record.dictItemId)}> <Popconfirm title={`确定删除字典项 "${record.itemLabel}" 吗?`} onConfirm={() => handleDeleteItem(record.dictItemId)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} /> <Button
type="text"
size="small"
danger
icon={<DeleteOutlined aria-hidden="true" />}
aria-label={`删除字典项 ${record.itemLabel}`}
/>
</Popconfirm> </Popconfirm>
)} )}
</Space> </Space>
@ -269,65 +338,81 @@ export default function Dictionaries() {
} }
]} ]}
/> />
</div>
) : (
<div className="flex-center h-full">
<Empty description="请先从左侧选择一个字典类型" />
</div>
)}
</Card> </Card>
</Col> </Col>
</Row> </Row>
{/* Type Drawer */} {/* Type Drawer */}
<Drawer <Drawer
title={editingType ? "编辑字典类型" : "新增字典类型"} title={
<Space>
<BookOutlined aria-hidden="true" />
<span>{editingType ? "编辑字典类型" : "新增字典类型"}</span>
</Space>
}
open={typeDrawerVisible} open={typeDrawerVisible}
onClose={() => setTypeDrawerVisible(false)} onClose={() => setTypeDrawerVisible(false)}
width={400} width={400}
destroyOnClose destroyOnClose
footer={ footer={
<div className="flex justify-end space-x-2"> <div className="flex justify-end gap-2">
<Button onClick={() => setTypeDrawerVisible(false)}></Button> <Button onClick={() => setTypeDrawerVisible(false)}></Button>
<Button type="primary" onClick={handleTypeSubmit}></Button> <Button type="primary" onClick={handleTypeSubmit}></Button>
</div> </div>
} }
> >
<Form form={typeForm} layout="vertical"> <Form form={typeForm} layout="vertical">
<Form.Item label="类型编码" name="typeCode" rules={[{ required: true }]}> <Form.Item label="类型编码" name="typeCode" rules={[{ required: true, message: "请输入类型编码" }]}>
<Input disabled={!!editingType} placeholder="例如user_status" /> <Input disabled={!!editingType} placeholder="例如user_status" />
</Form.Item> </Form.Item>
<Form.Item label="类型名称" name="typeName" rules={[{ required: true }]}> <Form.Item label="类型名称" name="typeName" rules={[{ required: true, message: "请输入类型名称" }]}>
<Input placeholder="例如:用户状态" /> <Input placeholder="例如:用户状态" />
</Form.Item> </Form.Item>
<Form.Item label="备注" name="remark"> <Form.Item label="备注说明" name="remark">
<Input.TextArea /> <Input.TextArea placeholder="该字典类型的用途描述…" rows={3} />
</Form.Item> </Form.Item>
</Form> </Form>
</Drawer> </Drawer>
{/* Item Drawer */} {/* Item Drawer */}
<Drawer <Drawer
title={editingItem ? "编辑字典项" : "新增字典项"} title={
<Space>
<ProfileOutlined aria-hidden="true" />
<span>{editingItem ? "编辑字典项" : "新增字典项"}</span>
</Space>
}
open={itemDrawerVisible} open={itemDrawerVisible}
onClose={() => setItemDrawerVisible(false)} onClose={() => setItemDrawerVisible(false)}
width={400} width={400}
destroyOnClose destroyOnClose
footer={ footer={
<div className="flex justify-end space-x-2"> <div className="flex justify-end gap-2">
<Button onClick={() => setItemDrawerVisible(false)}></Button> <Button onClick={() => setItemDrawerVisible(false)}></Button>
<Button type="primary" onClick={handleItemSubmit}></Button> <Button type="primary" onClick={handleItemSubmit}></Button>
</div> </div>
} }
> >
<Form form={itemForm} layout="vertical"> <Form form={itemForm} layout="vertical">
<Form.Item label="所属类型" name="typeCode"> <Form.Item label="所属类型" name="typeCode">
<Input disabled /> <Input disabled className="tabular-nums" />
</Form.Item> </Form.Item>
<Form.Item label="标签" name="itemLabel" rules={[{ required: true }]}> <Form.Item label="显示标签" name="itemLabel" rules={[{ required: true, message: "请输入展示标签" }]}>
<Input placeholder="例如:启用" /> <Input placeholder="例如:正常、禁用…" />
</Form.Item> </Form.Item>
<Form.Item label="数值" name="itemValue" rules={[{ required: true }]}> <Form.Item label="存储数值" name="itemValue" rules={[{ required: true, message: "请输入数值" }]}>
<Input placeholder="例如1" /> <Input placeholder="例如1、0…" className="tabular-nums" />
</Form.Item> </Form.Item>
<Form.Item label="排序" name="sortOrder" initialValue={0}> <Form.Item label="显示排序" name="sortOrder" initialValue={0}>
<InputNumber className="w-full" /> <InputNumber className="w-full tabular-nums" />
</Form.Item> </Form.Item>
<Form.Item label="状态" name="status" initialValue={1}> <Form.Item label="当前状态" name="status" initialValue={1}>
<Select <Select
options={[ options={[
{ label: "启用", value: 1 }, { label: "启用", value: 1 },
@ -335,8 +420,8 @@ export default function Dictionaries() {
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item label="备注" name="remark"> <Form.Item label="备注说明" name="remark">
<Input.TextArea /> <Input.TextArea placeholder="可选项,备注详细信息…" rows={3} />
</Form.Item> </Form.Item>
</Form> </Form>
</Drawer> </Drawer>

View File

@ -13,12 +13,14 @@
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
position: relative; position: relative;
overflow: hidden;
} }
.login-brand { .login-brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
z-index: 1;
} }
.brand-logo-img { .brand-logo-img {
@ -36,39 +38,43 @@
.login-hero { .login-hero {
margin-top: -40px; margin-top: -40px;
z-index: 1;
} }
.hero-title { .hero-title {
font-size: 36px; font-size: 42px;
font-weight: 600; font-weight: 700;
line-height: 1.25; line-height: 1.2;
color: #1d2b3a; color: #1d2b3a;
margin-bottom: 16px; margin-bottom: 24px;
letter-spacing: 0.5px; letter-spacing: -0.5px;
text-wrap: balance;
} }
.hero-accent { .hero-accent {
color: #2d6bff; color: #1677ff;
position: relative;
} }
.hero-desc { .hero-desc {
font-size: 14px; font-size: 16px;
line-height: 1.8; line-height: 1.8;
color: #687489; color: #687489;
max-width: 400px; max-width: 440px;
} }
.login-left-footer { .login-left-footer {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
color: #8c97a8; color: #8c97a8;
font-size: 12px; font-size: 13px;
z-index: 1;
} }
.footer-divider { .footer-divider {
width: 3px; width: 4px;
height: 3px; height: 4px;
background: #c4ccd7; background: #c4ccd7;
border-radius: 50%; border-radius: 50%;
} }
@ -85,32 +91,32 @@
.login-container { .login-container {
width: 100%; width: 100%;
max-width: 420px; max-width: 400px;
padding: 0 20px;
} }
.login-header { .login-header {
margin-bottom: 32px; margin-bottom: 40px;
} }
.login-header h2 { .login-header h2 {
font-size: 24px !important; font-size: 28px !important;
font-weight: 600 !important; font-weight: 700 !important;
color: #1f2a37 !important; color: #1f2a37 !important;
margin-bottom: 8px !important; margin-bottom: 8px !important;
letter-spacing: -0.5px;
} }
.login-header span { .login-header span {
font-size: 14px; font-size: 15px;
color: #96a0af; color: #6b7280;
} }
.login-form .ant-form-item { .login-form .ant-form-item {
margin-bottom: 24px; margin-bottom: 20px;
} }
.login-form .ant-input-lg, .login-form .ant-input-affix-wrapper-lg {
.login-form .ant-input-password-lg { padding: 10px 16px;
border-radius: 8px; border-radius: 8px;
} }
@ -119,71 +125,92 @@
gap: 12px; gap: 12px;
} }
.captcha-image-container { .captcha-image-btn {
padding: 0;
width: 120px; width: 120px;
height: 40px; height: 46px;
border: 1px solid #d9d9d9; flex-shrink: 0;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
cursor: pointer; display: flex;
background: #f5f5f5; align-items: center;
justify-content: center;
background: #f9fafb;
} }
.captcha-image-container img { .captcha-image-btn img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: opacity 0.2s;
}
.captcha-image-btn:hover img {
opacity: 0.8;
} }
.login-extra { .login-extra {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: -8px; margin-top: -4px;
margin-bottom: 24px; margin-bottom: 24px;
} }
.forgot-password { .forgot-password {
font-size: 14px; font-size: 14px;
color: #2d6bff; color: #1677ff;
}
.login-submit-btn {
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
} }
.login-footer { .login-footer {
margin-top: 24px; margin-top: 32px;
text-align: center; text-align: center;
font-size: 12px; padding: 16px;
background: #f9fafb;
border-radius: 12px;
}
.tabular-nums {
font-variant-numeric: tabular-nums;
} }
/* Responsive */ /* Responsive */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.login-left { .login-left {
padding: 40px; padding: 48px;
}
.hero-title {
font-size: 36px;
} }
} }
@media (max-width: 980px) { @media (max-width: 900px) {
.login-left { .login-left {
display: none; display: none;
} }
.login-right { .login-right {
background: #f6f9fe; background: #f3f4f6;
} }
.login-container { .login-container {
background: #ffffff; background: #ffffff;
padding: 48px 32px; padding: 48px 32px;
border-radius: 16px; border-radius: 20px;
box-shadow: 0 24px 60px rgba(30, 55, 90, 0.08); box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
} }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.login-right {
padding: 20px;
}
.login-container { .login-container {
padding: 32px 20px; padding: 32px 20px;
} }
.captcha-wrapper {
flex-direction: column;
}
.captcha-image-container {
width: 100%;
}
} }

View File

@ -1,7 +1,8 @@
import { Button, Checkbox, Form, Input, message, Typography } from "antd"; import { Button, Checkbox, Form, Input, message, Typography } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
import { fetchCaptcha, login, type CaptchaResponse } from "../api/auth"; import { fetchCaptcha, login, type CaptchaResponse } from "../api/auth";
import { getCurrentUser, getSystemParamValue } from "../api"; import { getCurrentUser, getSystemParamValue } from "../api";
import { UserOutlined, LockOutlined, SafetyOutlined, ReloadOutlined } from "@ant-design/icons";
import "./Login.css"; import "./Login.css";
const { Title, Text, Link } = Typography; const { Title, Text, Link } = Typography;
@ -12,7 +13,7 @@ export default function Login() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const loadCaptcha = async () => { const loadCaptcha = useCallback(async () => {
if (!captchaEnabled) { if (!captchaEnabled) {
return; return;
} }
@ -22,7 +23,7 @@ export default function Login() {
} catch (e) { } catch (e) {
message.error("加载验证码失败"); message.error("加载验证码失败");
} }
}; }, [captchaEnabled]);
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@ -39,7 +40,7 @@ export default function Login() {
} }
}; };
init(); init();
}, []); }, [loadCaptcha]);
const onFinish = async (values: any) => { const onFinish = async (values: any) => {
setLoading(true); setLoading(true);
@ -75,7 +76,7 @@ export default function Login() {
<div className="login-page"> <div className="login-page">
<div className="login-left"> <div className="login-left">
<div className="login-brand"> <div className="login-brand">
<img src="/logo.svg" alt="logo" className="brand-logo-img" /> <img src="/logo.svg" alt="MeetingAI Logo" className="brand-logo-img" />
<span className="brand-name">MeetingAI</span> <span className="brand-name">MeetingAI</span>
</div> </div>
@ -93,7 +94,7 @@ export default function Login() {
<div className="login-left-footer"> <div className="login-left-footer">
<div className="footer-item"></div> <div className="footer-item"></div>
<div className="footer-divider" /> <div className="footer-divider" aria-hidden="true" />
<div className="footer-item"></div> <div className="footer-item"></div>
</div> </div>
</div> </div>
@ -111,38 +112,56 @@ export default function Login() {
onFinish={onFinish} onFinish={onFinish}
className="login-form" className="login-form"
requiredMark={false} requiredMark={false}
autoComplete="off"
> >
<Form.Item <Form.Item
label="用户名"
name="username" name="username"
rules={[{ required: true, message: "请输入用户名" }]} rules={[{ required: true, message: "请输入用户名" }]}
> >
<Input size="large" placeholder="请输入用户名" /> <Input
size="large"
prefix={<UserOutlined className="text-gray-400" aria-hidden="true" />}
placeholder="用户名"
autoComplete="username"
spellCheck={false}
aria-label="用户名"
/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label="密码"
name="password" name="password"
rules={[{ required: true, message: "请输入密码" }]} rules={[{ required: true, message: "请输入密码" }]}
> >
<Input.Password size="large" placeholder="请输入密码" /> <Input.Password
size="large"
prefix={<LockOutlined className="text-gray-400" aria-hidden="true" />}
placeholder="密码"
autoComplete="current-password"
aria-label="密码"
/>
</Form.Item> </Form.Item>
{captchaEnabled && ( {captchaEnabled && (
<Form.Item <Form.Item
label="验证码"
name="captchaCode" name="captchaCode"
rules={[{ required: true, message: "请输入验证码" }]} rules={[{ required: true, message: "请输入验证码" }]}
> >
<div className="captcha-wrapper"> <div className="captcha-wrapper">
<Input size="large" placeholder="验证码" /> <Input
<div className="captcha-image-container" onClick={loadCaptcha}> size="large"
{captcha ? ( prefix={<SafetyOutlined className="text-gray-400" aria-hidden="true" />}
<img src={captcha.imageBase64} alt="captcha" /> placeholder="验证码"
) : ( maxLength={6}
<div className="captcha-placeholder" /> aria-label="验证码"
)} />
</div> <Button
className="captcha-image-btn"
onClick={loadCaptcha}
icon={!captcha ? <ReloadOutlined spin /> : null}
aria-label="点击刷新验证码"
>
{captcha && <img src={captcha.imageBase64} alt="验证码图片" />}
</Button>
</div> </div>
</Form.Item> </Form.Item>
)} )}
@ -155,15 +174,15 @@ export default function Login() {
</div> </div>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block size="large"> <Button type="primary" htmlType="submit" loading={loading} block size="large" className="login-submit-btn">
{loading ? "登录中…" : "立即登录"}
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
<div className="login-footer"> <div className="login-footer">
<Text type="secondary"> <Text type="secondary">
<Text strong>admin</Text> / <Text strong>123456</Text> <Text strong className="tabular-nums">admin</Text> / <Text strong className="tabular-nums">123456</Text>
</Text> </Text>
</div> </div>
</div> </div>
@ -171,4 +190,3 @@ export default function Login() {
</div> </div>
); );
} }

View File

@ -1,10 +1,11 @@
import { Card, Table, Tabs, Tag, Input, Space, Button, DatePicker, Select } from "antd"; import { Card, Table, Tabs, Tag, Input, Space, Button, DatePicker, Select, Typography, Modal, Descriptions } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fetchLogs } from "../api"; import { fetchLogs } from "../api";
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons"; import { SearchOutlined, ReloadOutlined, InfoCircleOutlined, EyeOutlined } from "@ant-design/icons";
import dayjs from "dayjs"; import dayjs from "dayjs";
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
const { Text, Title } = Typography;
export default function Logs() { export default function Logs() {
const [activeTab, setActiveTab] = useState("OPERATION"); const [activeTab, setActiveTab] = useState("OPERATION");
@ -20,6 +21,10 @@ export default function Logs() {
endDate: "" endDate: ""
}); });
// Modal for detail view
const [detailModalVisible, setDetailModalVisible] = useState(false);
const [selectedLog, setSelectedLog] = useState<any>(null);
const loadData = async (currentParams = params) => { const loadData = async (currentParams = params) => {
setLoading(true); setLoading(true);
try { try {
@ -54,31 +59,38 @@ export default function Logs() {
loadData(resetParams); loadData(resetParams);
}; };
const showDetail = (record: any) => {
setSelectedLog(record);
setDetailModalVisible(true);
};
const columns = [ const columns = [
{ {
title: "用户名", title: "操作账号",
dataIndex: "username", dataIndex: "username",
key: "username", key: "username",
width: 120, width: 140,
render: (text: string) => text || "系统/访客" render: (text: string) => <Text strong>{text || "系统/访客"}</Text>
}, },
{ {
title: activeTab === "LOGIN" ? "登录模块" : "操作模块", title: activeTab === "LOGIN" ? "登录模块" : "业务模块",
dataIndex: "resourceType", dataIndex: "resourceType",
key: "resourceType", key: "resourceType",
width: 150 width: 150
}, },
{ {
title: "操作详情", title: "操作描述",
dataIndex: "detail", dataIndex: "detail",
key: "detail", key: "detail",
ellipsis: true ellipsis: true,
render: (text: string) => <Text type="secondary">{text}</Text>
}, },
{ {
title: "IP地址", title: "IP 地址",
dataIndex: "ipAddress", dataIndex: "ipAddress",
key: "ipAddress", key: "ipAddress",
width: 140 width: 140,
className: "tabular-nums"
}, },
{ {
title: "状态", title: "状态",
@ -86,17 +98,33 @@ export default function Logs() {
key: "status", key: "status",
width: 100, width: 100,
render: (status: number) => ( render: (status: number) => (
<Tag color={status === 1 ? "green" : "red"}> <Tag color={status === 1 ? "green" : "red"} className="m-0">
{status === 1 ? "成功" : "失败"} {status === 1 ? "成功" : "失败"}
</Tag> </Tag>
) )
}, },
{ {
title: "操作时间", title: "发生时间",
dataIndex: "createdAt", dataIndex: "createdAt",
key: "createdAt", key: "createdAt",
width: 180, width: 180,
className: "tabular-nums",
render: (text: string) => text?.replace('T', ' ').substring(0, 19) render: (text: string) => text?.replace('T', ' ').substring(0, 19)
},
{
title: "详情",
key: "action",
width: 80,
fixed: "right" as const,
render: (_: any, record: any) => (
<Button
type="link"
size="small"
icon={<EyeOutlined aria-hidden="true" />}
onClick={() => showDetail(record)}
aria-label="查看详细日志信息"
/>
)
} }
]; ];
@ -106,22 +134,30 @@ export default function Logs() {
dataIndex: "operationType", dataIndex: "operationType",
key: "operationType", key: "operationType",
width: 100, width: 100,
render: (t: string) => <Tag>{t}</Tag> render: (t: string) => <Tag color="blue">{t}</Tag>
}); });
} }
return ( return (
<div className="p-6"> <div className="p-6">
<Card className="mb-4"> <div className="mb-6">
<Title level={4} className="mb-1"></Title>
<Text type="secondary"></Text>
</div>
<Card className="mb-4 shadow-sm">
<Space wrap size="middle"> <Space wrap size="middle">
<Input <Input
placeholder="用户名" placeholder="搜索用户名"
style={{ width: 160 }} style={{ width: 180 }}
value={params.username} value={params.username}
onChange={e => setParams({ ...params, username: e.target.value })} onChange={e => setParams({ ...params, username: e.target.value })}
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
aria-label="搜索用户名"
allowClear
/> />
<Select <Select
placeholder="状态" placeholder="执行状态"
style={{ width: 120 }} style={{ width: 120 }}
allowClear allowClear
value={params.status} value={params.status}
@ -130,6 +166,7 @@ export default function Logs() {
{ label: "成功", value: 1 }, { label: "成功", value: 1 },
{ label: "失败", value: 0 } { label: "失败", value: 0 }
]} ]}
aria-label="筛选执行状态"
/> />
<RangePicker <RangePicker
onChange={(dates) => { onChange={(dates) => {
@ -139,16 +176,34 @@ export default function Logs() {
endDate: dates ? dates[1]?.format("YYYY-MM-DD") || "" : "" endDate: dates ? dates[1]?.format("YYYY-MM-DD") || "" : ""
}); });
}} }}
aria-label="筛选时间范围"
/> />
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}></Button> <Button
<Button icon={<ReloadOutlined />} onClick={handleReset}></Button> type="primary"
icon={<SearchOutlined aria-hidden="true" />}
onClick={handleSearch}
>
</Button>
<Button
icon={<ReloadOutlined aria-hidden="true" />}
onClick={handleReset}
>
</Button>
</Space> </Space>
</Card> </Card>
<Card> <Card className="shadow-sm" styles={{ body: { paddingTop: 0 } }}>
<Tabs activeKey={activeTab} onChange={setActiveTab}> <Tabs activeKey={activeTab} onChange={setActiveTab} size="large">
<Tabs.TabPane tab="操作日志" key="OPERATION" /> <Tabs.TabPane
<Tabs.TabPane tab="登录日志" key="LOGIN" /> tab={<span><InfoCircleOutlined aria-hidden="true" /></span>}
key="OPERATION"
/>
<Tabs.TabPane
tab={<span><UserOutlined aria-hidden="true" /></span>}
key="LOGIN"
/>
</Tabs> </Tabs>
<Table <Table
@ -156,16 +211,71 @@ export default function Logs() {
columns={columns} columns={columns}
dataSource={data} dataSource={data}
loading={loading} loading={loading}
size="middle"
pagination={{ pagination={{
current: params.current, current: params.current,
pageSize: params.size, pageSize: params.size,
total: total, total: total,
showSizeChanger: true, showSizeChanger: true,
onChange: (page, size) => setParams({ ...params, current: page, size }), onChange: (page, size) => setParams({ ...params, current: page, size }),
showTotal: (total) => `${total}` showTotal: (total) => `${total}数据`
}} }}
/> />
</Card> </Card>
<Modal
title="日志详细信息"
open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)}
footer={[
<Button key="close" onClick={() => setDetailModalVisible(false)}>
</Button>
]}
width={700}
>
{selectedLog && (
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="操作模块">{selectedLog.resourceType}</Descriptions.Item>
<Descriptions.Item label="请求方式">
<Tag color="blue">{selectedLog.operationType}</Tag>
</Descriptions.Item>
<Descriptions.Item label="操作账号">{selectedLog.username || "系统"}</Descriptions.Item>
<Descriptions.Item label="IP 地址" className="tabular-nums">{selectedLog.ipAddress}</Descriptions.Item>
<Descriptions.Item label="User Agent">
<Text type="secondary" style={{ fontSize: '12px' }}>{selectedLog.userAgent}</Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={selectedLog.status === 1 ? "green" : "red"}>
{selectedLog.status === 1 ? "成功" : "失败"}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="时间" className="tabular-nums">{selectedLog.createdAt?.replace('T', ' ')}</Descriptions.Item>
<Descriptions.Item label="详情/参数">
<div style={{
background: '#f5f5f5',
padding: '12px',
borderRadius: '4px',
maxHeight: '200px',
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
fontFamily: 'monospace'
}}>
{selectedLog.detail}
</div>
</Descriptions.Item>
{selectedLog.errorMessage && (
<Descriptions.Item label="错误信息">
<Text type="danger">{selectedLog.errorMessage}</Text>
</Descriptions.Item>
)}
</Descriptions>
)}
</Modal>
</div> </div>
); );
} }
// Ensure UserOutlined is imported
import { UserOutlined } from "@ant-design/icons";

View File

@ -1,14 +1,41 @@
import { Button, Form, Input, Drawer, Popconfirm, Space, Table, Tag, Select, InputNumber } from "antd"; import {
Button,
Form,
Input,
Drawer,
Popconfirm,
Space,
Table,
Tag,
Select,
InputNumber,
Typography,
Card,
message,
Tooltip
} from "antd";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { createPermission, deletePermission, listMyPermissions, updatePermission } from "../api"; import { createPermission, deletePermission, listMyPermissions, updatePermission } from "../api";
import type { SysPermission } from "../types"; import type { SysPermission } from "../types";
import { usePermission } from "../hooks/usePermission"; import { usePermission } from "../hooks/usePermission";
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
ReloadOutlined,
ClusterOutlined,
MenuOutlined,
CheckSquareOutlined,
InfoCircleOutlined
} from "@ant-design/icons";
const { Option } = Select; const { Title, Text } = Typography;
type TreePermission = SysPermission & { key: number; children?: TreePermission[] }; type TreePermission = SysPermission & { key: number; children?: TreePermission[] };
function buildTree(list: SysPermission[]): TreePermission[] { function buildTree(list: SysPermission[]): TreePermission[] {
if (!list || list.length === 0) return [];
const map = new Map<number, TreePermission>(); const map = new Map<number, TreePermission>();
const roots: TreePermission[] = []; const roots: TreePermission[] = [];
@ -35,6 +62,7 @@ function buildTree(list: SysPermission[]): TreePermission[] {
export default function Permissions() { export default function Permissions() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [data, setData] = useState<SysPermission[]>([]); const [data, setData] = useState<SysPermission[]>([]);
const [query, setQuery] = useState({ name: "", code: "", permType: "" }); const [query, setQuery] = useState({ name: "", code: "", permType: "" });
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -88,7 +116,9 @@ export default function Permissions() {
}; };
const submit = async () => { const submit = async () => {
try {
const values = await form.validateFields(); const values = await form.validateFields();
setSaving(true);
const payload: Partial<SysPermission> = { const payload: Partial<SysPermission> = {
parentId: values.level === 1 ? undefined : values.parentId, parentId: values.level === 1 ? undefined : values.parentId,
name: values.name, name: values.name,
@ -105,173 +135,334 @@ export default function Permissions() {
}; };
if (editing) { if (editing) {
await updatePermission(editing.permId, payload); await updatePermission(editing.permId, payload);
message.success("权限已更新");
} else { } else {
await createPermission(payload); await createPermission(payload);
message.success("权限已创建");
} }
setOpen(false); setOpen(false);
load(); load();
} catch (e) {
if (e instanceof Error && e.message) message.error(e.message);
} finally {
setSaving(false);
}
}; };
const remove = async (id: number) => { const remove = async (id: number) => {
try {
await deletePermission(id); await deletePermission(id);
message.success("权限已删除");
load(); load();
} catch (e) {
message.error("删除失败");
}
}; };
return ( const handleReset = () => {
<div> setQuery({ name: "", code: "", permType: "" });
<Space style={{ marginBottom: 16 }}> };
<Input
placeholder="权限名称"
value={query.name}
onChange={(e) => setQuery({ ...query, name: e.target.value })}
/>
<Input
placeholder="权限编码"
value={query.code}
onChange={(e) => setQuery({ ...query, code: e.target.value })}
/>
<Select
placeholder="类型"
allowClear
value={query.permType || undefined}
onChange={(v) => setQuery({ ...query, permType: v || "" })}
options={[
{ value: "menu", label: "menu" },
{ value: "button", label: "button" }
]}
style={{ width: 120 }}
/>
{can("sys_permission:create") && (
<Button type="primary" onClick={openCreate}></Button>
)}
</Space>
<Table const columns = [
rowKey="permId"
loading={loading}
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: "可见", 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", dataIndex: "isVisible",
width: 80, width: 80,
render: (v) => (v === 1 ? <Tag color="blue"></Tag> : <Tag></Tag>) render: (v: number) => (v === 1 ? <Tag color="blue"></Tag> : <Tag></Tag>)
}, },
{ {
title: "状态", title: "状态",
dataIndex: "status", dataIndex: "status",
width: 80, width: 80,
render: (v) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>) render: (v: number) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>)
}, },
{ {
title: "操作", title: "操作",
width: 160, width: 120,
render: (_, record) => ( fixed: "right" as const,
render: (_: any, record: SysPermission) => (
<Space> <Space>
{can("sys_permission:update") && <Button onClick={() => openEdit(record)}></Button>} {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") && ( {can("sys_permission:delete") && (
<Popconfirm title="确认删除?" onConfirm={() => remove(record.permId)}> <Popconfirm title={`确定删除权限 "${record.name}" 吗?`} onConfirm={() => remove(record.permId)}>
<Button danger></Button> <Button
type="text"
size="small"
danger
icon={<DeleteOutlined aria-hidden="true" />}
aria-label={`删除权限 ${record.name}`}
/>
</Popconfirm> </Popconfirm>
)} )}
</Space> </Space>
) )
} }
]} ];
return (
<div className="p-6">
<div className="mb-6">
<Title level={4} className="mb-1"></Title>
<Text type="secondary"></Text>
</div>
<Card className="mb-4 shadow-sm">
<Space wrap size="middle">
<Input
placeholder="搜索权限名称…"
value={query.name}
onChange={(e) => setQuery({ ...query, name: e.target.value })}
prefix={<SearchOutlined className="text-gray-400" aria-hidden="true" />}
style={{ width: 180 }}
allowClear
aria-label="搜索权限名称"
/> />
<Input
placeholder="权限编码…"
value={query.code}
onChange={(e) => setQuery({ ...query, code: e.target.value })}
style={{ width: 180 }}
allowClear
aria-label="搜索权限编码"
/>
<Select
placeholder="权限类型"
allowClear
value={query.permType || undefined}
onChange={(v) => setQuery({ ...query, permType: v || "" })}
options={[
{ value: "menu", label: "菜单" },
{ value: "button", label: "按钮" }
]}
style={{ width: 120 }}
aria-label="筛选权限类型"
/>
<Button
type="primary"
icon={<SearchOutlined aria-hidden="true" />}
onClick={load}
>
</Button>
<Button
icon={<ReloadOutlined aria-hidden="true" />}
onClick={handleReset}
>
</Button>
{can("sys_permission:create") && (
<Button
type="primary"
icon={<PlusOutlined aria-hidden="true" />}
onClick={openCreate}
style={{ background: '#52c41a', borderColor: '#52c41a' }}
>
</Button>
)}
</Space>
</Card>
<Card className="shadow-sm" styles={{ body: { padding: 0 } }}>
<Table
rowKey="permId"
loading={loading}
dataSource={treeData}
columns={columns}
pagination={false}
size="middle"
expandable={{ defaultExpandAllRows: false }}
/>
</Card>
<Drawer <Drawer
title={editing ? "编辑权限" : "新增权限"} title={
<Space>
<ClusterOutlined aria-hidden="true" />
<span>{editing ? "修改权限点信息" : "新增功能权限"}</span>
</Space>
}
open={open} open={open}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
width={520} width={520}
destroyOnClose destroyOnClose
footer={ footer={
<Space style={{ width: "100%", justifyContent: "flex-end" }}> <div className="flex justify-end gap-2 p-2">
<Button onClick={() => setOpen(false)}></Button> <Button onClick={() => setOpen(false)}></Button>
<Button type="primary" onClick={submit}></Button> <Button type="primary" loading={saving} onClick={submit}>
</Space>
</Button>
</div>
} }
> >
<Form <Form
form={form} form={form}
layout="vertical" layout="vertical"
onValuesChange={(changed, values) => { className="permission-form"
onValuesChange={(changed) => {
if (changed.level === 1) { if (changed.level === 1) {
form.setFieldsValue({ parentId: undefined }); form.setFieldsValue({ parentId: undefined });
form.validateFields(["parentId"]).catch(() => undefined);
}
if (changed.level === 2) {
form.validateFields(["parentId"]).catch(() => undefined);
} }
}} }}
> >
<Form.Item label="层级" name="level" rules={[{ required: true }]}> <Row gutter={16}>
<Select> <Col span={12}>
<Option value={1}></Option> <Form.Item label="权限层级" name="level" rules={[{ required: true }]}>
<Option value={2}></Option> <Select aria-label="选择层级">
<Select.Option value={1}></Select.Option>
<Select.Option value={2}></Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Col>
<Col span={12}>
<Form.Item label="权限类型" name="permType" rules={[{ required: true }]}>
<Select options={[{ value: "menu", label: "菜单" }, { value: "button", label: "按钮" }]} aria-label="选择类型" />
</Form.Item>
</Col>
</Row>
<Form.Item <Form.Item
label="父级" label="上级权限"
name="parentId" name="parentId"
dependencies={["level"]} dependencies={["level"]}
rules={[ rules={[
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
required: getFieldValue("level") === 2, required: getFieldValue("level") === 2,
message: "请选择父级菜单" message: "二级子项必须选择父级菜单"
}) })
]} ]}
> >
<Select <Select
allowClear allowClear
placeholder="请选择父级菜单" showSearch
placeholder={level === 1 ? "一级入口无须父级" : "请选择父级菜单…"}
options={parentOptions} options={parentOptions}
disabled={level !== 2} disabled={level !== 2}
aria-label="选择上级权限"
/> />
</Form.Item> </Form.Item>
<Form.Item label="权限名称" name="name" rules={[{ required: true }]}>
<Input /> <Form.Item label="名称" name="name" rules={[{ required: true, message: "请输入权限展示名称" }]}>
<Input placeholder="例如:用户管理、导出报表…" />
</Form.Item> </Form.Item>
<Form.Item label="权限编码" name="code" dependencies={["permType"]} rules={[
<Form.Item
label={
<Space>
<span></span>
<Tooltip title="用于后端 @PreAuthorize 鉴权和前端按钮控制">
<InfoCircleOutlined className="text-gray-400" />
</Tooltip>
</Space>
}
name="code"
dependencies={["permType"]}
rules={[
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
required: getFieldValue("permType") === "button", required: getFieldValue("permType") === "button",
message: "按钮权限必须填写编码" message: "按钮权限必须填写编码"
}) })
]}> ]}
<Input /> >
</Form.Item> <Input placeholder="例如sys_user:export…" className="tabular-nums" />
<Form.Item label="类型" name="permType" rules={[{ required: true }]}>
<Select options={[{ value: "menu" }, { value: "button" }]} />
</Form.Item> </Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="路由路径" name="path"> <Form.Item label="路由路径" name="path">
<Input /> <Input placeholder="/users…" className="tabular-nums" />
</Form.Item> </Form.Item>
<Form.Item label="组件" name="component"> </Col>
<Input /> <Col span={12}>
<Form.Item label="组件路径" name="component">
<Input placeholder="pages/Users…" />
</Form.Item> </Form.Item>
<Form.Item label="图标" name="icon"> </Col>
<Input /> </Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="图标名称" name="icon">
<Input placeholder="AntD 图标名…" />
</Form.Item> </Form.Item>
<Form.Item label="排序" name="sortOrder" initialValue={0}> </Col>
<InputNumber style={{ width: "100%" }} min={0} /> <Col span={12}>
<Form.Item label="显示排序" name="sortOrder" initialValue={0}>
<InputNumber style={{ width: "100%" }} min={0} className="tabular-nums" />
</Form.Item> </Form.Item>
<Form.Item label="是否可见" name="isVisible" initialValue={1}> </Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="是否在菜单可见" name="isVisible" initialValue={1}>
<Select options={[{ value: 1, label: "显示" }, { value: 0, label: "隐藏" }]} /> <Select options={[{ value: 1, label: "显示" }, { value: 0, label: "隐藏" }]} />
</Form.Item> </Form.Item>
</Col>
<Col span={12}>
<Form.Item label="状态" name="status" initialValue={1}> <Form.Item label="状态" name="status" initialValue={1}>
<Select options={[{ value: 1, label: "启用" }, { value: 0, label: "禁用" }]} /> <Select options={[{ value: 1, label: "启用" }, { value: 0, label: "禁用" }]} />
</Form.Item> </Form.Item>
<Form.Item label="描述" name="description"> </Col>
<Input.TextArea rows={3} /> </Row>
<Form.Item label="描述说明" name="description">
<Input.TextArea rows={2} placeholder="简要描述权限用途…" />
</Form.Item> </Form.Item>
</Form> </Form>
</Drawer> </Drawer>
@ -279,3 +470,5 @@ export default function Permissions() {
); );
} }
// Fixed missing Row/Col imports
import { Row, Col } from "antd";

View File

@ -1,7 +1,21 @@
import { Button, Card, Col, message, Row, Space, Table, Tag, Tree, Typography } from "antd"; import {
Button,
Card,
Col,
message,
Row,
Space,
Table,
Tag,
Tree,
Typography,
Input,
Empty
} from "antd";
import type { DataNode } from "antd/es/tree"; import type { DataNode } from "antd/es/tree";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "../api"; import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "../api";
import { SearchOutlined, SafetyCertificateOutlined, SaveOutlined, KeyOutlined, ClusterOutlined } from "@ant-design/icons";
import type { SysPermission, SysRole } from "../types"; import type { SysPermission, SysRole } from "../types";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -9,16 +23,21 @@ const { Title, Text } = Typography;
type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] }; type PermissionNode = SysPermission & { key: number; children?: PermissionNode[] };
function buildPermissionTree(list: SysPermission[]): PermissionNode[] { function buildPermissionTree(list: SysPermission[]): PermissionNode[] {
if (!list || list.length === 0) return [];
const active = list.filter((p) => p.status !== 0);
const map = new Map<number, PermissionNode>(); const map = new Map<number, PermissionNode>();
const roots: PermissionNode[] = []; const roots: PermissionNode[] = [];
list.forEach((item) => { active.forEach((item) => {
map.set(item.permId, { ...item, key: item.permId, children: [] }); map.set(item.permId, { ...item, key: item.permId, children: [] });
}); });
map.forEach((node) => { map.forEach((node) => {
if (node.parentId && map.has(node.parentId)) { if (node.parentId && node.parentId !== 0) {
map.get(node.parentId)!.children!.push(node); const parent = map.get(node.parentId);
if (parent) {
parent.children!.push(node);
}
} else { } else {
roots.push(node); roots.push(node);
} }
@ -38,10 +57,10 @@ function toTreeData(nodes: PermissionNode[]): DataNode[] {
title: ( title: (
<Space> <Space>
<span>{node.name}</span> <span>{node.name}</span>
{node.permType === "button" && <Tag color="blue"></Tag>} {node.permType === "button" && <Tag color="blue" size="small" style={{ marginLeft: 4 }}></Tag>}
</Space> </Space>
), ),
children: node.children ? toTreeData(node.children) : undefined children: node.children && node.children.length > 0 ? toTreeData(node.children) : undefined
})); }));
} }
@ -52,7 +71,13 @@ export default function RolePermissionBinding() {
const [loadingPerms, setLoadingPerms] = useState(false); const [loadingPerms, setLoadingPerms] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null); const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
// Selection states
const [checkedPermIds, setCheckedPermIds] = useState<number[]>([]); const [checkedPermIds, setCheckedPermIds] = useState<number[]>([]);
const [halfCheckedIds, setHalfCheckedIds] = useState<number[]>([]);
// Search
const [searchText, setSearchText] = useState("");
const selectedRole = useMemo( const selectedRole = useMemo(
() => roles.find((r) => r.roleId === selectedRoleId) || null, () => roles.find((r) => r.roleId === selectedRoleId) || null,
@ -75,7 +100,7 @@ export default function RolePermissionBinding() {
const list = await listPermissions(); const list = await listPermissions();
setPermissions(list || []); setPermissions(list || []);
} catch (e) { } catch (e) {
message.error("加载权限失败,请确认接口已实现"); message.error("加载权限定义失败");
} finally { } finally {
setLoadingPerms(false); setLoadingPerms(false);
} }
@ -84,10 +109,18 @@ export default function RolePermissionBinding() {
const loadRolePermissions = async (roleId: number) => { const loadRolePermissions = async (roleId: number) => {
try { try {
const list = await listRolePermissions(roleId); const list = await listRolePermissions(roleId);
setCheckedPermIds(list || []); const normalized = (list || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id));
// Filter out parent IDs for Tree view回显 consistency
const leafIds = normalized.filter(id => {
return !permissions.some(p => p.parentId === id);
});
setCheckedPermIds(leafIds);
setHalfCheckedIds([]);
} catch (e) { } catch (e) {
setCheckedPermIds([]); setCheckedPermIds([]);
message.error("加载角色权限失败,请确认接口已实现"); message.error("加载角色授权数据失败");
} }
}; };
@ -97,93 +130,148 @@ export default function RolePermissionBinding() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (selectedRoleId) { if (selectedRoleId && permissions.length > 0) {
loadRolePermissions(selectedRoleId); loadRolePermissions(selectedRoleId);
} else { } else {
setCheckedPermIds([]); setCheckedPermIds([]);
} }
}, [selectedRoleId]); }, [selectedRoleId, permissions]);
const treeData = useMemo(() => toTreeData(buildPermissionTree(permissions)), [permissions]); const filteredRoles = useMemo(() => {
if (!searchText) return roles;
const lower = searchText.toLowerCase();
return roles.filter(r =>
r.roleName.toLowerCase().includes(lower) ||
r.roleCode.toLowerCase().includes(lower)
);
}, [roles, searchText]);
const treeData = useMemo(() => buildPermissionTree(permissions), [permissions]);
const antdTreeData = useMemo(() => toTreeData(treeData), [treeData]);
const handleSave = async () => { const handleSave = async () => {
if (!selectedRoleId) { if (!selectedRoleId) {
message.warning("请先选择角色"); message.warning("请从左侧列表中选择一个角色");
return; return;
} }
setSaving(true); setSaving(true);
try { try {
await saveRolePermissions(selectedRoleId, checkedPermIds); // Merge checked and half-checked for database persistence
message.success("角色权限绑定已保存"); const allPermIds = Array.from(new Set([...checkedPermIds, ...halfCheckedIds]));
await saveRolePermissions(selectedRoleId, allPermIds);
message.success("功能权限策略已成功更新并下发");
} catch (e) { } catch (e) {
message.error("保存失败,请确认接口已实现"); message.error("保存权限配置失败");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
return ( return (
<div className="page-shell"> <div className="p-6">
<div className="page-header"> <div className="mb-6 flex justify-between items-end">
<div> <div>
<Title level={4} className="page-title"></Title> <Title level={4} className="mb-1"></Title>
<Text type="secondary" className="page-subtitle"></Text> <Text type="secondary">访</Text>
</div> </div>
<Button type="primary" onClick={handleSave} loading={saving} disabled={!selectedRoleId}> <Button
type="primary"
icon={<SaveOutlined aria-hidden="true" />}
onClick={handleSave}
loading={saving}
disabled={!selectedRoleId}
>
{saving ? "正在同步…" : "保存权限策略"}
</Button> </Button>
</div> </div>
<Row gutter={[24, 24]}> <Row gutter={24}>
<Col xs={24} lg={10}> <Col xs={24} lg={10}>
<Card title="选择角色" bordered={false} className="surface-card"> <Card
title={<Space><SafetyCertificateOutlined aria-hidden="true" /><span></span></Space>}
className="shadow-sm full-height-card"
>
<div className="mb-4">
<Input
placeholder="搜索角色名称或编码…"
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
allowClear
aria-label="搜索角色"
/>
</div>
<Table <Table
rowKey="roleId" rowKey="roleId"
size="middle" size="middle"
loading={loadingRoles} loading={loadingRoles}
dataSource={roles} dataSource={filteredRoles}
rowSelection={{ rowSelection={{
type: "radio", type: "radio",
selectedRowKeys: selectedRoleId ? [selectedRoleId] : [], selectedRowKeys: selectedRoleId ? [selectedRoleId] : [],
onChange: (keys) => setSelectedRoleId(keys[0] as number) onChange: (keys) => setSelectedRoleId(keys[0] as number),
}} }}
pagination={{ pageSize: 8 }} onRow={(record) => ({
onClick: () => setSelectedRoleId(record.roleId),
className: "cursor-pointer"
})}
pagination={{ pageSize: 10, showTotal: (total) => `${total} 个角色` }}
columns={[ columns={[
{ title: "ID", dataIndex: "roleId", width: 80 }, {
{ title: "角色编码", dataIndex: "roleCode" }, title: "角色信息",
{ title: "角色名称", dataIndex: "roleName" }, key: "role",
render: (_, r) => (
<div className="min-w-0">
<div style={{ fontWeight: 500 }} className="truncate">{r.roleName}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="truncate tabular-nums">{r.roleCode}</div>
</div>
)
},
{ {
title: "状态", title: "状态",
dataIndex: "status", dataIndex: "status",
width: 90, width: 80,
render: (v) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>) render: (v) => (v === 1 ? <Tag color="green" className="m-0"></Tag> : <Tag className="m-0"></Tag>)
} }
]} ]}
/> />
</Card> </Card>
</Col> </Col>
<Col xs={24} lg={14}> <Col xs={24} lg={14}>
<Card <Card
title="配置权限" title={<Space><KeyOutlined aria-hidden="true" /><span></span></Space>}
bordered={false} className="shadow-sm full-height-card"
className="surface-card"
extra={ extra={
<Text type="secondary"> selectedRole && (
{selectedRole ? `当前角色:${selectedRole.roleName}` : "未选择角色"} <Tag color="blue">: {selectedRole.roleName}</Tag>
</Text> )
} }
> >
{selectedRoleId ? (
<div className="role-permission-tree-container" style={{ padding: '8px 0', maxHeight: '600px', overflowY: 'auto' }}>
<Tree <Tree
checkable checkable
selectable={false} selectable={false}
treeData={treeData} checkStrictly={false}
treeData={antdTreeData}
checkedKeys={checkedPermIds} checkedKeys={checkedPermIds}
onCheck={(keys) => setCheckedPermIds(keys as number[])} 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 defaultExpandAll
/> />
{!permissions.length && !loadingPerms && ( {!permissions.length && !loadingPerms && (
<div style={{ marginTop: 12 }}> <Empty description="暂无权限定义数据" />
<Text type="secondary"></Text> )}
</div>
) : (
<div className="flex flex-col items-center justify-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200">
<ClusterOutlined style={{ fontSize: 40, color: '#bfbfbf', marginBottom: 16 }} aria-hidden="true" />
<Text type="secondary"></Text>
</div> </div>
)} )}
</Card> </Card>

View File

@ -274,15 +274,26 @@ export default function Roles() {
<Card <Card
title="系统角色" title="系统角色"
className="full-height-card" className="full-height-card"
extra={can("sys_role:create") && <Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}></Button>} extra={can("sys_role:create") && (
<Button
type="primary"
size="small"
icon={<PlusOutlined aria-hidden="true" />}
onClick={openCreate}
aria-label="新增角色"
>
</Button>
)}
> >
<div className="mb-4"> <div className="mb-4">
<Input <Input
placeholder="搜索角色..." placeholder="搜索角色"
prefix={<SearchOutlined />} prefix={<SearchOutlined aria-hidden="true" />}
value={searchText} value={searchText}
onChange={e => setSearchText(e.target.value)} onChange={e => setSearchText(e.target.value)}
allowClear allowClear
aria-label="搜索角色"
/> />
</div> </div>
<div className="role-list-container"> <div className="role-list-container">
@ -292,6 +303,7 @@ export default function Roles() {
dataSource={filteredData} dataSource={filteredData}
loading={loading} loading={loading}
pagination={false} pagination={false}
locale={{ emptyText: <Empty description="暂无角色数据" /> }}
onRow={(record) => ({ onRow={(record) => ({
onClick: () => selectRole(record), onClick: () => selectRole(record),
className: `cursor-pointer role-row ${selectedRole?.roleId === record.roleId ? 'role-row-selected' : ''}` className: `cursor-pointer role-row ${selectedRole?.roleId === record.roleId ? 'role-row-selected' : ''}`
@ -301,15 +313,33 @@ export default function Roles() {
title: '角色', title: '角色',
render: (_, record) => ( render: (_, record) => (
<div className="role-item-content"> <div className="role-item-content">
<div className="role-item-main"> <div className="role-item-main min-w-0">
<div className="role-item-name">{record.roleName}</div> <div className="role-item-name truncate">{record.roleName}</div>
<div className="role-item-code">{record.roleCode}</div> <div className="role-item-code truncate">{record.roleCode}</div>
</div> </div>
<div className="role-item-actions"> <div className="role-item-actions">
{can("sys_role:update") && <Button type="text" size="small" icon={<EditOutlined />} onClick={e => openEditBasic(e, record)} />} {can("sys_role:update") && (
<Button
type="text"
size="small"
icon={<EditOutlined aria-hidden="true" />}
onClick={e => openEditBasic(e, record)}
aria-label={`编辑 ${record.roleName} 基础信息`}
/>
)}
{can("sys_role:delete") && record.roleCode !== 'ADMIN' && ( {can("sys_role:delete") && record.roleCode !== 'ADMIN' && (
<Popconfirm title="删除角色?" onConfirm={e => handleRemove(e!, record.roleId)}> <Popconfirm
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={e => e.stopPropagation()} /> title={`确定删除角色 "${record.roleName}" 吗?`}
onConfirm={e => handleRemove(e!, record.roleId)}
>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined aria-hidden="true" />}
onClick={e => e.stopPropagation()}
aria-label={`删除 ${record.roleName} 角色`}
/>
</Popconfirm> </Popconfirm>
)} )}
</div> </div>
@ -329,26 +359,26 @@ export default function Roles() {
className="full-height-card" className="full-height-card"
title={ title={
<Space> <Space>
<SafetyCertificateOutlined style={{ color: '#1890ff' }} /> <SafetyCertificateOutlined style={{ color: '#1890ff' }} aria-hidden="true" />
<span>{selectedRole.roleName}</span> <span className="truncate max-w-[200px] inline-block align-bottom">{selectedRole.roleName}</span>
<Tag color="blue">{selectedRole.roleCode}</Tag> <Tag color="blue">{selectedRole.roleCode}</Tag>
</Space> </Space>
} }
extra={ extra={
<Button <Button
type="primary" type="primary"
icon={<SaveOutlined />} icon={<SaveOutlined aria-hidden="true" />}
loading={saving} loading={saving}
onClick={savePermissions} onClick={savePermissions}
disabled={!can("sys_role:permission:save")} disabled={!can("sys_role:permission:save")}
> >
{saving ? "保存中…" : "保存权限更改"}
</Button> </Button>
} }
> >
<Tabs defaultActiveKey="permissions" className="role-tabs"> <Tabs defaultActiveKey="permissions" className="role-tabs">
<Tabs.TabPane <Tabs.TabPane
tab={<Space><KeyOutlined /></Space>} tab={<Space><KeyOutlined aria-hidden="true" /></Space>}
key="permissions" key="permissions"
> >
<div className="role-permission-tree-v2"> <div className="role-permission-tree-v2">
@ -369,7 +399,7 @@ export default function Roles() {
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane <Tabs.TabPane
tab={<Space><UserOutlined /> ({roleUsers.length})</Space>} tab={<Space><UserOutlined aria-hidden="true" /> ({roleUsers.length})</Space>}
key="users" key="users"
> >
<Table <Table
@ -377,25 +407,26 @@ export default function Roles() {
size="small" size="small"
loading={loadingUsers} loading={loadingUsers}
dataSource={roleUsers} dataSource={roleUsers}
pagination={{ pageSize: 10 }} pagination={{ pageSize: 10, showTotal: (total) => `${total}` }}
columns={[ columns={[
{ {
title: '用户', title: '用户',
render: (_, r) => ( render: (_, r) => (
<Space> <Space>
<UserOutlined /> <UserOutlined aria-hidden="true" />
<div> <div className="min-w-0">
<div style={{ fontWeight: 500 }}>{r.displayName}</div> <div style={{ fontWeight: 500 }} className="truncate">{r.displayName}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>@{r.username}</div> <div style={{ fontSize: 12, color: '#8c8c8c' }} className="truncate">@{r.username}</div>
</div> </div>
</Space> </Space>
) )
}, },
{ title: '手机号', dataIndex: 'phone' }, { title: '手机号', dataIndex: 'phone', className: 'tabular-nums' },
{ title: '邮箱', dataIndex: 'email' }, { title: '邮箱', dataIndex: 'email' },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 80,
render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag> render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag>
} }
]} ]}
@ -405,7 +436,7 @@ export default function Roles() {
</Card> </Card>
) : ( ) : (
<Card className="full-height-card flex-center"> <Card className="full-height-card flex-center">
<Empty description="请从左侧选择一个角色以查看详情" /> <Empty description="请从左侧列表选择一个角色进行管理" />
</Card> </Card>
)} )}
</Col> </Col>

View File

@ -1,6 +1,20 @@
import { Button, Card, Checkbox, Col, message, Row, Space, Table, Tag, Typography } from "antd"; import {
Button,
Card,
Checkbox,
Col,
message,
Row,
Space,
Table,
Tag,
Typography,
Input,
Empty
} from "antd";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "../api"; import { listRoles, listUserRoles, listUsers, saveUserRoles } from "../api";
import { SearchOutlined, UserOutlined, SaveOutlined, TeamOutlined } from "@ant-design/icons";
import type { SysRole, SysUser } from "../types"; import type { SysRole, SysUser } from "../types";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -14,6 +28,9 @@ export default function UserRoleBinding() {
const [selectedUserId, setSelectedUserId] = useState<number | null>(null); const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [checkedRoleIds, setCheckedRoleIds] = useState<number[]>([]); const [checkedRoleIds, setCheckedRoleIds] = useState<number[]>([]);
// Search state
const [searchText, setSearchText] = useState("");
const selectedUser = useMemo( const selectedUser = useMemo(
() => users.find((u) => u.userId === selectedUserId) || null, () => users.find((u) => u.userId === selectedUserId) || null,
[users, selectedUserId] [users, selectedUserId]
@ -45,7 +62,7 @@ export default function UserRoleBinding() {
setCheckedRoleIds(list || []); setCheckedRoleIds(list || []);
} catch (e) { } catch (e) {
setCheckedRoleIds([]); setCheckedRoleIds([]);
message.error("加载用户角色失败,请确认接口已实现"); message.error("加载用户角色数据失败");
} }
}; };
@ -62,87 +79,127 @@ export default function UserRoleBinding() {
} }
}, [selectedUserId]); }, [selectedUserId]);
const filteredUsers = useMemo(() => {
if (!searchText) return users;
const lower = searchText.toLowerCase();
return users.filter(u =>
u.username.toLowerCase().includes(lower) ||
u.displayName.toLowerCase().includes(lower)
);
}, [users, searchText]);
const handleSave = async () => { const handleSave = async () => {
if (!selectedUserId) { if (!selectedUserId) {
message.warning("请先选择用户"); message.warning("请先在左侧列表中选择一个用户");
return; return;
} }
setSaving(true); setSaving(true);
try { try {
await saveUserRoles(selectedUserId, checkedRoleIds); await saveUserRoles(selectedUserId, checkedRoleIds);
message.success("用户角色绑定已保存"); message.success("角色权限已成功授予用户");
} catch (e) { } catch (e) {
message.error("保存失败,请确认接口已实现"); message.error("更新绑定关系失败");
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
return ( return (
<div className="page-shell"> <div className="p-6">
<div className="page-header"> <div className="mb-6 flex justify-between items-end">
<div> <div>
<Title level={4} className="page-title"></Title> <Title level={4} className="mb-1"></Title>
<Text type="secondary" className="page-subtitle"></Text> <Text type="secondary">访</Text>
</div> </div>
<Button type="primary" onClick={handleSave} loading={saving} disabled={!selectedUserId}> <Button
type="primary"
icon={<SaveOutlined aria-hidden="true" />}
onClick={handleSave}
loading={saving}
disabled={!selectedUserId}
>
{saving ? "保存中…" : "保存权限更改"}
</Button> </Button>
</div> </div>
<Row gutter={[24, 24]}> <Row gutter={24}>
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
<Card title="选择用户" bordered={false} className="surface-card"> <Card
title={<Space><UserOutlined aria-hidden="true" /><span></span></Space>}
className="shadow-sm full-height-card"
>
<div className="mb-4">
<Input
placeholder="搜索用户名或显示名…"
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
allowClear
aria-label="搜索用户"
/>
</div>
<Table <Table
rowKey="userId" rowKey="userId"
size="middle" size="middle"
loading={loadingUsers} loading={loadingUsers}
dataSource={users} dataSource={filteredUsers}
rowSelection={{ rowSelection={{
type: "radio", type: "radio",
selectedRowKeys: selectedUserId ? [selectedUserId] : [], selectedRowKeys: selectedUserId ? [selectedUserId] : [],
onChange: (keys) => setSelectedUserId(keys[0] as number) onChange: (keys) => setSelectedUserId(keys[0] as number),
}} }}
pagination={{ pageSize: 8 }} onRow={(record) => ({
onClick: () => setSelectedUserId(record.userId),
className: "cursor-pointer"
})}
pagination={{ pageSize: 10, showTotal: (total) => `${total}` }}
columns={[ columns={[
{ title: "ID", dataIndex: "userId", width: 80 }, {
{ title: "用户名", dataIndex: "username" }, title: "用户信息",
{ title: "显示名", dataIndex: "displayName" }, key: "user",
render: (_, r) => (
<div className="min-w-0">
<div style={{ fontWeight: 500 }} className="truncate">{r.displayName}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="truncate">@{r.username}</div>
</div>
)
},
{ {
title: "状态", title: "状态",
dataIndex: "status", dataIndex: "status",
width: 90, width: 80,
render: (v) => (v === 1 ? <Tag color="green"></Tag> : <Tag color="red"></Tag>) render: (v) => (v === 1 ? <Tag color="green" className="m-0"></Tag> : <Tag className="m-0"></Tag>)
} }
]} ]}
/> />
</Card> </Card>
</Col> </Col>
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
<Card <Card
title="选择角色" title={<Space><TeamOutlined aria-hidden="true" /><span></span></Space>}
bordered={false} className="shadow-sm full-height-card"
className="surface-card"
extra={ extra={
<Text type="secondary"> selectedUser && (
{selectedUser ? `当前用户:${selectedUser.displayName || selectedUser.username}` : "未选择用户"} <Tag color="blue">: {selectedUser.displayName}</Tag>
</Text> )
} }
> >
<Space direction="vertical" size={12} style={{ width: "100%" }}> {selectedUserId ? (
<div style={{ padding: '8px 0' }}>
<Checkbox.Group <Checkbox.Group
style={{ width: "100%" }} style={{ width: "100%" }}
value={checkedRoleIds} value={checkedRoleIds}
onChange={(values) => setCheckedRoleIds(values as number[])} onChange={(values) => setCheckedRoleIds(values as number[])}
disabled={loadingRoles} disabled={loadingRoles}
> >
<Row gutter={[12, 12]}> <Row gutter={[16, 16]}>
{roles.map((role) => ( {roles.map((role) => (
<Col key={role.roleId} span={12}> <Col key={role.roleId} span={12}>
<Checkbox value={role.roleId}> <Checkbox value={role.roleId} className="w-full">
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<span>{role.roleName}</span> <span style={{ fontWeight: 500 }}>{role.roleName}</span>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }} className="tabular-nums">
{role.roleCode} {role.roleCode}
</Text> </Text>
</Space> </Space>
@ -152,9 +209,15 @@ export default function UserRoleBinding() {
</Row> </Row>
</Checkbox.Group> </Checkbox.Group>
{!roles.length && !loadingRoles && ( {!roles.length && !loadingRoles && (
<Text type="secondary"></Text> <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>
)} )}
</Space>
</Card> </Card>
</Col> </Col>
</Row> </Row>