+
{value}
{suffix && {suffix}}
{trend && (
- {trend.direction === 'up' ?
:
}
+ {trend.direction === 'up' ?
:
}
{Math.abs(trend.value)}%
)}
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 0f9ba64..a952c49 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -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}
+ },
+ {
+ title: '开始时间',
+ dataIndex: 'time',
+ key: 'time',
+ className: 'tabular-nums',
+ render: (text: string) =>
{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
} color="processing">转录中;
- if (status === 'success') return
} color="success">已完成;
+ width: 120,
+ render: (status: string) => {
+ if (status === 'processing') return
} color="processing">转录中;
+ if (status === 'success') return
} color="success">已完成;
return
待开始;
}
+ },
+ {
+ title: '操作',
+ key: 'action',
+ width: 80,
+ render: () =>
} aria-label="查看会议详情" />
}
];
export default function Dashboard() {
return (
-
-
-
系统总览
+
+
+
+
系统总览
+ 实时监控会议转录状态与系统关键指标
+
+
} size="small">刷新数据
@@ -46,7 +75,7 @@ export default function Dashboard() {
}
+ icon={}
color="blue"
trend={{ value: 8, direction: 'up' }}
/>
@@ -55,7 +84,7 @@ export default function Dashboard() {
}
+ icon={}
color="green"
trend={{ value: 2, direction: 'up' }}
/>
@@ -65,7 +94,7 @@ export default function Dashboard() {
title="转录时长"
value={1280}
suffix="min"
- icon={}
+ icon={}
color="orange"
trend={{ value: 5, direction: 'down' }}
/>
@@ -74,31 +103,39 @@ export default function Dashboard() {
}
+ icon={}
color="purple"
trend={{ value: 12, direction: 'up' }}
/>
-
-
-
+
+
+ 查看全部}
+ styles={{ body: { padding: 0 } }}
+ >
-
-
-
-
-
- 图表加载中...
-
+
+
+
diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx
index 73c423b..3d161e1 100644
--- a/frontend/src/pages/Devices.tsx
+++ b/frontend/src/pages/Devices.tsx
@@ -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
([]);
- const [query, setQuery] = useState({ userId: "", deviceCode: "", deviceName: "" });
- const [pagination, setPagination] = useState({ current: 1, pageSize: 10 });
+ const [users, setUsers] = useState([]);
+
+ // Search state
+ const [searchText, setSearchText] = useState("");
+
+ // Drawer state
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(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 = {};
+ 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 = {
- 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 = {
+ 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 (
-
-
- setQuery({ ...query, userId: e.target.value })}
- />
- setQuery({ ...query, deviceCode: e.target.value })}
- />
- setQuery({ ...query, deviceName: e.target.value })}
- />
- {can("device:create") && (
-
- )}
-
+ const columns = [
+ {
+ title: "设备信息",
+ key: "device",
+ render: (_: any, record: DeviceInfo) => (
+
+
+
+
+
+
{record.deviceName || "未命名设备"}
+
{record.deviceCode}
+
+
+ ),
+ },
+ {
+ title: "归属用户",
+ key: "user",
+ render: (_: any, record: DeviceInfo) => {
+ const user = userMap[record.userId];
+ return user ? (
+
+
+ {user.displayName}
+ (ID: {record.userId})
+
+ ) : (
+
ID: {record.userId}
+ );
+ }
+ },
+ {
+ title: "状态",
+ dataIndex: "status",
+ width: 100,
+ render: (status: number) => (
+
+ {status === 1 ? "启用" : "禁用"}
+
+ ),
+ },
+ {
+ title: "更新时间",
+ dataIndex: "updatedAt",
+ width: 180,
+ render: (text: string) =>
{text?.replace('T', ' ').substring(0, 19)}
+ },
+ {
+ title: "操作",
+ key: "action",
+ width: 120,
+ fixed: "right" as const,
+ render: (_: any, record: DeviceInfo) => (
+
+ {can("device:update") && (
+ }
+ onClick={() => openEdit(record)}
+ aria-label={`编辑设备 ${record.deviceName || record.deviceCode}`}
+ />
+ )}
+ {can("device:delete") && (
+ remove(record.deviceId)}>
+ }
+ aria-label={`删除设备 ${record.deviceName || record.deviceCode}`}
+ />
+
+ )}
+
+ ),
+ },
+ ];
-
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 ? 启用 : 禁用)
- },
- {
- title: "操作",
- render: (_, record) => (
-
- {can("device:update") && }
- {can("device:delete") && (
- remove(record.deviceId)}>
-
-
- )}
-
- )
- }
- ]}
- />
+ return (
+
+
+
+
设备管理
+ 管理接入系统的硬件终端及关联用户
+
+ {can("device:create") && (
+
} onClick={openCreate}>
+ 接入设备
+
+ )}
+
+
+
+
+ }
+ className="devices-search-input"
+ value={searchText}
+ onChange={(e) => setSearchText(e.target.value)}
+ allowClear
+ aria-label="搜索设备"
+ />
+
+
+ `共 ${total} 条数据`,
+ pageSize: 10,
+ }}
+ />
+
+
+ {editing ? "修改设备信息" : "接入新设备"}
+
+ }
open={open}
onClose={() => setOpen(false)}
width={420}
destroyOnClose
footer={
-
+
-
-
+
+
}
>
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
diff --git a/frontend/src/pages/Dictionaries.tsx b/frontend/src/pages/Dictionaries.tsx
index 3ac59dd..8adf897 100644
--- a/frontend/src/pages/Dictionaries.tsx
+++ b/frontend/src/pages/Dictionaries.tsx
@@ -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 (
-
-
字典管理
-
-
+
+
+
+
数据字典管理
+ 维护系统各类枚举值和常量的映射关系
+
+
+
+
+
+
+ 字典类型
+
+ }
+ className="full-height-card shadow-sm"
extra={
can("sys_dict:type:create") && (
- } onClick={handleAddType}>
+ }
+ onClick={handleAddType}
+ aria-label="新增字典类型"
+ >
新增
)
}
>
- ({
- 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) => (
-
- {can("sys_dict:type:update") && (
- }
- onClick={(e) => {
- e.stopPropagation();
- handleEditType(record);
- }}
- />
- )}
- {can("sys_dict:type:delete") && (
- {
- e?.stopPropagation();
- handleDeleteType(record.dictTypeId);
- }}
- >
- }
- onClick={(e) => e.stopPropagation()}
- />
-
- )}
-
- )
- }
- ]}
- />
+
+
({
+ onClick: () => setSelectedType(record),
+ className: `cursor-pointer dict-type-row ${selectedType?.dictTypeId === record.dictTypeId ? "dict-type-row-selected" : ""}`
+ })}
+ columns={[
+ {
+ render: (_, record) => (
+
+
+
{record.typeName}
+
{record.typeCode}
+
+
+ {can("sys_dict:type:update") && (
+
}
+ onClick={(e) => {
+ e.stopPropagation();
+ handleEditType(record);
+ }}
+ aria-label={`编辑类型 ${record.typeName}`}
+ />
+ )}
+ {can("sys_dict:type:delete") && (
+
{
+ e?.stopPropagation();
+ handleDeleteType(record.dictTypeId);
+ }}
+ >
+ }
+ onClick={(e) => e.stopPropagation()}
+ aria-label={`删除类型 ${record.typeName}`}
+ />
+
+ )}
+
+
+ )
+ }
+ ]}
+ />
+
-
+
+
+
+ 字典项内容{selectedType ? ` - ${selectedType.typeName}` : ""}
+
+ }
+ className="full-height-card shadow-sm"
extra={
can("sys_dict:item:create") && (
- } onClick={handleAddItem} disabled={!selectedType}>
- 新增
+ }
+ onClick={handleAddItem}
+ disabled={!selectedType}
+ aria-label="新增字典项"
+ >
+ 新增字典项
)
}
>
- (v === 1 ? 启用 : 禁用)
- },
- {
- title: "操作",
- width: 120,
- render: (_, record) => (
-
- {can("sys_dict:item:update") && (
- }
- onClick={() => handleEditItem(record)}
- />
- )}
- {can("sys_dict:item:delete") && (
- handleDeleteItem(record.dictItemId)}>
- } />
-
- )}
-
- )
- }
- ]}
- />
+ {selectedType ? (
+
+
{text}
+ },
+ {
+ title: "数据数值",
+ dataIndex: "itemValue",
+ className: "tabular-nums"
+ },
+ {
+ title: "排序",
+ dataIndex: "sortOrder",
+ width: 80,
+ className: "tabular-nums"
+ },
+ {
+ title: "状态",
+ dataIndex: "status",
+ width: 100,
+ render: (v) => (
+
+ {v === 1 ? "启用" : "禁用"}
+
+ )
+ },
+ {
+ title: "操作",
+ width: 120,
+ fixed: "right" as const,
+ render: (_, record) => (
+
+ {can("sys_dict:item:update") && (
+ }
+ onClick={() => handleEditItem(record)}
+ aria-label={`编辑字典项 ${record.itemLabel}`}
+ />
+ )}
+ {can("sys_dict:item:delete") && (
+ handleDeleteItem(record.dictItemId)}>
+ }
+ aria-label={`删除字典项 ${record.itemLabel}`}
+ />
+
+ )}
+
+ )
+ }
+ ]}
+ />
+
+ ) : (
+
+
+
+ )}
{/* Type Drawer */}
+
+ {editingType ? "编辑字典类型" : "新增字典类型"}
+
+ }
open={typeDrawerVisible}
onClose={() => setTypeDrawerVisible(false)}
width={400}
destroyOnClose
footer={
-
+
-
+
}
>
-
+
+
-
-
+
+
-
-
+
+
{/* Item Drawer */}
+
+ {editingItem ? "编辑字典项" : "新增字典项"}
+
+ }
open={itemDrawerVisible}
onClose={() => setItemDrawerVisible(false)}
width={400}
destroyOnClose
footer={
-
+
-
+
}
>
-
+
-
-
+
+
-
-
+
+
-
-
+
+
-
+
-
-
+
+
diff --git a/frontend/src/pages/Login.css b/frontend/src/pages/Login.css
index 6c214d5..d09eb00 100644
--- a/frontend/src/pages/Login.css
+++ b/frontend/src/pages/Login.css
@@ -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%;
- }
}
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx
index 89991ea..7e53407 100644
--- a/frontend/src/pages/Login.tsx
+++ b/frontend/src/pages/Login.tsx
@@ -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() {
-

+
MeetingAI
@@ -93,7 +94,7 @@ export default function Login() {
@@ -111,38 +112,56 @@ export default function Login() {
onFinish={onFinish}
className="login-form"
requiredMark={false}
+ autoComplete="off"
>
-
+ }
+ placeholder="用户名"
+ autoComplete="username"
+ spellCheck={false}
+ aria-label="用户名"
+ />
-
+ }
+ placeholder="密码"
+ autoComplete="current-password"
+ aria-label="密码"
+ />
{captchaEnabled && (
-
-
- {captcha ? (
-

- ) : (
-
- )}
-
+
}
+ placeholder="验证码"
+ maxLength={6}
+ aria-label="验证码"
+ />
+
: null}
+ aria-label="点击刷新验证码"
+ >
+ {captcha &&

}
+
)}
@@ -155,15 +174,15 @@ export default function Login() {
-
- 默认账号:admin / 密码:123456
+ 演示账号:admin / 密码:123456
@@ -171,4 +190,3 @@ export default function Login() {
);
}
-
diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx
index a76c830..c916094 100644
--- a/frontend/src/pages/Logs.tsx
+++ b/frontend/src/pages/Logs.tsx
@@ -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(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 || "系统/访客"}
},
{
- 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}
},
{
- 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) => (
-
+
{status === 1 ? "成功" : "失败"}
)
},
{
- 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) => (
+ }
+ onClick={() => showDetail(record)}
+ aria-label="查看详细日志信息"
+ />
+ )
}
];
@@ -106,22 +134,30 @@ export default function Logs() {
dataIndex: "operationType",
key: "operationType",
width: 100,
- render: (t: string) => {t}
+ render: (t: string) => {t}
});
}
return (
-
+
+
系统日志管理
+ 追踪系统内的每一次重要操作,保障系统安全与可追溯性
+
+
+
setParams({ ...params, username: e.target.value })}
+ prefix={}
+ aria-label="搜索用户名"
+ allowClear
/>
{
@@ -139,16 +176,34 @@ export default function Logs() {
endDate: dates ? dates[1]?.format("YYYY-MM-DD") || "" : ""
});
}}
+ aria-label="筛选时间范围"
/>
- } onClick={handleSearch}>搜索
- } onClick={handleReset}>重置
+ }
+ onClick={handleSearch}
+ >
+ 查询
+
+ }
+ onClick={handleReset}
+ >
+ 重置
+
-
-
-
-
+
+
+ 操作日志}
+ key="OPERATION"
+ />
+ 登录日志}
+ key="LOGIN"
+ />
setParams({ ...params, current: page, size }),
- showTotal: (total) => `共 ${total} 条`
+ showTotal: (total) => `共 ${total} 条数据`
}}
/>
+
+ setDetailModalVisible(false)}
+ footer={[
+ setDetailModalVisible(false)}>
+ 关闭
+
+ ]}
+ width={700}
+ >
+ {selectedLog && (
+
+ {selectedLog.resourceType}
+
+ {selectedLog.operationType}
+
+ {selectedLog.username || "系统"}
+ {selectedLog.ipAddress}
+
+ {selectedLog.userAgent}
+
+
+
+ {selectedLog.status === 1 ? "成功" : "失败"}
+
+
+ {selectedLog.createdAt?.replace('T', ' ')}
+
+
+ {selectedLog.detail}
+
+
+ {selectedLog.errorMessage && (
+
+ {selectedLog.errorMessage}
+
+ )}
+
+ )}
+
);
}
+
+// Ensure UserOutlined is imported
+import { UserOutlined } from "@ant-design/icons";
diff --git a/frontend/src/pages/Permissions.tsx b/frontend/src/pages/Permissions.tsx
index 9abbaeb..ecca309 100644
--- a/frontend/src/pages/Permissions.tsx
+++ b/frontend/src/pages/Permissions.tsx
@@ -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();
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([]);
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 = {
- 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 = {
+ 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 (
-
-
- setQuery({ ...query, name: e.target.value })}
- />
- setQuery({ ...query, code: e.target.value })}
- />
-
+ const handleReset = () => {
+ setQuery({ name: "", code: "", permType: "" });
+ };
-
(v === 1 ? 显示 : 隐藏)
- },
- {
- title: "状态",
- dataIndex: "status",
- width: 80,
- render: (v) => (v === 1 ? 启用 : 禁用)
- },
- {
- title: "操作",
- width: 160,
- render: (_, record) => (
-
- {can("sys_permission:update") && openEdit(record)}>编辑}
- {can("sys_permission:delete") && (
- remove(record.permId)}>
- 删除
-
- )}
-
- )
+ const columns = [
+ {
+ title: "权限名称",
+ dataIndex: "name",
+ key: "name",
+ render: (text: string, record: SysPermission) => (
+
+ {record.permType === 'menu' ?
+ :
+
}
- ]}
- />
+ {text}
+
+ )
+ },
+ {
+ title: "权限编码",
+ dataIndex: "code",
+ key: "code",
+ render: (text: string) => text ? {text} : "-"
+ },
+ {
+ title: "类型",
+ dataIndex: "permType",
+ width: 90,
+ render: (type: string) => (
+
+ {type === 'menu' ? '菜单' : '按钮'}
+
+ )
+ },
+ {
+ title: "排序",
+ dataIndex: "sortOrder",
+ width: 80,
+ className: "tabular-nums"
+ },
+ {
+ title: "路由/组件",
+ key: "route",
+ ellipsis: true,
+ render: (_: any, record: SysPermission) => (
+
+ {record.path && {record.path}}
+ {record.component && {record.component}}
+
+ )
+ },
+ {
+ title: "显示",
+ dataIndex: "isVisible",
+ width: 80,
+ render: (v: number) => (v === 1 ? 可见 : 隐藏)
+ },
+ {
+ title: "状态",
+ dataIndex: "status",
+ width: 80,
+ render: (v: number) => (v === 1 ? 启用 : 禁用)
+ },
+ {
+ title: "操作",
+ width: 120,
+ fixed: "right" as const,
+ render: (_: any, record: SysPermission) => (
+
+ {can("sys_permission:update") && (
+ }
+ onClick={() => openEdit(record)}
+ aria-label={`编辑权限 ${record.name}`}
+ />
+ )}
+ {can("sys_permission:delete") && (
+ remove(record.permId)}>
+ }
+ aria-label={`删除权限 ${record.name}`}
+ />
+
+ )}
+
+ )
+ }
+ ];
+
+ return (
+
+
+
功能权限管理
+ 配置系统的菜单结构与功能按钮的操作权限点
+
+
+
+
+ setQuery({ ...query, name: e.target.value })}
+ prefix={}
+ style={{ width: 180 }}
+ allowClear
+ aria-label="搜索权限名称"
+ />
+ setQuery({ ...query, code: e.target.value })}
+ style={{ width: 180 }}
+ allowClear
+ aria-label="搜索权限编码"
+ />
+
+
+
+
+
+
+
+ {editing ? "修改权限点信息" : "新增功能权限"}
+
+ }
open={open}
onClose={() => setOpen(false)}
width={520}
destroyOnClose
footer={
-
+
setOpen(false)}>取消
- 确认
-
+
+ 确认并同步
+
+
}
>
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
({
required: getFieldValue("level") === 2,
- message: "请选择父级菜单"
+ message: "二级子项必须选择父级菜单"
})
]}
>
-
-
+
+
+
- ({
- required: getFieldValue("permType") === "button",
- message: "按钮权限必须填写编码"
- })
- ]}>
-
+
+
+ 权限唯一编码
+
+
+
+
+ }
+ name="code"
+ dependencies={["permType"]}
+ rules={[
+ ({ getFieldValue }) => ({
+ required: getFieldValue("permType") === "button",
+ message: "按钮权限必须填写编码"
+ })
+ ]}
+ >
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -279,3 +470,5 @@ export default function Permissions() {
);
}
+// Fixed missing Row/Col imports
+import { Row, Col } from "antd";
diff --git a/frontend/src/pages/RolePermissionBinding.tsx b/frontend/src/pages/RolePermissionBinding.tsx
index d352519..29a5914 100644
--- a/frontend/src/pages/RolePermissionBinding.tsx
+++ b/frontend/src/pages/RolePermissionBinding.tsx
@@ -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
();
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: (
{node.name}
- {node.permType === "button" && 按钮}
+ {node.permType === "button" && 按钮}
),
- 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(null);
+
+ // Selection states
const [checkedPermIds, setCheckedPermIds] = useState([]);
+ const [halfCheckedIds, setHalfCheckedIds] = useState([]);
+
+ // 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 (
-
-
+
+
-
角色权限绑定
- 为角色配置菜单与按钮权限
+ 角色权限授权
+ 配置系统中各类角色所拥有的菜单访问权限与功能操作权限点
-
- 保存绑定
+ }
+ onClick={handleSave}
+ loading={saving}
+ disabled={!selectedRoleId}
+ >
+ {saving ? "正在同步…" : "保存权限策略"}
-
+
-
+ 系统角色列表}
+ className="shadow-sm full-height-card"
+ >
+
+ }
+ value={searchText}
+ onChange={e => setSearchText(e.target.value)}
+ allowClear
+ aria-label="搜索角色"
+ />
+
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) => (
+
+
{r.roleName}
+
{r.roleCode}
+
+ )
+ },
{
title: "状态",
dataIndex: "status",
- width: 90,
- render: (v) => (v === 1 ? 启用 : 禁用)
+ width: 80,
+ render: (v) => (v === 1 ? 启用 : 禁用)
}
]}
/>
+
功能权限配置}
+ className="shadow-sm full-height-card"
extra={
-
- {selectedRole ? `当前角色:${selectedRole.roleName}` : "未选择角色"}
-
+ selectedRole && (
+ 当前角色: {selectedRole.roleName}
+ )
}
>
- setCheckedPermIds(keys as number[])}
- defaultExpandAll
- />
- {!permissions.length && !loadingPerms && (
-
-
暂无权限数据
+ {selectedRoleId ? (
+
+ {
+ 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 && (
+
+ )}
+
+ ) : (
+
+
+ 请先从左侧列表中选择一个角色
)}
diff --git a/frontend/src/pages/Roles.tsx b/frontend/src/pages/Roles.tsx
index 7e11c5a..b67944e 100644
--- a/frontend/src/pages/Roles.tsx
+++ b/frontend/src/pages/Roles.tsx
@@ -274,15 +274,26 @@ export default function Roles() {
} onClick={openCreate}>新增}
+ extra={can("sys_role:create") && (
+
}
+ onClick={openCreate}
+ aria-label="新增角色"
+ >
+ 新增
+
+ )}
>
}
+ placeholder="搜索角色…"
+ prefix={}
value={searchText}
onChange={e => setSearchText(e.target.value)}
allowClear
+ aria-label="搜索角色"
/>
@@ -292,6 +303,7 @@ export default function Roles() {
dataSource={filteredData}
loading={loading}
pagination={false}
+ locale={{ emptyText:
}}
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) => (
-
-
{record.roleName}
-
{record.roleCode}
+
+
{record.roleName}
+
{record.roleCode}
- {can("sys_role:update") &&
} onClick={e => openEditBasic(e, record)} />}
+ {can("sys_role:update") && (
+
}
+ onClick={e => openEditBasic(e, record)}
+ aria-label={`编辑 ${record.roleName} 基础信息`}
+ />
+ )}
{can("sys_role:delete") && record.roleCode !== 'ADMIN' && (
-
handleRemove(e!, record.roleId)}>
- } onClick={e => e.stopPropagation()} />
+ handleRemove(e!, record.roleId)}
+ >
+ }
+ onClick={e => e.stopPropagation()}
+ aria-label={`删除 ${record.roleName} 角色`}
+ />
)}
@@ -329,26 +359,26 @@ export default function Roles() {
className="full-height-card"
title={
-
- {selectedRole.roleName}
+
+ {selectedRole.roleName}
{selectedRole.roleCode}
}
extra={
}
+ icon={
}
loading={saving}
onClick={savePermissions}
disabled={!can("sys_role:permission:save")}
>
- 保存权限配置
+ {saving ? "保存中…" : "保存权限更改"}
}
>
功能权限}
+ tab={功能权限}
key="permissions"
>
@@ -369,7 +399,7 @@ export default function Roles() {
关联用户 ({roleUsers.length})}
+ tab={关联用户 ({roleUsers.length})}
key="users"
>
`共 ${total} 人` }}
columns={[
{
title: '用户',
render: (_, r) => (
-
-
-
{r.displayName}
-
@{r.username}
+
+
+
{r.displayName}
+
@{r.username}
)
},
- { title: '手机号', dataIndex: 'phone' },
+ { title: '手机号', dataIndex: 'phone', className: 'tabular-nums' },
{ title: '邮箱', dataIndex: 'email' },
{
title: '状态',
dataIndex: 'status',
+ width: 80,
render: s =>
{s === 1 ? '正常' : '禁用'}
}
]}
@@ -405,7 +436,7 @@ export default function Roles() {
) : (
-
+
)}
diff --git a/frontend/src/pages/UserRoleBinding.tsx b/frontend/src/pages/UserRoleBinding.tsx
index 5ec0289..881075a 100644
--- a/frontend/src/pages/UserRoleBinding.tsx
+++ b/frontend/src/pages/UserRoleBinding.tsx
@@ -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;
@@ -13,6 +27,9 @@ export default function UserRoleBinding() {
const [saving, setSaving] = useState(false);
const [selectedUserId, setSelectedUserId] = useState
(null);
const [checkedRoleIds, setCheckedRoleIds] = useState([]);
+
+ // Search state
+ const [searchText, setSearchText] = useState("");
const selectedUser = useMemo(
() => users.find((u) => u.userId === selectedUserId) || null,
@@ -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 (
-
-
+
+
-
用户角色绑定
- 为用户分配一个或多个角色
+ 用户角色授权
+ 为指定用户分配系统访问角色,控制其操作权限边界
-
- 保存绑定
+ }
+ onClick={handleSave}
+ loading={saving}
+ disabled={!selectedUserId}
+ >
+ {saving ? "保存中…" : "保存权限更改"}
-
+
-
+ 用户选择列表}
+ className="shadow-sm full-height-card"
+ >
+
+ }
+ value={searchText}
+ onChange={e => setSearchText(e.target.value)}
+ allowClear
+ aria-label="搜索用户"
+ />
+
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) => (
+
+
{r.displayName}
+
@{r.username}
+
+ )
+ },
{
title: "状态",
dataIndex: "status",
- width: 90,
- render: (v) => (v === 1 ? 启用 : 禁用)
+ width: 80,
+ render: (v) => (v === 1 ? 正常 : 禁用)
}
]}
/>
+
授予角色权限}
+ className="shadow-sm full-height-card"
extra={
-
- {selectedUser ? `当前用户:${selectedUser.displayName || selectedUser.username}` : "未选择用户"}
-
+ selectedUser && (
+ 正在编辑: {selectedUser.displayName}
+ )
}
>
-
- setCheckedRoleIds(values as number[])}
- disabled={loadingRoles}
- >
-
- {roles.map((role) => (
-
-
-
- {role.roleName}
-
- {role.roleCode}
-
-
-
-
- ))}
-
-
- {!roles.length && !loadingRoles && (
- 暂无角色数据
- )}
-
+ {selectedUserId ? (
+
+ setCheckedRoleIds(values as number[])}
+ disabled={loadingRoles}
+ >
+
+ {roles.map((role) => (
+
+
+
+ {role.roleName}
+
+ {role.roleCode}
+
+
+
+
+ ))}
+
+
+ {!roles.length && !loadingRoles && (
+
+ )}
+
+ ) : (
+
+
+ 请先从左侧选择一个用户
+
+ )}