feat(i18n): 实现国际化功能支持多语言切换

- 集成 react-i18next 库实现国际化框架
- 在 AppLayout 中添加语言切换下拉菜单和切换功能
- 添加 GlobalOutlined 图标用于语言切换入口
- 将所有页面中的硬编码文本替换为国际化键值
- 在 Dashboard 页面实现标题和按钮的国际化
- 在 Devices 页面实现表格列名和操作按钮的国际化
- 在 Dictionaries 页面实现所有界面元素的国际化
- 在 Login 页面实现登录表单和提示信息的国际化
- 在 Logs 页面实现日志表格和筛选器的国际化
- 统一错误提示和成功提示为国际化消息
master
chenhao 2026-02-25 09:44:43 +08:00
parent a497deacfc
commit 5fe3b53680
17 changed files with 668 additions and 552 deletions

View File

@ -25,16 +25,7 @@
- **反馈及时**:操作有明确状态反馈 - **反馈及时**:操作有明确状态反馈
- **容错友好**:预防错误,提示明确 - **容错友好**:预防错误,提示明确
---
## 颜色系统
| 类型 | 用途 | 颜色值 |
|------|------|-------|
| 主色调 | 关键按钮、重要信息、链接 | `#b8178d` |
| 辅助色 | 信息提示 | `#1677ff` |
| 功能色 | Success / Warning / Error / Info | 按语义使用 |
| 中性色 | 文本、背景、边框 | - |
**使用规范** **使用规范**

View File

@ -11,8 +11,11 @@
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"antd": "^5.13.2", "antd": "^5.13.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"i18next": "^25.8.6",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^16.5.4",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
@ -2051,6 +2054,55 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "25.8.6",
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.8.6.tgz",
"integrity": "sha512-HsS6p2yr/Vo5EPljWuBJ9OxKVFok2Q/Oa6PvFTpv2bMcDt2sQMOnKDQ7FTDDdME+3d1YULQjKj7aVSZP1bCouQ==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/is-mobile": { "node_modules/is-mobile": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmmirror.com/is-mobile/-/is-mobile-5.0.0.tgz", "resolved": "https://registry.npmmirror.com/is-mobile/-/is-mobile-5.0.0.tgz",
@ -2856,6 +2908,33 @@
"react": "^18.3.1" "react": "^18.3.1"
} }
}, },
"node_modules/react-i18next": {
"version": "16.5.4",
"resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-16.5.4.tgz",
"integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz",
@ -3024,7 +3103,7 @@
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -3134,6 +3213,15 @@
} }
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",

View File

@ -12,8 +12,11 @@
"@ant-design/icons": "^6.1.0", "@ant-design/icons": "^6.1.0",
"antd": "^5.13.2", "antd": "^5.13.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"i18next": "^25.8.6",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^16.5.4",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },

View File

