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",
"antd": "^5.13.2",
"axios": "^1.6.7",
"i18next": "^25.8.6",
"i18next-browser-languagedetector": "^8.2.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^16.5.4",
"react-router-dom": "^6.22.3",
"zustand": "^4.5.2"
},
@ -2051,6 +2054,55 @@
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/is-mobile/-/is-mobile-5.0.0.tgz",
@ -2856,6 +2908,33 @@
"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": {
"version": "18.3.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz",
@ -3024,7 +3103,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",

View File

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

View File

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

View File

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

View File

@ -8,72 +8,75 @@ import {
SyncOutlined,
ArrowRightOutlined
} from "@ant-design/icons";
import { useTranslation } from "react-i18next";
import StatCard from "../components/shared/StatCard/StatCard";
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() {
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 (
<div className="dashboard-page p-6">
<div className="mb-6 flex justify-between items-end">
<div>
<Title level={4} className="mb-1"></Title>
<Text type="secondary"></Text>
<Title level={4} className="mb-1">{t('dashboard.title')}</Title>
<Text type="secondary">{t('dashboard.subtitle')}</Text>
</div>
<Button icon={<SyncOutlined aria-hidden="true" />} size="small"></Button>
<Button icon={<SyncOutlined aria-hidden="true" />} size="small">{t('common.refresh')}</Button>
</div>
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={6}>
<StatCard
title="今日会议"
title={t('dashboard.todayMeetings')}
value={12}
icon={<VideoCameraOutlined aria-hidden="true" />}
color="blue"
@ -82,7 +85,7 @@ export default function Dashboard() {
</Col>
<Col xs={24} sm={12} lg={6}>
<StatCard
title="活跃设备"
title={t('dashboard.activeDevices')}
value={45}
icon={<DesktopOutlined aria-hidden="true" />}
color="green"
@ -91,7 +94,7 @@ export default function Dashboard() {
</Col>
<Col xs={24} sm={12} lg={6}>
<StatCard
title="转录时长"
title={t('dashboard.transcriptionDuration')}
value={1280}
suffix="min"
icon={<ClockCircleOutlined aria-hidden="true" />}
@ -101,7 +104,7 @@ export default function Dashboard() {
</Col>
<Col xs={24} sm={12} lg={6}>
<StatCard
title="总用户数"
title={t('dashboard.totalUsers')}
value={320}
icon={<UserOutlined aria-hidden="true" />}
color="purple"
@ -113,10 +116,10 @@ export default function Dashboard() {
<Row gutter={[24, 24]} className="mt-6">
<Col xs={24} xl={16}>
<Card
title="最近会议"
title={t('dashboard.recentMeetings')}
bordered={false}
className="shadow-sm"
extra={<Button type="link" size="small"></Button>}
extra={<Button type="link" size="small">{t('dashboard.viewAll')}</Button>}
styles={{ body: { padding: 0 } }}
>
<Table
@ -129,7 +132,7 @@ export default function Dashboard() {
</Card>
</Col>
<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">
<Skeleton active paragraph={{ rows: 4 }} />
<div className="mt-4 text-gray-400 flex items-center gap-2">

View File

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

View File

@ -17,6 +17,7 @@ import {
Empty
} from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
createDictItem,
createDictType,
@ -35,6 +36,7 @@ import "./Dictionaries.css";
const { Title, Text } = Typography;
export default function Dictionaries() {
const { t } = useTranslation();
const { can } = usePermission();
const [types, setTypes] = useState<SysDictType[]>([]);
const [items, setItems] = useState<SysDictItem[]>([]);
@ -102,7 +104,7 @@ export default function Dictionaries() {
const handleDeleteType = async (id: number) => {
await deleteDictType(id);
message.success("类型删除成功");
message.success(t('common.success'));
loadTypes();
};
@ -113,7 +115,7 @@ export default function Dictionaries() {
} else {
await createDictType(values);
}
message.success(editingType ? "类型更新成功" : "类型创建成功");
message.success(t('common.success'));
setTypeDrawerVisible(false);
loadTypes();
};
@ -121,7 +123,7 @@ export default function Dictionaries() {
// Item Actions
const handleAddItem = () => {
if (!selectedType) {
message.warning("请先从左侧选择一个字典类型");
message.warning(t('dicts.selectType'));
return;
}
setEditingItem(null);
@ -138,7 +140,7 @@ export default function Dictionaries() {
const handleDeleteItem = async (id: number) => {
await deleteDictItem(id);
message.success("字典项删除成功");
message.success(t('common.success'));
if (selectedType) loadItems(selectedType.typeCode);
};
@ -149,27 +151,27 @@ export default function Dictionaries() {
} else {
await createDictItem(values);
}
message.success(editingItem ? "字典项更新成功" : "字典项创建成功");
message.success(t('common.success'));
setItemDrawerVisible(false);
if (selectedType) loadItems(selectedType.typeCode);
};
return (
<div className="dictionaries-page">
<div className="dictionaries-header">
<div className="dictionaries-page p-6">
<div className="dictionaries-header mb-6">
<div>
<Title level={4} className="dictionaries-title"></Title>
<Text type="secondary"></Text>
<Title level={4} className="mb-1">{t('dicts.title')}</Title>
<Text type="secondary">{t('dicts.subtitle')}</Text>
</div>
</div>
<Row gutter={24} className="dictionaries-content">
<Col span={8} className="full-height">
<Row gutter={24} style={{ height: 'calc(100vh - 180px)' }}>
<Col span={8} style={{ height: '100%' }}>
<Card
title={
<Space>
<BookOutlined aria-hidden="true" />
<span></span>
<span>{t('dicts.dictType')}</span>
</Space>
}
className="full-height-card shadow-sm"
@ -180,14 +182,13 @@ export default function Dictionaries() {
size="small"
icon={<PlusOutlined aria-hidden="true" />}
onClick={handleAddType}
aria-label="新增字典类型"
>
{t('common.create')}
</Button>
)
}
>
<div className="scroll-container">
<div style={{ height: 'calc(100% - 10px)', overflowY: 'auto' }}>
<Table
rowKey="dictTypeId"
loading={loadingTypes}
@ -202,12 +203,12 @@ export default function Dictionaries() {
columns={[
{
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="dict-type-name truncate">{record.typeName}</div>
<div className="dict-type-code truncate tabular-nums">{record.typeCode}</div>
<div className="dict-type-name font-medium truncate">{record.typeName}</div>
<div className="dict-type-code text-xs text-gray-400 truncate tabular-nums">{record.typeCode}</div>
</div>
<div className="dict-type-actions">
<div className="dict-type-actions flex gap-1">
{can("sys_dict:type:update") && (
<Button
type="text"
@ -217,7 +218,6 @@ export default function Dictionaries() {
e.stopPropagation();
handleEditType(record);
}}
aria-label={`编辑类型 ${record.typeName}`}
/>
)}
{can("sys_dict:type:delete") && (
@ -234,7 +234,6 @@ export default function Dictionaries() {
danger
icon={<DeleteOutlined aria-hidden="true" />}
onClick={(e) => e.stopPropagation()}
aria-label={`删除类型 ${record.typeName}`}
/>
</Popconfirm>
)}
@ -248,12 +247,12 @@ export default function Dictionaries() {
</Card>
</Col>
<Col span={16} className="full-height">
<Col span={16} style={{ height: '100%' }}>
<Card
title={
<Space>
<ProfileOutlined aria-hidden="true" />
<span>{selectedType ? ` - ${selectedType.typeName}` : ""}</span>
<span>{t('dicts.dictItem')}{selectedType ? ` - ${selectedType.typeName}` : ""}</span>
</Space>
}
className="full-height-card shadow-sm"
@ -265,15 +264,14 @@ export default function Dictionaries() {
icon={<PlusOutlined aria-hidden="true" />}
onClick={handleAddItem}
disabled={!selectedType}
aria-label="新增字典项"
>
{t('dicts.drawerTitleItemCreate')}
</Button>
)
}
>
{selectedType ? (
<div className="scroll-container">
<div style={{ height: 'calc(100% - 10px)', overflowY: 'auto' }}>
<Table
rowKey="dictItemId"
loading={loadingItems}
@ -282,23 +280,23 @@ export default function Dictionaries() {
size="middle"
columns={[
{
title: "展示标签",
title: t('dicts.itemLabel'),
dataIndex: "itemLabel",
render: (text) => <Text strong>{text}</Text>
},
{
title: "数据数值",
title: t('dicts.itemValue'),
dataIndex: "itemValue",
className: "tabular-nums"
},
{
title: "排序",
title: t('dicts.sort'),
dataIndex: "sortOrder",
width: 80,
className: "tabular-nums"
},
{
title: "状态",
title: t('common.status'),
dataIndex: "status",
width: 100,
render: (v) => (
@ -308,7 +306,7 @@ export default function Dictionaries() {
)
},
{
title: "操作",
title: t('common.action'),
width: 120,
fixed: "right" as const,
render: (_, record) => (
@ -319,7 +317,7 @@ export default function Dictionaries() {
size="small"
icon={<EditOutlined aria-hidden="true" />}
onClick={() => handleEditItem(record)}
aria-label={`编辑字典项 ${record.itemLabel}`}
aria-label={t('common.edit')}
/>
)}
{can("sys_dict:item:delete") && (
@ -329,7 +327,7 @@ export default function Dictionaries() {
size="small"
danger
icon={<DeleteOutlined aria-hidden="true" />}
aria-label={`删除字典项 ${record.itemLabel}`}
aria-label={t('common.delete')}
/>
</Popconfirm>
)}
@ -340,8 +338,8 @@ export default function Dictionaries() {
/>
</div>
) : (
<div className="flex-center h-full">
<Empty description="请先从左侧选择一个字典类型" />
<div className="flex items-center justify-center h-full">
<Empty description={t('dicts.selectType')} />
</div>
)}
</Card>
@ -353,7 +351,7 @@ export default function Dictionaries() {
title={
<Space>
<BookOutlined aria-hidden="true" />
<span>{editingType ? "编辑字典类型" : "新增字典类型"}</span>
<span>{editingType ? t('dicts.drawerTitleTypeEdit') : t('dicts.drawerTitleTypeCreate')}</span>
</Space>
}
open={typeDrawerVisible}
@ -361,20 +359,20 @@ export default function Dictionaries() {
width={400}
destroyOnClose
footer={
<div className="flex justify-end gap-2">
<Button onClick={() => setTypeDrawerVisible(false)}></Button>
<Button type="primary" onClick={handleTypeSubmit}></Button>
<div className="flex justify-end gap-2 p-2">
<Button onClick={() => setTypeDrawerVisible(false)}>{t('common.cancel')}</Button>
<Button type="primary" onClick={handleTypeSubmit}>{t('common.confirm')}</Button>
</div>
}
>
<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…" />
</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="例如:用户状态…" />
</Form.Item>
<Form.Item label="备注说明" name="remark">
<Form.Item label={t('common.remark')} name="remark">
<Input.TextArea placeholder="该字典类型的用途描述…" rows={3} />
</Form.Item>
</Form>
@ -385,7 +383,7 @@ export default function Dictionaries() {
title={
<Space>
<ProfileOutlined aria-hidden="true" />
<span>{editingItem ? "编辑字典项" : "新增字典项"}</span>
<span>{editingItem ? t('dicts.drawerTitleItemEdit') : t('dicts.drawerTitleItemCreate')}</span>
</Space>
}
open={itemDrawerVisible}
@ -393,26 +391,26 @@ export default function Dictionaries() {
width={400}
destroyOnClose
footer={
<div className="flex justify-end gap-2">
<Button onClick={() => setItemDrawerVisible(false)}></Button>
<Button type="primary" onClick={handleItemSubmit}></Button>
<div className="flex justify-end gap-2 p-2">
<Button onClick={() => setItemDrawerVisible(false)}>{t('common.cancel')}</Button>
<Button type="primary" onClick={handleItemSubmit}>{t('common.confirm')}</Button>
</div>
}
>
<Form form={itemForm} layout="vertical">
<Form.Item label="所属类型" name="typeCode">
<Form.Item label={t('dicts.typeCode')} name="typeCode">
<Input disabled className="tabular-nums" />
</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="例如:正常、禁用…" />
</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" />
</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" />
</Form.Item>
<Form.Item label="当前状态" name="status" initialValue={1}>
<Form.Item label={t('common.status')} name="status" initialValue={1}>
<Select
options={[
{ label: "启用", value: 1 },
@ -420,7 +418,7 @@ export default function Dictionaries() {
]}
/>
</Form.Item>
<Form.Item label="备注说明" name="remark">
<Form.Item label={t('common.remark')} name="remark">
<Input.TextArea placeholder="可选项,备注详细信息…" rows={3} />
</Form.Item>
</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 { useTranslation } from "react-i18next";
import { fetchCaptcha, login, type CaptchaResponse } from "../api/auth";
import { getCurrentUser, getSystemParamValue } from "../api";
import { UserOutlined, LockOutlined, SafetyOutlined, ReloadOutlined, ShopOutlined } from "@ant-design/icons";
@ -8,6 +9,7 @@ import "./Login.css";
const { Title, Text, Link } = Typography;
export default function Login() {
const { t } = useTranslation();
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null);
const [captchaEnabled, setCaptchaEnabled] = useState(true);
const [loading, setLoading] = useState(false);
@ -21,9 +23,9 @@ export default function Login() {
const data = await fetchCaptcha();
setCaptcha(data);
} catch (e) {
message.error("加载验证码失败");
message.error(t('common.error'));
}
}, [captchaEnabled]);
}, [captchaEnabled, t]);
useEffect(() => {
const init = async () => {
@ -61,10 +63,10 @@ export default function Login() {
} catch (e) {
sessionStorage.removeItem("userProfile");
}
message.success("登录成功");
message.success(t('common.success'));
window.location.href = "/";
} catch (e: any) {
message.error(e.message || "登录失败");
message.error(e.message || t('common.error'));
if (captchaEnabled) {
loadCaptcha();
}
@ -83,28 +85,27 @@ export default function Login() {
<div className="login-hero">
<h1 className="hero-title">
<br />
<span className="hero-accent"></span><br />
{t('login.heroTitle1')}<br />
<span className="hero-accent">{t('login.heroTitle2')}</span><br />
{t('login.heroTitle3')}
</h1>
<p className="hero-desc">
<br />
{t('login.heroDesc')}
</p>
</div>
<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-item"></div>
<div className="footer-item">{t('login.multiLang')}</div>
</div>
</div>
<div className="login-right">
<div className="login-container">
<div className="login-header">
<Title level={2}></Title>
<Text type="secondary"></Text>
<Title level={2}>{t('login.welcome')}</Title>
<Text type="secondary">{t('login.subtitle')}</Text>
</div>
<Form
@ -122,50 +123,50 @@ export default function Login() {
<Input
size="large"
prefix={<ShopOutlined className="text-gray-400" aria-hidden="true" />}
placeholder="租户编码 (平台管理可留空)"
aria-label="租户编码"
placeholder={t('login.tenantCodePlaceholder')}
aria-label={t('login.tenantCode')}
/>
</Form.Item>
<Form.Item
name="username"
rules={[{ required: true, message: "请输入用户名" }]}
rules={[{ required: true, message: t('login.username') }]}
>
<Input
size="large"
prefix={<UserOutlined className="text-gray-400" aria-hidden="true" />}
placeholder="用户名"
placeholder={t('login.username')}
autoComplete="username"
spellCheck={false}
aria-label="用户名"
aria-label={t('login.username')}
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: "请输入密码" }]}
rules={[{ required: true, message: t('login.password') }]}
>
<Input.Password
size="large"
prefix={<LockOutlined className="text-gray-400" aria-hidden="true" />}
placeholder="密码"
placeholder={t('login.password')}
autoComplete="current-password"
aria-label="密码"
aria-label={t('login.password')}
/>
</Form.Item>
{captchaEnabled && (
<Form.Item
name="captchaCode"
rules={[{ required: true, message: "请输入验证码" }]}
rules={[{ required: true, message: t('login.captcha') }]}
>
<div className="captcha-wrapper">
<Input
size="large"
prefix={<SafetyOutlined className="text-gray-400" aria-hidden="true" />}
placeholder="验证码"
placeholder={t('login.captcha')}
maxLength={6}
aria-label="验证码"
aria-label={t('login.captcha')}
/>
<Button
className="captcha-image-btn"
@ -181,21 +182,21 @@ export default function Login() {
<div className="login-extra">
<Form.Item name="remember" valuePropName="checked" noStyle>
<Checkbox></Checkbox>
<Checkbox>{t('login.rememberMe')}</Checkbox>
</Form.Item>
<Link className="forgot-password"></Link>
<Link className="forgot-password">{t('login.forgotPassword')}</Link>
</div>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block size="large" className="login-submit-btn">
{loading ? "登录中…" : "立即登录"}
{loading ? t('login.loggingIn') : t('login.submit')}
</Button>
</Form.Item>
</Form>
<div className="login-footer">
<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>
</div>
</div>

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ import {
} from "antd";
import type { DataNode } from "antd/es/tree";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { listPermissions, listRolePermissions, listRoles, saveRolePermissions } from "../api";
import { SearchOutlined, SafetyCertificateOutlined, SaveOutlined, KeyOutlined, ClusterOutlined } from "@ant-design/icons";
import type { SysPermission, SysRole } from "../types";
@ -51,20 +52,21 @@ function buildPermissionTree(list: SysPermission[]): PermissionNode[] {
return roots;
}
function toTreeData(nodes: PermissionNode[]): DataNode[] {
function toTreeData(nodes: PermissionNode[], t: any): DataNode[] {
return nodes.map((node) => ({
key: node.permId,
title: (
<Space>
<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>
),
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() {
const { t } = useTranslation();
const [roles, setRoles] = useState<SysRole[]>([]);
const [permissions, setPermissions] = useState<SysPermission[]>([]);
const [loadingRoles, setLoadingRoles] = useState(false);
@ -100,7 +102,7 @@ export default function RolePermissionBinding() {
const list = await listPermissions();
setPermissions(list || []);
} catch (e) {
message.error("加载权限定义失败");
message.error(t('common.error'));
} finally {
setLoadingPerms(false);
}
@ -111,7 +113,6 @@ export default function RolePermissionBinding() {
const list = await listRolePermissions(roleId);
const normalized = (list || []).map((id) => Number(id)).filter((id) => !Number.isNaN(id));
// Filter out parent IDs for Tree view回显 consistency
const leafIds = normalized.filter(id => {
return !permissions.some(p => p.parentId === id);
});
@ -120,7 +121,7 @@ export default function RolePermissionBinding() {
setHalfCheckedIds([]);
} catch (e) {
setCheckedPermIds([]);
message.error("加载角色授权数据失败");
message.error(t('common.error'));
}
};
@ -147,21 +148,20 @@ export default function RolePermissionBinding() {
}, [roles, searchText]);
const treeData = useMemo(() => buildPermissionTree(permissions), [permissions]);
const antdTreeData = useMemo(() => toTreeData(treeData), [treeData]);
const antdTreeData = useMemo(() => toTreeData(treeData, t), [treeData, t]);
const handleSave = async () => {
if (!selectedRoleId) {
message.warning("请从左侧列表中选择一个角色");
message.warning(t('rolePerm.selectRole'));
return;
}
setSaving(true);
try {
// Merge checked and half-checked for database persistence
const allPermIds = Array.from(new Set([...checkedPermIds, ...halfCheckedIds]));
await saveRolePermissions(selectedRoleId, allPermIds);
message.success("功能权限策略已成功更新并下发");
message.success(t('common.success'));
} catch (e) {
message.error("保存权限配置失败");
message.error(t('common.error'));
} finally {
setSaving(false);
}
@ -171,8 +171,8 @@ export default function RolePermissionBinding() {
<div className="p-6">
<div className="mb-6 flex justify-between items-end">
<div>
<Title level={4} className="mb-1"></Title>
<Text type="secondary">访</Text>
<Title level={4} className="mb-1">{t('rolePerm.title')}</Title>
<Text type="secondary">{t('rolePerm.subtitle')}</Text>
</div>
<Button
type="primary"
@ -181,75 +181,77 @@ export default function RolePermissionBinding() {
loading={saving}
disabled={!selectedRoleId}
>
{saving ? "正在同步…" : "保存权限策略"}
{saving ? t('common.loading') : t('rolePerm.savePolicy')}
</Button>
</div>
<Row gutter={24}>
<Col xs={24} lg={10}>
<Row gutter={24} style={{ height: 'calc(100vh - 180px)' }}>
<Col xs={24} lg={10} style={{ height: '100%' }}>
<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"
>
<div className="mb-4">
<Input
placeholder="搜索角色名称或编码…"
placeholder={t('rolePerm.searchRole')}
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
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>
<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>
</Col>
<Col xs={24} lg={14}>
<Col xs={24} lg={14} style={{ height: '100%' }}>
<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"
extra={
selectedRole && (
<Tag color="blue">: {selectedRole.roleName}</Tag>
<Tag color="blue">{t('rolePerm.currentRole')}: {selectedRole.roleName}</Tag>
)
}
>
{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
checkable
selectable={false}
@ -259,7 +261,7 @@ export default function RolePermissionBinding() {
onCheck={(keys, info) => {
const checked = Array.isArray(keys) ? keys : keys.checked;
const halfChecked = info.halfCheckedKeys || [];
setCheckedPermIds(checked.map(k => Number(k)));
setSelectedPermIds(checked.map(k => Number(k)));
setHalfCheckedIds(halfChecked.map(k => Number(k)));
}}
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">
<ClusterOutlined style={{ fontSize: 40, color: '#bfbfbf', marginBottom: 16 }} aria-hidden="true" />
<Text type="secondary"></Text>
<Text type="secondary">{t('rolePerm.selectRole')}</Text>
</div>
)}
</Card>

View File

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

View File

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

View File

@ -13,6 +13,7 @@ import {
Empty
} from "antd";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { listRoles, listUserRoles, listUsers, saveUserRoles } from "../api";
import { SearchOutlined, UserOutlined, SaveOutlined, TeamOutlined } from "@ant-design/icons";
import type { SysRole, SysUser } from "../types";
@ -20,6 +21,7 @@ import type { SysRole, SysUser } from "../types";
const { Title, Text } = Typography;
export default function UserRoleBinding() {
const { t } = useTranslation();
const [users, setUsers] = useState<SysUser[]>([]);
const [roles, setRoles] = useState<SysRole[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
@ -62,7 +64,7 @@ export default function UserRoleBinding() {
setCheckedRoleIds(list || []);
} catch (e) {
setCheckedRoleIds([]);
message.error("加载用户角色数据失败");
message.error(t('common.error'));
}
};
@ -90,15 +92,15 @@ export default function UserRoleBinding() {
const handleSave = async () => {
if (!selectedUserId) {
message.warning("请先在左侧列表中选择一个用户");
message.warning(t('userRole.selectUser'));
return;
}
setSaving(true);
try {
await saveUserRoles(selectedUserId, checkedRoleIds);
message.success("角色权限已成功授予用户");
message.success(t('common.success'));
} catch (e) {
message.error("更新绑定关系失败");
message.error(t('common.error'));
} finally {
setSaving(false);
}
@ -108,8 +110,8 @@ export default function UserRoleBinding() {
<div className="p-6">
<div className="mb-6 flex justify-between items-end">
<div>
<Title level={4} className="mb-1"></Title>
<Text type="secondary">访</Text>
<Title level={4} className="mb-1">{t('userRole.title')}</Title>
<Text type="secondary">{t('userRole.subtitle')}</Text>
</div>
<Button
type="primary"
@ -118,75 +120,77 @@ export default function UserRoleBinding() {
loading={saving}
disabled={!selectedUserId}
>
{saving ? "保存中…" : "保存权限更改"}
{saving ? t('common.loading') : t('common.save')}
</Button>
</div>
<Row gutter={24}>
<Col xs={24} lg={12}>
<Row gutter={24} style={{ height: 'calc(100vh - 180px)' }}>
<Col xs={24} lg={12} style={{ height: '100%' }}>
<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"
>
<div className="mb-4">
<Input
placeholder="搜索用户名或显示名…"
placeholder={t('userRole.searchUser')}
prefix={<SearchOutlined aria-hidden="true" className="text-gray-400" />}
value={searchText}
onChange={e => setSearchText(e.target.value)}
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>
<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>
</Col>
<Col xs={24} lg={12}>
<Col xs={24} lg={12} style={{ height: '100%' }}>
<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"
extra={
selectedUser && (
<Tag color="blue">: {selectedUser.displayName}</Tag>
<Tag color="blue">{t('userRole.editing')}: {selectedUser.displayName}</Tag>
)
}
>
{selectedUserId ? (
<div style={{ padding: '8px 0' }}>
<div style={{ padding: '8px 0', height: '100%', overflowY: 'auto' }}>
<Checkbox.Group
style={{ width: "100%" }}
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">
<UserOutlined style={{ fontSize: 40, color: '#bfbfbf', marginBottom: 16 }} aria-hidden="true" />
<Text type="secondary"></Text>
<Text type="secondary">{t('userRole.selectUser')}</Text>
</div>
)}
</Card>

View File

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