@ -1,6 +1,7 @@
import { Layout, Menu, Button, Space, Avatar, Dropdown, message, type MenuProps } from "antd"; import { Layout, Menu, Button, Space, Avatar, Dropdown, message, type MenuProps } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { import {
DashboardOutlined, DashboardOutlined,
VideoCameraOutlined, VideoCameraOutlined,
@ -12,7 +13,8 @@ import {
MenuUnfoldOutlined, MenuUnfoldOutlined,
MenuFoldOutlined, MenuFoldOutlined,
BellOutlined, BellOutlined,
SettingOutlined SettingOutlined,
GlobalOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { usePermission } from "../hooks/usePermission"; import { usePermission } from "../hooks/usePermission";
@ -31,6 +33,7 @@ const iconMap: Record<string, any> = {
}; };
export default function AppLayout() { export default function AppLayout() {
const { t, i18n } = useTranslation();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [menus, setMenus] = useState<SysPermission[]>([]); const [menus, setMenus] = useState<SysPermission[]>([]);
const location = useLocation(); const location = useLocation();
@ -50,7 +53,7 @@ export default function AppLayout() {
.sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)); .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
setMenus(filtered); setMenus(filtered);
} catch (e) { } catch (e) {
message.error("获取菜单失败"); message.error(t('common.error'));
} }
}; };
@ -63,6 +66,11 @@ export default function AppLayout() {
navigate("/login"); navigate("/login");
}; };
const changeLanguage = (lng: string) => {
i18n.changeLanguage(lng);
message.success(lng === 'zh-CN' ? '已切换至中文' : 'Switched to English');
};
const buildMenuTree = (list: SysPermission[]) => { const buildMenuTree = (list: SysPermission[]) => {
const map = new Map<number, SysPermission & { children?: SysPermission[] }>(); const map = new Map<number, SysPermission & { children?: SysPermission[] }>();
const roots: (SysPermission & { children?: SysPermission[] })[] = []; const roots: (SysPermission & { children?: SysPermission[] })[] = [];
@ -104,10 +112,15 @@ export default function AppLayout() {
const menuItems = toMenuItems(buildMenuTree(menus)); const menuItems = toMenuItems(buildMenuTree(menus));
const userMenuItems: MenuProps["items"] = [ const userMenuItems: MenuProps["items"] = [
{ key: 'profile', label: '个人信息', icon: <UserOutlined /> }, { key: 'profile', label: t('layout.profile'), icon: <UserOutlined /> },
{ key: 'settings', label: '系统设置', icon: <SettingOutlined /> }, { key: 'settings', label: t('layout.settings'), icon: <SettingOutlined /> },
{ type: 'divider' }, { type: 'divider' },
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, onClick: handleLogout }, { key: 'logout', label: t('layout.logout'), icon: <LogoutOutlined />, onClick: handleLogout },
];
const langMenuItems: MenuProps["items"] = [
{ key: 'zh-CN', label: '简体中文', onClick: () => changeLanguage('zh-CN') },
{ key: 'en-US', label: 'English', onClick: () => changeLanguage('en-US') },
]; ];
return ( return (
@ -167,6 +180,9 @@ export default function AppLayout() {
style={{ fontSize: '16px', width: 64, height: 64 }} style={{ fontSize: '16px', width: 64, height: 64 }}
/> />
<Space size={20}> <Space size={20}>
<Dropdown menu={{ items: langMenuItems }} placement="bottomRight">
<GlobalOutlined style={{ fontSize: '18px', color: '#666', cursor: 'pointer' }} />
</Dropdown>
<BellOutlined style={{ fontSize: '18px', color: '#666', cursor: 'pointer' }} /> <BellOutlined style={{ fontSize: '18px', color: '#666', cursor: 'pointer' }} />
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight"> <Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}> <Space style={{ cursor: 'pointer' }}>

View File

@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import "antd/dist/reset.css"; import "antd/dist/reset.css";
import "./index.css"; import "./index.css";
import "./i18n";
import App from "./App"; import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(

View File

@ -8,72 +8,75 @@ import {
SyncOutlined, SyncOutlined,
ArrowRightOutlined ArrowRightOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import StatCard from "../components/shared/StatCard/StatCard"; import StatCard from "../components/shared/StatCard/StatCard";
const { Title, Text } = Typography; const { Title, Text } = Typography;
const recentMeetings = [
{ key: '1', name: '产品周会', type: '转录中', time: '2024-02-10 14:00', duration: '45min', status: 'processing' },
{ key: '2', name: '技术分享', type: '已完成', time: '2024-02-10 10:00', duration: '60min', status: 'success' },
{ key: '3', name: '部门早会', type: '已完成', time: '2024-02-10 09:00', duration: '15min', status: 'success' },
{ key: '4', name: '客户会议', type: '待开始', time: '2024-02-10 16:30', duration: '30min', status: 'default' },
];
const columns = [
{
title: '会议名称',
dataIndex: 'name',
key: 'name',
render: (text: string) => <Text strong>{text}</Text>
},
{
title: '开始时间',
dataIndex: 'time',
key: 'time',
className: 'tabular-nums',
render: (text: string) => <Text type="secondary">{text}</Text>
},
{
title: '时长',
dataIndex: 'duration',
key: 'duration',
width: 100,
className: 'tabular-nums'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 120,
render: (status: string) => {
if (status === 'processing') return <Tag icon={<SyncOutlined spin aria-hidden="true" />} color="processing"></Tag>;
if (status === 'success') return <Tag icon={<CheckCircleOutlined aria-hidden="true" />} color="success"></Tag>;
return <Tag color="default"></Tag>;
}
},
{
title: '操作',
key: 'action',
width: 80,
render: () => <Button type="link" size="small" icon={<ArrowRightOutlined aria-hidden="true" />} aria-label="查看会议详情" />
}
];
export default function Dashboard() { export default function Dashboard() {
const { t } = useTranslation();
const recentMeetings = [
{ key: '1', name: '产品周会', type: '转录中', time: '2024-02-10 14:00', duration: '45min', status: 'processing' },
{ key: '2', name: '技术分享', type: '已完成', time: '2024-02-10 10:00', duration: '60min', status: 'success' },
{ key: '3', name: '部门早会', type: '已完成', time: '2024-02-10 09:00', duration: '15min', status: 'success' },
{ key: '4', name: '客户会议', type: '待开始', time: '2024-02-10 16:30', duration: '30min', status: 'default' },
];
const columns = [
{
title: t('dashboard.meetingName'),
dataIndex: 'name',
key: 'name',
render: (text: string) => <Text strong>{text}</Text>
},
{
title: t('dashboard.startTime'),
dataIndex: 'time',
key: 'time',
className: 'tabular-nums',
render: (text: string) => <Text type="secondary">{text}</Text>
},
{
title: t('dashboard.duration'),
dataIndex: 'duration',
key: 'duration',
width: 100,
className: 'tabular-nums'
},
{
title: t('common.status'),
dataIndex: 'status',
key: 'status',
width: 120,
render: (status: string) => {
if (status === 'processing') return <Tag icon={<SyncOutlined spin aria-hidden="true" />} color="processing"></Tag>;
if (status === 'success') return <Tag icon={<CheckCircleOutlined aria-hidden="true" />} color="success"></Tag>;
return <Tag color="default"></Tag>;
}
},
{
title: t('common.action'),
key: 'action',
width: 80,
render: () => <Button type="link" size="small" icon={<ArrowRightOutlined aria-hidden="true" />} aria-label={t('dashboard.viewAll')} />
}
];
return ( return (
<div className="dashboard-page p-6"> <div className="dashboard-page p-6">
<div className="mb-6 flex justify-between items-end"> <div className="mb-6 flex justify-between items-end">
<div> <div>
<Title level={4} className="mb-1"></Title> <Title level={4} className="mb-1">{t('dashboard.title')}</Title>
<Text type="secondary"></Text> <Text type="secondary">{t('dashboard.subtitle')}</Text>
</div> </div>
<Button icon={<SyncOutlined aria-hidden="true" />} size="small"></Button> <Button icon={<SyncOutlined aria-hidden="true" />} size="small">{t('common.refresh')}</Button>
</div> </div>
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
<StatCard <StatCard
title="今日会议" title={t('dashboard.todayMeetings')}
value={12} value={12}
icon={<VideoCameraOutlined aria-hidden="true" />} icon={<VideoCameraOutlined aria-hidden="true" />}
color="blue" color="blue"
@ -82,7 +85,7 @@ export default function Dashboard() {
</Col> </Col>
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
<StatCard <StatCard
title="活跃设备" title={t('dashboard.activeDevices')}
value={45} value={45}
icon={<DesktopOutlined aria-hidden="true" />} icon={<DesktopOutlined aria-hidden="true" />}
color="green" color="green"
@ -91,7 +94,7 @@ export default function Dashboard() {
</Col> </Col>
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
<StatCard <StatCard
title="转录时长" title={t('dashboard.transcriptionDuration')}
value={1280} value={1280}
suffix="min" suffix="min"
icon={<ClockCircleOutlined aria-hidden="true" />} icon={<ClockCircleOutlined aria-hidden="true" />}
@ -101,7 +104,7 @@ export default function Dashboard() {
</Col> </Col>
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
<StatCard <StatCard
title="总用户数" title={t('dashboard.totalUsers')}
value={320} value={320}
icon={<UserOutlined aria-hidden="true" />} icon={<UserOutlined aria-hidden="true" />}
color="purple" color="purple"
@ -113,10 +116,10 @@ export default function Dashboard() {
<Row gutter={[24, 24]} className="mt-6"> <Row gutter={[24, 24]} className="mt-6">
<Col xs={24} xl={16}> <Col xs={24} xl={16}>
<Card <Card
title="最近会议" title={t('dashboard.recentMeetings')}
bordered={false} bordered={false}
className="shadow-sm" className="shadow-sm"
extra={<Button type="link" size="small"></Button>} extra={<Button type="link" size="small">{t('dashboard.viewAll')}</Button>}
styles={{ body: { padding: 0 } }} styles={{ body: { padding: 0 } }}
> >
<Table <Table
@ -129,7 +132,7 @@ export default function Dashboard() {
</Card> </Card>
</Col> </Col>
<Col xs={24} xl={8}> <Col xs={24} xl={8}>
<Card title="设备负载" bordered={false} className="shadow-sm"> <Card title={t('dashboard.deviceLoad')} bordered={false} className="shadow-sm">
<div className="flex flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center py-12">
<Skeleton active paragraph={{ rows: 4 }} /> <Skeleton active paragraph={{ rows: 4 }} />
<div className="mt-4 text-gray-400 flex items-center gap-2"> <div className="mt-4 text-gray-400 flex items-center gap-2">

View File

@ -13,6 +13,7 @@ import {
message message
} from "antd"; } from "antd";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { createDevice, deleteDevice, listDevices, updateDevice, listUsers } from "../api"; import { createDevice, deleteDevice, listDevices, updateDevice, listUsers } from "../api";
import type { DeviceInfo, SysUser } from "../types"; import type { DeviceInfo, SysUser } from "../types";
import { usePermission } from "../hooks/usePermission"; import { usePermission } from "../hooks/usePermission";
@ -29,6 +30,7 @@ import "./Devices.css";
const { Title, Text } = Typography; const { Title, Text } = Typography;
export default function Devices() { export default function Devices() {
const { t } = useTranslation();
const { can } = usePermission(); const { can } = usePermission();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -50,7 +52,7 @@ export default function Devices() {
setData(deviceList || []); setData(deviceList || []);
setUsers(usersList || []); setUsers(usersList || []);
} catch (e) { } catch (e) {
message.error("加载数据失败"); message.error(t('common.error'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -103,10 +105,10 @@ export default function Devices() {
}; };
if (editing) { if (editing) {
await updateDevice(editing.deviceId, payload); await updateDevice(editing.deviceId, payload);
message.success("设备已更新"); message.success(t('common.success'));
} else { } else {
await createDevice(payload); await createDevice(payload);
message.success("设备已创建"); message.success(t('common.success'));
} }
setOpen(false); setOpen(false);
loadData(); loadData();
@ -120,16 +122,16 @@ export default function Devices() {
const remove = async (id: number) => { const remove = async (id: number) => {
try { try {
await deleteDevice(id); await deleteDevice(id);
message.success("设备已删除"); message.success(t('common.success'));
loadData(); loadData();
} catch (e) { } catch (e) {
message.error("删除失败"); message.error(t('common.error'));
} }
}; };
const columns = [ const columns = [
{ {
title: "设备信息", title: t('devices.deviceInfo'),
key: "device", key: "device",
render: (_: any, record: DeviceInfo) => ( render: (_: any, record: DeviceInfo) => (
<Space> <Space>
@ -137,14 +139,14 @@ export default function Devices() {
<DesktopOutlined aria-hidden="true" /> <DesktopOutlined aria-hidden="true" />
</div> </div>
<div> <div>
<div className="device-name">{record.deviceName || "未命名设备"}</div> <div className="device-name font-medium">{record.deviceName || "未命名设备"}</div>
<div className="device-code tabular-nums">{record.deviceCode}</div> <div className="device-code text-xs text-gray-400 tabular-nums">{record.deviceCode}</div>
</div> </div>
</Space> </Space>
), ),
}, },
{ {
title: "归属用户", title: t('devices.owner'),
key: "user", key: "user",
render: (_: any, record: DeviceInfo) => { render: (_: any, record: DeviceInfo) => {
const user = userMap[record.userId]; const user = userMap[record.userId];
@ -152,7 +154,7 @@ export default function Devices() {
<Space> <Space>
<UserOutlined aria-hidden="true" style={{ color: '#8c8c8c' }} /> <UserOutlined aria-hidden="true" style={{ color: '#8c8c8c' }} />
<span>{user.displayName}</span> <span>{user.displayName}</span>
<Text type="secondary" size="small" className="tabular-nums">(ID: {record.userId})</Text> <Text type="secondary" style={{ fontSize: '12px' }} className="tabular-nums">(ID: {record.userId})</Text>
</Space> </Space>
) : ( ) : (
<span className="tabular-nums">ID: {record.userId}</span> <span className="tabular-nums">ID: {record.userId}</span>
@ -160,7 +162,7 @@ export default function Devices() {
} }
}, },
{ {
title: "状态", title: t('common.status'),
dataIndex: "status", dataIndex: "status",
width: 100, width: 100,
render: (status: number) => ( render: (status: number) => (
@ -170,13 +172,13 @@ export default function Devices() {
), ),
}, },
{ {
title: "更新时间", title: t('devices.updateTime'),
dataIndex: "updatedAt", dataIndex: "updatedAt",
width: 180, width: 180,
render: (text: string) => <Text type="secondary" className="tabular-nums">{text?.replace('T', ' ').substring(0, 19)}</Text> render: (text: string) => <Text type="secondary" className="tabular-nums">{text?.replace('T', ' ').substring(0, 19)}</Text>
}, },
{ {
title: "操作", title: t('common.action'),
key: "action", key: "action",
width: 120, width: 120,
fixed: "right" as const, fixed: "right" as const,
@ -187,7 +189,7 @@ export default function Devices() {
type="text" type="text"
icon={<EditOutlined aria-hidden="true" />} icon={<EditOutlined aria-hidden="true" />}
onClick={() => openEdit(record)} onClick={() => openEdit(record)}
aria-label={`编辑设备 ${record.deviceName || record.deviceCode}`} aria-label={t('common.edit')}
/> />
)} )}
{can("device:delete") && ( {can("device:delete") && (
@ -196,7 +198,7 @@ export default function Devices() {
type="text" type="text"
danger danger
icon={<DeleteOutlined aria-hidden="true" />} icon={<DeleteOutlined aria-hidden="true" />}
aria-label={`删除设备 ${record.deviceName || record.deviceCode}`} aria-label={t('common.delete')}
/> />
</Popconfirm> </Popconfirm>
)} )}
@ -206,29 +208,29 @@ export default function Devices() {
]; ];
return ( return (
<div className="devices-page"> <div className="devices-page p-6">
<div className="devices-header"> <div className="devices-header flex justify-between items-end mb-6">
<div> <div>
<Title level={4} className="devices-title"></Title> <Title level={4} className="mb-1">{t('devices.title')}</Title>
<Text type="secondary"></Text> <Text type="secondary">{t('devices.subtitle')}</Text>
</div> </div>
{can("device:create") && ( {can("device:create") && (
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
{t('devices.drawerTitleCreate')}
</Button> </Button>
)} )}
</div> </div>
<Card className="devices-table-card shadow-sm"> <Card className="devices-table-card shadow-sm">
<div className="devices-table-toolbar"> <div className="devices-table-toolbar mb-4">
<Input <Input
placeholder="搜索设备名称、编码或归属用户…" placeholder={t('devices.searchPlaceholder')}
prefix={<SearchOutlined aria-hidden="true" />} prefix={<SearchOutlined aria-hidden="true" />}
className="devices-search-input" style={{ width: 350 }}
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
allowClear allowClear
aria-label="搜索设备" aria-label={t('common.search')}
/> />
</div> </div>
@ -237,8 +239,9 @@ export default function Devices() {
columns={columns} columns={columns}
dataSource={filteredData} dataSource={filteredData}
loading={loading} loading={loading}
size="middle"
pagination={{ pagination={{
showTotal: (total) => `${total} 条数据`, showTotal: (total) => t('common.total', { total }),
pageSize: 10, pageSize: 10,
}} }}
/> />
@ -248,7 +251,7 @@ export default function Devices() {
title={ title={
<div className="device-drawer-title"> <div className="device-drawer-title">
<DesktopOutlined className="mr-2" aria-hidden="true" /> <DesktopOutlined className="mr-2" aria-hidden="true" />
{editing ? "修改设备信息" : "接入新设备"} {editing ? t('devices.drawerTitleEdit') : t('devices.drawerTitleCreate')}
</div> </div>
} }
open={open} open={open}
@ -257,15 +260,15 @@ export default function Devices() {
destroyOnClose destroyOnClose
footer={ footer={
<div className="flex justify-end gap-2 p-2"> <div className="flex justify-end gap-2 p-2">
<Button onClick={() => setOpen(false)}></Button> <Button onClick={() => setOpen(false)}>{t('common.cancel')}</Button>
<Button type="primary" loading={saving} onClick={submit}> <Button type="primary" loading={saving} onClick={submit}>
{t('common.confirm')}
</Button> </Button>
</div> </div>
} }
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item label="归属用户" name="userId" rules={[{ required: true, message: "请选择归属用户" }]}> <Form.Item label={t('devices.owner')} name="userId" rules={[{ required: true, message: t('devices.owner') }]}>
<Select <Select
showSearch showSearch
placeholder="搜索并选择用户" placeholder="搜索并选择用户"
@ -273,13 +276,13 @@ export default function Devices() {
options={users.map(u => ({ label: `${u.displayName} (@${u.username})`, value: u.userId }))} options={users.map(u => ({ label: `${u.displayName} (@${u.username})`, value: u.userId }))}
/> />
</Form.Item> </Form.Item>
<Form.Item label="设备识别码" name="deviceCode" rules={[{ required: true, message: "请输入设备识别码" }]}> <Form.Item label={t('devices.deviceCode')} name="deviceCode" rules={[{ required: true, message: t('devices.deviceCode') }]}>
<Input placeholder="由硬件生成的唯一识别码" /> <Input placeholder="由硬件生成的唯一识别码" />
</Form.Item> </Form.Item>
<Form.Item label="设备名称" name="deviceName"> <Form.Item label={t('devices.deviceName')} name="deviceName">
<Input placeholder="例如:会议室 A 转录仪" /> <Input placeholder="例如:会议室 A 转录仪" />
</Form.Item> </Form.Item>
<Form.Item label="设备状态" name="status" initialValue={1}> <Form.Item label={t('common.status')} 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>

View File

@ -17,6 +17,7 @@ import {
Empty Empty
} from "antd"; } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { import {
createDictItem, createDictItem,
createDictType, createDictType,
@ -35,6 +36,7 @@ import "./Dictionaries.css";
const { Title, Text } = Typography; const { Title, Text } = Typography;
export default function Dictionaries() { export default function Dictionaries() {
const { t } = useTranslation();
const { can } = usePermission(); const { can } = usePermission();
const [types, setTypes] = useState<SysDictType[]>([]); const [types, setTypes] = useState<SysDictType[]>([]);
const [items, setItems] = useState<SysDictItem[]>([]); const [items, setItems] = useState<SysDictItem[]>([]);
@ -102,7 +104,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(t('common.success'));
loadTypes(); loadTypes();
}; };
@ -113,7 +115,7 @@ export default function Dictionaries() {
} else { } else {
await createDictType(values); await createDictType(values);
} }
message.success(editingType ? "类型更新成功" : "类型创建成功"); message.success(t('common.success'));
setTypeDrawerVisible(false); setTypeDrawerVisible(false);
loadTypes(); loadTypes();
}; };
@ -121,7 +123,7 @@ export default function Dictionaries() {
// Item Actions // Item Actions
const handleAddItem = () => { const handleAddItem = () => {
if (!selectedType) { if (!selectedType) {
message.warning("请先从左侧选择一个字典类型"); message.warning(t('dicts.selectType'));
return; return;
} }
setEditingItem(null); setEditingItem(null);
@ -138,7 +140,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(t('common.success'));
if (selectedType) loadItems(selectedType.typeCode); if (selectedType) loadItems(selectedType.typeCode);
}; };
@ -149,27 +151,27 @@ export default function Dictionaries() {
} else { } else {
await createDictItem(values); await createDictItem(values);
} }
message.success(editingItem ? "字典项更新成功" : "字典项创建成功"); message.success(t('common.success'));
setItemDrawerVisible(false); setItemDrawerVisible(false);
if (selectedType) loadItems(selectedType.typeCode); if (selectedType) loadItems(selectedType.typeCode);
}; };
return ( return (
<div className="dictionaries-page"> <div className="dictionaries-page p-6">
<div className="dictionaries-header"> <div className="dictionaries-header mb-6">
<div> <div>
<Title level={4} className="dictionaries-title"></Title> <Title level={4} className="mb-1">{t('dicts.title')}</Title>
<Text type="secondary"></Text> <Text type="secondary">{t('dicts.subtitle')}</Text>
</div> </div>
</div> </div>
<Row gutter={24} className="dictionaries-content"> <Row gutter={24} style={{ height: 'calc(100vh - 180px)' }}>
<Col span={8} className="full-height"> <Col span={8} style={{ height: '100%' }}>
<Card <Card
title={ title={
<Space> <Space>
<BookOutlined aria-hidden="true" /> <BookOutlined aria-hidden="true" />
<span></span> <span>{t('dicts.dictType')}</span>
</Space> </Space>
} }
className="full-height-card shadow-sm" className="full-height-card shadow-sm"
@ -180,14 +182,13 @@ export default function Dictionaries() {
size="small" size="small"
icon={<PlusOutlined aria-hidden="true" />} icon={<PlusOutlined aria-hidden="true" />}
onClick={handleAddType} onClick={handleAddType}
aria-label="新增字典类型"
> >
{t('common.create')}
</Button> </Button>
) )
} }
> >
<div className="scroll-container"> <div style={{ height: 'calc(100% - 10px)', overflowY: 'auto' }}>
<Table <Table
rowKey="dictTypeId" rowKey="dictTypeId"
loading={loadingTypes} loading={loadingTypes}
@ -202,12 +203,12 @@ export default function Dictionaries() {
columns={[ columns={[
{ {
render: (_, record) => ( render: (_, record) => (
<div className="dict-type-item"> <div className="dict-type-item flex justify-between items-center p-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="dict-type-name truncate">{record.typeName}</div> <div className="dict-type-name font-medium truncate">{record.typeName}</div>
<div className="dict-type-code truncate tabular-nums">{record.typeCode}</div> <div className="dict-type-code text-xs text-gray-400 truncate tabular-nums">{record.typeCode}</div>
</div> </div>
<div className="dict-type-actions"> <div className="dict-type-actions flex gap-1">
{can("sys_dict:type:update") && ( {can("sys_dict:type:update") && (
<Button <Button
type="text" type="text"
@ -217,7 +218,6 @@ export default function Dictionaries() {
e.stopPropagation(); e.stopPropagation();
handleEditType(record); handleEditType(record);
}} }}
aria-label={`编辑类型 ${record.typeName}`}
/> />
)} )}
{can("sys_dict:type:delete") && ( {can("sys_dict:type:delete") && (
@ -234,7 +234,6 @@ export default function Dictionaries() {
danger danger
icon={<DeleteOutlined aria-hidden="true" />} icon={<DeleteOutlined aria-hidden="true" />}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
aria-label={`删除类型 ${record.typeName}`}
/> />
</Popconfirm> </Popconfirm>
)} )}
@ -248,12 +247,12 @@ export default function Dictionaries() {
</Card> </Card>
</Col> </Col>
<Col span={16} className="full-height"> <Col span={16} style={{ height: '100%' }}>
<Card <Card
title={ title={
<Space> <Space>
<ProfileOutlined aria-hidden="true" /> <ProfileOutlined aria-hidden="true" />
<span>{selectedType ? ` - ${selectedType.typeName}` : ""}</span> <span>{t('dicts.dictItem')}{selectedType ? ` - ${selectedType.typeName}` : ""}</span>
</Space> </Space>
} }
className="full-height-card shadow-sm" className="full-height-card shadow-sm"
@ -265,15 +264,14 @@ export default function Dictionaries() {
icon={<PlusOutlined aria-hidden="true" />} icon={<PlusOutlined aria-hidden="true" />}
onClick={handleAddItem} onClick={handleAddItem}
disabled={!selectedType} disabled={!selectedType}
aria-label="新增字典项"
> >
{t('dicts.drawerTitleItemCreate')}
</Button> </Button>
) )
} }
> >
{selectedType ? ( {selectedType ? (
<div className="scroll-container"> <div style={{ height: 'calc(100% - 10px)', overflowY: 'auto' }}>
<Table <Table
rowKey="dictItemId" rowKey="dictItemId"
loading={loadingItems} loading={loadingItems}
@ -282,23 +280,23 @@ export default function Dictionaries() {
size="middle" size="middle"
columns={[ columns={[
{ {
title: "展示标签", title: t('dicts.itemLabel'),
dataIndex: "itemLabel", dataIndex: "itemLabel",
render: (text) => <Text strong>{text}</Text> render: (text) => <Text strong>{text}</Text>
}, },
{ {
title: "数据数值", title: t('dicts.itemValue'),
dataIndex: "itemValue", dataIndex: "itemValue",
className: "tabular-nums" className: "tabular-nums"
}, },
{ {
title: "排序", title: t('dicts.sort'),
dataIndex: "sortOrder", dataIndex: "sortOrder",
width: 80, width: 80,
className: "tabular-nums" className: "tabular-nums"
}, },
{ {
title: "状态", title: t('common.status'),
dataIndex: "status", dataIndex: "status",
width: 100, width: 100,
render: (v) => ( render: (v) => (
@ -308,7 +306,7 @@ export default function Dictionaries() {
) )
}, },
{ {
title: "操作", title: t('common.action'),
width: 120, width: 120,
fixed: "right" as const, fixed: "right" as const,
render: (_, record) => ( render: (_, record) => (
@ -319,7 +317,7 @@ export default function Dictionaries() {
size="small" size="small"
icon={<EditOutlined aria-hidden="true" />} icon={<EditOutlined aria-hidden="true" />}
onClick={() => handleEditItem(record)} onClick={() => handleEditItem(record)}
aria-label={`编辑字典项 ${record.itemLabel}`} aria-label={t('common.edit')}
/> />
)} )}
{can("sys_dict:item:delete") && ( {can("sys_dict:item:delete") && (
@ -329,7 +327,7 @@ export default function Dictionaries() {
size="small" size="small"
danger danger
icon={<DeleteOutlined aria-hidden="true" />} icon={<DeleteOutlined aria-hidden="true" />}
aria-label={`删除字典项 ${record.itemLabel}`} aria-label={t('common.delete')}
/> />
</Popconfirm> </Popconfirm>
)} )}
@ -340,8 +338,8 @@ export default function Dictionaries() {
/> />
</div> </div>
) : ( ) : (
<div className="flex-center h-full"> <div className="flex items-center justify-center h-full">
<Empty description="请先从左侧选择一个字典类型" /> <Empty description={t('dicts.selectType')} />
</div> </div>
)} )}
</Card> </Card>
@ -353,7 +351,7 @@ export default function Dictionaries() {
title={ title={
<Space> <Space>
<BookOutlined aria-hidden="true" /> <BookOutlined aria-hidden="true" />
<span>{editingType ? "编辑字典类型" : "新增字典类型"}</span> <span>{editingType ? t('dicts.drawerTitleTypeEdit') : t('dicts.drawerTitleTypeCreate')}</span>
</Space> </Space>
} }
open={typeDrawerVisible} open={typeDrawerVisible}
@ -361,20 +359,20 @@ export default function Dictionaries() {
width={400} width={400}
destroyOnClose destroyOnClose
footer={ footer={
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2 p-2">
<Button onClick={() => setTypeDrawerVisible(false)}></Button> <Button onClick={() => setTypeDrawerVisible(false)}>{t('common.cancel')}</Button>
<Button type="primary" onClick={handleTypeSubmit}></Button> <Button type="primary" onClick={handleTypeSubmit}>{t('common.confirm')}</Button>
</div> </div>
} }
> >
<Form form={typeForm} layout="vertical"> <Form form={typeForm} layout="vertical">
<Form.Item label="类型编码" name="typeCode" rules={[{ required: true, message: "请输入类型编码" }]}> <Form.Item label={t('dicts.typeCode')} name="typeCode" rules={[{ required: true, message: t('dicts.typeCode') }]}>
<Input disabled={!!editingType} placeholder="例如user_status…" /> <Input disabled={!!editingType} placeholder="例如user_status…" />
</Form.Item> </Form.Item>
<Form.Item label="类型名称" name="typeName" rules={[{ required: true, message: "请输入类型名称" }]}> <Form.Item label={t('dicts.typeName')} name="typeName" rules={[{ required: true, message: t('dicts.typeName') }]}>
<Input placeholder="例如:用户状态…" /> <Input placeholder="例如:用户状态…" />
</Form.Item> </Form.Item>
<Form.Item label="备注说明" name="remark"> <Form.Item label={t('common.remark')} name="remark">
<Input.TextArea placeholder="该字典类型的用途描述…" rows={3} /> <Input.TextArea placeholder="该字典类型的用途描述…" rows={3} />
</Form.Item> </Form.Item>
</Form> </Form>
@ -385,7 +383,7 @@ export default function Dictionaries() {
title={ title={
<Space> <Space>
<ProfileOutlined aria-hidden="true" /> <ProfileOutlined aria-hidden="true" />
<span>{editingItem ? "编辑字典项" : "新增字典项"}</span> <span>{editingItem ? t('dicts.drawerTitleItemEdit') : t('dicts.drawerTitleItemCreate')}</span>
</Space> </Space>
} }
open={itemDrawerVisible} open={itemDrawerVisible}
@ -393,26 +391,26 @@ export default function Dictionaries() {
width={400} width={400}
destroyOnClose destroyOnClose
footer={ footer={
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2 p-2">
<Button onClick={() => setItemDrawerVisible(false)}></Button> <Button onClick={() => setItemDrawerVisible(false)}>{t('common.cancel')}</Button>
<Button type="primary" onClick={handleItemSubmit}></Button> <Button type="primary" onClick={handleItemSubmit}>{t('common.confirm')}</Button>
</div> </div>
} }
> >
<Form form={itemForm} layout="vertical"> <Form form={itemForm} layout="vertical">
<Form.Item label="所属类型" name="typeCode"> <Form.Item label={t('dicts.typeCode')} name="typeCode">
<Input disabled className="tabular-nums" /> <Input disabled className="tabular-nums" />
</Form.Item> </Form.Item>
<Form.Item label="显示标签" name="itemLabel" rules={[{ required: true, message: "请输入展示标签" }]}> <Form.Item label={t('dicts.itemLabel')} name="itemLabel" rules={[{ required: true, message: t('dicts.itemLabel') }]}>
<Input placeholder="例如:正常、禁用…" /> <Input placeholder="例如:正常、禁用…" />
</Form.Item> </Form.Item>
<Form.Item label="存储数值" name="itemValue" rules={[{ required: true, message: "请输入数值" }]}> <Form.Item label={t('dicts.itemValue')} name="itemValue" rules={[{ required: true, message: t('dicts.itemValue') }]}>
<Input placeholder="例如1、0…" className="tabular-nums" /> <Input placeholder="例如1、0…" className="tabular-nums" />
</Form.Item> </Form.Item>
<Form.Item label="显示排序" name="sortOrder" initialValue={0}> <Form.Item label={t('dicts.sort')} name="sortOrder" initialValue={0}>
<InputNumber className="w-full tabular-nums" /> <InputNumber className="w-full tabular-nums" />
</Form.Item> </Form.Item>
<Form.Item label="当前状态" name="status" initialValue={1}> <Form.Item label={t('common.status')} name="status" initialValue={1}>
<Select <Select
options={[ options={[
{ label: "启用", value: 1 }, { label: "启用", value: 1 },
@ -420,7 +418,7 @@ export default function Dictionaries() {
]} ]}
/> />
</Form.Item> </Form.Item>
<Form.Item label="备注说明" name="remark"> <Form.Item label={t('common.remark')} name="remark">
<Input.TextArea placeholder="可选项,备注详细信息…" rows={3} /> <Input.TextArea placeholder="可选项,备注详细信息…" rows={3} />
</Form.Item> </Form.Item>
</Form> </Form>

View File

@ -1,5 +1,6 @@
import { Button, Checkbox, Form, Input, message, Typography } from "antd"; import { Button, Checkbox, Form, Input, message, Typography, Space } from "antd";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
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, ShopOutlined } from "@ant-design/icons"; import { UserOutlined, LockOutlined, SafetyOutlined, ReloadOutlined, ShopOutlined } from "@ant-design/icons";
@ -8,6 +9,7 @@ import "./Login.css";
const { Title, Text, Link } = Typography; const { Title, Text, Link } = Typography;
export default function Login() { export default function Login() {
const { t } = useTranslation();
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null); const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null);
const [captchaEnabled, setCaptchaEnabled] = useState(true); const [captchaEnabled, setCaptchaEnabled] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -21,9 +23,9 @@ export default function Login() {
const data = await fetchCaptcha(); const data = await fetchCaptcha();
setCaptcha(data); setCaptcha(data);
} catch (e) { } catch (e) {
message.error("加载验证码失败"); message.error(t('common.error'));
} }
}, [captchaEnabled]); }, [captchaEnabled, t]);
useEffect(() => { useEffect(() => {
const init = async () => { const init = async () => {
@ -61,10 +63,10 @@ export default function Login() {
} catch (e) { } catch (e) {
sessionStorage.removeItem("userProfile"); sessionStorage.removeItem("userProfile");
} }
message.success("登录成功"); message.success(t('common.success'));
window.location.href = "/"; window.location.href = "/";
} catch (e: any) { } catch (e: any) {
message.error(e.message || "登录失败"); message.error(e.message || t('common.error'));
if (captchaEnabled) { if (captchaEnabled) {
loadCaptcha(); loadCaptcha();
} }
@ -83,28 +85,27 @@ export default function Login() {
<div className="login-hero"> <div className="login-hero">
<h1 className="hero-title"> <h1 className="hero-title">
<br /> {t('login.heroTitle1')}<br />
<span className="hero-accent"></span><br /> <span className="hero-accent">{t('login.heroTitle2')}</span><br />
{t('login.heroTitle3')}
</h1> </h1>
<p className="hero-desc"> <p className="hero-desc">
<br /> {t('login.heroDesc')}
</p> </p>
</div> </div>
<div className="login-left-footer"> <div className="login-left-footer">
<div className="footer-item"></div> <div className="footer-item">{t('login.enterpriseSecurity')}</div>
<div className="footer-divider" aria-hidden="true" /> <div className="footer-divider" aria-hidden="true" />
<div className="footer-item"></div> <div className="footer-item">{t('login.multiLang')}</div>
</div> </div>
</div> </div>
<div className="login-right"> <div className="login-right">
<div className="login-container"> <div className="login-container">
<div className="login-header"> <div className="login-header">
<Title level={2}></Title> <Title level={2}>{t('login.welcome')}</Title>
<Text type="secondary"></Text> <Text type="secondary">{t('login.subtitle')}</Text>
</div> </div>
<Form <Form
@ -122,50 +123,50 @@ export default function Login() {
<Input <Input
size="large" size="large"
prefix={<ShopOutlined className="text-gray-400" aria-hidden="true" />} prefix={<ShopOutlined className="text-gray-400" aria-hidden="true" />}
placeholder="租户编码 (平台管理可留空)" placeholder={t('login.tenantCodePlaceholder')}
aria-label="租户编码" aria-label={t('login.tenantCode')}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="username" name="username"
rules={[{ required: true, message: "请输入用户名" }]} rules={[{ required: true, message: t('login.username') }]}
> >
<Input <Input
size="large" size="large"
prefix={<UserOutlined className="text-gray-400" aria-hidden="true" />} prefix={<UserOutlined className="text-gray-400" aria-hidden="true" />}
placeholder="用户名" placeholder={t('login.username')}
autoComplete="username" autoComplete="username"
spellCheck={false} spellCheck={false}
aria-label="用户名" aria-label={t('login.username')}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="password" name="password"
rules={[{ required: true, message: "请输入密码" }]} rules={[{ required: true, message: t('login.password') }]}
> >
<Input.Password <Input.Password
size="large" size="large"
prefix={<LockOutlined className="text-gray-400" aria-hidden="true" />} prefix={<LockOutlined className="text-gray-400" aria-hidden="true" />}
placeholder="密码" placeholder={t('login.password')}
autoComplete="current-password" autoComplete="current-password"
aria-label="密码" aria-label={t('login.password')}
/> />
</Form.Item> </Form.Item>
{captchaEnabled && ( {captchaEnabled && (
<Form.Item <Form.Item
name="captchaCode" name="captchaCode"
rules={[{ required: true, message: "请输入验证码" }]} rules={[{ required: true, message: t('login.captcha') }]}
> >
<div className="captcha-wrapper"> <div className="captcha-wrapper">
<Input <Input
size="large" size="large"
prefix={<SafetyOutlined className="text-gray-400" aria-hidden="true" />} prefix={<SafetyOutlined className="text-gray-400" aria-hidden="true" />}
placeholder="验证码" placeholder={t('login.captcha')}
maxLength={6} maxLength={6}
aria-label="验证码" aria-label={t('login.captcha')}
/> />
<Button <Button
className="captcha-image-btn" className="captcha-image-btn"
@ -181,21 +182,21 @@ export default function Login() {
<div className="login-extra"> <div className="login-extra">
<Form.Item name="remember" valuePropName="checked" noStyle> <Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox></Checkbox> <Checkbox>{t('login.rememberMe')}</Checkbox>
</Form.Item> </Form.Item>
<Link className="forgot-password"></Link> <Link className="forgot-password">{t('login.forgotPassword')}</Link>
</div> </div>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block size="large" className="login-submit-btn"> <Button type="primary" htmlType="submit" loading={loading} block size="large" className="login-submit-btn">
{loading ? "登录中…" : "立即登录"} {loading ? t('login.loggingIn') : t('login.submit')}
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
<div className="login-footer"> <div className="login-footer">
<Text type="secondary"> <Text type="secondary">
<Text strong className="tabular-nums">admin</Text> / <Text strong className="tabular-nums">123456</Text> {t('login.demoAccount')}<Text strong className="tabular-nums">admin</Text> / {t('login.password')}<Text strong className="tabular-nums">123456</Text>
</Text> </Text>
</div> </div>
</div> </div>

View File

@ -1,14 +1,15 @@
import { Card, Table, Tabs, Tag, Input, Space, Button, DatePicker, Select, Typography, Modal, Descriptions } from "antd"; import { Card, Table, Tabs, Tag, Input, Space, Button, DatePicker, Select, Typography, Modal, Descriptions } from "antd";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { fetchLogs } from "../api"; import { fetchLogs } from "../api";
import { SearchOutlined, ReloadOutlined, InfoCircleOutlined, EyeOutlined, UserOutlined } from "@ant-design/icons"; import { SearchOutlined, ReloadOutlined, InfoCircleOutlined, EyeOutlined, UserOutlined } from "@ant-design/icons";
import { SysLog, UserProfile } from "../types"; import { SysLog, UserProfile } from "../types";
import dayjs from "dayjs";
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
const { Text, Title } = Typography; const { Text, Title } = Typography;
export default function Logs() { export default function Logs() {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState("OPERATION"); const [activeTab, setActiveTab] = useState("OPERATION");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [data, setData] = useState<SysLog[]>([]); const [data, setData] = useState<SysLog[]>([]);
@ -109,35 +110,35 @@ export default function Logs() {
const columns = [ const columns = [
...(isPlatformAdmin ? [{ ...(isPlatformAdmin ? [{
title: "所属租户", title: t('users.tenant'),
dataIndex: "tenantName", dataIndex: "tenantName",
key: "tenantName", key: "tenantName",
width: 150, width: 150,
render: (text: string) => <Text type="warning">{text || "系统平台"}</Text> render: (text: string) => <Text type="warning">{text || "系统平台"}</Text>
}] : []), }] : []),
{ {
title: "操作账号", title: t('logs.opAccount'),
dataIndex: "username", dataIndex: "username",
key: "username", key: "username",
width: 120, width: 120,
render: (text: string) => <Text strong>{text || "系统"}</Text> render: (text: string) => <Text strong>{text || "系统"}</Text>
}, },
{ {
title: "操作详情", title: t('logs.opDetail'),
dataIndex: "operation", dataIndex: "operation",
key: "operation", key: "operation",
ellipsis: true, ellipsis: true,
render: (text: string) => <Text type="secondary">{text}</Text> render: (text: string) => <Text type="secondary">{text}</Text>
}, },
{ {
title: "IP 地址", title: t('logs.ip'),
dataIndex: "ip", dataIndex: "ip",
key: "ip", key: "ip",
width: 130, width: 130,
className: "tabular-nums" className: "tabular-nums"
}, },
{ {
title: "耗时", title: t('logs.duration'),
dataIndex: "duration", dataIndex: "duration",
key: "duration", key: "duration",
width: 100, width: 100,
@ -146,7 +147,7 @@ export default function Logs() {
render: renderDuration render: renderDuration
}, },
{ {
title: "状态", title: t('common.status'),
dataIndex: "status", dataIndex: "status",
key: "status", key: "status",
width: 90, width: 90,
@ -157,7 +158,7 @@ export default function Logs() {
) )
}, },
{ {
title: "发生时间", title: t('logs.time'),
dataIndex: "createdAt", dataIndex: "createdAt",
key: "createdAt", key: "createdAt",
width: 180, width: 180,
@ -167,17 +168,17 @@ export default function Logs() {
render: (text: string) => text?.replace('T', ' ').substring(0, 19) render: (text: string) => text?.replace('T', ' ').substring(0, 19)
}, },
{ {
title: "详情", title: t('common.action'),
key: "action", key: "action",
width: 60, width: 60,
fixed: "right" as const, fixed: "right" as const,
render: (_: any, record: SysLog) => ( render: (_: any, record: SysLog) => (
<Button <Button
type="link" type="text"
size="small" size="small"
icon={<EyeOutlined aria-hidden="true" />} icon={<EyeOutlined aria-hidden="true" />}
onClick={() => showDetail(record)} onClick={() => showDetail(record)}
aria-label="查看详细日志信息" aria-label={t('common.view')}
/> />
) )
} }
@ -185,7 +186,7 @@ export default function Logs() {
if (activeTab === "OPERATION") { if (activeTab === "OPERATION") {
columns.splice(isPlatformAdmin ? 2 : 1, 0, { columns.splice(isPlatformAdmin ? 2 : 1, 0, {
title: "请求方法", title: t('logs.method'),
dataIndex: "method", dataIndex: "method",
key: "method", key: "method",
width: 180, width: 180,
@ -196,14 +197,14 @@ export default function Logs() {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="mb-6"> <div className="mb-6">
<Title level={4} className="mb-1"></Title> <Title level={4} className="mb-1">{t('logs.title')}</Title>
<Text type="secondary"></Text> <Text type="secondary">{t('logs.subtitle')}</Text>
</div> </div>
<Card className="mb-4 shadow-sm"> <Card className="mb-4 shadow-sm">
<Space wrap size="middle"> <Space wrap size="middle">
<Input <Input
placeholder="搜索操作内容…" placeholder={t('logs.searchPlaceholder')}
style={{ width: 180 }} style={{ width: 180 }}
value={params.operation} value={params.operation}
onChange={e => setParams({ ...params, operation: e.target.value })} onChange={e => setParams({ ...params, operation: e.target.value })}
@ -211,7 +212,7 @@ export default function Logs() {
allowClear allowClear
/> />
<Select <Select
placeholder="执行状态" placeholder={t('common.status')}
style={{ width: 120 }} style={{ width: 120 }}
allowClear allowClear
value={params.status} value={params.status}
@ -220,7 +221,7 @@ export default function Logs() {
{ label: "成功", value: 1 }, { label: "成功", value: 1 },
{ label: "失败", value: 0 } { label: "失败", value: 0 }
]} ]}
aria-label="筛选执行状态" aria-label={t('common.status')}
/> />
<RangePicker <RangePicker
onChange={(dates) => { onChange={(dates) => {
@ -237,13 +238,13 @@ export default function Logs() {
icon={<SearchOutlined aria-hidden="true" />} icon={<SearchOutlined aria-hidden="true" />}
onClick={handleSearch} onClick={handleSearch}
> >
{t('common.search')}
</Button> </Button>
<Button <Button
icon={<ReloadOutlined aria-hidden="true" />} icon={<ReloadOutlined aria-hidden="true" />}
onClick={handleReset} onClick={handleReset}
> >
{t('common.reset')}
</Button> </Button>
</Space> </Space>
</Card> </Card>
@ -251,11 +252,11 @@ export default function Logs() {
<Card className="shadow-sm" styles={{ body: { paddingTop: 0 } }}> <Card className="shadow-sm" styles={{ body: { paddingTop: 0 } }}>
<Tabs activeKey={activeTab} onChange={setActiveTab} size="large"> <Tabs activeKey={activeTab} onChange={setActiveTab} size="large">
<Tabs.TabPane <Tabs.TabPane
tab={<span><InfoCircleOutlined aria-hidden="true" /></span>} tab={<span><InfoCircleOutlined aria-hidden="true" />{t('logs.opLog')}</span>}
key="OPERATION" key="OPERATION"
/> />
<Tabs.TabPane <Tabs.TabPane
tab={<span><UserOutlined aria-hidden="true" /></span>} tab={<span><UserOutlined aria-hidden="true" />{t('logs.loginLog')}</span>}
key="LOGIN" key="LOGIN"
/> />
</Tabs> </Tabs>
@ -272,18 +273,18 @@ export default function Logs() {
pageSize: params.size, pageSize: params.size,
total: total, total: total,
showSizeChanger: true, showSizeChanger: true,
showTotal: (total) => `${total} 条数据` showTotal: (total) => t('common.total', { total })
}} }}
/> />
</Card> </Card>
<Modal <Modal
title="日志详细信息" title={t('logs.detailTitle')}
open={detailModalVisible} open={detailModalVisible}
onCancel={() => setDetailModalVisible(false)} onCancel={() => setDetailModalVisible(false)}
footer={[ footer={[
<Button key="close" onClick={() => setDetailModalVisible(false)}> <Button key="close" onClick={() => setDetailModalVisible(false)}>
{t('common.cancel')}
</Button> </Button>
]} ]}
width={700} width={700}
@ -291,24 +292,24 @@ export default function Logs() {
{selectedLog && ( {selectedLog && (
<Descriptions bordered column={1} size="small"> <Descriptions bordered column={1} size="small">
{isPlatformAdmin && ( {isPlatformAdmin && (
<Descriptions.Item label="所属租户"> <Descriptions.Item label={t('users.tenant')}>
<Text type="warning">{selectedLog.tenantName || "系统平台"}</Text> <Text type="warning">{selectedLog.tenantName || "系统平台"}</Text>
</Descriptions.Item> </Descriptions.Item>
)} )}
<Descriptions.Item label="操作详情">{selectedLog.operation}</Descriptions.Item> <Descriptions.Item label={t('logs.opDetail')}>{selectedLog.operation}</Descriptions.Item>
<Descriptions.Item label="请求方法"> <Descriptions.Item label={t('logs.method')}>
<Tag color="blue">{selectedLog.method || "N/A"}</Tag> <Tag color="blue">{selectedLog.method || "N/A"}</Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="操作账号">{selectedLog.username || "系统"}</Descriptions.Item> <Descriptions.Item label={t('logs.opAccount')}>{selectedLog.username || "系统"}</Descriptions.Item>
<Descriptions.Item label="IP 地址" className="tabular-nums">{selectedLog.ip}</Descriptions.Item> <Descriptions.Item label={t('logs.ip')} className="tabular-nums">{selectedLog.ip}</Descriptions.Item>
<Descriptions.Item label="耗时">{selectedLog.duration ? `${selectedLog.duration}ms` : "-"}</Descriptions.Item> <Descriptions.Item label={t('logs.duration')}>{selectedLog.duration ? `${selectedLog.duration}ms` : "-"}</Descriptions.Item>
<Descriptions.Item label="状态"> <Descriptions.Item label={t('common.status')}>
<Tag color={selectedLog.status === 1 ? "green" : "red"}> <Tag color={selectedLog.status === 1 ? "green" : "red"}>
{selectedLog.status === 1 ? "成功" : "失败"} {selectedLog.status === 1 ? "成功" : "失败"}
</Tag> </Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="时间" className="tabular-nums">{selectedLog.createdAt?.replace('T', ' ')}</Descriptions.Item> <Descriptions.Item label={t('logs.time')} className="tabular-nums">{selectedLog.createdAt?.replace('T', ' ')}</Descriptions.Item>
<Descriptions.Item label="请求参数"> <Descriptions.Item label={t('logs.params')}>
<div style={{ <div style={{
background: '#f5f5f5', background: '#f5f5f5',
padding: '12px', padding: '12px',

View File

@ -17,6 +17,7 @@ import {
Empty Empty
} from "antd"; } from "antd";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { createOrg, deleteOrg, listOrgs, updateOrg, listTenants } from "../api"; import { createOrg, deleteOrg, listOrgs, updateOrg, listTenants } from "../api";
import { usePermission } from "../hooks/usePermission"; import { usePermission } from "../hooks/usePermission";
import { import {
@ -24,7 +25,6 @@ import {
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
ApartmentOutlined, ApartmentOutlined,
SearchOutlined,
ReloadOutlined, ReloadOutlined,
ShopOutlined ShopOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
@ -57,6 +57,7 @@ function buildOrgTree(list: SysOrg[]): OrgNode[] {
} }
export default function Orgs() { export default function Orgs() {
const { t } = useTranslation();
const { can } = usePermission(); const { can } = usePermission();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -77,7 +78,7 @@ export default function Orgs() {
setSelectedTenantId(list[0].id); setSelectedTenantId(list[0].id);
} }
} catch (e) { } catch (e) {
message.error("加载租户列表失败"); message.error(t('common.error'));
} }
}; };
@ -88,7 +89,7 @@ export default function Orgs() {
const list = await listOrgs(selectedTenantId); const list = await listOrgs(selectedTenantId);
setData(list || []); setData(list || []);
} catch (e) { } catch (e) {
message.error("加载组织架构失败"); message.error(t('common.error'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -129,10 +130,10 @@ export default function Orgs() {
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
await deleteOrg(id); await deleteOrg(id);
message.success("组织已删除"); message.success(t('common.success'));
loadOrgs(); loadOrgs();
} catch (e: any) { } catch (e: any) {
message.error(e.message || "删除失败"); message.error(e.message || t('common.error'));
} }
}; };
@ -142,10 +143,10 @@ export default function Orgs() {
setSaving(true); setSaving(true);
if (editing) { if (editing) {
await updateOrg(editing.id, values); await updateOrg(editing.id, values);
message.success("更新成功"); message.success(t('common.success'));
} else { } else {
await createOrg(values); await createOrg(values);
message.success("创建成功"); message.success(t('common.success'));
} }
setDrawerOpen(false); setDrawerOpen(false);
loadOrgs(); loadOrgs();
@ -158,45 +159,45 @@ export default function Orgs() {
const columns = [ const columns = [
{ {
title: "组织名称", title: t('orgs.orgName'),
dataIndex: "orgName", dataIndex: "orgName",
key: "orgName", key: "orgName",
render: (text: string) => <Text strong>{text}</Text> render: (text: string) => <Text strong>{text}</Text>
}, },
{ {
title: "组织编码", title: t('orgs.orgCode'),
dataIndex: "orgCode", dataIndex: "orgCode",
key: "orgCode", key: "orgCode",
width: 150, width: 150,
render: (text: string) => <Tag className="tabular-nums">{text || "-"}</Tag> render: (text: string) => <Tag className="tabular-nums">{text || "-"}</Tag>
}, },
{ {
title: "排序", title: t('orgs.sort'),
dataIndex: "sortOrder", dataIndex: "sortOrder",
width: 100, width: 100,
className: "tabular-nums" className: "tabular-nums"
}, },
{ {
title: "状态", title: t('common.status'),
dataIndex: "status", dataIndex: "status",
width: 100, width: 100,
render: (s: number) => <Tag color={s === 1 ? "green" : "red"}>{s === 1 ? "启用" : "禁用"}</Tag> render: (s: number) => <Tag color={s === 1 ? "green" : "red"}>{s === 1 ? "启用" : "禁用"}</Tag>
}, },
{ {
title: "操作", title: t('common.action'),
key: "action", key: "action",
width: 180, width: 180,
render: (_: any, record: SysOrg) => ( render: (_: any, record: SysOrg) => (
<Space> <Space>
{can("sys_org:create") && ( {can("sys_org:create") && (
<Button type="link" size="small" onClick={() => openCreate(record.id)}></Button> <Button type="link" size="small" onClick={() => openCreate(record.id)}>{t('orgs.addSub')}</Button>
)} )}
{can("sys_org:update") && ( {can("sys_org:update") && (
<Button type="text" size="small" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label="编辑组织" /> <Button type="text" size="small" icon={<EditOutlined aria-hidden="true" />} onClick={() => openEdit(record)} aria-label={t('common.edit')} />
)} )}
{can("sys_org:delete") && ( {can("sys_org:delete") && (
<Popconfirm title={`确定删除 "${record.orgName}" 吗?`} onConfirm={() => handleDelete(record.id)}> <Popconfirm title={`确定删除 "${record.orgName}" 吗?`} onConfirm={() => handleDelete(record.id)}>
<Button type="text" size="small" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label="删除组织" /> <Button type="text" size="small" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={t('common.delete')} />
</Popconfirm> </Popconfirm>
)} )}
</Space> </Space>
@ -208,28 +209,28 @@ export default function Orgs() {
<div className="p-6"> <div className="p-6">
<div className="mb-6 flex justify-between items-end"> <div className="mb-6 flex justify-between items-end">
<div> <div>
<Title level={4} className="mb-1"></Title> <Title level={4} className="mb-1">{t('orgs.title')}</Title>
<Text type="secondary"></Text> <Text type="secondary">{t('orgs.subtitle')}</Text>
</div> </div>
{can("sys_org:create") && ( {can("sys_org:create") && (
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={() => openCreate()}> <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={() => openCreate()}>
{t('orgs.createRoot')}
</Button> </Button>
)} )}
</div> </div>
<Card className="shadow-sm mb-4"> <Card className="shadow-sm mb-4">
<Space> <Space>
<Text strong></Text> <Text strong>{t('users.tenant')}</Text>
<Select <Select
style={{ width: 220 }} style={{ width: 220 }}
placeholder="切换租户查看架构" placeholder={t('orgs.selectTenant')}
value={selectedTenantId} value={selectedTenantId}
onChange={setSelectedTenantId} onChange={setSelectedTenantId}
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))} options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
suffixIcon={<ShopOutlined aria-hidden="true" />} suffixIcon={<ShopOutlined aria-hidden="true" />}
/> />
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadOrgs}></Button> <Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadOrgs}>{t('common.refresh')}</Button>
</Space> </Space>
</Card> </Card>
@ -246,7 +247,7 @@ export default function Orgs() {
/> />
) : ( ) : (
<div className="py-20 flex justify-center"> <div className="py-20 flex justify-center">
<Empty description="请先选择一个租户以查看其组织架构" /> <Empty description={t('orgs.selectTenant')} />
</div> </div>
)} )}
</Card> </Card>
@ -255,7 +256,7 @@ export default function Orgs() {
title={ title={
<Space> <Space>
<ApartmentOutlined aria-hidden="true" /> <ApartmentOutlined aria-hidden="true" />
<span>{editing ? "编辑组织节点" : "新增组织部门"}</span> <span>{editing ? t('orgs.drawerTitleEdit') : t('orgs.drawerTitleCreate')}</span>
</Space> </Space>
} }
open={drawerOpen} open={drawerOpen}
@ -264,19 +265,19 @@ export default function Orgs() {
destroyOnClose destroyOnClose
footer={ footer={
<div className="flex justify-end gap-2 p-2"> <div className="flex justify-end gap-2 p-2">
<Button onClick={() => setDrawerOpen(false)}></Button> <Button onClick={() => setDrawerOpen(false)}>{t('common.cancel')}</Button>
<Button type="primary" loading={saving} onClick={submit}></Button> <Button type="primary" loading={saving} onClick={submit}>{t('common.confirm')}</Button>
</div> </div>
} }
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item label="所属租户" name="tenantId" rules={[{ required: true }]}> <Form.Item label={t('users.tenant')} name="tenantId" rules={[{ required: true }]}>
<Select disabled options={tenants.map(t => ({ label: t.tenantName, value: t.id }))} /> <Select disabled options={tenants.map(t => ({ label: t.tenantName, value: t.id }))} />
</Form.Item> </Form.Item>
<Form.Item label="上级部门" name="parentId"> <Form.Item label={t('orgs.parentOrg')} name="parentId">
<Select <Select
placeholder="顶级部门" placeholder={t('orgs.rootOrg')}
allowClear allowClear
showSearch showSearch
optionFilterProp="label" optionFilterProp="label"
@ -284,22 +285,22 @@ export default function Orgs() {
/> />
</Form.Item> </Form.Item>
<Form.Item label="部门名称" name="orgName" rules={[{ required: true, message: "请输入部门/组织名称" }]}> <Form.Item label={t('orgs.orgName')} name="orgName" rules={[{ required: true, message: t('orgs.orgName') }]}>
<Input placeholder="例如:技术部、财务处…" /> <Input placeholder="例如:技术部、财务处…" />
</Form.Item> </Form.Item>
<Form.Item label="部门编码" name="orgCode"> <Form.Item label={t('orgs.orgCode')} name="orgCode">
<Input placeholder="例如DEPT_TECH" className="tabular-nums" /> <Input placeholder="例如DEPT_TECH" className="tabular-nums" />
</Form.Item> </Form.Item>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="显示排序" name="sortOrder" initialValue={0}> <Form.Item label={t('dicts.sort')} name="sortOrder" initialValue={0}>
<InputNumber style={{ width: "100%" }} min={0} className="tabular-nums" /> <InputNumber style={{ width: "100%" }} min={0} className="tabular-nums" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="状态" name="status" initialValue={1}> <Form.Item label={t('common.status')} name="status" initialValue={1}>
<Select options={[{ label: "启用", value: 1 }, { label: "禁用", value: 0 }]} /> <Select options={[{ label: "启用", value: 1 }, { label: "禁用", value: 0 }]} />
</Form.Item> </Form.Item>
</Col> </Col>

View File

@ -12,9 +12,12 @@ import {
Typography, Typography,
Card, Card,
message, message,
Tooltip Tooltip,
Row,
Col
} from "antd"; } from "antd";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
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";
@ -61,6 +64,7 @@ function buildTree(list: SysPermission[]): TreePermission[] {
} }
export default function Permissions() { export default function Permissions() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [data, setData] = useState<SysPermission[]>([]); const [data, setData] = useState<SysPermission[]>([]);
@ -135,10 +139,10 @@ export default function Permissions() {
}; };
if (editing) { if (editing) {
await updatePermission(editing.permId, payload); await updatePermission(editing.permId, payload);
message.success("权限已更新"); message.success(t('common.success'));
} else { } else {
await createPermission(payload); await createPermission(payload);
message.success("权限已创建"); message.success(t('common.success'));
} }
setOpen(false); setOpen(false);
load(); load();
@ -152,10 +156,10 @@ export default function Permissions() {
const remove = async (id: number) => { const remove = async (id: number) => {
try { try {
await deletePermission(id); await deletePermission(id);
message.success("权限已删除"); message.success(t('common.success'));
load(); load();
} catch (e) { } catch (e) {
message.error("删除失败"); message.error(t('common.error'));
} }
}; };
@ -165,7 +169,7 @@ export default function Permissions() {
const columns = [ const columns = [
{ {
title: "权限名称", title: t('permissions.permName'),
dataIndex: "name", dataIndex: "name",
key: "name", key: "name",
render: (text: string, record: SysPermission) => ( render: (text: string, record: SysPermission) => (
@ -179,13 +183,13 @@ export default function Permissions() {
) )
}, },
{ {
title: "权限编码", title: t('permissions.permCode'),
dataIndex: "code", dataIndex: "code",
key: "code", key: "code",
render: (text: string) => text ? <Tag color="blue" className="tabular-nums">{text}</Tag> : "-" render: (text: string) => text ? <Tag color="blue" className="tabular-nums">{text}</Tag> : "-"
}, },
{ {
title: "类型", title: t('permissions.permType'),
dataIndex: "permType", dataIndex: "permType",
width: 90, width: 90,
render: (type: string) => ( render: (type: string) => (
@ -195,36 +199,36 @@ export default function Permissions() {
) )
}, },
{ {
title: "排序", title: t('permissions.sort'),
dataIndex: "sortOrder", dataIndex: "sortOrder",
width: 80, width: 80,
className: "tabular-nums" className: "tabular-nums"
}, },
{ {
title: "路由/组件", title: t('permissions.route'),
key: "route", key: "route",
ellipsis: true, ellipsis: true,
render: (_: any, record: SysPermission) => ( render: (_: any, record: SysPermission) => (
<div className="flex flex-col"> <div className="flex flex-col">
{record.path && <Text type="secondary" size="small" className="tabular-nums">{record.path}</Text>} {record.path && <Text type="secondary" style={{ fontSize: '12px' }} className="tabular-nums">{record.path}</Text>}
{record.component && <Text type="secondary" size="small" style={{ fontSize: '11px' }}>{record.component}</Text>} {record.component && <Text type="secondary" style={{ fontSize: '11px' }}>{record.component}</Text>}
</div> </div>
) )
}, },
{ {
title: "显示", title: t('permissions.visible'),
dataIndex: "isVisible", dataIndex: "isVisible",
width: 80, width: 80,
render: (v: number) => (v === 1 ? <Tag color="blue"></Tag> : <Tag></Tag>) render: (v: number) => (v === 1 ? <Tag color="blue"></Tag> : <Tag></Tag>)
}, },
{ {
title: "状态", title: t('common.status'),
dataIndex: "status", dataIndex: "status",
width: 80, width: 80,
render: (v: number) => (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: t('common.action'),
width: 120, width: 120,
fixed: "right" as const, fixed: "right" as const,
render: (_: any, record: SysPermission) => ( render: (_: any, record: SysPermission) => (
@ -235,7 +239,7 @@ export default function Permissions() {
size="small" size="small"
icon={<EditOutlined aria-hidden="true" />} icon={<EditOutlined aria-hidden="true" />}
onClick={() => openEdit(record)} onClick={() => openEdit(record)}
aria-label={`编辑权限 ${record.name}`} aria-label={t('common.edit')}
/> />
)} )}
{can("sys_permission:delete") && ( {can("sys_permission:delete") && (
@ -245,7 +249,7 @@ export default function Permissions() {
size="small" size="small"
danger danger
icon={<DeleteOutlined aria-hidden="true" />} icon={<DeleteOutlined aria-hidden="true" />}
aria-label={`删除权限 ${record.name}`} aria-label={t('common.delete')}
/> />
</Popconfirm> </Popconfirm>
)} )}
@ -256,32 +260,43 @@ export default function Permissions() {
return ( return (
<div className="p-6"> <div className="p-6">
<div className="mb-6"> <div className="mb-6 flex justify-between items-end">
<Title level={4} className="mb-1"></Title> <div>
<Text type="secondary"></Text> <Title level={4} className="mb-1">{t('permissions.title')}</Title>
<Text type="secondary">{t('permissions.subtitle')}</Text>
</div>
{can("sys_permission:create") && (
<Button
type="primary"
icon={<PlusOutlined aria-hidden="true" />}
onClick={openCreate}
>
{t('common.create')}
</Button>
)}
</div> </div>
<Card className="mb-4 shadow-sm"> <Card className="mb-4 shadow-sm">
<Space wrap size="middle"> <Space wrap size="middle">
<Input <Input
placeholder="搜索权限名称…" placeholder={t('permissions.permName')}
value={query.name} value={query.name}
onChange={(e) => setQuery({ ...query, name: e.target.value })} onChange={(e) => setQuery({ ...query, name: e.target.value })}
prefix={<SearchOutlined className="text-gray-400" aria-hidden="true" />} prefix={<SearchOutlined className="text-gray-400" aria-hidden="true" />}
style={{ width: 180 }} style={{ width: 180 }}
allowClear allowClear
aria-label="搜索权限名称" aria-label={t('permissions.permName')}
/> />
<Input <Input
placeholder="权限编码…" placeholder={t('permissions.permCode')}
value={query.code} value={query.code}
onChange={(e) => setQuery({ ...query, code: e.target.value })} onChange={(e) => setQuery({ ...query, code: e.target.value })}
style={{ width: 180 }} style={{ width: 180 }}
allowClear allowClear
aria-label="搜索权限编码" aria-label={t('permissions.permCode')}
/> />
<Select <Select
placeholder="权限类型" placeholder={t('permissions.permType')}
allowClear allowClear
value={query.permType || undefined} value={query.permType || undefined}
onChange={(v) => setQuery({ ...query, permType: v || "" })} onChange={(v) => setQuery({ ...query, permType: v || "" })}
@ -290,31 +305,21 @@ export default function Permissions() {
{ value: "button", label: "按钮" } { value: "button", label: "按钮" }
]} ]}
style={{ width: 120 }} style={{ width: 120 }}
aria-label="筛选权限类型" aria-label={t('permissions.permType')}
/> />
<Button <Button
type="primary" type="primary"
icon={<SearchOutlined aria-hidden="true" />} icon={<SearchOutlined aria-hidden="true" />}
onClick={load} onClick={load}
> >
{t('common.search')}
</Button> </Button>
<Button <Button
icon={<ReloadOutlined aria-hidden="true" />} icon={<ReloadOutlined aria-hidden="true" />}
onClick={handleReset} onClick={handleReset}
> >
{t('common.reset')}
</Button> </Button>
{can("sys_permission:create") && (
<Button
type="primary"
icon={<PlusOutlined aria-hidden="true" />}
onClick={openCreate}
style={{ background: '#52c41a', borderColor: '#52c41a' }}
>
</Button>
)}
</Space> </Space>
</Card> </Card>
@ -334,7 +339,7 @@ export default function Permissions() {
title={ title={
<Space> <Space>
<ClusterOutlined aria-hidden="true" /> <ClusterOutlined aria-hidden="true" />
<span>{editing ? "修改权限点信息" : "新增功能权限"}</span> <span>{editing ? t('permissions.drawerTitleEdit') : t('permissions.drawerTitleCreate')}</span>
</Space> </Space>
} }
open={open} open={open}
@ -343,9 +348,9 @@ export default function Permissions() {
destroyOnClose destroyOnClose
footer={ footer={
<div className="flex justify-end gap-2 p-2"> <div className="flex justify-end gap-2 p-2">
<Button onClick={() => setOpen(false)}></Button> <Button onClick={() => setOpen(false)}>{t('common.cancel')}</Button>
<Button type="primary" loading={saving} onClick={submit}> <Button type="primary" loading={saving} onClick={submit}>
{t('common.confirm')}
</Button> </Button>
</div> </div>
} }
@ -362,22 +367,22 @@ export default function Permissions() {
> >
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="权限层级" name="level" rules={[{ required: true }]}> <Form.Item label={t('permissions.level')} name="level" rules={[{ required: true }]}>
<Select aria-label="选择层级"> <Select aria-label={t('permissions.level')}>
<Select.Option value={1}></Select.Option> <Select.Option value={1}></Select.Option>
<Select.Option value={2}></Select.Option> <Select.Option value={2}></Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="权限类型" name="permType" rules={[{ required: true }]}> <Form.Item label={t('permissions.permType')} name="permType" rules={[{ required: true }]}>
<Select options={[{ value: "menu", label: "菜单" }, { value: "button", label: "按钮" }]} aria-label="选择类型" /> <Select options={[{ value: "menu", label: "菜单" }, { value: "button", label: "按钮" }]} aria-label={t('permissions.permType')} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item <Form.Item
label="上级权限" label={t('permissions.parentId')}
name="parentId" name="parentId"
dependencies={["level"]} dependencies={["level"]}
rules={[ rules={[
@ -393,18 +398,18 @@ export default function Permissions() {
placeholder={level === 1 ? "一级入口无须父级" : "请选择父级菜单…"} placeholder={level === 1 ? "一级入口无须父级" : "请选择父级菜单…"}
options={parentOptions} options={parentOptions}
disabled={level !== 2} disabled={level !== 2}
aria-label="选择上级权限" aria-label={t('permissions.parentId')}
/> />
</Form.Item> </Form.Item>
<Form.Item label="名称" name="name" rules={[{ required: true, message: "请输入权限展示名称" }]}> <Form.Item label={t('permissions.permName')} name="name" rules={[{ required: true, message: t('permissions.permName') }]}>
<Input placeholder="例如:用户管理、导出报表…" /> <Input placeholder="例如:用户管理、导出报表…" />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
label={ label={
<Space> <Space>
<span></span> <span>{t('permissions.permCode')}</span>
<Tooltip title="用于后端 @PreAuthorize 鉴权和前端按钮控制"> <Tooltip title="用于后端 @PreAuthorize 鉴权和前端按钮控制">
<InfoCircleOutlined className="text-gray-400" /> <InfoCircleOutlined className="text-gray-400" />
</Tooltip> </Tooltip>
@ -424,12 +429,12 @@ export default function Permissions() {
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="路由路径" name="path"> <Form.Item label={t('permissions.path')} name="path">
<Input placeholder="/users…" className="tabular-nums" /> <Input placeholder="/users…" className="tabular-nums" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="组件路径" name="component"> <Form.Item label={t('permissions.component')} name="component">
<Input placeholder="pages/Users…" /> <Input placeholder="pages/Users…" />
</Form.Item> </Form.Item>
</Col> </Col>
@ -437,12 +442,12 @@ export default function Permissions() {
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="图标名称" name="icon"> <Form.Item label={t('permissions.icon')} name="icon">
<Input placeholder="AntD 图标名…" /> <Input placeholder="AntD 图标名…" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="显示排序" name="sortOrder" initialValue={0}> <Form.Item label={t('permissions.sort')} name="sortOrder" initialValue={0}>
<InputNumber style={{ width: "100%" }} min={0} className="tabular-nums" /> <InputNumber style={{ width: "100%" }} min={0} className="tabular-nums" />
</Form.Item> </Form.Item>
</Col> </Col>
@ -450,18 +455,18 @@ export default function Permissions() {
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="是否在菜单可见" name="isVisible" initialValue={1}> <Form.Item label={t('permissions.isVisible')} 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>
<Col span={12}> <Col span={12}>
<Form.Item label="状态" name="status" initialValue={1}> <Form.Item label={t('common.status')} name="status" initialValue={1}>
<Select options={[{ value: 1, label: "启用" }, { value: 0, label: "禁用" }]} /> <Select options={[{ value: 1, label: "启用" }, { value: 0, label: "禁用" }]} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item label="描述说明" name="description"> <Form.Item label={t('permissions.description')} name="description">
<Input.TextArea rows={2} placeholder="简要描述权限用途…" /> <Input.TextArea rows={2} placeholder="简要描述权限用途…" />
</Form.Item> </Form.Item>
</Form> </Form>
@ -469,6 +474,3 @@ export default function Permissions() {
</div> </div>
); );
} }
// Fixed missing Row/Col imports
import { Row, Col } from "antd";

View File

@ -14,6 +14,7 @@ import {
} from "antd"; } 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 { useTranslation } from "react-i18next";
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 { SearchOutlined, SafetyCertificateOutlined, SaveOutlined, KeyOutlined, ClusterOutlined } from "@ant-design/icons";
import type { SysPermission, SysRole } from "../types"; import type { SysPermission, SysRole } from "../types";
@ -51,20 +52,21 @@ function buildPermissionTree(list: SysPermission[]): PermissionNode[] {
return roots; return roots;
} }
function toTreeData(nodes: PermissionNode[]): DataNode[] { function toTreeData(nodes: PermissionNode[], t: any): DataNode[] {
return nodes.map((node) => ({ return nodes.map((node) => ({
key: node.permId, key: node.permId,
title: ( title: (
<Space> <Space>
<span>{node.name}</span> <span>{node.name}</span>
{node.permType === "button" && <Tag color="blue" size="small" style={{ marginLeft: 4 }}></Tag>} {node.permType === "button" && <Tag color="blue" size="small" style={{ marginLeft: 4 }}>{t('permissions.permType') === '按钮' ? '按钮' : 'Button'}</Tag>}
</Space> </Space>
), ),
children: node.children && node.children.length > 0 ? toTreeData(node.children) : undefined children: node.children && node.children.length > 0 ? toTreeData(node.children, t) : undefined
})); }));
} }
export default function RolePermissionBinding() { export default function RolePermissionBinding() {
const { t } = useTranslation();
const [roles, setRoles] = useState<SysRole[]>([]); const [roles, setRoles] = useState<SysRole[]>([]);
const [permissions, setPermissions] = useState<SysPermission[]>([]); const [permissions, setPermissions] = useState<SysPermission[]>([]);
const [loadingRoles, setLoadingRoles] = useState(false); const [loadingRoles, setLoadingRoles] = useState(false);
@ -100,7 +102,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(t('common.error'));
} finally { } finally {
setLoadingPerms(false); setLoadingPerms(false);
} }
@ -111,7 +113,6 @@ export default function RolePermissionBinding() {
const list = await listRolePermissions(roleId); const list = await listRolePermissions(roleId);
const normalized = (list || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id)); 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 => { const leafIds = normalized.filter(id => {
return !permissions.some(p => p.parentId === id); return !permissions.some(p => p.parentId === id);
}); });
@ -120,7 +121,7 @@ export default function RolePermissionBinding() {
setHalfCheckedIds([]); setHalfCheckedIds([]);
} catch (e) { } catch (e) {
setCheckedPermIds([]); setCheckedPermIds([]);
message.error("加载角色授权数据失败"); message.error(t('common.error'));
} }
}; };
@ -147,21 +148,20 @@ export default function RolePermissionBinding() {
}, [roles, searchText]); }, [roles, searchText]);
const treeData = useMemo(() => buildPermissionTree(permissions), [permissions]); const treeData = useMemo(() => buildPermissionTree(permissions), [permissions]);
const antdTreeData = useMemo(() => toTreeData(treeData), [treeData]); const antdTreeData = useMemo(() => toTreeData(treeData, t), [treeData, t]);
const handleSave = async () => { const handleSave = async () => {
if (!selectedRoleId) { if (!selectedRoleId) {
message.warning("请从左侧列表中选择一个角色"); message.warning(t('rolePerm.selectRole'));
return; return;
} }
setSaving(true); setSaving(true);
try { try {
// Merge checked and half-checked for database persistence
const allPermIds = Array.from(new Set([...checkedPermIds, ...halfCheckedIds])); const allPermIds = Array.from(new Set([...checkedPermIds, ...halfCheckedIds]));
await saveRolePermissions(selectedRoleId, allPermIds); await saveRolePermissions(selectedRoleId, allPermIds);
message.success("功能权限策略已成功更新并下发"); message.success(t('common.success'));
} catch (e) { } catch (e) {
message.error("保存权限配置失败"); message.error(t('common.error'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -171,8 +171,8 @@ export default function RolePermissionBinding() {
<div className="p-6"> <div className="p-6">
<div className="mb-6 flex justify-between items-end"> <div className="mb-6 flex justify-between items-end">
<div> <div>
<Title level={4} className="mb-1"></Title> <Title level={4} className="mb-1">{t('rolePerm.title')}</Title>
<Text type="secondary">访</Text> <Text type="secondary">{t('rolePerm.subtitle')}</Text>
</div> </div>
<Button <Button
type="primary" type="primary"
@ -181,75 +181,77 @@ export default function RolePermissionBinding() {
loading={saving} loading={saving}
disabled={!selectedRoleId} disabled={!selectedRoleId}
> >
{saving ? "正在同步…" : "保存权限策略"} {saving ? t('common.loading') : t('rolePerm.savePolicy')}
</Button> </Button>
</div> </div>
<Row gutter={24}> <Row gutter={24} style={{ height: 'calc(100vh - 180px)' }}>
<Col xs={24} lg={10}> <Col xs={24} lg={10} style={{ height: '100%' }}>
<Card <Card
title={<Space><SafetyCertificateOutlined aria-hidden="true" /><span></span></Space>} title={<Space><SafetyCertificateOutlined aria-hidden="true" /><span>{t('rolePerm.roleList')}</span></Space>}
className="shadow-sm full-height-card" className="shadow-sm full-height-card"
> >
<div className="mb-4"> <div className="mb-4">
<Input <Input
placeholder="搜索角色名称或编码…" placeholder={t('rolePerm.searchRole')}
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />} prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
value={searchText} value={searchText}
onChange={e => setSearchText(e.target.value)} onChange={e => setSearchText(e.target.value)}
allowClear allowClear
aria-label="搜索角色" aria-label={t('rolePerm.searchRole')}
/>
</div>
<div style={{ height: 'calc(100% - 60px)', overflowY: 'auto' }}>
<Table
rowKey="roleId"
size="middle"
loading={loadingRoles}
dataSource={filteredRoles}
rowSelection={{
type: "radio",
selectedRowKeys: selectedRoleId ? [selectedRoleId] : [],
onChange: (keys) => setSelectedRoleId(keys[0] as number),
}}
onRow={(record) => ({
onClick: () => setSelectedRoleId(record.roleId),
className: "cursor-pointer"
})}
pagination={{ pageSize: 10, showTotal: (total) => t('common.total', { total }) }}
columns={[
{
title: t('roles.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: t('common.status'),
dataIndex: "status",
width: 80,
render: (v) => (v === 1 ? <Tag color="green" className="m-0"></Tag> : <Tag className="m-0"></Tag>)
}
]}
/> />
</div> </div>
<Table
rowKey="roleId"
size="middle"
loading={loadingRoles}
dataSource={filteredRoles}
rowSelection={{
type: "radio",
selectedRowKeys: selectedRoleId ? [selectedRoleId] : [],
onChange: (keys) => setSelectedRoleId(keys[0] as number),
}}
onRow={(record) => ({
onClick: () => setSelectedRoleId(record.roleId),
className: "cursor-pointer"
})}
pagination={{ pageSize: 10, showTotal: (total) => `${total} 个角色` }}
columns={[
{
title: "角色信息",
key: "role",
render: (_, r) => (
<div className="min-w-0">
<div style={{ fontWeight: 500 }} className="truncate">{r.roleName}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="truncate tabular-nums">{r.roleCode}</div>
</div>
)
},
{
title: "状态",
dataIndex: "status",
width: 80,
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} style={{ height: '100%' }}>
<Card <Card
title={<Space><KeyOutlined aria-hidden="true" /><span></span></Space>} title={<Space><KeyOutlined aria-hidden="true" /><span>{t('rolePerm.permConfig')}</span></Space>}
className="shadow-sm full-height-card" className="shadow-sm full-height-card"
extra={ extra={
selectedRole && ( selectedRole && (
<Tag color="blue">: {selectedRole.roleName}</Tag> <Tag color="blue">{t('rolePerm.currentRole')}: {selectedRole.roleName}</Tag>
) )
} }
> >
{selectedRoleId ? ( {selectedRoleId ? (
<div className="role-permission-tree-container" style={{ padding: '8px 0', maxHeight: '600px', overflowY: 'auto' }}> <div className="role-permission-tree-container" style={{ padding: '8px 0', height: '100%', overflowY: 'auto' }}>
<Tree <Tree
checkable checkable
selectable={false} selectable={false}
@ -259,7 +261,7 @@ export default function RolePermissionBinding() {
onCheck={(keys, info) => { onCheck={(keys, info) => {
const checked = Array.isArray(keys) ? keys : keys.checked; const checked = Array.isArray(keys) ? keys : keys.checked;
const halfChecked = info.halfCheckedKeys || []; const halfChecked = info.halfCheckedKeys || [];
setCheckedPermIds(checked.map(k => Number(k))); setSelectedPermIds(checked.map(k => Number(k)));
setHalfCheckedIds(halfChecked.map(k => Number(k))); setHalfCheckedIds(halfChecked.map(k => Number(k)));
}} }}
defaultExpandAll defaultExpandAll
@ -271,7 +273,7 @@ export default function RolePermissionBinding() {
) : ( ) : (
<div className="flex flex-col items-center justify-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200"> <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" /> <ClusterOutlined style={{ fontSize: 40, color: '#bfbfbf', marginBottom: 16 }} aria-hidden="true" />
<Text type="secondary"></Text> <Text type="secondary">{t('rolePerm.selectRole')}</Text>
</div> </div>
)} )}
</Card> </Card>

View File

@ -14,10 +14,12 @@ import {
Row, Row,
Col, Col,
Tabs, Tabs,
Empty Empty,
Select
} from "antd"; } 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 { useTranslation } from "react-i18next";
import { import {
createRole, createRole,
listPermissions, listPermissions,
@ -77,21 +79,22 @@ const buildPermissionTree = (list: SysPermission[]): PermissionNode[] => {
return roots; return roots;
}; };
const toTreeData = (nodes: PermissionNode[]): DataNode[] => const toTreeData = (nodes: PermissionNode[], t: any): DataNode[] =>
nodes.map((node) => ({ nodes.map((node) => ({
key: node.permId, key: node.permId,
title: ( title: (
<span className="role-permission-node"> <span className="role-permission-node">
<span>{node.name}</span> <span>{node.name}</span>
{node.permType === "button" && <Tag color="blue" style={{ marginLeft: 8 }}></Tag>} {node.permType === "button" && <Tag color="blue" style={{ marginLeft: 8 }}>{t('permissions.permType') === '按钮' ? '按钮' : 'Button'}</Tag>}
</span> </span>
), ),
children: node.children && node.children.length > 0 ? toTreeData(node.children) : undefined children: node.children && node.children.length > 0 ? toTreeData(node.children, t) : undefined
})); }));
const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`; const generateRoleCode = () => `ROLE_${Date.now().toString(36).toUpperCase()}`;
export default function Roles() { export default function Roles() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [data, setData] = useState<SysRole[]>([]); const [data, setData] = useState<SysRole[]>([]);
@ -115,8 +118,8 @@ export default function Roles() {
const { can } = usePermission(); const { can } = usePermission();
const permissionTreeData = useMemo( const permissionTreeData = useMemo(
() => toTreeData(buildPermissionTree(permissions)), () => toTreeData(buildPermissionTree(permissions), t),
[permissions] [permissions, t]
); );
const loadPermissions = async () => { const loadPermissions = async () => {
@ -165,7 +168,7 @@ export default function Roles() {
const users = await fetchUsersByRoleId(role.roleId); const users = await fetchUsersByRoleId(role.roleId);
setRoleUsers(users || []); setRoleUsers(users || []);
} catch (e) { } catch (e) {
message.error("加载角色详情失败"); message.error(t('common.error'));
} finally { } finally {
setLoadingUsers(false); setLoadingUsers(false);
} }
@ -178,8 +181,6 @@ export default function Roles() {
// Reload role detail if permissions list loaded later // Reload role detail if permissions list loaded later
useEffect(() => { useEffect(() => {
if (selectedRole && permissions.length > 0) { if (selectedRole && permissions.length > 0) {
// We don't want to infinite loop, but we need to ensure leafIds are correct
// after permissions are loaded.
const leafIds = selectedPermIds.filter(id => { const leafIds = selectedPermIds.filter(id => {
return !permissions.some(p => p.parentId === id); return !permissions.some(p => p.parentId === id);
}); });
@ -216,11 +217,11 @@ export default function Roles() {
e.stopPropagation(); e.stopPropagation();
try { try {
await deleteRole(id); await deleteRole(id);
message.success("角色已删除"); message.success(t('common.success'));
if (selectedRole?.roleId === id) setSelectedRole(null); if (selectedRole?.roleId === id) setSelectedRole(null);
loadRoles(); loadRoles();
} catch (e) { } catch (e) {
message.error("删除失败"); message.error(t('common.error'));
} }
}; };
@ -237,10 +238,10 @@ export default function Roles() {
if (editing) { if (editing) {
await updateRole(editing.roleId, payload); await updateRole(editing.roleId, payload);
message.success("角色已更新"); message.success(t('common.success'));
} else { } else {
await createRole(payload); await createRole(payload);
message.success("角色已创建"); message.success(t('common.success'));
} }
setDrawerOpen(false); setDrawerOpen(false);
@ -258,52 +259,51 @@ export default function Roles() {
try { try {
const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds])); const allPermIds = Array.from(new Set([...selectedPermIds, ...halfCheckedIds]));
await saveRolePermissions(selectedRole.roleId, allPermIds); await saveRolePermissions(selectedRole.roleId, allPermIds);
message.success("权限已保存并生效"); message.success(t('common.success'));
} catch (e) { } catch (e) {
message.error("保存权限失败"); message.error(t('common.error'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
return ( return (
<div className="roles-page-v2"> <div className="roles-page-v2 p-6">
<Row gutter={24} style={{ height: 'calc(100vh - 120px)' }}> <Row gutter={24} style={{ height: 'calc(100vh - 180px)' }}>
{/* Left: Role List */} {/* Left: Role List */}
<Col span={8} style={{ height: '100%' }}> <Col span={8} style={{ height: '100%' }}>
<Card <Card
title="系统角色" title={t('roles.title')}
className="full-height-card" className="full-height-card shadow-sm"
extra={can("sys_role:create") && ( extra={can("sys_role:create") && (
<Button <Button
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined aria-hidden="true" />} icon={<PlusOutlined aria-hidden="true" />}
onClick={openCreate} onClick={openCreate}
aria-label="新增角色"
> >
{t('common.create')}
</Button> </Button>
)} )}
> >
<div className="mb-4"> <div className="mb-4">
<Input <Input
placeholder="搜索角色…" placeholder={t('roles.searchPlaceholder')}
prefix={<SearchOutlined aria-hidden="true" />} 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="搜索角色" aria-label={t('roles.searchPlaceholder')}
/> />
</div> </div>
<div className="role-list-container"> <div className="role-list-container" style={{ height: 'calc(100% - 60px)', overflowY: 'auto' }}>
<Table <Table
rowKey="roleId" rowKey="roleId"
showHeader={false} showHeader={false}
dataSource={filteredData} dataSource={filteredData}
loading={loading} loading={loading}
pagination={false} pagination={false}
locale={{ emptyText: <Empty description="暂无角色数据" /> }} locale={{ emptyText: <Empty description={t('roles.selectRole')} /> }}
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' : ''}`
@ -312,19 +312,18 @@ export default function Roles() {
{ {
title: '角色', title: '角色',
render: (_, record) => ( render: (_, record) => (
<div className="role-item-content"> <div className="role-item-content flex justify-between items-center p-2">
<div className="role-item-main min-w-0"> <div className="role-item-main min-w-0">
<div className="role-item-name truncate">{record.roleName}</div> <div className="role-item-name font-medium truncate">{record.roleName}</div>
<div className="role-item-code truncate">{record.roleCode}</div> <div className="role-item-code text-xs text-gray-400 truncate">{record.roleCode}</div>
</div> </div>
<div className="role-item-actions"> <div className="role-item-actions flex gap-1">
{can("sys_role:update") && ( {can("sys_role:update") && (
<Button <Button
type="text" type="text"
size="small" size="small"
icon={<EditOutlined aria-hidden="true" />} icon={<EditOutlined aria-hidden="true" />}
onClick={e => openEditBasic(e, record)} onClick={e => openEditBasic(e, record)}
aria-label={`编辑 ${record.roleName} 基础信息`}
/> />
)} )}
{can("sys_role:delete") && record.roleCode !== 'ADMIN' && ( {can("sys_role:delete") && record.roleCode !== 'ADMIN' && (
@ -338,7 +337,6 @@ export default function Roles() {
danger danger
icon={<DeleteOutlined aria-hidden="true" />} icon={<DeleteOutlined aria-hidden="true" />}
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
aria-label={`删除 ${record.roleName} 角色`}
/> />
</Popconfirm> </Popconfirm>
)} )}
@ -356,7 +354,7 @@ export default function Roles() {
<Col span={16} style={{ height: '100%' }}> <Col span={16} style={{ height: '100%' }}>
{selectedRole ? ( {selectedRole ? (
<Card <Card
className="full-height-card" className="full-height-card shadow-sm"
title={ title={
<Space> <Space>
<SafetyCertificateOutlined style={{ color: '#1890ff' }} aria-hidden="true" /> <SafetyCertificateOutlined style={{ color: '#1890ff' }} aria-hidden="true" />
@ -372,16 +370,16 @@ export default function Roles() {
onClick={savePermissions} onClick={savePermissions}
disabled={!can("sys_role:permission:save")} disabled={!can("sys_role:permission:save")}
> >
{saving ? "保存中…" : "保存权限更改"} {t('roles.savePerms')}
</Button> </Button>
} }
> >
<Tabs defaultActiveKey="permissions" className="role-tabs"> <Tabs defaultActiveKey="permissions" className="role-tabs">
<Tabs.TabPane <Tabs.TabPane
tab={<Space><KeyOutlined aria-hidden="true" /></Space>} tab={<Space><KeyOutlined aria-hidden="true" />{t('roles.funcPerms')}</Space>}
key="permissions" key="permissions"
> >
<div className="role-permission-tree-v2"> <div className="role-permission-tree-v2" style={{ maxHeight: 'calc(100vh - 400px)', overflowY: 'auto' }}>
<Tree <Tree
checkable checkable
selectable={false} selectable={false}
@ -399,7 +397,7 @@ export default function Roles() {
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane <Tabs.TabPane
tab={<Space><UserOutlined aria-hidden="true" /> ({roleUsers.length})</Space>} tab={<Space><UserOutlined aria-hidden="true" />{t('roles.assignedUsers')} ({roleUsers.length})</Space>}
key="users" key="users"
> >
<Table <Table
@ -407,10 +405,10 @@ export default function Roles() {
size="small" size="small"
loading={loadingUsers} loading={loadingUsers}
dataSource={roleUsers} dataSource={roleUsers}
pagination={{ pageSize: 10, showTotal: (total) => `${total}` }} pagination={{ pageSize: 10, showTotal: (total) => t('common.total', { total }) }}
columns={[ columns={[
{ {
title: '用户', title: t('users.userInfo'),
render: (_, r) => ( render: (_, r) => (
<Space> <Space>
<UserOutlined aria-hidden="true" /> <UserOutlined aria-hidden="true" />
@ -421,10 +419,10 @@ export default function Roles() {
</Space> </Space>
) )
}, },
{ title: '手机号', dataIndex: 'phone', className: 'tabular-nums' }, { title: t('users.phone'), dataIndex: 'phone', className: 'tabular-nums' },
{ title: '邮箱', dataIndex: 'email' }, { title: t('users.email'), dataIndex: 'email' },
{ {
title: '状态', title: t('common.status'),
dataIndex: 'status', dataIndex: 'status',
width: 80, width: 80,
render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag> render: s => <Tag color={s === 1 ? 'green' : 'red'}>{s === 1 ? '正常' : '禁用'}</Tag>
@ -435,8 +433,8 @@ export default function Roles() {
</Tabs> </Tabs>
</Card> </Card>
) : ( ) : (
<Card className="full-height-card flex-center"> <Card className="full-height-card flex items-center justify-center shadow-sm">
<Empty description="请从左侧列表选择一个角色进行管理" /> <Empty description={t('roles.selectRole')} />
</Card> </Card>
)} )}
</Col> </Col>
@ -444,29 +442,29 @@ export default function Roles() {
{/* Basic Info Drawer */} {/* Basic Info Drawer */}
<Drawer <Drawer
title={editing ? "修改角色基础信息" : "新增系统角色"} title={editing ? t('roles.drawerTitleEdit') : t('roles.drawerTitleCreate')}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
width={400} width={400}
destroyOnClose destroyOnClose
footer={ footer={
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2 p-2">
<Button onClick={() => setDrawerOpen(false)}></Button> <Button onClick={() => setDrawerOpen(false)}>{t('common.cancel')}</Button>
<Button type="primary" loading={saving} onClick={submitBasic}></Button> <Button type="primary" loading={saving} onClick={submitBasic}>{t('common.confirm')}</Button>
</div> </div>
} }
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item label="角色名称" name="roleName" rules={[{ required: true }]}> <Form.Item label={t('roles.roleName')} name="roleName" rules={[{ required: true }]}>
<Input placeholder="输入名称" /> <Input placeholder={t('roles.roleName')} />
</Form.Item> </Form.Item>
<Form.Item label="角色编码" name="roleCode" rules={[{ required: true }]}> <Form.Item label={t('roles.roleCode')} name="roleCode" rules={[{ required: true }]}>
<Input placeholder="输入唯一编码" disabled={!!editing} /> <Input placeholder={t('roles.roleCode')} disabled={!!editing} />
</Form.Item> </Form.Item>
<Form.Item label="状态" name="status" initialValue={1}> <Form.Item label={t('common.status')} name="status" initialValue={1}>
<Select options={[{label: '启用', value: 1}, {label: '禁用', value: 0}]} /> <Select options={[{label: '启用', value: 1}, {label: '禁用', value: 0}]} />
</Form.Item> </Form.Item>
<Form.Item label="备注" name="remark"> <Form.Item label={t('common.remark')} name="remark">
<Input.TextArea rows={3} /> <Input.TextArea rows={3} />
</Form.Item> </Form.Item>
</Form> </Form>
@ -474,5 +472,3 @@ export default function Roles() {
</div> </div>
); );
} }
import { Select } from "antd";

View File

@ -15,7 +15,8 @@ import {
Col, Col,
Select Select
} from "antd"; } from "antd";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { createTenant, deleteTenant, listTenants, updateTenant } from "../api"; import { createTenant, deleteTenant, listTenants, updateTenant } from "../api";
import { usePermission } from "../hooks/usePermission"; import { usePermission } from "../hooks/usePermission";
import { import {
@ -35,6 +36,7 @@ import dayjs from "dayjs";
const { Title, Text } = Typography; const { Title, Text } = Typography;
export default function Tenants() { export default function Tenants() {
const { t } = useTranslation();
const { can } = usePermission(); const { can } = usePermission();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -58,7 +60,7 @@ export default function Tenants() {
setData(result.records || []); setData(result.records || []);
setTotal(result.total || 0); setTotal(result.total || 0);
} catch (e) { } catch (e) {
message.error("加载租户列表失败"); message.error(t('common.error'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -103,10 +105,10 @@ export default function Tenants() {
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
await deleteTenant(id); await deleteTenant(id);
message.success("租户已删除"); message.success(t('common.success'));
loadData(); loadData();
} catch (e) { } catch (e) {
message.error("删除失败"); message.error(t('common.error'));
} }
}; };
@ -121,10 +123,10 @@ export default function Tenants() {
if (editing) { if (editing) {
await updateTenant(editing.id, payload); await updateTenant(editing.id, payload);
message.success("租户信息已更新"); message.success(t('common.success'));
} else { } else {
await createTenant(payload); await createTenant(payload);
message.success("租户已成功创建"); message.success(t('common.success'));
} }
setDrawerOpen(false); setDrawerOpen(false);
loadData(); loadData();
@ -137,7 +139,7 @@ export default function Tenants() {
const columns = [ const columns = [
{ {
title: "租户信息", title: t('tenants.tenantInfo'),
key: "tenant", key: "tenant",
render: (_: any, record: SysTenant) => ( render: (_: any, record: SysTenant) => (
<Space> <Space>
@ -152,7 +154,7 @@ export default function Tenants() {
), ),
}, },
{ {
title: "联系人", title: t('tenants.contact'),
key: "contact", key: "contact",
render: (_: any, record: SysTenant) => ( render: (_: any, record: SysTenant) => (
<div> <div>
@ -162,7 +164,7 @@ export default function Tenants() {
) )
}, },
{ {
title: "状态", title: t('common.status'),
dataIndex: "status", dataIndex: "status",
width: 100, width: 100,
render: (status: number) => ( render: (status: number) => (
@ -172,18 +174,18 @@ export default function Tenants() {
), ),
}, },
{ {
title: "过期时间", title: t('tenants.expireTime'),
dataIndex: "expireTime", dataIndex: "expireTime",
width: 180, width: 180,
render: (text: string) => ( render: (text: string) => (
<Space> <Space>
<CalendarOutlined style={{ color: '#8c8c8c' }} /> <CalendarOutlined style={{ color: '#8c8c8c' }} />
<Text className="tabular-nums">{text ? text.substring(0, 10) : "永久有效"}</Text> <Text className="tabular-nums">{text ? text.substring(0, 10) : t('tenants.forever')}</Text>
</Space> </Space>
) )
}, },
{ {
title: "操作", title: t('common.action'),
key: "action", key: "action",
width: 120, width: 120,
fixed: "right" as const, fixed: "right" as const,
@ -194,12 +196,12 @@ export default function Tenants() {
type="text" type="text"
icon={<EditOutlined aria-hidden="true" />} icon={<EditOutlined aria-hidden="true" />}
onClick={() => openEdit(record)} onClick={() => openEdit(record)}
aria-label={`编辑租户 ${record.tenantName}`} aria-label={t('common.edit')}
/> />
)} )}
{can("sys_tenant:delete") && ( {can("sys_tenant:delete") && (
<Popconfirm title={`确定删除租户 "${record.tenantName}" 吗?`} onConfirm={() => handleDelete(record.id)}> <Popconfirm title={`确定删除租户 "${record.tenantName}" 吗?`} onConfirm={() => handleDelete(record.id)}>
<Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={`删除租户 ${record.tenantName}`} /> <Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={t('common.delete')} />
</Popconfirm> </Popconfirm>
)} )}
</Space> </Space>
@ -211,12 +213,12 @@ export default function Tenants() {
<div className="p-6"> <div className="p-6">
<div className="mb-6 flex justify-between items-end"> <div className="mb-6 flex justify-between items-end">
<div> <div>
<Title level={4} className="mb-1"></Title> <Title level={4} className="mb-1">{t('tenants.title')}</Title>
<Text type="secondary"></Text> <Text type="secondary">{t('tenants.subtitle')}</Text>
</div> </div>
{can("sys_tenant:create") && ( {can("sys_tenant:create") && (
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
{t('tenants.drawerTitleCreate')}
</Button> </Button>
)} )}
</div> </div>
@ -224,7 +226,7 @@ export default function Tenants() {
<Card className="shadow-sm mb-4"> <Card className="shadow-sm mb-4">
<Space wrap size="middle"> <Space wrap size="middle">
<Input <Input
placeholder="租户名称…" placeholder={t('tenants.tenantName')}
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />} prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
style={{ width: 200 }} style={{ width: 200 }}
value={params.name} value={params.name}
@ -232,14 +234,14 @@ export default function Tenants() {
allowClear allowClear
/> />
<Input <Input
placeholder="租户编码…" placeholder={t('tenants.tenantCode')}
style={{ width: 180 }} style={{ width: 180 }}
value={params.code} value={params.code}
onChange={e => setParams({ ...params, code: e.target.value })} onChange={e => setParams({ ...params, code: e.target.value })}
allowClear allowClear
/> />
<Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}></Button> <Button type="primary" icon={<SearchOutlined aria-hidden="true" />} onClick={handleSearch}>{t('common.search')}</Button>
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={handleReset}></Button> <Button icon={<ReloadOutlined aria-hidden="true" />} onClick={handleReset}>{t('common.reset')}</Button>
</Space> </Space>
</Card> </Card>
@ -249,13 +251,14 @@ export default function Tenants() {
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) => t('common.total', { total })
}} }}
/> />
</Card> </Card>
@ -264,7 +267,7 @@ export default function Tenants() {
title={ title={
<Space> <Space>
<ShopOutlined aria-hidden="true" /> <ShopOutlined aria-hidden="true" />
<span>{editing ? "编辑租户信息" : "创建新租户"}</span> <span>{editing ? t('tenants.drawerTitleEdit') : t('tenants.drawerTitleCreate')}</span>
</Space> </Space>
} }
open={drawerOpen} open={drawerOpen}
@ -273,20 +276,20 @@ export default function Tenants() {
destroyOnClose destroyOnClose
footer={ footer={
<div className="flex justify-end gap-2 p-2"> <div className="flex justify-end gap-2 p-2">
<Button onClick={() => setDrawerOpen(false)}></Button> <Button onClick={() => setDrawerOpen(false)}>{t('common.cancel')}</Button>
<Button type="primary" loading={saving} onClick={submit}></Button> <Button type="primary" loading={saving} onClick={submit}>{t('common.confirm')}</Button>
</div> </div>
} }
> >
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="租户名称" name="tenantName" rules={[{ required: true, message: "请输入租户名称" }]}> <Form.Item label={t('tenants.tenantName')} name="tenantName" rules={[{ required: true, message: t('tenants.tenantName') }]}>
<Input placeholder="例如:云合智慧" /> <Input placeholder="例如:云合智慧" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="租户编码" name="tenantCode" rules={[{ required: true, message: "请输入租户编码" }]}> <Form.Item label={t('tenants.tenantCode')} name="tenantCode" rules={[{ required: true, message: t('tenants.tenantCode') }]}>
<Input placeholder="例如UNIS" disabled={!!editing} className="tabular-nums" /> <Input placeholder="例如UNIS" disabled={!!editing} className="tabular-nums" />
</Form.Item> </Form.Item>
</Col> </Col>
@ -294,26 +297,26 @@ export default function Tenants() {
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="联系人姓名" name="contactName"> <Form.Item label={t('tenants.contactName')} name="contactName">
<Input placeholder="姓名" /> <Input placeholder="姓名" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="联系电话" name="contactPhone"> <Form.Item label={t('tenants.contactPhone')} name="contactPhone">
<Input placeholder="手机或座机" className="tabular-nums" /> <Input placeholder="手机或座机" className="tabular-nums" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item label="过期时间" name="expireTime"> <Form.Item label={t('tenants.expireTime')} name="expireTime">
<DatePicker style={{ width: "100%" }} placeholder="留空为永久有效" /> <DatePicker style={{ width: "100%" }} placeholder={t('tenants.forever')} />
</Form.Item> </Form.Item>
<Form.Item label="租户状态" name="status" initialValue={1}> <Form.Item label={t('common.status')} name="status" initialValue={1}>
<Select options={[{ label: "正常启用", value: 1 }, { label: "禁止访问", value: 0 }]} /> <Select options={[{ label: "正常启用", value: 1 }, { label: "禁止访问", value: 0 }]} />
</Form.Item> </Form.Item>
<Form.Item label="备注说明" name="remark"> <Form.Item label={t('common.remark')} name="remark">
<Input.TextArea rows={3} placeholder="选填,租户详细背景说明…" /> <Input.TextArea rows={3} placeholder="选填,租户详细背景说明…" />
</Form.Item> </Form.Item>
</Form> </Form>

View File

@ -13,6 +13,7 @@ import {
Empty Empty
} from "antd"; } from "antd";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "../api"; import { listRoles, listUserRoles, listUsers, saveUserRoles } from "../api";
import { SearchOutlined, UserOutlined, SaveOutlined, TeamOutlined } from "@ant-design/icons"; import { SearchOutlined, UserOutlined, SaveOutlined, TeamOutlined } from "@ant-design/icons";
import type { SysRole, SysUser } from "../types"; import type { SysRole, SysUser } from "../types";
@ -20,6 +21,7 @@ import type { SysRole, SysUser } from "../types";
const { Title, Text } = Typography; const { Title, Text } = Typography;
export default function UserRoleBinding() { export default function UserRoleBinding() {
const { t } = useTranslation();
const [users, setUsers] = useState<SysUser[]>([]); const [users, setUsers] = useState<SysUser[]>([]);
const [roles, setRoles] = useState<SysRole[]>([]); const [roles, setRoles] = useState<SysRole[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false); const [loadingUsers, setLoadingUsers] = useState(false);
@ -62,7 +64,7 @@ export default function UserRoleBinding() {
setCheckedRoleIds(list || []); setCheckedRoleIds(list || []);
} catch (e) { } catch (e) {
setCheckedRoleIds([]); setCheckedRoleIds([]);
message.error("加载用户角色数据失败"); message.error(t('common.error'));
} }
}; };
@ -90,15 +92,15 @@ export default function UserRoleBinding() {
const handleSave = async () => { const handleSave = async () => {
if (!selectedUserId) { if (!selectedUserId) {
message.warning("请先在左侧列表中选择一个用户"); message.warning(t('userRole.selectUser'));
return; return;
} }
setSaving(true); setSaving(true);
try { try {
await saveUserRoles(selectedUserId, checkedRoleIds); await saveUserRoles(selectedUserId, checkedRoleIds);
message.success("角色权限已成功授予用户"); message.success(t('common.success'));
} catch (e) { } catch (e) {
message.error("更新绑定关系失败"); message.error(t('common.error'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -108,8 +110,8 @@ export default function UserRoleBinding() {
<div className="p-6"> <div className="p-6">
<div className="mb-6 flex justify-between items-end"> <div className="mb-6 flex justify-between items-end">
<div> <div>
<Title level={4} className="mb-1"></Title> <Title level={4} className="mb-1">{t('userRole.title')}</Title>
<Text type="secondary">访</Text> <Text type="secondary">{t('userRole.subtitle')}</Text>
</div> </div>
<Button <Button
type="primary" type="primary"
@ -118,75 +120,77 @@ export default function UserRoleBinding() {
loading={saving} loading={saving}
disabled={!selectedUserId} disabled={!selectedUserId}
> >
{saving ? "保存中…" : "保存权限更改"} {saving ? t('common.loading') : t('common.save')}
</Button> </Button>
</div> </div>
<Row gutter={24}> <Row gutter={24} style={{ height: 'calc(100vh - 180px)' }}>
<Col xs={24} lg={12}> <Col xs={24} lg={12} style={{ height: '100%' }}>
<Card <Card
title={<Space><UserOutlined aria-hidden="true" /><span></span></Space>} title={<Space><UserOutlined aria-hidden="true" /><span>{t('userRole.userList')}</span></Space>}
className="shadow-sm full-height-card" className="shadow-sm full-height-card"
> >
<div className="mb-4"> <div className="mb-4">
<Input <Input
placeholder="搜索用户名或显示名…" placeholder={t('userRole.searchUser')}
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />} prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
value={searchText} value={searchText}
onChange={e => setSearchText(e.target.value)} onChange={e => setSearchText(e.target.value)}
allowClear allowClear
aria-label="搜索用户" aria-label={t('userRole.searchUser')}
/>
</div>
<div style={{ height: 'calc(100% - 60px)', overflowY: 'auto' }}>
<Table
rowKey="userId"
size="middle"
loading={loadingUsers}
dataSource={filteredUsers}
rowSelection={{
type: "radio",
selectedRowKeys: selectedUserId ? [selectedUserId] : [],
onChange: (keys) => setSelectedUserId(keys[0] as number),
}}
onRow={(record) => ({
onClick: () => setSelectedUserId(record.userId),
className: "cursor-pointer"
})}
pagination={{ pageSize: 10, showTotal: (total) => t('common.total', { total }) }}
columns={[
{
title: t('users.userInfo'),
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: t('common.status'),
dataIndex: "status",
width: 80,
render: (v) => (v === 1 ? <Tag color="green" className="m-0"></Tag> : <Tag className="m-0"></Tag>)
}
]}
/> />
</div> </div>
<Table
rowKey="userId"
size="middle"
loading={loadingUsers}
dataSource={filteredUsers}
rowSelection={{
type: "radio",
selectedRowKeys: selectedUserId ? [selectedUserId] : [],
onChange: (keys) => setSelectedUserId(keys[0] as number),
}}
onRow={(record) => ({
onClick: () => setSelectedUserId(record.userId),
className: "cursor-pointer"
})}
pagination={{ pageSize: 10, showTotal: (total) => `${total}` }}
columns={[
{
title: "用户信息",
key: "user",
render: (_, r) => (
<div className="min-w-0">
<div style={{ fontWeight: 500 }} className="truncate">{r.displayName}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }} className="truncate">@{r.username}</div>
</div>
)
},
{
title: "状态",
dataIndex: "status",
width: 80,
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} style={{ height: '100%' }}>
<Card <Card
title={<Space><TeamOutlined aria-hidden="true" /><span></span></Space>} title={<Space><TeamOutlined aria-hidden="true" /><span>{t('userRole.grantRoles')}</span></Space>}
className="shadow-sm full-height-card" className="shadow-sm full-height-card"
extra={ extra={
selectedUser && ( selectedUser && (
<Tag color="blue">: {selectedUser.displayName}</Tag> <Tag color="blue">{t('userRole.editing')}: {selectedUser.displayName}</Tag>
) )
} }
> >
{selectedUserId ? ( {selectedUserId ? (
<div style={{ padding: '8px 0' }}> <div style={{ padding: '8px 0', height: '100%', overflowY: 'auto' }}>
<Checkbox.Group <Checkbox.Group
style={{ width: "100%" }} style={{ width: "100%" }}
value={checkedRoleIds} value={checkedRoleIds}
@ -215,7 +219,7 @@ export default function UserRoleBinding() {
) : ( ) : (
<div className="flex flex-col items-center justify-center py-20 bg-gray-50 rounded-lg border border-dashed border-gray-200"> <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" /> <UserOutlined style={{ fontSize: 40, color: '#bfbfbf', marginBottom: 16 }} aria-hidden="true" />
<Text type="secondary"></Text> <Text type="secondary">{t('userRole.selectUser')}</Text>
</div> </div>
)} )}
</Card> </Card>

View File

@ -17,6 +17,7 @@ import {
TreeSelect TreeSelect
} from "antd"; } from "antd";
import { useEffect, useState, useMemo } from "react"; import { useEffect, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { import {
createUser, createUser,
deleteUser, deleteUser,
@ -39,7 +40,7 @@ import {
ApartmentOutlined, ApartmentOutlined,
ReloadOutlined ReloadOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import type { SysRole, SysUser, SysTenant, SysOrg, OrgNode } from "../types"; import type { SysRole, SysUser, SysTenant, SysOrg } from "../types";
import "./Users.css"; import "./Users.css";
const { Title, Text } = Typography; const { Title, Text } = Typography;
@ -65,6 +66,7 @@ function buildOrgTree(list: SysOrg[]): any[] {
} }
export default function Users() { export default function Users() {
const { t } = useTranslation();
const { can } = usePermission(); const { can } = usePermission();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -93,7 +95,7 @@ export default function Users() {
setRoles(rolesList || []); setRoles(rolesList || []);
setTenants(tenantsResp.records || []); setTenants(tenantsResp.records || []);
} catch (e) { } catch (e) {
message.error("加载基础数据失败"); message.error(t('common.error'));
} }
}; };
@ -169,17 +171,17 @@ export default function Users() {
}); });
setDrawerOpen(true); setDrawerOpen(true);
} catch (e) { } catch (e) {
message.error("获取用户信息详情失败"); message.error(t('common.error'));
} }
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
try { try {
await deleteUser(id); await deleteUser(id);
message.success("用户已移除"); message.success(t('common.success'));
loadUsersData(); loadUsersData();
} catch (e) { } catch (e) {
message.error("操作失败"); message.error(t('common.error'));
} }
}; };
@ -217,7 +219,7 @@ export default function Users() {
await saveUserRoles(userId, values.roleIds || []); await saveUserRoles(userId, values.roleIds || []);
} }
message.success(editing ? "更新成功" : "创建成功"); message.success(t('common.success'));
setDrawerOpen(false); setDrawerOpen(false);
loadUsersData(); loadUsersData();
} catch (e) { } catch (e) {
@ -229,7 +231,7 @@ export default function Users() {
const columns = [ const columns = [
{ {
title: "用户信息", title: t('users.userInfo'),
key: "user", key: "user",
render: (_: any, record: SysUser) => ( render: (_: any, record: SysUser) => (
<Space> <Space>
@ -239,7 +241,7 @@ export default function Users() {
<div> <div>
<Space size={4}> <Space size={4}>
<div className="user-display-name">{record.displayName}</div> <div className="user-display-name">{record.displayName}</div>
{record.isPlatformAdmin && <Tag color="gold" size="small" style={{ fontSize: 10 }}></Tag>} {record.isPlatformAdmin && <Tag color="gold" size="small" style={{ fontSize: 10 }}>{t('users.platformAdmin')}</Tag>}
</Space> </Space>
<div className="user-username tabular-nums">@{record.username}</div> <div className="user-username tabular-nums">@{record.username}</div>
</div> </div>
@ -247,7 +249,7 @@ export default function Users() {
), ),
}, },
{ {
title: "所属租户/组织", title: t('users.org'),
key: "org", key: "org",
render: (_: any, record: SysUser) => ( render: (_: any, record: SysUser) => (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@ -265,7 +267,7 @@ export default function Users() {
) )
}, },
{ {
title: "状态", title: t('common.status'),
dataIndex: "status", dataIndex: "status",
width: 80, width: 80,
render: (status: number) => ( render: (status: number) => (
@ -275,7 +277,7 @@ export default function Users() {
), ),
}, },
{ {
title: "操作", title: t('common.action'),
key: "action", key: "action",
width: 100, width: 100,
fixed: "right" as const, fixed: "right" as const,
@ -286,12 +288,12 @@ export default function Users() {
type="text" type="text"
icon={<EditOutlined aria-hidden="true" />} icon={<EditOutlined aria-hidden="true" />}
onClick={() => openEdit(record)} onClick={() => openEdit(record)}
aria-label={`编辑用户 ${record.displayName}`} aria-label={t('common.edit')}
/> />
)} )}
{can("sys_user:delete") && record.userId !== 1 && ( {can("sys_user:delete") && record.userId !== 1 && (
<Popconfirm title="确定注销该用户吗?" onConfirm={() => handleDelete(record.userId)}> <Popconfirm title="确定注销该用户吗?" onConfirm={() => handleDelete(record.userId)}>
<Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={`删除用户 ${record.displayName}`} /> <Button type="text" danger icon={<DeleteOutlined aria-hidden="true" />} aria-label={t('common.delete')} />
</Popconfirm> </Popconfirm>
)} )}
</Space> </Space>
@ -300,24 +302,24 @@ export default function Users() {
]; ];
return ( return (
<div className="users-page"> <div className="users-page p-6">
<div className="users-header"> <div className="users-header flex justify-between items-end mb-6">
<div> <div>
<Title level={4} className="users-title"></Title> <Title level={4} className="mb-1">{t('users.title')}</Title>
<Text type="secondary"></Text> <Text type="secondary">{t('users.subtitle')}</Text>
</div> </div>
{can("sys_user:create") && ( {can("sys_user:create") && (
<Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined aria-hidden="true" />} onClick={openCreate}>
{t('users.drawerTitleCreate')}
</Button> </Button>
)} )}
</div> </div>
<Card className="users-table-card shadow-sm"> <Card className="users-table-card shadow-sm">
<div className="users-table-toolbar"> <div className="users-table-toolbar mb-4">
<Space size="middle"> <Space size="middle" wrap>
<Select <Select
placeholder="按租户筛选…" placeholder={t('users.tenantFilter')}
style={{ width: 200 }} style={{ width: 200 }}
allowClear allowClear
value={filterTenantId} value={filterTenantId}
@ -326,15 +328,16 @@ export default function Users() {
suffixIcon={<ShopOutlined aria-hidden="true" />} suffixIcon={<ShopOutlined aria-hidden="true" />}
/> />
<Input <Input
placeholder="搜索用户名、姓名或邮箱…" placeholder={t('users.searchPlaceholder')}
prefix={<SearchOutlined aria-hidden="true" />} prefix={<SearchOutlined aria-hidden="true" />}
className="users-search-input" className="users-search-input"
style={{ width: 300 }}
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
allowClear allowClear
aria-label="搜索用户" aria-label={t('common.search')}
/> />
<Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadUsersData}></Button> <Button icon={<ReloadOutlined aria-hidden="true" />} onClick={loadUsersData}>{t('common.refresh')}</Button>
</Space> </Space>
</div> </div>
@ -345,7 +348,7 @@ export default function Users() {
loading={loading} loading={loading}
size="middle" size="middle"
pagination={{ pagination={{
showTotal: (total) => `${total} 条数据`, showTotal: (total) => t('common.total', { total }),
pageSize: 10, pageSize: 10,
}} }}
/> />
@ -355,7 +358,7 @@ export default function Users() {
title={ title={
<div className="user-drawer-title"> <div className="user-drawer-title">
<UserOutlined className="mr-2" aria-hidden="true" /> <UserOutlined className="mr-2" aria-hidden="true" />
{editing ? "修改用户信息" : "创建系统用户"} {editing ? t('users.drawerTitleEdit') : t('users.drawerTitleCreate')}
</div> </div>
} }
open={drawerOpen} open={drawerOpen}
@ -364,9 +367,9 @@ export default function Users() {
destroyOnClose destroyOnClose
footer={ footer={
<div className="flex justify-end gap-2 p-2"> <div className="flex justify-end gap-2 p-2">
<Button onClick={() => setDrawerOpen(false)}></Button> <Button onClick={() => setDrawerOpen(false)}>{t('common.cancel')}</Button>
<Button type="primary" loading={saving} onClick={submit}> <Button type="primary" loading={saving} onClick={submit}>
{t('common.save')}
</Button> </Button>
</div> </div>
} }
@ -374,9 +377,9 @@ export default function Users() {
<Form form={form} layout="vertical" className="user-form"> <Form form={form} layout="vertical" className="user-form">
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="所属租户" name="tenantId" rules={[{ required: true, message: "请选择所属租户" }]}> <Form.Item label={t('users.tenant')} name="tenantId" rules={[{ required: true, message: t('users.tenant') }]}>
<Select <Select
placeholder="选择租户" placeholder={t('users.tenant')}
showSearch showSearch
optionFilterProp="label" optionFilterProp="label"
options={tenants.map(t => ({ label: t.tenantName, value: t.id }))} options={tenants.map(t => ({ label: t.tenantName, value: t.id }))}
@ -384,9 +387,9 @@ export default function Users() {
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="所属组织" name="orgId"> <Form.Item label={t('users.orgNode')} name="orgId">
<TreeSelect <TreeSelect
placeholder="请选择组织节点" placeholder={t('users.orgNode')}
allowClear allowClear
treeData={orgTreeData} treeData={orgTreeData}
disabled={!selectedTenantId} disabled={!selectedTenantId}
@ -397,54 +400,54 @@ export default function Users() {
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="用户名" name="username" rules={[{ required: true, message: "请输入用户名" }]}> <Form.Item label={t('users.username')} name="username" rules={[{ required: true, message: t('users.username') }]}>
<Input placeholder="登录账号" disabled={!!editing} className="tabular-nums" /> <Input placeholder={t('users.username')} disabled={!!editing} className="tabular-nums" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="显示姓名" name="displayName" rules={[{ required: true, message: "请输入显示姓名" }]}> <Form.Item label={t('users.displayName')} name="displayName" rules={[{ required: true, message: t('users.displayName') }]}>
<Input placeholder="真实姓名" /> <Input placeholder={t('users.displayName')} />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="邮箱地址" name="email"> <Form.Item label={t('users.email')} name="email">
<Input placeholder="example@domain.com" className="tabular-nums" /> <Input placeholder="example@domain.com" className="tabular-nums" />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="手机号码" name="phone"> <Form.Item label={t('users.phone')} name="phone">
<Input placeholder="联系电话" className="tabular-nums" /> <Input placeholder={t('users.phone')} className="tabular-nums" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
<Form.Item <Form.Item
label="登录密码" label={t('users.password')}
name="password" name="password"
rules={[{ required: !editing, message: "请输入登录密码" }]} rules={[{ required: !editing, message: t('users.password') }]}
> >
<Input.Password placeholder={editing ? "留空表示不修改" : "设置初始密码"} /> <Input.Password placeholder={editing ? "留空表示不修改" : "设置初始密码"} />
</Form.Item> </Form.Item>
<Form.Item label="授予角色" name="roleIds" rules={[{ required: true, message: "请至少选择一个角色" }]}> <Form.Item label={t('users.roles')} name="roleIds" rules={[{ required: true, message: t('users.roles') }]}>
<Select <Select
mode="multiple" mode="multiple"
placeholder="选择系统角色" placeholder={t('users.roles')}
options={roles.map(r => ({ label: r.roleName, value: r.roleId }))} options={roles.map(r => ({ label: r.roleName, value: r.roleId }))}
/> />
</Form.Item> </Form.Item>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item label="账号状态" name="status" initialValue={1}> <Form.Item label={t('common.status')} name="status" initialValue={1}>
<Select options={[{ label: "启用", value: 1 }, { label: "禁用", value: 0 }]} /> <Select options={[{ label: "启用", value: 1 }, { label: "禁用", value: 0 }]} />
</Form.Item> </Form.Item>
</Col> </Col>
<Col span={12}> <Col span={12}>
<Form.Item label="平台管理员" name="isPlatformAdmin" valuePropName="checked"> <Form.Item label={t('users.platformAdmin')} name="isPlatformAdmin" valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
</Col> </Col>