first commit
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Empty,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
theme, Spin, Avatar, Card, Tooltip,
|
||||
} from 'antd';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { getAgentList, updateAgent } from '@/api/ai/agent.ts';
|
||||
import type { IAgent } from '@/domain/iAgents.ts';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
export default function AgentPage() {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [agents, setAgents] = useState<IAgent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadAgents = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getAgentList();
|
||||
setAgents(res.data.data ?? []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
const handleToggleEnabled = async (id: number, enabled: boolean) => {
|
||||
try {
|
||||
await updateAgent(id, { enabled });
|
||||
setAgents((prev) =>
|
||||
prev.map((a) => (a.id === id ? { ...a, enabled } : a)),
|
||||
);
|
||||
message.success(t('ai.agent.update.success'));
|
||||
} catch {
|
||||
message.error(t('ai.agent.update.failed'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: token.marginLG }}>
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: token.marginXS }}>
|
||||
{t('ai.agent.page.title')}
|
||||
</Title>
|
||||
<Text type="secondary">{t('ai.agent.page.description')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{agents.length > 0 ? (
|
||||
<div className={'flex flex-wrap gap-6'}>
|
||||
{agents.map((agent) => (
|
||||
<Tooltip title={agent.description}>
|
||||
<Card hoverable variant={'borderless'} styles={{ body: { width: 300, padding: 20, overflow: "hidden" }}}>
|
||||
<div className={'flex justify-between items-center mb-2.5'}>
|
||||
<Space align={'center'}>
|
||||
<Avatar src={agent.icon} size={32} />
|
||||
<span style={{fontWeight: 700, fontSize: 18}}>{agent.name}</span>
|
||||
</Space>
|
||||
<Switch
|
||||
checked={agent.enabled}
|
||||
size="small"
|
||||
onChange={(checked) =>
|
||||
handleToggleEnabled(agent.id!, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ marginBottom: token.marginSM }}
|
||||
>
|
||||
{agent.description}
|
||||
</Paragraph>
|
||||
<Space size={[4, 4]} wrap>
|
||||
{agent.tags?.map((tag) => (
|
||||
<Tag key={tag} color="blue">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
<div style={{ marginTop: token.marginSM }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
block
|
||||
onClick={() => navigate(`/ai/chat?agent_id=${agent.id}`)}
|
||||
>
|
||||
{t('ai.agent.goChat')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty description={t('ai.agent.empty')} />
|
||||
)}
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {
|
||||
Bubble, type BubbleListProps,
|
||||
Conversations,
|
||||
Sender,
|
||||
Welcome,
|
||||
} from '@ant-design/x';
|
||||
import type { BubbleItemType } from '@ant-design/x';
|
||||
import {useXConversations, XRequest} from '@ant-design/x-sdk';
|
||||
import {App, Avatar, Button, Card, Space, Typography, theme, type UploadFile} from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { XMarkdown } from '@ant-design/x-markdown';
|
||||
import '@ant-design/x-markdown/themes/light.css';
|
||||
import { getConversations, getMessages, deleteConversation } from '@/api/ai/chat.ts';
|
||||
import { getAgent } from '@/api/ai/agent.ts';
|
||||
import type { IAgent } from '@/domain/iAgents.ts';
|
||||
import useGlobalStore from "@/stores/global";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/** 自定义气泡扩展字段(通过 extraInfo 传递) */
|
||||
interface ChatExtraInfo {
|
||||
thinking?: string;
|
||||
isStreaming?: boolean;
|
||||
files?: UploadFile[];
|
||||
}
|
||||
|
||||
|
||||
const ChatPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { message: appMessage, modal } = App.useApp();
|
||||
const { token } = theme.useToken();
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig)
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const agentIdParam = searchParams.get('agent_id');
|
||||
|
||||
const [agent, setAgent] = useState<IAgent | null>(null);
|
||||
|
||||
const bubbleListRef = useRef<any>(null);
|
||||
|
||||
const {
|
||||
conversations,
|
||||
setConversations,
|
||||
activeConversationKey,
|
||||
setActiveConversationKey,
|
||||
} = useXConversations({});
|
||||
|
||||
// 消息列表
|
||||
const [messages, setMessages] = useState<BubbleItemType[]>([]);
|
||||
// 加载状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
// 输入框内容
|
||||
const [senderValue, setSenderValue] = useState<string>("");
|
||||
// 输入框只读
|
||||
const [senderDisabled, setSenderDisabled] = useState<boolean>(false);
|
||||
|
||||
// 加载会话列表
|
||||
const loadConversations = async () => {
|
||||
const {data} = await getConversations();
|
||||
if (data.success && data.data) {
|
||||
setConversations(data.data);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载消息列表
|
||||
const loadMessages = async (conversationId: string) => {
|
||||
const { data } = await getMessages(conversationId);
|
||||
if (data.success && data.data) {
|
||||
setMessages(data.data);
|
||||
setTimeout(() => {
|
||||
bubbleListRef.current?.scrollTo({ top: 'bottom' });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadConversations(); }, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (agentIdParam) {
|
||||
getAgent(Number(agentIdParam)).then(({ data }) => {
|
||||
if (data.success && data.data) {
|
||||
setAgent(data.data);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [agentIdParam]);
|
||||
|
||||
// 会话操作
|
||||
const handleActiveChange = (key: string) => {
|
||||
setActiveConversationKey(key);
|
||||
loadMessages(key);
|
||||
}
|
||||
|
||||
// 新建会话
|
||||
const handleNewChat = () => {
|
||||
setActiveConversationKey('');
|
||||
setMessages([]);
|
||||
};
|
||||
|
||||
// 删除会话
|
||||
const handleDelete = (convId: string) => {
|
||||
modal.confirm({
|
||||
title: t('ai.chat.delete.confirm'),
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await deleteConversation(convId);
|
||||
appMessage.success(t('ai.chat.delete.success'));
|
||||
if (activeConversationKey === convId) {
|
||||
setActiveConversationKey('');
|
||||
setMessages([]);
|
||||
}
|
||||
await loadConversations();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const handleSend = async (text: string) => {
|
||||
if (!text.trim() || loading) return;
|
||||
|
||||
// 用户消息
|
||||
const userMessage: BubbleItemType = {
|
||||
key: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: text,
|
||||
status: 'success',
|
||||
};
|
||||
|
||||
// AI 占位消息
|
||||
const aiMessage: BubbleItemType = {
|
||||
key: `assistant-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
loading: true,
|
||||
extraInfo: { isStreaming: true } as ChatExtraInfo,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage, aiMessage]);
|
||||
setLoading(true);
|
||||
setSenderDisabled(true);
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
||||
let aiContent = '';
|
||||
XRequest(`${baseUrl}/ai/chat/send`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
method: "post",
|
||||
params: {
|
||||
conversation_id: activeConversationKey,
|
||||
message: text,
|
||||
agent_id: agentIdParam ? Number(agentIdParam) : undefined,
|
||||
},
|
||||
callbacks: {
|
||||
onSuccess: async () => {
|
||||
setLoading(false);
|
||||
setSenderValue("");
|
||||
setSenderDisabled(false);
|
||||
if (!activeConversationKey) {
|
||||
const {data} = await getConversations();
|
||||
if (data.success && data.data && data.data.length > 0) {
|
||||
setConversations(data.data);
|
||||
const latest = data.data[0].key;
|
||||
setActiveConversationKey(latest as string);
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
setLoading(false);
|
||||
setSenderDisabled(false);
|
||||
},
|
||||
onUpdate: (msg) => {
|
||||
console.log("onUpdate", msg);
|
||||
const payload = msg.data;
|
||||
if (payload === '[DONE]') return;
|
||||
try {
|
||||
const event = JSON.parse(payload);
|
||||
// 正文内容增量
|
||||
if (event.type === 'text_delta' && event.delta) {
|
||||
aiContent += event.delta;
|
||||
setMessages((prev) => prev.map(m => (
|
||||
m.key === aiMessage.key ? {
|
||||
...m,
|
||||
content: aiContent,
|
||||
loading: false
|
||||
} : m
|
||||
)));
|
||||
}
|
||||
// 内容结束
|
||||
if (event.type === 'text_end') {
|
||||
setMessages((prev) => prev.map(m => (
|
||||
m.key === aiMessage.key ? {
|
||||
...m,
|
||||
key: event.message_id,
|
||||
loading: false
|
||||
} : m
|
||||
)));
|
||||
}
|
||||
} catch (err) {
|
||||
/* empty */
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 角色配置 ====================
|
||||
const roleConfig: BubbleListProps['role'] = {
|
||||
assistant: {
|
||||
placement: 'start',
|
||||
variant: 'filled',
|
||||
avatar: <Avatar src={agent?.icon || 'https://file.xinadmin.cn/file/favicons.ico'} />,
|
||||
// AI 消息内容:Markdown 渲染
|
||||
contentRender: (content: string, info: any) => {
|
||||
if (!content) return null;
|
||||
const isStreaming = info?.extraInfo?.isStreaming === true;
|
||||
return (
|
||||
<XMarkdown
|
||||
content={content}
|
||||
streaming={{
|
||||
hasNextChunk: isStreaming,
|
||||
enableAnimation: true,
|
||||
tail: isStreaming,
|
||||
}}
|
||||
openLinksInNewTab
|
||||
escapeRawHtml
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
user: {
|
||||
placement: 'end',
|
||||
variant: 'filled',
|
||||
avatar: (
|
||||
<Avatar
|
||||
icon={<UserOutlined />}
|
||||
style={{ background: token.colorFillSecondary, color: token.colorTextSecondary }}
|
||||
size={36}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
styles={{ body: { padding: 0, overflow: 'hidden', display: 'flex', height: 'calc(100vh - 180px)' } }}
|
||||
title={
|
||||
<Space align="center">
|
||||
{agent ? (
|
||||
<>
|
||||
<Avatar src={agent.icon} size={36} />
|
||||
<div>
|
||||
<span style={{ fontSize: 18, fontWeight: 600, marginRight: 10 }}>{agent.name}</span>
|
||||
{agent.description && (
|
||||
<Text type="secondary" style={{ fontSize: 12, lineHeight: 1 }}>{agent.description}</Text>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span>{t('ai.chat.defaultTitle')}</span>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button onClick={() => navigate('/ai/agent')}>
|
||||
{t('ai.chat.switchAgent')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* 左侧会话列表 */}
|
||||
<div
|
||||
style={{
|
||||
width: 280,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`
|
||||
}}
|
||||
>
|
||||
<Conversations
|
||||
items={conversations}
|
||||
activeKey={activeConversationKey}
|
||||
onActiveChange={handleActiveChange}
|
||||
menu={(item) => ({
|
||||
items: [
|
||||
{
|
||||
label: 'Rename',
|
||||
key: 'Rename',
|
||||
icon: <EditOutlined />,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
label: 'Delete Chat',
|
||||
key: 'delete',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
onClick: () => handleDelete(item.key as string),
|
||||
},
|
||||
],
|
||||
})}
|
||||
creation={{
|
||||
onClick: handleNewChat,
|
||||
}}
|
||||
style={{ flex: 1, overflow: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧对话区域 */}
|
||||
<div className={"flex-1 flex flex-col min-w-0 px-1"}>
|
||||
{/* 空状态 - 欢迎页 */}
|
||||
{!hasMessages && (
|
||||
<div className={"h-full flex items-center justify-center"}>
|
||||
<Welcome
|
||||
icon={agent?.icon || "https://file.xinadmin.cn/file/favicons.ico"}
|
||||
style={{
|
||||
backgroundImage: themeConfig.themeScheme === 'dark' ?
|
||||
'linear-gradient(97deg, rgba(90,196,255,0.12) 0%, rgba(174,136,255,0.12) 100%)'
|
||||
: 'linear-gradient(97deg, #f2f9fe 0%, #f7f3ff 100%)',
|
||||
borderRadius: 20,
|
||||
width: '80%',
|
||||
padding: '20px'
|
||||
}}
|
||||
title={t('ai.chat.welcome.title')}
|
||||
description={t('ai.chat.welcome.description')}
|
||||
variant="borderless"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息列表 */}
|
||||
{hasMessages && (
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<Bubble.List
|
||||
ref={bubbleListRef}
|
||||
items={messages}
|
||||
autoScroll
|
||||
style={{ height: '100%', padding: 16 }}
|
||||
role={roleConfig}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 发送区域 */}
|
||||
<div style={{ padding: '0 16px 16px' }}>
|
||||
<Sender
|
||||
loading={loading}
|
||||
disabled={senderDisabled}
|
||||
value={senderValue}
|
||||
onChange={setSenderValue}
|
||||
placeholder={t('ai.chat.placeholder')}
|
||||
onSubmit={handleSend}
|
||||
onCancel={() => setLoading(false)}
|
||||
autoSize={{minRows: 1, maxRows: 6}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPage;
|
||||
@@ -0,0 +1,192 @@
|
||||
import XinTable from '@/components/XinTable';
|
||||
import {Button, Drawer, Table, Tag, Tooltip, Typography} from 'antd';
|
||||
import {EyeOutlined} from '@ant-design/icons';
|
||||
import type {XinTableColumn} from '@/components/XinTable/typings.ts';
|
||||
import type {IAgentConversation, IAgentMessage} from '@/domain/iAgents.ts';
|
||||
import {getMessages} from '@/api/ai/conversation.ts';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import dayjs from 'dayjs';
|
||||
import {useState} from 'react';
|
||||
|
||||
const {Title, Text} = Typography;
|
||||
|
||||
export default function AgentConversationPage() {
|
||||
const {t} = useTranslation();
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerTitle, setDrawerTitle] = useState('');
|
||||
const [messages, setMessages] = useState<IAgentMessage[]>([]);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [messagesTotal, setMessagesTotal] = useState(0);
|
||||
const [messagesPage, setMessagesPage] = useState(1);
|
||||
const [currentConversationId, setCurrentConversationId] = useState('');
|
||||
|
||||
const fetchMessages = async (conversationId: string, page: number) => {
|
||||
setMessagesLoading(true);
|
||||
try {
|
||||
const res = await getMessages(conversationId, {page, pageSize: 20});
|
||||
const listData = res.data.data!;
|
||||
setMessages(listData.data);
|
||||
setMessagesTotal(listData.total);
|
||||
} finally {
|
||||
setMessagesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewMessages = async (record: IAgentConversation) => {
|
||||
setCurrentConversationId(record.id!);
|
||||
setDrawerTitle(record.title || '');
|
||||
setMessagesPage(1);
|
||||
setDrawerOpen(true);
|
||||
await fetchMessages(record.id!, 1);
|
||||
};
|
||||
|
||||
const handleMessagesPageChange = (page: number) => {
|
||||
setMessagesPage(page);
|
||||
fetchMessages(currentConversationId, page);
|
||||
};
|
||||
|
||||
const columns: XinTableColumn<IAgentConversation>[] = [
|
||||
{
|
||||
title: t('ai.conversation.id'),
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
width: 260,
|
||||
ellipsis: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('ai.conversation.username'),
|
||||
dataIndex: 'username',
|
||||
hideInForm: true,
|
||||
align: 'center',
|
||||
width: 120,
|
||||
render: (value: string) => value || t('ai.conversation.noUser'),
|
||||
},
|
||||
{
|
||||
title: t('ai.conversation.title'),
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('ai.conversation.messageCount'),
|
||||
dataIndex: 'message_count',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: t('ai.conversation.createdAt'),
|
||||
dataIndex: 'created_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
width: 180,
|
||||
render: (value: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||||
},
|
||||
{
|
||||
title: t('ai.conversation.updatedAt'),
|
||||
dataIndex: 'updated_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
width: 180,
|
||||
render: (value: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||||
},
|
||||
];
|
||||
|
||||
const messageColumns = [
|
||||
{
|
||||
title: t('ai.conversation.message.role'),
|
||||
dataIndex: 'role',
|
||||
width: 100,
|
||||
render: (role: string) => {
|
||||
const colorMap: Record<string, string> = {
|
||||
user: 'blue',
|
||||
assistant: 'green',
|
||||
system: 'orange',
|
||||
};
|
||||
return <Tag color={colorMap[role] || 'default'}>{t(`ai.conversation.message.role.${role}`, role)}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('ai.conversation.message.agent'),
|
||||
dataIndex: 'agent',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('ai.conversation.message.content'),
|
||||
dataIndex: 'content',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('ai.conversation.message.createdAt'),
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (value: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '-',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('ai.conversation.page.title')}</Title>
|
||||
<Text type="secondary">{t('ai.conversation.page.description')}</Text>
|
||||
</div>
|
||||
<XinTable<IAgentConversation>
|
||||
api={'/ai/conversation'}
|
||||
columns={columns}
|
||||
rowKey={'id'}
|
||||
accessName={'ai.conversation'}
|
||||
addShow={false}
|
||||
editShow={false}
|
||||
formProps={false}
|
||||
operateProps={{
|
||||
fixed: 'right',
|
||||
width: 120,
|
||||
}}
|
||||
operateRender={(record, dom) => [
|
||||
<Tooltip title={t('ai.conversation.viewMessages')} key="view">
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<EyeOutlined/>}
|
||||
size="small"
|
||||
onClick={() => handleViewMessages(record)}
|
||||
/>
|
||||
</Tooltip>,
|
||||
dom.del,
|
||||
]}
|
||||
scroll={{x: 1100}}
|
||||
cardProps={{
|
||||
variant: 'borderless'
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title={`${t('ai.conversation.messageTitle')} - ${drawerTitle}`}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={900}
|
||||
>
|
||||
<Table<IAgentMessage>
|
||||
dataSource={messages}
|
||||
columns={messageColumns}
|
||||
rowKey="id"
|
||||
loading={messagesLoading}
|
||||
pagination={{
|
||||
current: messagesPage,
|
||||
total: messagesTotal,
|
||||
pageSize: 20,
|
||||
onChange: handleMessagesPageChange,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
scroll={{x: 700}}
|
||||
size="small"
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
import React from "react";
|
||||
import {Card, Col, Row, theme, Radio, Table, Tag, Space, List, Avatar} from "antd";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import {ArrowDownOutlined, ArrowUpOutlined, LikeOutlined, TeamOutlined} from "@ant-design/icons";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
const {useToken} = theme;
|
||||
|
||||
interface DataType {
|
||||
key: string;
|
||||
name: string;
|
||||
age: number;
|
||||
address: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const Index: React.FC = () => {
|
||||
const {token} = useToken();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const optionsBar = {
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: false,
|
||||
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
|
||||
},
|
||||
yAxis: {
|
||||
show: false,
|
||||
type: 'value'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: [120, 88, 116, 60, 70],
|
||||
type: 'bar',
|
||||
itemStyle: {
|
||||
color: token.colorPrimary
|
||||
},
|
||||
barWidth: 10,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const optionsLine = {
|
||||
xAxis: {
|
||||
show: false,
|
||||
type: 'category',
|
||||
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
},
|
||||
yAxis: {
|
||||
show: false,
|
||||
type: 'value'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: [1, 2, 3, 2, 3, 2, 1],
|
||||
type: 'line',
|
||||
itemStyle: {
|
||||
color: token.colorPrimary
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const options = {
|
||||
title: {
|
||||
text: t('dashboard.analysis.annualSales'),
|
||||
textStyle: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeLG,
|
||||
fontWeight: token.fontWeightStrong
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: token.colorPrimaryBg,
|
||||
color: token.colorPrimary
|
||||
}
|
||||
},
|
||||
borderWidth: 0,
|
||||
backgroundColor: token.colorPrimaryBg,
|
||||
textStyle: {
|
||||
color: token.colorText
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: [t('dashboard.analysis.grossProfit'), t('dashboard.analysis.netProfit'), t('dashboard.analysis.totalExpense')],
|
||||
textStyle: {
|
||||
color: token.colorText
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: [
|
||||
t('dashboard.analysis.january'),
|
||||
t('dashboard.analysis.february'),
|
||||
t('dashboard.analysis.march'),
|
||||
t('dashboard.analysis.april'),
|
||||
t('dashboard.analysis.may'),
|
||||
t('dashboard.analysis.june'),
|
||||
t('dashboard.analysis.july'),
|
||||
t('dashboard.analysis.august'),
|
||||
t('dashboard.analysis.september'),
|
||||
t('dashboard.analysis.october'),
|
||||
t('dashboard.analysis.november'),
|
||||
t('dashboard.analysis.december')
|
||||
]
|
||||
}
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
// 使用深浅的间隔色
|
||||
color: token.colorBorder
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: t('dashboard.analysis.grossProfit'),
|
||||
type: 'line',
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: token.colorPrimaryBorder
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
itemStyle: {
|
||||
color: token.colorPrimary
|
||||
},
|
||||
data: [30,36,42,33,21,26,29,35,42,32,28,26]
|
||||
},
|
||||
{
|
||||
name: t('dashboard.analysis.netProfit'),
|
||||
type: 'line',
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: token.colorSuccessBorder
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
itemStyle: {
|
||||
color: token.colorSuccess
|
||||
},
|
||||
data: [32,16,18,30,15,19,22,17,24,19,30,31]
|
||||
},
|
||||
{
|
||||
name: t('dashboard.analysis.totalExpense'),
|
||||
type: 'line',
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: token.colorWarningBorder
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
itemStyle: {
|
||||
color: token.colorWarning
|
||||
},
|
||||
data: [36,24,36,36,39,56,24,23,21,12,16,19]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const optionsPie = {
|
||||
title: {
|
||||
text: t('dashboard.analysis.accessFrom'),
|
||||
textStyle: {
|
||||
color: token.colorText,
|
||||
fontSize: token.fontSizeLG,
|
||||
fontWeight: token.fontWeightStrong
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
legend: {
|
||||
bottom: '0%',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
color: token.colorText
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: t('dashboard.analysis.accessFrom'),
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: token.borderRadius,
|
||||
borderColor: token.colorBorder,
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 40,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{ value: 1048, name: t('dashboard.analysis.searchEngine') },
|
||||
{ value: 735, name: t('dashboard.analysis.direct') },
|
||||
{ value: 580, name: t('dashboard.analysis.email') },
|
||||
{ value: 484, name: t('dashboard.analysis.unionAds') },
|
||||
{ value: 300, name: t('dashboard.analysis.videoAds') }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const data: DataType[] = [
|
||||
{
|
||||
key: '1',
|
||||
name: 'John Brown',
|
||||
age: 32,
|
||||
address: 'New York No. 1 Lake Park',
|
||||
tags: ['nice', 'developer'],
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
name: 'Jim Green',
|
||||
age: 42,
|
||||
address: 'London No. 1 Lake Park',
|
||||
tags: ['loser'],
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
name: 'Joe Black',
|
||||
age: 32,
|
||||
address: 'Sydney No. 1 Lake Park',
|
||||
tags: ['cool', 'teacher'],
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
name: 'Jim Green',
|
||||
age: 42,
|
||||
address: 'London No. 1 Lake Park',
|
||||
tags: ['loser'],
|
||||
},
|
||||
{
|
||||
key: '5',
|
||||
name: 'Joe Black',
|
||||
age: 32,
|
||||
address: 'Sydney No. 1 Lake Park',
|
||||
tags: ['cool', 'teacher'],
|
||||
},
|
||||
];
|
||||
|
||||
const msgData = [
|
||||
{
|
||||
title: 'Ant Design Title 1',
|
||||
},
|
||||
{
|
||||
title: 'Ant Design Title 2',
|
||||
},
|
||||
{
|
||||
title: 'Ant Design Title 3',
|
||||
},
|
||||
{
|
||||
title: 'Ant Design Title 4',
|
||||
},
|
||||
{
|
||||
title: 'Ant Design Title 5',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col xxl={6} lg={12} xs={24}>
|
||||
<Card variant={"borderless"}>
|
||||
<div>{t('dashboard.analysis.totalRevenue')}</div>
|
||||
<div className={"flex items-center justify-between pt-4 pb-2"}>
|
||||
<div className={"text-4xl flex-0"}>¥3,415.00</div>
|
||||
<ReactECharts style={{width: 120, height: 80}} option={optionsBar} />
|
||||
</div>
|
||||
<div>{t('dashboard.since.lastWeek')} <span style={{color: token.colorError}}><ArrowUpOutlined />11.28%</span></div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xxl={6} lg={12} xs={24}>
|
||||
<Card variant={"borderless"}>
|
||||
<div>{t('dashboard.analysis.totalExpenses')}</div>
|
||||
<div className={"flex items-center justify-between pt-4 pb-2"}>
|
||||
<div className={"text-4xl flex-0"}>¥8,425.00</div>
|
||||
<ReactECharts style={{width: 120, height: 80}} option={optionsLine} />
|
||||
</div>
|
||||
<div>{t('dashboard.since.lastWeek')} <span style={{color: token.colorSuccess}}><ArrowDownOutlined />15.33%</span></div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xxl={6} lg={12} xs={24}>
|
||||
<Card variant={"borderless"}>
|
||||
<div>{t('dashboard.analysis.visitors')}</div>
|
||||
<div className={"flex items-center justify-between pt-4 pb-2"}>
|
||||
<div className={"text-4xl flex-0"}>
|
||||
1,128
|
||||
</div>
|
||||
<div className={"text-4xl rounded-full flex items-center justify-center"} style={{height: 80, width: 80, background: token.colorPrimaryBg}}>
|
||||
<TeamOutlined style={{ color: token.colorPrimary }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div>{t('dashboard.since.lastWeek')} <span style={{color: token.colorError}}><ArrowUpOutlined />32.60%</span></div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xxl={6} lg={12} xs={24}>
|
||||
<Card variant={"borderless"}>
|
||||
<div>{t('dashboard.analysis.likes')}</div>
|
||||
<div className={"flex items-center justify-between pt-4 pb-2"}>
|
||||
<div className={"text-4xl flex-0"}>
|
||||
668
|
||||
</div>
|
||||
<div className={"text-4xl rounded-full flex items-center justify-center"} style={{height: 80, width: 80, background: token.colorPrimaryBg}}>
|
||||
<LikeOutlined style={{ color: token.colorPrimary }}/>
|
||||
</div>
|
||||
</div>
|
||||
<div>{t('dashboard.since.lastWeek')} <span style={{color: token.colorSuccess}}><ArrowDownOutlined />9.60%</span></div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xl={18} xs={24}>
|
||||
<Card variant={"borderless"}>
|
||||
<ReactECharts style={{width: '100%', height: 460}} option={options} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xl={6} xs={24}>
|
||||
<Card variant={"borderless"}>
|
||||
<ReactECharts style={{width: '100%', height: 460}} option={optionsPie} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xl={12} xs={24}>
|
||||
<Card variant={"borderless"}>
|
||||
<div className={"flex items-center justify-between mb-5"}>
|
||||
<div style={{ fontSize: token.fontSizeLG, fontWeight: token.fontWeightStrong }}>{t('dashboard.analysis.salesRanking')}</div>
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ label: t('dashboard.analysis.month'), value: 'month'},
|
||||
{ label: t('dashboard.analysis.year'), value: 'year'},
|
||||
{ label: t('dashboard.analysis.day'), value: 'day'},
|
||||
]}
|
||||
defaultValue={'day'}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{
|
||||
title: t('dashboard.analysis.article'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text) => <a>{text}</a>,
|
||||
},
|
||||
{
|
||||
title: t('dashboard.analysis.age'),
|
||||
dataIndex: 'age',
|
||||
key: 'age',
|
||||
},
|
||||
{
|
||||
title: t('dashboard.analysis.address'),
|
||||
dataIndex: 'address',
|
||||
key: 'address',
|
||||
},
|
||||
{
|
||||
title: t('dashboard.analysis.tags'),
|
||||
key: 'tags',
|
||||
dataIndex: 'tags',
|
||||
render: (_, { tags }) => (
|
||||
<>
|
||||
{tags.map((tag) => {
|
||||
let color = tag.length > 5 ? 'geekblue' : 'green';
|
||||
if (tag === 'loser') {
|
||||
color = 'volcano';
|
||||
}
|
||||
return (
|
||||
<Tag color={color} key={tag}>
|
||||
{tag.toUpperCase()}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('dashboard.analysis.action'),
|
||||
key: 'action',
|
||||
render: (_, record) => (
|
||||
<Space size="middle">
|
||||
<a>{t('dashboard.analysis.invite')} {record.name}</a>
|
||||
<a>{t('dashboard.analysis.delete')}</a>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
dataSource={data}
|
||||
pagination={false}
|
||||
scroll={{x: 800}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xl={12} xs={24}>
|
||||
<Card variant={"borderless"}>
|
||||
<div className={"mb-5"} style={{ fontSize: token.fontSizeLG, fontWeight: token.fontWeightStrong }}>{t('dashboard.analysis.userReviews')}</div>
|
||||
<List
|
||||
dataSource={msgData}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
<Avatar src={`https://xsgames.co/randomusers/avatar.php?g=pixel&key=${index}`} />
|
||||
}
|
||||
title={<a href="https://ant.design">{item.title}</a>}
|
||||
description={t('dashboard.analysis.reviewDescription')}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Index
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
AlipayOutlined,
|
||||
LockOutlined,
|
||||
QqOutlined,
|
||||
TaobaoOutlined,
|
||||
UserOutlined,
|
||||
WechatOutlined,
|
||||
WeiboOutlined,
|
||||
BulbOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Col, Divider, Row, Space, Button, Form, Input, Checkbox, Typography } from 'antd';
|
||||
import {type CSSProperties, useEffect, useState} from 'react';
|
||||
import React from 'react';
|
||||
import useGlobalStore from '@/stores/global';
|
||||
import useAuthStore from '@/stores/user';
|
||||
import {useNavigate} from "react-router";
|
||||
import type { LoginParams } from '@/api/system/sys_user';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { darkColorTheme, defaultColorTheme } from '@/layout/theme';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
|
||||
// 样式定义函数,支持暗黑模式
|
||||
const getBodyStyle = (isDark: boolean): CSSProperties => ({
|
||||
backgroundImage: isDark ? 'url(/static/bg-dark.jpg)' : 'url(/static/bg.png)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
minHeight: '100vh',
|
||||
minWidth: '520px',
|
||||
backgroundColor: isDark ? '#141414' : '#f0f2f5',
|
||||
transition: 'background-color 0.3s ease',
|
||||
});
|
||||
|
||||
const loginBodyStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '40px',
|
||||
position: 'relative',
|
||||
};
|
||||
|
||||
const getLoginCardStyle = (isDark: boolean): CSSProperties => ({
|
||||
background: isDark ? '#1f1f1f' : 'white',
|
||||
borderRadius: '24px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: isDark
|
||||
? '0 8px 24px rgba(0, 0, 0, 0.45)'
|
||||
: '0 8px 24px rgba(0, 0, 0, 0.12)',
|
||||
padding: '48px 32px',
|
||||
position: 'relative',
|
||||
transition: 'all 0.3s ease',
|
||||
width: '400px',
|
||||
});
|
||||
|
||||
const getIconStyle = (isDark: boolean): CSSProperties => ({
|
||||
color: isDark ? 'rgba(255, 255, 255, 0.45)' : 'rgba(0, 0, 0, 0.45)',
|
||||
fontSize: '20px',
|
||||
verticalAlign: 'middle',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
});
|
||||
|
||||
const getIconDivStyle = (isDark: boolean): CSSProperties => ({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: 48,
|
||||
width: 48,
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.12)' : '1px solid #D4D8DD',
|
||||
borderRadius: '50%',
|
||||
transition: 'all 0.3s ease',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const login = useAuthStore(state => state.login);
|
||||
const user = useAuthStore(state => state.userinfo);
|
||||
const { t } = useTranslation();
|
||||
const themeConfig = useGlobalStore(state => state.themeConfig);
|
||||
const setThemeConfig = useGlobalStore(state => state.setThemeConfig);
|
||||
const logo = useGlobalStore(state => state.logo);
|
||||
const title = useGlobalStore(state => state.title);
|
||||
const subtitle = useGlobalStore(state => state.subtitle);
|
||||
const [isDark, setIsDark] = useState(themeConfig.algorithm === 'darkAlgorithm');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if(localStorage.getItem('token') && user) {
|
||||
console.log(t('login.alreadyLoggedIn'));
|
||||
window.location.href = '/';
|
||||
}
|
||||
}, [user, navigate, t]);
|
||||
|
||||
const handleSubmit = (values: LoginParams) => {
|
||||
setLoading(true);
|
||||
login(values).then(() => {
|
||||
window.$message?.success(t('login.success'));
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1000);
|
||||
}).catch((err) => {
|
||||
console.log(err);
|
||||
setLoading(false);
|
||||
})
|
||||
};
|
||||
|
||||
// 暗黑模式切换
|
||||
const toggleTheme = () => {
|
||||
const newIsDark = !isDark;
|
||||
setIsDark(newIsDark);
|
||||
setThemeConfig({
|
||||
...themeConfig,
|
||||
...newIsDark ? darkColorTheme : defaultColorTheme,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Row style={getBodyStyle(isDark)}>
|
||||
<Col lg={12} xs={24} style={loginBodyStyle}>
|
||||
<div style={getLoginCardStyle(isDark)}>
|
||||
{/* 右上角工具栏 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
zIndex: 10,
|
||||
}}>
|
||||
<Space size="middle">
|
||||
<LanguageSwitcher size={"large"} type={'text'} />
|
||||
<Button
|
||||
icon={<BulbOutlined />}
|
||||
size="large"
|
||||
type="text"
|
||||
onClick={toggleTheme}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Logo and Title */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<img src={logo} alt="logo" style={{ height: 44, marginBottom: 16 }} />
|
||||
<Typography.Title level={3} style={{ marginBottom: 8, color: isDark ? '#fff' : undefined }}>{title}</Typography.Title>
|
||||
<Typography.Text type="secondary">{subtitle}</Typography.Text>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
initialValues={{ remember: true }}
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
label={t('login.username')}
|
||||
rules={[{ required: true, message: t('login.usernameRequired') }]}
|
||||
>
|
||||
<Input
|
||||
size="large"
|
||||
variant="filled"
|
||||
prefix={<UserOutlined />}
|
||||
placeholder={t('login.usernamePlaceholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
label={t('login.password')}
|
||||
rules={[{ required: true, message: t('login.passwordRequired') }]}
|
||||
>
|
||||
<Input.Password
|
||||
size="large"
|
||||
variant="filled"
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('login.passwordPlaceholder')}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Form.Item name="remember" valuePropName="checked" noStyle>
|
||||
<Checkbox>{t('login.remember')}</Checkbox>
|
||||
</Form.Item>
|
||||
<a>{t('login.forgotPassword')}</a>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
|
||||
{t('login.submit') || '登录'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider plain>{t('login.otherLogin')}</Divider>
|
||||
<Space align="center" size={24} style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<div style={getIconDivStyle(isDark)}>
|
||||
<QqOutlined style={{ ...getIconStyle(isDark), color: 'rgb(123, 212, 239)' }} />
|
||||
</div>
|
||||
<div style={getIconDivStyle(isDark)}>
|
||||
<WechatOutlined style={{ ...getIconStyle(isDark), color: 'rgb(51, 204, 0)' }} />
|
||||
</div>
|
||||
<div style={getIconDivStyle(isDark)}>
|
||||
<AlipayOutlined style={{ ...getIconStyle(isDark), color: '#1677FF' }} />
|
||||
</div>
|
||||
<div style={getIconDivStyle(isDark)}>
|
||||
<TaobaoOutlined style={{ ...getIconStyle(isDark), color: '#FF6A10' }} />
|
||||
</div>
|
||||
<div style={getIconDivStyle(isDark)}>
|
||||
<WeiboOutlined style={{ ...getIconStyle(isDark), color: '#e71f19' }} />
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
</Col>
|
||||
<Col lg={12} xs={24}></Col>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,338 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Spin,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {getAiList, getAiConfig, saveAiConfig} from '@/api/system/sysAi.ts';
|
||||
import type {AiLab, AiList} from '@/domain/iAi.ts';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender';
|
||||
import XinForm, { type XinFormRef } from '@/components/XinForm';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface AiResponse {
|
||||
default: string;
|
||||
providers: Record<string, Record<string, any>>;
|
||||
}
|
||||
|
||||
const AiConfig: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const formRef = useRef<XinFormRef>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [aiList, setAiList] = useState<AiList>({
|
||||
default: []
|
||||
});
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
const [testError, setTestError] = useState<string | null>(null);
|
||||
|
||||
const dependency = (lab: AiLab): FormColumn<AiResponse>['dependency'] => ({
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === lab
|
||||
})
|
||||
|
||||
const apikeyColumn = (lab: AiLab): FormColumn<AiResponse> => ({
|
||||
dataIndex: ['providers', lab, 'key'],
|
||||
valueType: 'password',
|
||||
title: t('system.ai.provider.api_key', { lab: lab }),
|
||||
fieldProps: { placeholder: 'sk-...' },
|
||||
colProps: { span: 16 },
|
||||
dependency: dependency(lab)
|
||||
})
|
||||
|
||||
const urlColumn = (lab: AiLab, url: string = ''): FormColumn<AiResponse> => ({
|
||||
dataIndex: ['providers', lab, 'url'],
|
||||
valueType: 'text',
|
||||
title: t('system.ai.provider.url', { lab: lab }),
|
||||
fieldProps: { placeholder: t('system.ai.provider.url.placeholder', {url: url}) },
|
||||
colProps: { span: 8 },
|
||||
dependency: dependency(lab)
|
||||
})
|
||||
|
||||
const anthropicColumns: FormColumn<AiResponse>[] = [
|
||||
apikeyColumn('anthropic'),
|
||||
urlColumn('anthropic', 'https://api.anthropic.com/v1')
|
||||
];
|
||||
|
||||
const openaiColumns: FormColumn<AiResponse>[] = [
|
||||
apikeyColumn('openai'),
|
||||
urlColumn('openai', 'https://api.openai.com/v1'),
|
||||
];
|
||||
|
||||
const geminiColumns: FormColumn<AiResponse>[] = [
|
||||
apikeyColumn('gemini'),
|
||||
urlColumn('gemini', 'https://generativelanguage.googleapis.com/v1beta/')
|
||||
];
|
||||
|
||||
const ollamaColumns: FormColumn<AiResponse>[] = [
|
||||
apikeyColumn('ollama'),
|
||||
urlColumn('ollama', 'http://localhost:11434')
|
||||
];
|
||||
|
||||
const azureColumns: FormColumn<AiResponse>[] = [
|
||||
apikeyColumn('azure'),
|
||||
urlColumn('azure', 'https://<resource>.openai.azure.com'),
|
||||
{
|
||||
dataIndex: ['providers', 'azure', 'api_version'],
|
||||
valueType: 'text',
|
||||
title: t('system.ai.azure.api_version'),
|
||||
fieldProps: { placeholder: '2025-04-01-preview' },
|
||||
colProps: { span: 8 },
|
||||
dependency: dependency('azure')
|
||||
},
|
||||
{
|
||||
dataIndex: ['providers', 'azure', 'deployment'],
|
||||
valueType: 'text',
|
||||
title: t('system.ai.azure.deployment'),
|
||||
fieldProps: { placeholder: 'gpt-4o' },
|
||||
colProps: { span: 8 },
|
||||
dependency: dependency('azure')
|
||||
},
|
||||
{
|
||||
dataIndex: ['providers', 'azure', 'embedding_deployment'],
|
||||
valueType: 'text',
|
||||
title: t('system.ai.azure.embedding_deployment'),
|
||||
fieldProps: { placeholder: 'text-embedding-3-small' },
|
||||
colProps: { span: 8 },
|
||||
dependency: dependency('azure')
|
||||
},
|
||||
{
|
||||
dataIndex: ['providers', 'azure', 'image_deployment'],
|
||||
valueType: 'text',
|
||||
title: t('system.ai.azure.image_deployment'),
|
||||
fieldProps: { placeholder: 'gpt-image-1' },
|
||||
colProps: { span: 12 },
|
||||
dependency: dependency('azure')
|
||||
},
|
||||
]
|
||||
|
||||
const bedrockColumns: FormColumn<AiResponse>[] = [
|
||||
{
|
||||
dataIndex: ['providers', 'bedrock', 'region'],
|
||||
valueType: 'text',
|
||||
title: t('system.ai.bedrock.region'),
|
||||
fieldProps: { placeholder: 'us-east-1' },
|
||||
colProps: { span: 12 },
|
||||
dependency: dependency('bedrock')
|
||||
},
|
||||
apikeyColumn('bedrock'),
|
||||
{
|
||||
dataIndex: ['providers', 'bedrock', 'access_key_id'],
|
||||
valueType: 'password',
|
||||
title: t('system.ai.bedrock.access_key_id'),
|
||||
fieldProps: { placeholder: t('system.ai.provider.api_key.placeholder') },
|
||||
colProps: { span: 12 },
|
||||
dependency: dependency('bedrock')
|
||||
},
|
||||
{
|
||||
dataIndex: ['providers', 'bedrock', 'secret_access_key'],
|
||||
valueType: 'password',
|
||||
title: t('system.ai.bedrock.secret_access_key'),
|
||||
fieldProps: { placeholder: t('system.ai.provider.api_key.placeholder') },
|
||||
colProps: { span: 12 },
|
||||
dependency: dependency('bedrock')
|
||||
},
|
||||
{
|
||||
dataIndex: ['providers', 'bedrock', 'session_token'],
|
||||
valueType: 'password',
|
||||
title: t('system.ai.bedrock.session_token'),
|
||||
fieldProps: { placeholder: t('system.ai.provider.api_key.placeholder') },
|
||||
colProps: { span: 24 },
|
||||
dependency: dependency('bedrock')
|
||||
},
|
||||
]
|
||||
|
||||
const columns: FormColumn<AiResponse>[] = [
|
||||
{
|
||||
dataIndex: 'default',
|
||||
valueType: 'radio',
|
||||
title: t('system.ai.default'),
|
||||
tooltip: t('system.ai.default.tooltip'),
|
||||
colProps: { span: 24 },
|
||||
fieldProps: {
|
||||
options: aiList.default.map(item => ({
|
||||
label: item,
|
||||
value: item
|
||||
}))
|
||||
}
|
||||
},
|
||||
...anthropicColumns,
|
||||
...openaiColumns,
|
||||
...geminiColumns,
|
||||
...azureColumns,
|
||||
...bedrockColumns,
|
||||
...ollamaColumns,
|
||||
apikeyColumn('cohere'),
|
||||
apikeyColumn('deepseek'),
|
||||
apikeyColumn('eleven'),
|
||||
apikeyColumn('groq'),
|
||||
apikeyColumn('jina'),
|
||||
apikeyColumn('mistral'),
|
||||
apikeyColumn('openrouter'),
|
||||
apikeyColumn('voyageai'),
|
||||
apikeyColumn('xai'),
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
getAiList().then(res => (
|
||||
res.data.data && setAiList(res.data.data)
|
||||
))
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
const { data } = await getAiConfig();
|
||||
if (data.success) {
|
||||
form.setFieldsValue(data.data);
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
await saveAiConfig(values);
|
||||
message.success(t('system.ai.save.success'));
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
setTestError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
||||
const response = await fetch(`${baseUrl}/system/ai/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'text/event-stream',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.msg || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error('Stream not supported');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let resultText = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
// Keep the last (potentially incomplete) line in buffer
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const payload = line.slice(6).trim();
|
||||
if (payload === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload);
|
||||
if (event.type === 'text_delta' && event.delta) {
|
||||
resultText += event.delta;
|
||||
setTestResult(resultText);
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
message.success(t('system.ai.test.success'));
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || 'Connection test failed';
|
||||
setTestError(msg);
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.ai.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.ai.page.description')}</Text>
|
||||
</div>
|
||||
<Row gutter={24}>
|
||||
<Col span={16}>
|
||||
<Card variant={'borderless'}>
|
||||
<XinForm
|
||||
columns={columns}
|
||||
layout="vertical"
|
||||
formRef={formRef}
|
||||
form={form}
|
||||
grid={true}
|
||||
onFinish={onFinish}
|
||||
rowProps={{
|
||||
gutter: [10, 0],
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card title={t('system.ai.test.title')}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text type="secondary">{t('system.ai.test.hint')}</Text>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={testing}
|
||||
onClick={handleTestConnection}
|
||||
>
|
||||
{testing ? t('system.ai.test.testing') : t('system.ai.test.button')}
|
||||
</Button>
|
||||
|
||||
{testing && !testResult && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Spin description={t('system.ai.test.testing')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<Alert
|
||||
type="success"
|
||||
title={t('system.ai.test.success')}
|
||||
description={testResult}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
{testError && (
|
||||
<Alert
|
||||
type="error"
|
||||
title={t('system.ai.test.error')}
|
||||
description={testError}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiConfig;
|
||||
@@ -0,0 +1,630 @@
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Menu,
|
||||
message,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Row,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {DeleteOutlined, EditOutlined, PlusOutlined, SaveOutlined, SettingOutlined,} from '@ant-design/icons';
|
||||
import XinForm from '@/components/XinForm';
|
||||
import type { XinFormRef } from '@/components/XinForm/typings';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender/typings';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import type {IConfigGroup} from '@/domain/iConfigGroup.ts';
|
||||
import type {IConfigItem} from '@/domain/iConfigItem.ts';
|
||||
import {
|
||||
createConfigGroup,
|
||||
createConfigItem,
|
||||
deleteConfigGroup,
|
||||
deleteConfigItem,
|
||||
getConfigGroupList,
|
||||
getConfigItemList,
|
||||
saveConfigItems,
|
||||
updateConfigGroup,
|
||||
updateConfigItem,
|
||||
} from '@/api/system/sys_config.ts';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const ConfigManagement: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
/** 表单组件类型选项 */
|
||||
const FORM_COMPONENT_OPTIONS = [
|
||||
{ label: t('system.config.component.Input'), value: 'Input' },
|
||||
{ label: t('system.config.component.TextArea'), value: 'TextArea' },
|
||||
{ label: t('system.config.component.InputNumber'), value: 'InputNumber' },
|
||||
{ label: t('system.config.component.Switch'), value: 'Switch' },
|
||||
{ label: t('system.config.component.Radio'), value: 'Radio' },
|
||||
{ label: t('system.config.component.Checkbox'), value: 'Checkbox' },
|
||||
];
|
||||
const [configGroups, setConfigGroups] = useState<IConfigGroup[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<number>();
|
||||
const [selectedGroupKey, setSelectedGroupKey] = useState<string>();
|
||||
const [configItems, setConfigItems] = useState<IConfigItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [itemsLoading, setItemsLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 设置组表单
|
||||
const [editingGroup, setEditingGroup] = useState<IConfigGroup | null>(null);
|
||||
const groupFormRef = useRef<XinFormRef>(null);
|
||||
|
||||
// 设置项表单
|
||||
const [editingItem, setEditingItem] = useState<IConfigItem | null>(null);
|
||||
const itemFormRef = useRef<XinFormRef>(null);
|
||||
|
||||
// 设置项值表单
|
||||
const [valuesForm] = Form.useForm();
|
||||
|
||||
/** 加载设置组列表 */
|
||||
const loadConfigGroups = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data } = await getConfigGroupList();
|
||||
const groups = data.data || [];
|
||||
setConfigGroups(groups);
|
||||
|
||||
// 如果当前没有选中的组,选中第一个
|
||||
if (!selectedGroupId && groups.length > 0) {
|
||||
setSelectedGroupId(groups[0].id);
|
||||
setSelectedGroupKey(groups[0].key);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 加载设置项列表 */
|
||||
const loadConfigItems = async (groupId?: number) => {
|
||||
if (!groupId) return;
|
||||
|
||||
try {
|
||||
setItemsLoading(true);
|
||||
const { data } = await getConfigItemList(groupId);
|
||||
const items = data.data || [];
|
||||
setConfigItems(items);
|
||||
|
||||
// 初始化表单值
|
||||
const initialValues: { [key: string]: any } = {};
|
||||
items.forEach(item => {
|
||||
if(!item.type || ['Input', 'TextArea', 'Radio'].includes(item.type)) {
|
||||
initialValues[`item_${item.id}`] = String(item.values);
|
||||
}
|
||||
if(item.type && item.type === 'InputNumber') {
|
||||
initialValues[`item_${item.id}`] = Number(item.values);
|
||||
}
|
||||
if(item.type && item.type === 'Switch') {
|
||||
if(item.values === '0' || item.values === 'false') {
|
||||
initialValues[`item_${item.id}`] = false;
|
||||
}else {
|
||||
initialValues[`item_${item.id}`] = Boolean(item.values);
|
||||
}
|
||||
}
|
||||
if(item.type && item.type === 'Checkbox') {
|
||||
try {
|
||||
initialValues[`item_${item.id}`] = JSON.parse(item.values || '[]');
|
||||
} catch {
|
||||
initialValues[`item_${item.id}`] = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log(initialValues)
|
||||
valuesForm.setFieldsValue(initialValues);
|
||||
} finally {
|
||||
setItemsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfigGroups();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedGroupId) {
|
||||
loadConfigItems(selectedGroupId);
|
||||
}
|
||||
}, [selectedGroupId]);
|
||||
|
||||
/** 打开新增设置组对话框 */
|
||||
const handleAddGroup = () => {
|
||||
setEditingGroup(null);
|
||||
groupFormRef.current?.resetFields();
|
||||
groupFormRef.current?.open();
|
||||
};
|
||||
|
||||
/** 打开编辑设置组对话框 */
|
||||
const handleEditGroup = (group: IConfigGroup) => {
|
||||
setEditingGroup(group);
|
||||
groupFormRef.current?.setFieldsValue(group);
|
||||
groupFormRef.current?.open();
|
||||
};
|
||||
|
||||
/** 保存设置组 */
|
||||
const handleSaveGroup = async (values: IConfigGroup) => {
|
||||
try {
|
||||
if (editingGroup) {
|
||||
await updateConfigGroup(editingGroup.id!, values);
|
||||
message.success(t('system.config.group.updateSuccess'));
|
||||
} else {
|
||||
await createConfigGroup(values);
|
||||
message.success(t('system.config.group.createSuccess'));
|
||||
}
|
||||
groupFormRef.current?.close();
|
||||
await loadConfigGroups();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 删除设置组 */
|
||||
const handleDeleteGroup = async (id: number) => {
|
||||
try {
|
||||
await deleteConfigGroup(id);
|
||||
message.success(t('system.config.group.deleteSuccess'));
|
||||
if (selectedGroupId === id) {
|
||||
setSelectedGroupId(undefined);
|
||||
setSelectedGroupKey(undefined);
|
||||
}
|
||||
await loadConfigGroups();
|
||||
} catch (error) {
|
||||
console.error('删除设置组失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 打开新增设置项对话框 */
|
||||
const handleAddItem = () => {
|
||||
setEditingItem(null);
|
||||
itemFormRef.current?.resetFields();
|
||||
itemFormRef.current?.setFieldsValue({ group_id: selectedGroupId });
|
||||
itemFormRef.current?.open();
|
||||
};
|
||||
|
||||
/** 打开编辑设置项对话框 */
|
||||
const handleEditItem = (item: IConfigItem) => {
|
||||
setEditingItem(item);
|
||||
itemFormRef.current?.setFieldsValue(item);
|
||||
itemFormRef.current?.open();
|
||||
};
|
||||
|
||||
/** 保存设置项 */
|
||||
const handleSaveItem = async (values: IConfigItem) => {
|
||||
try {
|
||||
if (editingItem) {
|
||||
await updateConfigItem(editingItem.id!, { ...values, group_id: selectedGroupId });
|
||||
message.success(t('system.config.item.updateSuccess'));
|
||||
} else {
|
||||
await createConfigItem({ ...values, group_id: selectedGroupId });
|
||||
message.success(t('system.config.item.createSuccess'));
|
||||
}
|
||||
itemFormRef.current?.close();
|
||||
await loadConfigItems(selectedGroupId);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** 删除设置项 */
|
||||
const handleDeleteItem = async (id: number) => {
|
||||
try {
|
||||
await deleteConfigItem(id);
|
||||
message.success(t('system.config.item.deleteSuccess'));
|
||||
await loadConfigItems(selectedGroupId);
|
||||
} catch (error) {
|
||||
console.error('删除设置项失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/** 批量保存所有设置项值 */
|
||||
const handleSaveAll = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const values = valuesForm.getFieldsValue();
|
||||
const configs = configItems.map(item => {
|
||||
const value = values[`item_${item.id}`];
|
||||
if(!item.type || ['Input', 'TextArea', 'Radio'].includes(item.type)) {
|
||||
return { id: item.id!, value: String(value) };
|
||||
}
|
||||
if(item.type && item.type === 'InputNumber') {
|
||||
return { id: item.id!, value: String(value) };
|
||||
}
|
||||
if(item.type && item.type === 'Switch') {
|
||||
if(value === '0' || value === 'false' || !!value) {
|
||||
return { id: item.id!, value: 'false' };
|
||||
}else {
|
||||
return { id: item.id!, value: 'true' };
|
||||
}
|
||||
}
|
||||
if(item.type && item.type === 'Checkbox') {
|
||||
return { id: item.id!, value: JSON.stringify(value) };
|
||||
}
|
||||
return { id: item.id!, value: value };
|
||||
});
|
||||
await saveConfigItems(configs);
|
||||
message.success(t('system.config.item.updateSuccess'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 渲染设置项的表单组件 */
|
||||
const renderConfigItemComponent = (item: IConfigItem) => {
|
||||
const fieldName = `item_${item.id}`;
|
||||
const componentType = item.type || 'Input';
|
||||
const usage = `site_config('${selectedGroupKey}.${item.key}')`;
|
||||
|
||||
// 解析options
|
||||
let options: any[] = [];
|
||||
if (item.options_json) {
|
||||
try {
|
||||
options = JSON.parse(item.options_json);
|
||||
} catch {
|
||||
console.error('options解析失败:', item.options_json);
|
||||
}
|
||||
}
|
||||
|
||||
// 解析props
|
||||
let componentProps: any = {};
|
||||
if (item.props_json) {
|
||||
try {
|
||||
componentProps = JSON.parse(item.props_json);
|
||||
} catch {
|
||||
console.error('props解析失败:', item.props_json);
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义Label组件
|
||||
const CustomLabel = () => (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Space>
|
||||
<Text strong>{item.title}</Text>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleEditItem(item)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('system.config.item.deleteConfirm')}
|
||||
onConfirm={() => handleDeleteItem(item.id!)}
|
||||
okText={t('system.config.confirm.ok')}
|
||||
cancelText={t('system.config.confirm.cancel')}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.describe} <Text code copyable>{usage}</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
let component;
|
||||
switch (componentType) {
|
||||
case 'Input':
|
||||
component = (
|
||||
<Input
|
||||
placeholder={item.describe || t('system.config.form.placeholder.input')}
|
||||
{...componentProps}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'TextArea':
|
||||
component = (
|
||||
<TextArea
|
||||
placeholder={item.describe || t('system.config.form.placeholder.input')}
|
||||
rows={4}
|
||||
{...componentProps}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'InputNumber':
|
||||
component = (
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
placeholder={item.describe || t('system.config.form.placeholder.input')}
|
||||
{...componentProps}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'Switch':
|
||||
component = <Switch {...componentProps} />;
|
||||
break;
|
||||
case 'Radio':
|
||||
component = <Radio.Group options={options} {...componentProps} />;
|
||||
break;
|
||||
case 'Checkbox':
|
||||
component = <Checkbox.Group options={options} {...componentProps} />;
|
||||
break;
|
||||
default:
|
||||
component = <Input placeholder={item.describe || t('system.config.form.placeholder.input')} {...componentProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<CustomLabel />
|
||||
<Form.Item name={fieldName} noStyle>
|
||||
{component}
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 设置组表单列 */
|
||||
const groupColumns: FormColumn<IConfigGroup>[] = [
|
||||
{
|
||||
title: t('system.config.group.field.title'),
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: t('system.config.group.field.title.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.config.group.field.key'),
|
||||
dataIndex: 'key',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: t('system.config.group.field.key.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.config.group.field.remark'),
|
||||
dataIndex: 'remark',
|
||||
valueType: 'textarea',
|
||||
colProps: {span: 24},
|
||||
},
|
||||
];
|
||||
|
||||
/** 设置项表单列 */
|
||||
const itemColumns: FormColumn<IConfigItem>[] = [
|
||||
{
|
||||
title: t('system.config.item.field.key'),
|
||||
dataIndex: 'key',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: t('system.config.item.field.key.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.title'),
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: t('system.config.item.field.title.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.type'),
|
||||
dataIndex: 'type',
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: FORM_COMPONENT_OPTIONS,
|
||||
},
|
||||
rules: [{ required: true, message: t('system.config.item.field.type.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.sort'),
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.describe'),
|
||||
dataIndex: 'describe',
|
||||
valueType: 'textarea',
|
||||
colProps: { span: 24 },
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.options'),
|
||||
dataIndex: 'options',
|
||||
valueType: 'textarea',
|
||||
tooltip: t('system.config.item.field.options.tooltip'),
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.props'),
|
||||
dataIndex: 'props',
|
||||
valueType: 'textarea',
|
||||
tooltip: t('system.config.item.field.props.tooltip'),
|
||||
},
|
||||
{
|
||||
title: t('system.config.item.field.values'),
|
||||
dataIndex: 'values',
|
||||
valueType: 'text',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.config.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.config.page.description')}</Text>
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* 左侧设置组菜单 */}
|
||||
<Col xs={24} lg={4}>
|
||||
<Card
|
||||
styles={{
|
||||
header: { paddingInline: 16, paddingBlock: 0, minHeight: 48 },
|
||||
body: { padding: 16, minHeight: 52 },
|
||||
}}
|
||||
title={(
|
||||
<Space>
|
||||
<SettingOutlined />
|
||||
{t('system.config.group.title')}
|
||||
</Space>
|
||||
)}
|
||||
extra={
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddGroup}
|
||||
>
|
||||
{t('system.config.group.add')}
|
||||
</Button>
|
||||
}
|
||||
loading={loading}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={selectedGroupId ? [String(selectedGroupId)] : []}
|
||||
items={configGroups.map(group => ({
|
||||
key: String(group.id),
|
||||
label: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{group.title}</span>
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditGroup(group);
|
||||
}}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('system.config.group.deleteConfirm')}
|
||||
description={t('system.config.group.deleteWarning')}
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDeleteGroup(group.id!);
|
||||
}}
|
||||
okText={t('system.config.confirm.ok')}
|
||||
cancelText={t('system.config.confirm.cancel')}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
),
|
||||
onClick: () => {
|
||||
setSelectedGroupId(group.id);
|
||||
setSelectedGroupKey(group.key);
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 右侧设置项 */}
|
||||
<Col xs={24} lg={20}>
|
||||
<Card
|
||||
styles={{
|
||||
header: { paddingInline: 16, paddingBlock: 0, minHeight: 48 },
|
||||
body: { padding: 16, minHeight: 52 },
|
||||
}}
|
||||
title={t('system.config.item.title')}
|
||||
extra={
|
||||
selectedGroupId && (
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
onClick={handleSaveAll}
|
||||
>
|
||||
{t('system.config.save.button')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddItem}
|
||||
>
|
||||
{t('system.config.item.add')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{!selectedGroupId ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0', color: '#999' }}>
|
||||
{t('system.config.item.selectGroup')}
|
||||
</div>
|
||||
) : (
|
||||
<Spin spinning={itemsLoading}>
|
||||
<Form form={valuesForm} layout="vertical">
|
||||
{configItems.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0', color: '#999' }}>
|
||||
{t('system.config.item.empty')}
|
||||
</div>
|
||||
) : (
|
||||
configItems.map((item, index) => (
|
||||
<div key={item.id} style={{ position: 'relative' }}>
|
||||
<div style={{ paddingRight: 80 }}>
|
||||
{renderConfigItemComponent(item)}
|
||||
</div>
|
||||
{index < configItems.length - 1 && <Divider />}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 设置组表单弹窗 */}
|
||||
<XinForm<IConfigGroup>
|
||||
formRef={groupFormRef}
|
||||
layoutType="ModalForm"
|
||||
columns={groupColumns}
|
||||
onFinish={handleSaveGroup}
|
||||
modalProps={{
|
||||
title: editingGroup ? t('system.config.group.edit') : t('system.config.group.create'),
|
||||
onCancel: () => groupFormRef.current?.close(),
|
||||
forceRender: true,
|
||||
width: 800
|
||||
}}
|
||||
grid
|
||||
layout={'vertical'}
|
||||
colProps={{span: 8}}
|
||||
rowProps={{gutter: [30, 0]}}
|
||||
trigger={<span style={{display: 'none'}} />}
|
||||
/>
|
||||
|
||||
{/* 设置项表单弹窗 */}
|
||||
<XinForm<IConfigItem>
|
||||
formRef={itemFormRef}
|
||||
layoutType="ModalForm"
|
||||
columns={itemColumns}
|
||||
onFinish={handleSaveItem}
|
||||
grid={true}
|
||||
colProps={{ span: 12 }}
|
||||
rowProps={{gutter: 30}}
|
||||
layout={'vertical'}
|
||||
modalProps={{
|
||||
title: editingItem ? t('system.config.item.edit') : t('system.config.item.create'),
|
||||
onCancel: () => itemFormRef.current?.close(),
|
||||
forceRender: true,
|
||||
width: 800,
|
||||
}}
|
||||
trigger={<span style={{display: 'none'}} />}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigManagement;
|
||||
@@ -0,0 +1,452 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
type CardProps,
|
||||
Col,
|
||||
message,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Space, Table, type TableProps, Tag,
|
||||
Tree,
|
||||
type TreeDataNode, type TreeProps, Typography
|
||||
} from "antd";
|
||||
import {BankOutlined, DeleteOutlined, PlusOutlined, TeamOutlined, UserOutlined} from "@ant-design/icons";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import {queryDept, createDept, updateDept, deleteDept, deptUsers} from "@/api/system/sys_dept.ts";
|
||||
import type {ISysDept} from "@/domain/iSysDept.ts";
|
||||
import {isArray, omit} from 'lodash';
|
||||
import type {FormColumn} from "@/components/XinFormField/FieldRender/typings.ts";
|
||||
import XinForm, {type XinFormRef} from "@/components/XinForm";
|
||||
import * as React from "react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import AuthButton from "@/components/AuthButton";
|
||||
import useAuth from "@/hooks/useAuth.ts";
|
||||
import type ISysUser from "@/domain/iSysUser.ts";
|
||||
|
||||
const deptMap = new Map<string, ISysDept>();
|
||||
|
||||
interface TableParams {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const Dept = () => {
|
||||
const {t} = useTranslation();
|
||||
const {auth} = useAuth();
|
||||
/** 部门信息表单 */
|
||||
const formRef = useRef<XinFormRef<ISysDept>>(null);
|
||||
/** 新增表单 */
|
||||
const modalFormRef = useRef<XinFormRef<ISysDept>>(null);
|
||||
/** 当前选中的部门 key */
|
||||
const [selectKey, setSelectKey] = useState<string>('');
|
||||
/** 当前(多选框)选择的部门 keys */
|
||||
const [checkedKeys , setCheckedKeys] = useState<React.Key[]>([]);
|
||||
/** 标签页 KEY */
|
||||
const [tabKey, setTabKey] = useState<string>('info');
|
||||
/** 标签页 Tab */
|
||||
const tabList: CardProps['tabList'] = [
|
||||
{
|
||||
key: 'info',
|
||||
label: t("system.dept.tab.info")
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
label: t("system.dept.tab.users"),
|
||||
disabled: !auth("system.dept.users")
|
||||
},
|
||||
];
|
||||
/** 部门数据 */
|
||||
const [deptData, setDeptData] = useState<TreeDataNode[]>([]);
|
||||
/** 部门用户列表数据 */
|
||||
const [users, setUsers] = useState<ISysUser[]>([]);
|
||||
/** 部门用户列表表格参数 */
|
||||
const [tableParams, setTableParams] = useState<TableParams>({ page: 1, pageSize: 10, total: 0 });
|
||||
/** 加载状态 */
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [tableLoading, setTableLoading] = useState<boolean>(false);
|
||||
/** 获取部门用户列表 */
|
||||
const selectUsers = async (id: number, params?: TableParams) => {
|
||||
try {
|
||||
setTableLoading(true);
|
||||
const data = await deptUsers(id, omit(params || tableParams, 'total'));
|
||||
setUsers(data.data.data!.data);
|
||||
setTableParams({
|
||||
...tableParams,
|
||||
total: data.data.data!.total
|
||||
})
|
||||
} finally {
|
||||
setTableLoading(false);
|
||||
}
|
||||
}
|
||||
/** 刷新部门数据 */
|
||||
const refreshDept = async () => {
|
||||
setLoading(true);
|
||||
/** 转换部门类型 */
|
||||
const convertDeptToTreeData = (depts: ISysDept[]): TreeDataNode[] => {
|
||||
if (!depts?.length) return [];
|
||||
return depts.map(dept => {
|
||||
deptMap.set(dept.id!.toString(), omit(dept, 'children'));
|
||||
return {
|
||||
title: dept.name || t("system.dept.tab.users"),
|
||||
key: dept.id?.toString() || '',
|
||||
icon: dept.type === 0 ? <BankOutlined /> : dept.type === 1 ? <TeamOutlined /> : <UserOutlined />,
|
||||
children: convertDeptToTreeData(dept.children || [])
|
||||
}
|
||||
});
|
||||
};
|
||||
try {
|
||||
const res = await queryDept();
|
||||
if(res.data.data && res.data.data.length > 0) {
|
||||
deptMap.clear();
|
||||
setDeptData(convertDeptToTreeData(res.data.data));
|
||||
if(!selectKey || !deptMap.has(selectKey)) {
|
||||
setSelectKey(res.data.data[0].id!.toString());
|
||||
formRef.current?.setFieldsValue(omit(res.data.data[0], 'children'));
|
||||
if(auth("system.dept.users")) {
|
||||
await selectUsers(res.data.data[0].id!);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
/** 新增部门点击事件 */
|
||||
const addChange = (children: boolean = false) => {
|
||||
modalFormRef.current?.resetFields();
|
||||
modalFormRef.current?.setFieldsValue({
|
||||
parent_id: children ? Number(selectKey) : 0,
|
||||
sort: 0,
|
||||
status: 0,
|
||||
type: 0
|
||||
})
|
||||
modalFormRef.current?.open();
|
||||
}
|
||||
/** 部门选择事件 */
|
||||
const onSelect: TreeProps['onSelect'] = (key) => {
|
||||
if(key && key.length >= 1) {
|
||||
setSelectKey(key[0].toString());
|
||||
const deptInfo = deptMap.get(key[0].toString());
|
||||
if (deptInfo) {
|
||||
formRef.current?.setFieldsValue(deptInfo);
|
||||
}
|
||||
selectUsers(Number(key[0])).then();
|
||||
}
|
||||
}
|
||||
/** 部门多选框选中事件 */
|
||||
const onCheck: TreeProps['onCheck'] = (checkedKeys) => {
|
||||
if(isArray(checkedKeys)) {
|
||||
setCheckedKeys(checkedKeys);
|
||||
}else {
|
||||
setCheckedKeys(checkedKeys.checked);
|
||||
}
|
||||
}
|
||||
/** 表单提交 */
|
||||
const onSubmit = async (data: ISysDept, update: boolean = false) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if(!update) {
|
||||
await createDept(data);
|
||||
message.success(t("system.dept.createSuccess"));
|
||||
modalFormRef.current?.close();
|
||||
}else {
|
||||
await updateDept(Number(selectKey), data);
|
||||
message.success(t("system.dept.updateSuccess"));
|
||||
}
|
||||
await refreshDept();
|
||||
}finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
/** 批量删除 */
|
||||
const onDeleteConfirm = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await deleteDept(checkedKeys);
|
||||
await refreshDept();
|
||||
setCheckedKeys([]);
|
||||
message.success(t("system.dept.deleteSuccess"));
|
||||
}finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
/** 表单列数据 */
|
||||
const columns: FormColumn<ISysDept>[] = [
|
||||
{
|
||||
title: t("system.dept.column.name"),
|
||||
valueType: 'text',
|
||||
dataIndex: 'name',
|
||||
rules: [{required: true, message: t("system.dept.column.name.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.code"),
|
||||
valueType: 'text',
|
||||
dataIndex: 'code',
|
||||
rules: [{required: true, message: t("system.dept.column.code.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.type"),
|
||||
valueType: 'radioButton',
|
||||
dataIndex: 'type',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 0, label: t("system.dept.column.type.0") },
|
||||
{ value: 1, label: t("system.dept.column.type.1") },
|
||||
{ value: 2, label: t("system.dept.column.type.2") },
|
||||
],
|
||||
},
|
||||
rules: [{required: true, message: t("system.dept.column.type.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.parent"),
|
||||
valueType: 'treeSelect',
|
||||
dataIndex: 'parent_id',
|
||||
fieldProps: {
|
||||
treeData: [
|
||||
{
|
||||
title: t("system.dept.column.parent.0"),
|
||||
value: 0,
|
||||
children: deptData
|
||||
}
|
||||
],
|
||||
fieldNames: { label: 'title', value: 'key' },
|
||||
disabled: true
|
||||
},
|
||||
rules: [{required: true, message: t("system.dept.column.parent.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.email"),
|
||||
valueType: 'text',
|
||||
dataIndex: 'email',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.address"),
|
||||
valueType: 'text',
|
||||
dataIndex: 'address',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.phone"),
|
||||
valueType: 'text',
|
||||
dataIndex: 'phone',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.sort"),
|
||||
valueType: 'digit',
|
||||
dataIndex: 'sort',
|
||||
rules: [{required: true, message: t("system.dept.column.sort.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.status"),
|
||||
valueType: 'radioButton',
|
||||
dataIndex: 'status',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 0, label: t("system.dept.column.status.0") },
|
||||
{ value: 1, label: t("system.dept.column.status.1") },
|
||||
]
|
||||
},
|
||||
rules: [{required: true, message: t("system.dept.column.status.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.dept.column.remark"),
|
||||
valueType: 'textarea',
|
||||
dataIndex: 'remark',
|
||||
},
|
||||
];
|
||||
/** 部门用户列表表格列 */
|
||||
const usersColumns: TableProps<ISysUser>['columns'] = [
|
||||
{
|
||||
title: t("system.dept.users.column.id"),
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.users.column.username"),
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.users.column.nickname"),
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.users.column.nickname"),
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.users.column.mobile"),
|
||||
dataIndex: 'mobile',
|
||||
key: 'mobile',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.dept.users.column.status"),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
align: 'center',
|
||||
render: (value) => (
|
||||
<>
|
||||
{value === 1 && <Tag color={'success'}>{t("system.dept.users.column.status.0")}</Tag>}
|
||||
{value === 0 && <Tag color={'error'}>{t("system.dept.users.column.status.1")}</Tag>}
|
||||
</>
|
||||
)
|
||||
},
|
||||
];
|
||||
/** 初始化 */
|
||||
useEffect(() => { refreshDept() }, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Typography.Title level={3}>{t("system.dept.page.title")}</Typography.Title>
|
||||
<Typography.Text type="secondary">{t("system.dept.page.description")}</Typography.Text>
|
||||
</div>
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col xxl={12} lg={12} xs={24}>
|
||||
<XinForm<ISysDept>
|
||||
layoutType={'ModalForm'}
|
||||
formRef={modalFormRef}
|
||||
modalProps={{
|
||||
title: t("system.dept.createModalTitle"),
|
||||
styles: { body: { paddingTop: 20} },
|
||||
width: 800
|
||||
}}
|
||||
onFinish={async (data: ISysDept) => {
|
||||
await onSubmit(data, false);
|
||||
return true;
|
||||
}}
|
||||
columns={columns}
|
||||
layout={'vertical'}
|
||||
grid
|
||||
rowProps={{gutter: [30, 0]}}
|
||||
colProps={{ span: 12 }}
|
||||
/>
|
||||
<Card
|
||||
title={(
|
||||
<Space>
|
||||
<AuthButton auth={"system.dept.create"}>
|
||||
<Button
|
||||
loading={loading}
|
||||
children={t("system.dept.createButton")}
|
||||
icon={<PlusOutlined />}
|
||||
type={'primary'}
|
||||
onClick={() => addChange()}
|
||||
/>
|
||||
</AuthButton>
|
||||
<AuthButton auth={"system.dept.create"}>
|
||||
<Button
|
||||
loading={loading}
|
||||
children={t("system.dept.createChildrenButton")}
|
||||
icon={<PlusOutlined />}
|
||||
type={'primary'}
|
||||
onClick={() => addChange(true)}
|
||||
/>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
)}
|
||||
variant={'borderless'}
|
||||
loading={loading}
|
||||
styles={{ body: { minHeight: '70vh' } }}
|
||||
>
|
||||
{checkedKeys.length > 0 && (
|
||||
<Alert
|
||||
style={{ marginBottom: 20 }}
|
||||
description={t("system.dept.checkedMessage", {checked: checkedKeys.length})}
|
||||
type="info"
|
||||
action={
|
||||
<Space>
|
||||
<Button size="small" type="primary" onClick={()=> setCheckedKeys([])}>
|
||||
{t("system.dept.unselect")}
|
||||
</Button>
|
||||
<AuthButton auth={"system.dept.delete"}>
|
||||
<Popconfirm
|
||||
okText={t("system.dept.delete.ok")}
|
||||
cancelText={t("system.dept.delete.cancel")}
|
||||
title={t("system.dept.delete.title")}
|
||||
description={t("system.dept.delete.description")}
|
||||
onConfirm={() => onDeleteConfirm()}
|
||||
>
|
||||
<Button type="primary" icon={<DeleteOutlined />} size={'small'} danger loading={loading}/>
|
||||
</Popconfirm>
|
||||
</AuthButton>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Tree
|
||||
checkable
|
||||
treeData={deptData}
|
||||
showIcon={true}
|
||||
checkStrictly={true}
|
||||
selectedKeys={[selectKey]}
|
||||
defaultExpandedKeys={[selectKey]}
|
||||
onSelect={onSelect}
|
||||
checkedKeys={checkedKeys}
|
||||
onCheck={onCheck}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xxl={12} lg={12} xs={24}>
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
tabList={tabList}
|
||||
tabProps={{ accessKey: tabKey }}
|
||||
onTabChange={setTabKey}
|
||||
styles={{ body: { minHeight: '70vh' } }}
|
||||
>
|
||||
<XinForm<ISysDept>
|
||||
formRef={formRef}
|
||||
onFinish={async (data: ISysDept) => {
|
||||
await onSubmit(data, true);
|
||||
return true;
|
||||
}}
|
||||
columns={columns}
|
||||
layout={'horizontal'}
|
||||
style={{ display: tabKey === 'info' ? "block" : "none" }}
|
||||
submitter={{
|
||||
render: (dom) => (
|
||||
<AuthButton auth={"system.dept.update"}>
|
||||
{dom['submit']}
|
||||
</AuthButton>
|
||||
),
|
||||
submitText: t("system.dept.saveInfo")
|
||||
}}
|
||||
/>
|
||||
<AuthButton auth={"system.dept.users"}>
|
||||
<Table<ISysUser>
|
||||
style={{ display: tabKey === 'users' ? "block" : "none" }}
|
||||
dataSource={users}
|
||||
bordered={true}
|
||||
columns={usersColumns}
|
||||
loading={tableLoading}
|
||||
size={'small'}
|
||||
pagination={{
|
||||
current: tableParams.page,
|
||||
pageSize: tableParams.pageSize,
|
||||
total: tableParams.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, pageSize) => {
|
||||
const params = { total: tableParams.total, pageSize, page }
|
||||
setTableParams(params);
|
||||
selectUsers(Number(selectKey), params).then();
|
||||
}
|
||||
}}
|
||||
scroll={{x: 600}}
|
||||
/>
|
||||
</AuthButton>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dept;
|
||||
@@ -0,0 +1,154 @@
|
||||
import XinTable from '@/components/XinTable';
|
||||
import {Badge, Button, Tooltip, Typography} from 'antd';
|
||||
import {UnorderedListOutlined} from '@ant-design/icons';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
import {type IDict} from '@/domain/iDict';
|
||||
import type {XinTableColumn} from '@/components/XinTable/typings';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useNavigate} from 'react-router';
|
||||
import dayjs from 'dayjs';
|
||||
import useDictStore from '@/stores/dict';
|
||||
|
||||
/** 字典类型管理 */
|
||||
export default function DictPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const initDict = useDictStore(state => state.initDict);
|
||||
|
||||
const columns: XinTableColumn<IDict>[] = [
|
||||
{
|
||||
title: t('system.dict.id'),
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
width: 80,
|
||||
sorter: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.dict.name'),
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
colProps: {span: 12},
|
||||
rules: [{required: true, message: t('system.dict.name.required')}],
|
||||
},
|
||||
{
|
||||
title: t('system.dict.code'),
|
||||
dataIndex: 'code',
|
||||
valueType: 'text',
|
||||
colProps: {span: 12},
|
||||
rules: [{required: true, message: t('system.dict.code.required')}],
|
||||
},
|
||||
{
|
||||
title: t('system.dict.status'),
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
filters: [
|
||||
{text: t('system.dict.status.normal'), value: 0},
|
||||
{text: t('system.dict.status.disabled'), value: 1},
|
||||
],
|
||||
colProps: {span: 12},
|
||||
rules: [{required: true, message: t('system.dict.status.required')}],
|
||||
fieldProps: {
|
||||
options: [
|
||||
{label: t('system.dict.status.normal'), value: 0},
|
||||
{label: t('system.dict.status.disabled'), value: 1},
|
||||
],
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 0
|
||||
? <Badge status="success" text={t('system.dict.status.normal')}/>
|
||||
: <Badge status="error" text={t('system.dict.status.disabled')}/>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('system.dict.sort'),
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
colProps: {span: 12},
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
min: 0,
|
||||
style: {width: '100%'}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('system.dict.describe'),
|
||||
dataIndex: 'describe',
|
||||
valueType: 'textarea',
|
||||
colProps: {span: 24},
|
||||
hideInSearch: true,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('system.dict.createdAt'),
|
||||
dataIndex: 'created_at',
|
||||
render: (value: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 160,
|
||||
},
|
||||
];
|
||||
|
||||
// 刷新字典缓存
|
||||
const handleRefreshCache = async () => {
|
||||
await initDict();
|
||||
window.$message?.success(t('system.dict.refreshSuccess'));
|
||||
};
|
||||
|
||||
// 跳转到字典项页面
|
||||
const handleGoToItems = (record: IDict) => {
|
||||
navigate(`/system/dict/item?dictId=${record.id}&dictName=${encodeURIComponent(record.name || '')}&dictCode=${record.code}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.dict.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.dict.page.description')}</Text>
|
||||
</div>
|
||||
<XinTable<IDict>
|
||||
api={'/system/dict/list'}
|
||||
columns={columns}
|
||||
rowKey={'id'}
|
||||
accessName={'system.dict.list'}
|
||||
searchProps={false}
|
||||
formProps={{
|
||||
grid: true,
|
||||
colProps: {span: 12},
|
||||
rowProps: {gutter: [30, 0]},
|
||||
layout: 'vertical'
|
||||
}}
|
||||
modalProps={{ width: 800 }}
|
||||
actionBarRender={(dom) => [
|
||||
dom.add,
|
||||
<Button
|
||||
type="primary"
|
||||
key="refresh"
|
||||
onClick={handleRefreshCache}
|
||||
>
|
||||
{t('system.dict.refreshCache')}
|
||||
</Button>,
|
||||
dom.keywordSearch,
|
||||
]}
|
||||
operateProps={{
|
||||
fixed: 'right',
|
||||
width: 180,
|
||||
}}
|
||||
scroll={{x: 1000}}
|
||||
operateRender={(record, dom) => [
|
||||
<Tooltip title={t('system.dict.manageItems')}>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<UnorderedListOutlined/>}
|
||||
size="small"
|
||||
onClick={() => handleGoToItems(record)}
|
||||
/>
|
||||
</Tooltip>,
|
||||
dom.edit,
|
||||
dom.del
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import XinTable from '@/components/XinTable';
|
||||
import {Badge, Button, Space, Tag, Typography} from 'antd';
|
||||
import type {IDictItem} from '@/domain/iDictItem';
|
||||
import {COLORS} from '@/domain/iDictItem';
|
||||
import type {XinTableColumn} from '@/components/XinTable/typings';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useNavigate, useSearchParams} from 'react-router';
|
||||
import {Create, Update} from "@/api/common/table.ts";
|
||||
import dayjs from 'dayjs';
|
||||
import {LeftOutlined} from "@ant-design/icons";
|
||||
|
||||
/** 字典数据管理 */
|
||||
export default function DictItemPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// 从 URL 参数获取字典信息
|
||||
const dictId = searchParams.get('dictId');
|
||||
const dictName = searchParams.get('dictName') || '';
|
||||
const dictCode = searchParams.get('dictCode') || '';
|
||||
|
||||
const [currentDict, setCurrentDict] = useState({
|
||||
id: dictId ? parseInt(dictId) : 0,
|
||||
name: decodeURIComponent(dictName),
|
||||
code: dictCode,
|
||||
});
|
||||
|
||||
// 监听 URL 参数变化
|
||||
useEffect(() => {
|
||||
if (dictId) {
|
||||
setCurrentDict({
|
||||
id: parseInt(dictId),
|
||||
name: decodeURIComponent(dictName),
|
||||
code: dictCode,
|
||||
});
|
||||
}
|
||||
}, [dictId, dictName, dictCode]);
|
||||
|
||||
const columns: XinTableColumn<IDictItem>[] = [
|
||||
{
|
||||
title: t('system.system.dict.item.id'),
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
width: 80,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.label'),
|
||||
dataIndex: 'label',
|
||||
valueType: 'text',
|
||||
rules: [{required: true, message: t('system.system.dict.item.label.required')}],
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.value'),
|
||||
dataIndex: 'value',
|
||||
valueType: 'text',
|
||||
rules: [{required: true, message: t('system.system.dict.item.value.required')}],
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.color'),
|
||||
dataIndex: 'color',
|
||||
valueType: 'select',
|
||||
colProps: {span: 12},
|
||||
initialValue: 'default',
|
||||
fieldProps: {
|
||||
options: COLORS.map(item => ({
|
||||
label: <Tag color={item.value}>{item.label}</Tag>,
|
||||
value: item.value
|
||||
})),
|
||||
},
|
||||
render: (value: string) => {
|
||||
const item = COLORS.find(i => i.value === value);
|
||||
return <Tag color={value}>{item?.label || value}</Tag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.isDefault'),
|
||||
dataIndex: 'is_default',
|
||||
valueType: 'select',
|
||||
colProps: {span: 12},
|
||||
initialValue: 0,
|
||||
rules: [{required: true, message: t('system.system.dict.item.isDefault.required')}],
|
||||
fieldProps: {
|
||||
options: [
|
||||
{label: t('system.system.dict.item.isDefault.yes'), value: 1},
|
||||
{label: t('system.system.dict.item.isDefault.no'), value: 0},
|
||||
],
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 1
|
||||
? <Tag color="blue">{t('system.system.dict.item.isDefault.yes')}</Tag>
|
||||
: t('system.system.dict.item.isDefault.no');
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.sort'),
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
colProps: {span: 12},
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
fieldProps: {
|
||||
min: 0,
|
||||
style: {width: '100%'}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.status'),
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
colProps: {span: 12},
|
||||
initialValue: 0,
|
||||
rules: [{required: true, message: t('system.system.dict.item.status.required')}],
|
||||
fieldProps: {
|
||||
options: [
|
||||
{label: t('system.system.dict.item.status.normal'), value: 0},
|
||||
{label: t('system.system.dict.item.status.disabled'), value: 1},
|
||||
],
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 0
|
||||
? <Badge status="success" text={t('system.system.dict.item.status.normal')}/>
|
||||
: <Badge status="error" text={t('system.system.dict.item.status.disabled')}/>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t('system.system.dict.item.createTime'),
|
||||
dataIndex: 'created_at',
|
||||
render: (value: string) => value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 160,
|
||||
},
|
||||
];
|
||||
|
||||
// 返回字典列表
|
||||
const handleGoBack = () => {
|
||||
navigate('/system/dict');
|
||||
};
|
||||
|
||||
// 如果没有字典ID,显示提示
|
||||
if (!currentDict.id) {
|
||||
return (
|
||||
<div style={{padding: 50, textAlign: 'center'}}>
|
||||
<div style={{marginTop: 50, color: '#999'}}>
|
||||
{t('system.dict.selectDictFirst')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space orientation={'vertical'} style={{width: '100%'}}>
|
||||
<div>
|
||||
<Button type={'link'} onClick={handleGoBack} icon={<LeftOutlined/>} classNames={{ root: 'p-0 mb-2' }}>
|
||||
{t('system.dict.backToList')}
|
||||
</Button>
|
||||
<Typography.Title level={3}>
|
||||
<span className={'mr-2'}>{t('system.dict.itemManagement')} - {currentDict.name}</span>
|
||||
<Typography.Text type="secondary">{currentDict.code}</Typography.Text>
|
||||
</Typography.Title>
|
||||
|
||||
</div>
|
||||
<XinTable<IDictItem>
|
||||
api={'/system/dict/item'}
|
||||
columns={columns}
|
||||
rowKey={'id'}
|
||||
accessName={'system.dict.item'}
|
||||
formProps={{
|
||||
grid: true,
|
||||
colProps: {span: 12},
|
||||
layout: 'vertical',
|
||||
rowProps: {gutter: [30, 0]}
|
||||
}}
|
||||
modalProps={{ width: 600 }}
|
||||
requestParams={(params) => {
|
||||
return {
|
||||
...params,
|
||||
dict_id: currentDict.id,
|
||||
}
|
||||
}}
|
||||
searchShow={false}
|
||||
handleFinish={async (values, mode, _form, defaultValue) => {
|
||||
if (mode === 'create') {
|
||||
await Create('/system/dict/item', {
|
||||
...values,
|
||||
dict_id: currentDict.id,
|
||||
});
|
||||
window.$message?.success(t('system.dict.item.createSuccess'));
|
||||
} else {
|
||||
await Update('/system/dict/item/' + defaultValue?.id, {
|
||||
...values,
|
||||
dict_id: currentDict.id,
|
||||
});
|
||||
window.$message?.success(t('system.dict.item.updateSuccess'));
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
||||
import { Card, Divider, Tag, Timeline, Avatar, Typography } from 'antd';
|
||||
import {
|
||||
InfoCircleOutlined,
|
||||
LinkOutlined,
|
||||
HistoryOutlined,
|
||||
UserOutlined,
|
||||
FileTextOutlined,
|
||||
GithubOutlined,
|
||||
QqOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const SystemInfoPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 系统基本信息
|
||||
const systemInfo = [
|
||||
{ label: t('system.info.label.name'), value: 'XinAdmin' },
|
||||
{ label: t('system.info.label.version'), value: 'v2.0' },
|
||||
{ label: t('system.info.label.build'), value: 'Vite 7.1.5' },
|
||||
{ label: t('system.info.label.frontend'), value: 'React 19.1.0' },
|
||||
{ label: t('system.info.label.ui'), value: 'Ant Design 5.27.1' },
|
||||
{ label: t('system.info.label.css'), value: 'TailwindCSS 4.1.11' },
|
||||
{ label: t('system.info.label.router'), value: 'React Router 7.8.2' },
|
||||
{ label: t('system.info.label.state'), value: 'Zustand 5.0.8' },
|
||||
{ label: t('system.info.label.ts'), value: '5.8.3' },
|
||||
];
|
||||
|
||||
// 项目地址
|
||||
const projectLinks = [
|
||||
{ name: t('system.info.link.github'), url: 'https://github.com/xin-admin/xin-admin', icon: <GithubOutlined /> },
|
||||
{ name: t('system.info.link.docs'), url: 'https://xinadmin.cn/ui/intro', icon: <FileTextOutlined /> },
|
||||
{ name: t('system.info.link.demo'), url: 'https://ui.xinadmin.cn', icon: <LinkOutlined /> },
|
||||
{ name: t('system.info.link.issues'), url: 'https://github.com/xin-admin/xin-admin/issues', icon: <InfoCircleOutlined /> },
|
||||
];
|
||||
|
||||
// 更新日志
|
||||
const changelog = [
|
||||
{ time: '2025-11', version: 'v2.0', content: t('system.info.log.content') },
|
||||
];
|
||||
|
||||
// 作者信息
|
||||
const authorInfo = {
|
||||
name: t('system.info.author.name'),
|
||||
avatar: 'https://file.xinadmin.cn/file/favicons.ico',
|
||||
role: t('system.info.author.role'),
|
||||
contact: [
|
||||
{ type: t('system.info.contact.github'), value: 'https://github.com/xin-admin/xin-admin', icon: <GithubOutlined /> },
|
||||
{ type: t('system.info.contact.group'), value: 'Xin Admin Official Community', icon: <QqOutlined /> },
|
||||
{ type: t('system.info.contact.discuss'), value: 'https://github.com/xin-admin/xin-admin/discussions', icon: <LinkOutlined /> },
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.info.title')}</Title>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* 系统基本信息卡片 */}
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<InfoCircleOutlined className="mr-2 text-blue-500" />
|
||||
<span>{t('system.info.basic.title')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{systemInfo.map((item, index) => (
|
||||
<div key={index} className="flex justify-between items-center py-1">
|
||||
<span className="text-gray-600 text-sm">{item.label}:</span>
|
||||
<span className="font-medium text-sm">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Divider className="my-4" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tag color="blue">React 19</Tag>
|
||||
<Tag color="geekblue">Ant Design</Tag>
|
||||
<Tag color="cyan">TailwindCSS</Tag>
|
||||
<Tag color="purple">TypeScript</Tag>
|
||||
<Tag color="green">Vite 7</Tag>
|
||||
<Tag color="orange">Zustand</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 项目地址卡片 */}
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<LinkOutlined className="mr-2 text-green-500" />
|
||||
<span>{t('system.info.project.title')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{projectLinks.map((link, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<span className="mr-2 text-gray-500">{link.icon}</span>
|
||||
<span className="text-gray-600 mr-2">{link.name}:</span>
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
{link.url}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Divider className="my-4" />
|
||||
<div className="text-sm text-gray-500">
|
||||
{t('system.info.join')}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 更新日志卡片 */}
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<HistoryOutlined className="mr-2 text-orange-500" />
|
||||
<span>{t('system.info.changelog.title')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Timeline mode="left" className="mt-4">
|
||||
{changelog.map((log, index) => (
|
||||
<Timeline.Item key={index}>
|
||||
<div className="font-medium">{log.version}<span className="ml-2 text-gray-400 font-normal">{log.time} </span></div>
|
||||
<div className="text-gray-600">{log.content}</div>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
</Card>
|
||||
|
||||
{/* 作者介绍卡片 */}
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<UserOutlined className="mr-2 text-purple-500" />
|
||||
<span>{t('system.info.author.title')}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center mb-4">
|
||||
<Avatar
|
||||
src={authorInfo.avatar}
|
||||
size={64}
|
||||
className="mr-4 border-2 border-purple-200"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold">{authorInfo.name}</h3>
|
||||
<p className="text-gray-600">{authorInfo.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="my-4" />
|
||||
<div className="space-y-3">
|
||||
{authorInfo.contact.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<span className="mr-2 text-gray-500">{item.icon}</span>
|
||||
<span className="w-16 text-gray-600">{item.type}:</span>
|
||||
<span className="font-medium text-sm">
|
||||
{item.value.startsWith('http') ? (
|
||||
<a
|
||||
href={item.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
{item.value}
|
||||
</a>
|
||||
) : (
|
||||
item.value
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 系统描述卡片 */}
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<FileTextOutlined className="mr-2 text-indigo-500" />
|
||||
<span>{t('system.info.desc.title')}</span>
|
||||
</div>
|
||||
}
|
||||
className="md:col-span-2 lg:col-span-1"
|
||||
>
|
||||
<div className="prose max-w-none">
|
||||
<p className="text-gray-700 leading-relaxed whitespace-pre-line">
|
||||
{t('system.info.desc.content')}
|
||||
</p>
|
||||
</div>
|
||||
<Divider className="my-4" />
|
||||
<div className="flex justify-center flex-wrap gap-2">
|
||||
<Tag color="magenta">{t('system.info.tag.enterprise')}</Tag>
|
||||
<Tag color="red">{t('system.info.tag.ready')}</Tag>
|
||||
<Tag color="volcano">{t('system.info.tag.scalable')}</Tag>
|
||||
<Tag color="gold">{t('system.info.tag.modern')}</Tag>
|
||||
<Tag color="blue">{t('system.info.tag.i18n')}</Tag>
|
||||
<Tag color="green">{t('system.info.tag.auth')}</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemInfoPage;
|
||||
@@ -0,0 +1,514 @@
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Divider
|
||||
} from 'antd';
|
||||
import { SendOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getMailConfig, saveMailConfig, sendTestMail } from '@/api/system/sysMail.ts';
|
||||
import type {FormColumn} from "@/components/XinFormField/FieldRender";
|
||||
import XinForm, {type XinFormRef} from "@/components/XinForm";
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface MailResponse {
|
||||
other: {
|
||||
mode: 'single' | 'failover' | 'roundrobin',
|
||||
mailers: string[];
|
||||
},
|
||||
mail: {
|
||||
default: string;
|
||||
mailers: {
|
||||
smtp: {
|
||||
url: string;
|
||||
host: string;
|
||||
port: string;
|
||||
username: string;
|
||||
password: string;
|
||||
},
|
||||
log: {
|
||||
channel: string;
|
||||
}
|
||||
},
|
||||
from: {
|
||||
address: string;
|
||||
name: string;
|
||||
}
|
||||
},
|
||||
services: {
|
||||
postmark: {
|
||||
token: string;
|
||||
},
|
||||
ses: {
|
||||
key: string;
|
||||
secret: string;
|
||||
region: string;
|
||||
token: string;
|
||||
},
|
||||
resend: {
|
||||
key: string;
|
||||
},
|
||||
mailgun: {
|
||||
domain: string;
|
||||
secret: string;
|
||||
endpoint: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MailConfig: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const formRef = useRef<XinFormRef>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [testForm] = Form.useForm();
|
||||
const [testing, setTesting] = useState(false);
|
||||
|
||||
const driverOptions = [
|
||||
{ label: t('system.mail.driver.smtp'), value: 'smtp' },
|
||||
{ label: t('system.mail.driver.ses'), value: 'ses' },
|
||||
{ label: t('system.mail.driver.mailgun'), value: 'mailgun' },
|
||||
{ label: t('system.mail.driver.postmark'), value: 'postmark' },
|
||||
{ label: t('system.mail.driver.resend'), value: 'resend' },
|
||||
{ label: t('system.mail.driver.log'), value: 'log' },
|
||||
{ label: t('system.mail.driver.array'), value: 'array' },
|
||||
];
|
||||
|
||||
const columns: FormColumn<MailResponse>[] = [
|
||||
{
|
||||
dataIndex: ['other', 'mode'],
|
||||
valueType: 'radio',
|
||||
title: t('system.mail.mode'),
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: t('system.mail.mode.single'), value: 'single'},
|
||||
{ label: t('system.mail.mode.failover'), value: 'failover'},
|
||||
{ label: t('system.mail.mode.roundrobin'), value: 'roundrobin'},
|
||||
]
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 24}
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'default'],
|
||||
valueType: 'radioButton',
|
||||
title: t('system.mail.driver'),
|
||||
fieldProps: {
|
||||
options: driverOptions
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 24},
|
||||
dependency: {
|
||||
dependencies: ['other'],
|
||||
visible: (values) => {
|
||||
return values.other?.mode === 'single'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['other', 'mailers'],
|
||||
valueType: 'checkbox',
|
||||
title: t('system.mail.driver'),
|
||||
fieldProps: { options: driverOptions},
|
||||
colProps: {span: 24},
|
||||
dependency: {
|
||||
dependencies: ['other'],
|
||||
visible: (values) => {
|
||||
return values.other?.mode !== 'single'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'from', 'address'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.from_address'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.from_address.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12}
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'from', 'name'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.from_name'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.from_name.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12}
|
||||
},
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.mail.smtp.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'smtp' || !!values.other?.mailers?.includes('smtp')
|
||||
}
|
||||
},
|
||||
colProps: {span: 24},
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'mailers', 'smtp', 'host'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.host'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.host.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 16},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'smtp' || !!values.other?.mailers?.includes('smtp')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'mailers', 'smtp', 'port'],
|
||||
valueType: 'digit',
|
||||
title: t('system.mail.port'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.port.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 8},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'smtp' || !!values.other?.mailers?.includes('smtp')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'mailers', 'smtp', 'username'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.username'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.username.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'smtp' || !!values.other?.mailers?.includes('smtp')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'mailers', 'smtp', 'password'],
|
||||
valueType: 'password',
|
||||
title: t('system.mail.password'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.password.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'smtp' || !!values.other?.mailers?.includes('smtp')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.mail.log.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'log' || !!values.other?.mailers?.includes('log')
|
||||
}
|
||||
},
|
||||
colProps: {span: 24},
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['mail', 'mailers', 'log', 'channel'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.log.channel'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.log.channel.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 24},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'log' || !!values.other?.mailers?.includes('log')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.mail.postmark.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'postmark' || !!values.other?.mailers?.includes('postmark')
|
||||
}
|
||||
},
|
||||
colProps: {span: 24},
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'postmark', 'token'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.postmark.token'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.postmark.token.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 24},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'postmark' || !!values.other?.mailers?.includes('postmark')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.mail.resend.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'resend' || !!values.other?.mailers?.includes('resend')
|
||||
}
|
||||
},
|
||||
colProps: {span: 24},
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'resend', 'key'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.resend.key'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.resend.key.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 24},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'resend' || !!values.other?.mailers?.includes('resend')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.mail.mailgun.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'mailgun' || !!values.other?.mailers?.includes('mailgun')
|
||||
}
|
||||
},
|
||||
colProps: {span: 24},
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'mailgun', 'domain'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.mailgun.domain'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.mailgun.domain.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'mailgun' || !!values.other?.mailers?.includes('mailgun')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'mailgun', 'secret'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.mailgun.secret'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.mailgun.secret.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'mailgun' || !!values.other?.mailers?.includes('mailgun')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'mailgun', 'endpoint'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.mailgun.endpoint'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.mailgun.endpoint.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'mailgun' || !!values.other?.mailers?.includes('mailgun')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.mail.ses.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'ses' || !!values.other?.mailers?.includes('ses')
|
||||
}
|
||||
},
|
||||
colProps: {span: 24},
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'ses', 'key'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.ses.key'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.ses.key.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'ses' || !!values.other?.mailers?.includes('ses')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'ses', 'secret'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.ses.secret'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.ses.secret.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'ses' || !!values.other?.mailers?.includes('ses')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'ses', 'region'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.ses.region'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.ses.region.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'ses' || !!values.other?.mailers?.includes('ses')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['services', 'ses', 'token'],
|
||||
valueType: 'text',
|
||||
title: t('system.mail.ses.token'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.mail.ses.token.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: {span: 12},
|
||||
dependency: {
|
||||
dependencies: ['mail', 'other'],
|
||||
visible: (values) => {
|
||||
return values.mail?.default === 'ses' || !!values.other?.mailers?.includes('ses')
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
const { data } = await getMailConfig();
|
||||
if (data.success) {
|
||||
form.setFieldsValue(data.data);
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
await saveMailConfig(values);
|
||||
message.success(t('system.mail.save.success'));
|
||||
};
|
||||
|
||||
const onTestFinish = async (values: any) => {
|
||||
try {
|
||||
setTesting(true);
|
||||
await sendTestMail(values.to);
|
||||
message.success(t('system.mail.test.success'));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.mail.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.mail.page.description')}</Text>
|
||||
</div>
|
||||
<Row gutter={24}>
|
||||
<Col span={16}>
|
||||
<Card variant={'borderless'}>
|
||||
<XinForm
|
||||
columns={columns}
|
||||
layout="vertical"
|
||||
formRef={formRef}
|
||||
form={form}
|
||||
grid={true}
|
||||
onFinish={onFinish}
|
||||
rowProps={{
|
||||
gutter: [10, 0]
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card title={t('system.mail.test.title')}>
|
||||
<Form
|
||||
form={testForm}
|
||||
layout="vertical"
|
||||
onFinish={onTestFinish}
|
||||
>
|
||||
<Form.Item
|
||||
name="to"
|
||||
label={t('system.mail.test.to')}
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input placeholder={t('system.mail.test.to.placeholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Button type="primary" htmlType="submit" block icon={<SendOutlined />} loading={testing}>
|
||||
{t('system.mail.test.send')}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MailConfig;
|
||||
@@ -0,0 +1,427 @@
|
||||
import XinTable from "@/components/XinTable";
|
||||
import type {XinTableColumn} from "@/components/XinTable/typings.ts";
|
||||
import {Button, Card, type CardProps, Col, message, Row, Switch, Table, type TableProps, Tag, Tooltip, Tree, type TreeProps, Typography} from "antd";
|
||||
import {type RuleFieldsList, rulesList, setRule, statusRole, users as usersApi} from "@/api/system/sys_role";
|
||||
import type {ISysRole} from "@/domain/iSysRole.ts";
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import type ISysUser from "@/domain/iSysUser.ts";
|
||||
import {KeyOutlined, SaveOutlined, SmileOutlined, TeamOutlined} from "@ant-design/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isArray } from "lodash";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const Role = () => {
|
||||
const {t} = useTranslation();
|
||||
// 状态管理
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<number | undefined>();
|
||||
const [activeTab, setActiveTab] = useState('users');
|
||||
const [roleUsers, setRoleUsers] = useState<ISysUser[]>([]);
|
||||
const [roleUsersTotal, setRoleUsersTotal] = useState<number>(0);
|
||||
const [isLoadingUsers, setIsLoadingUsers] = useState<boolean>(false);
|
||||
const [ruleFields, setRuleFields] = useState<RuleFieldsList[]>([]);
|
||||
const [checkedRuleKeys, setCheckedRuleKeys] = useState<React.Key[]>([]);
|
||||
const [isSavingRules, setIsSavingRules] = useState<boolean>(false);
|
||||
// 树展开状态
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||
// 树引用
|
||||
const treeRef = useRef<any>(null);
|
||||
// Tab 配置
|
||||
const tabList: CardProps['tabList'] = [
|
||||
{
|
||||
key: "users",
|
||||
icon: <TeamOutlined />,
|
||||
label: t('system.role.tab.users'),
|
||||
},
|
||||
{
|
||||
key: "rules",
|
||||
icon: <KeyOutlined />,
|
||||
label: t('system.role.tab.rules')
|
||||
}
|
||||
];
|
||||
// 角色表格列配置
|
||||
const roleColumns: XinTableColumn<ISysRole>[] = [
|
||||
{
|
||||
title: 'id',
|
||||
dataIndex: "id",
|
||||
valueType: "text",
|
||||
align: 'center',
|
||||
width: 80,
|
||||
hideInForm: true
|
||||
},
|
||||
{
|
||||
title: t('system.role.table.roleName'),
|
||||
dataIndex: "name",
|
||||
valueType: "text",
|
||||
align: "center",
|
||||
required: true,
|
||||
colProps: {span: 8},
|
||||
rules: [{ required: true, message: t('system.role.table.roleName.required') }],
|
||||
render: (value: any, record: ISysRole) => (
|
||||
<>
|
||||
<Tooltip title={record.description}>
|
||||
<Tag variant="filled" color="blue">{value}</Tag>
|
||||
</Tooltip>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('system.role.table.sort'),
|
||||
dataIndex: "sort",
|
||||
valueType: "digit",
|
||||
hideInSearch: true,
|
||||
align: "center",
|
||||
required: true,
|
||||
colProps: {span: 8},
|
||||
rules: [{ required: true, message: t('system.role.table.sort.required') }],
|
||||
render: (value: number) => <Tag variant="filled" color="purple">{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('system.role.table.userCount'),
|
||||
dataIndex: "countUser",
|
||||
valueType: "text",
|
||||
hideInForm: true,
|
||||
align: "center",
|
||||
render: (value: number) => <a><u>{value}{t('system.role.userTable.person')}</u></a>,
|
||||
},
|
||||
{
|
||||
title: t('system.role.table.status'),
|
||||
dataIndex: "status",
|
||||
valueType: "switch",
|
||||
align: "center",
|
||||
colProps: {span: 8},
|
||||
filters: [
|
||||
{ text: t('system.role.table.status.disable'), value: 0 },
|
||||
{ text: t('system.role.table.status.enable'), value: 1 },
|
||||
],
|
||||
hideInSearch: true,
|
||||
required: true,
|
||||
rules: [{ required: true, message: t('system.role.table.status.required') }],
|
||||
render: (_, record: ISysRole) => (
|
||||
<Switch
|
||||
disabled={record.id === 1}
|
||||
checked={record.status === 1}
|
||||
checkedChildren={t('system.role.table.status.enable')}
|
||||
unCheckedChildren={t('system.role.table.status.disable')}
|
||||
onChange={async (_, event) => {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await statusRole(record.id!);
|
||||
message.success(t('system.role.message.statusUpdateSuccess'));
|
||||
} catch (error) {
|
||||
message.error(t('system.role.message.statusUpdateFailed'));
|
||||
console.error('Failed to update role status:', error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
valueType: 'textarea',
|
||||
title: t('system.role.table.description'),
|
||||
dataIndex: 'description',
|
||||
colProps: {span: 24},
|
||||
hideInTable: true,
|
||||
},
|
||||
{
|
||||
title: t('system.role.table.createdAt'),
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'created_at',
|
||||
align: 'center',
|
||||
render: (value: string) => value ? dayjs(value).fromNow() : '-',
|
||||
},
|
||||
{
|
||||
title: t('system.role.table.updatedAt'),
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'updated_at',
|
||||
align: 'center',
|
||||
render: (value: string) => value ? dayjs(value).fromNow() : '-',
|
||||
},
|
||||
];
|
||||
// 用户列表表格列配置
|
||||
const userColumns: TableProps<ISysUser>['columns'] = [
|
||||
{
|
||||
title: t('system.role.userTable.userId'),
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: t('system.role.userTable.username'),
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.role.userTable.nickname'),
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.role.userTable.email'),
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
align: 'center',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('system.role.userTable.mobile'),
|
||||
dataIndex: 'mobile',
|
||||
key: 'mobile',
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.role.userTable.status'),
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
align: 'center',
|
||||
render: (value: number) => {
|
||||
const status = value === 0
|
||||
? { color: 'error', text: t('system.role.userTable.status.banned') }
|
||||
: { color: 'success', text: t('system.role.userTable.status.normal') };
|
||||
return <Tag color={status.color}>{status.text}</Tag>;
|
||||
},
|
||||
width: 80,
|
||||
},
|
||||
];
|
||||
// 获取角色用户列表
|
||||
const fetchRoleUsers = async (page: number = 1, pageSize: number = 10) => {
|
||||
if (!selectedRoleId) return;
|
||||
setIsLoadingUsers(true);
|
||||
try {
|
||||
const response = await usersApi(selectedRoleId, { page, pageSize });
|
||||
const { data, total } = response.data.data!;
|
||||
setRoleUsers(data);
|
||||
setRoleUsersTotal(total);
|
||||
} finally {
|
||||
setIsLoadingUsers(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
rulesList().then(response => {
|
||||
setRuleFields(response.data.data!);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 当selectedRoleId变化时,重新获取用户列表
|
||||
useEffect(() => { fetchRoleUsers(1, 10) }, [selectedRoleId]);
|
||||
|
||||
// 事件处理函数
|
||||
const handleRoleSelect = (record: ISysRole) => {
|
||||
setSelectedRoleId(record.id!);
|
||||
// 回显当前角色的权限
|
||||
if (record.ruleIds && record.ruleIds.length > 0) {
|
||||
setCheckedRuleKeys(record.ruleIds);
|
||||
} else {
|
||||
setCheckedRuleKeys([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染权限树节点
|
||||
const renderRuleTreeNode = (node: RuleFieldsList) => {
|
||||
if (node.local) {
|
||||
return (
|
||||
<>
|
||||
{t(node.local)} - <span style={{ color: '#00000040' }}>{node.title}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <>{node.title}</>;
|
||||
};
|
||||
|
||||
// 处理权限树勾选变化
|
||||
const handleRuleCheck: TreeProps['onCheck'] = (checkedKeys) => {
|
||||
if(isArray(checkedKeys)) {
|
||||
setCheckedRuleKeys(checkedKeys);
|
||||
}else {
|
||||
setCheckedRuleKeys(checkedKeys.checked);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理树展开状态变化
|
||||
const handleTreeExpand = (keys: React.Key[]) => {
|
||||
setExpandedKeys(keys);
|
||||
};
|
||||
|
||||
// 获取所有节点的key
|
||||
const getAllNodeKeys = (nodes: RuleFieldsList[]): React.Key[] => {
|
||||
let keys: React.Key[] = [];
|
||||
nodes.forEach(node => {
|
||||
keys.push(node.key);
|
||||
if (node.children && node.children.length > 0) {
|
||||
keys = [...keys, ...getAllNodeKeys(node.children)];
|
||||
}
|
||||
});
|
||||
return keys;
|
||||
};
|
||||
|
||||
// 展开全部节点
|
||||
const expandAll = () => {
|
||||
const allKeys = getAllNodeKeys(ruleFields);
|
||||
setExpandedKeys(allKeys);
|
||||
};
|
||||
|
||||
// 折叠全部节点
|
||||
const collapseAll = () => {
|
||||
setExpandedKeys([]);
|
||||
};
|
||||
|
||||
// 选择全部节点
|
||||
const selectAll = () => {
|
||||
const allKeys = getAllNodeKeys(ruleFields);
|
||||
setCheckedRuleKeys(allKeys);
|
||||
};
|
||||
|
||||
// 取消选择全部节点
|
||||
const deselectAll = () => {
|
||||
setCheckedRuleKeys([]);
|
||||
};
|
||||
|
||||
// 反选节点
|
||||
const invertSelection = () => {
|
||||
const allKeys = getAllNodeKeys(ruleFields);
|
||||
const currentCheckedSet = new Set(checkedRuleKeys);
|
||||
const invertedKeys = allKeys.filter(key => !currentCheckedSet.has(key));
|
||||
setCheckedRuleKeys(invertedKeys);
|
||||
};
|
||||
|
||||
// 保存权限设置
|
||||
const handleSaveRules = async () => {
|
||||
if (!selectedRoleId) {
|
||||
message.warning(t('system.role.message.selectRoleFirst'));
|
||||
return;
|
||||
}
|
||||
setIsSavingRules(true);
|
||||
try {
|
||||
const ruleIds = checkedRuleKeys.map(key => Number(key));
|
||||
await setRule(selectedRoleId, ruleIds);
|
||||
message.success(t('system.role.message.rulesSaveSuccess'));
|
||||
} finally {
|
||||
setIsSavingRules(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.role.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.role.page.description')}</Text>
|
||||
</div>
|
||||
<Row gutter={[20, 20]}>
|
||||
{/* 角色列表 */}
|
||||
<Col xxl={14} lg={12} xs={24}>
|
||||
<XinTable<ISysRole>
|
||||
api="/system/role"
|
||||
accessName="system.role"
|
||||
columns={roleColumns}
|
||||
rowKey="id"
|
||||
searchShow={false}
|
||||
searchProps={false}
|
||||
scroll={{x: 800}}
|
||||
editShow={(row) => row.id !== 1}
|
||||
deleteShow={(row) => row.id !== 1}
|
||||
formProps={{
|
||||
grid: true,
|
||||
rowProps: { gutter: [20, 0] },
|
||||
layout: 'vertical'
|
||||
}}
|
||||
modalProps={{
|
||||
width: 800
|
||||
}}
|
||||
operateProps={{
|
||||
fixed: 'right'
|
||||
}}
|
||||
rowSelection={{
|
||||
type: 'radio',
|
||||
selectedRowKeys: selectedRoleId ? [selectedRoleId] : [],
|
||||
onChange: (_, rows) => handleRoleSelect(rows[0])
|
||||
}}
|
||||
onRow ={(record) => ({
|
||||
onClick: () => handleRoleSelect(record)
|
||||
})}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{/* 用户列表和权限管理 */}
|
||||
<Col xxl={10} lg={12} xs={24}>
|
||||
<Card
|
||||
tabList={tabList}
|
||||
onTabChange={setActiveTab}
|
||||
activeTabKey={activeTab}
|
||||
styles={{ body: { minHeight: '70vh' } }}
|
||||
>
|
||||
{ selectedRoleId ? (
|
||||
activeTab === 'users' ? (
|
||||
<Table<ISysUser>
|
||||
loading={isLoadingUsers}
|
||||
dataSource={roleUsers}
|
||||
bordered={true}
|
||||
columns={userColumns}
|
||||
size="small"
|
||||
pagination={{
|
||||
total: roleUsersTotal,
|
||||
showSizeChanger: true,
|
||||
onChange: fetchRoleUsers
|
||||
}}
|
||||
scroll={{x: 600}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ height: '600px', overflow: 'auto', marginBottom: 12 }}>
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
checkable
|
||||
treeData={ruleFields}
|
||||
checkedKeys={checkedRuleKeys}
|
||||
onCheck={handleRuleCheck}
|
||||
titleRender={renderRuleTreeNode}
|
||||
checkStrictly={true}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={handleTreeExpand}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ color: '#666', fontSize: 12 }}>
|
||||
{t('system.role.permission.selectedCount', { count: checkedRuleKeys.length })}
|
||||
</span>
|
||||
<Button size="small" onClick={expandAll}>{t('system.role.button.expandAll')}</Button>
|
||||
<Button size="small" onClick={collapseAll}>{t('system.role.button.collapseAll')}</Button>
|
||||
<Button size="small" onClick={selectAll}>{t('system.role.button.selectAll')}</Button>
|
||||
<Button size="small" onClick={deselectAll}>{t('system.role.button.clearAll')}</Button>
|
||||
<Button size="small" onClick={invertSelection}>{t('system.role.button.invertSelection')}</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={isSavingRules}
|
||||
onClick={handleSaveRules}
|
||||
size="small"
|
||||
disabled={selectedRoleId === 1}
|
||||
>
|
||||
{t('system.role.button.saveRules')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: '#00000040' }}>
|
||||
<SmileOutlined style={{ fontSize: 40, marginBottom: 12 }} />
|
||||
<p>{t('system.role.placeholder.selectRole')}</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Role;
|
||||
@@ -0,0 +1,300 @@
|
||||
import type {ISysRule} from "@/domain/iSysRule.ts";
|
||||
import {listRule, ruleParent, showRule, statusRule} from "@/api/system/sys_rule.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import IconFont from "@/components/IconFont";
|
||||
import XinTable from "@/components/XinTable";
|
||||
import type {XinTableColumn, XinTableInstance} from "@/components/XinTable/typings.ts";
|
||||
import {message, Switch, Tag, Typography} from "antd";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import useAuth from "@/hooks/useAuth.ts";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import useDictStore from "@/stores/dict";
|
||||
import DictTag from "@/components/DictTag";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface RuleParent {
|
||||
id: number;
|
||||
name: string;
|
||||
children?: RuleParent[];
|
||||
}
|
||||
|
||||
const Rule = () => {
|
||||
const {t} = useTranslation();
|
||||
const {auth} = useAuth();
|
||||
const [parentOptions, setParentOptions] = useState<RuleParent[]>([]);
|
||||
const tableRef = useRef<XinTableInstance<ISysRule>>(null);
|
||||
const getOptions = useDictStore(state => state.getOptions);
|
||||
|
||||
// 加载父级选项
|
||||
useEffect(() => {
|
||||
ruleParent().then(res => {
|
||||
const children = (res.data.data || []).map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
children: item.children
|
||||
}));
|
||||
setParentOptions([
|
||||
{
|
||||
name: t("system.rule.parent.0"),
|
||||
id: 0,
|
||||
children
|
||||
}
|
||||
]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const columns: XinTableColumn<ISysRule>[] = [
|
||||
/** ----------------- 表单使用的 Column ------------------- */
|
||||
{
|
||||
title: t("system.rule.type"),
|
||||
dataIndex: 'type',
|
||||
valueType: 'radioButton',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
colProps: { span: 24 },
|
||||
rules: [{ required: true, message: t("system.rule.type.required") }],
|
||||
fieldProps: {
|
||||
options: getOptions('sys_rule_type'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.parent"),
|
||||
dataIndex: 'parent_id',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
valueType: 'treeSelect',
|
||||
rules: [{ required: true, message: t("system.rule.parent.required") }],
|
||||
fieldProps: {
|
||||
treeData: parentOptions,
|
||||
fieldNames: { label: 'name', value: 'id' },
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.order"),
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'order',
|
||||
valueType: 'digit',
|
||||
rules: [{ required: true, message: t("system.rule.order.required") }],
|
||||
},
|
||||
{
|
||||
title: t("system.rule.name"),
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'name',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: t("system.rule.name.required") }],
|
||||
},
|
||||
{
|
||||
title: t("system.rule.key"),
|
||||
valueType: 'text',
|
||||
dataIndex: 'key',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
rules: [{ required: true, message: t("system.rule.key.required") }],
|
||||
},
|
||||
{
|
||||
title: t("system.rule.link"),
|
||||
dataIndex: 'link',
|
||||
valueType: 'radio',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
dependency: {
|
||||
dependencies: ['type'],
|
||||
visible: values => values.type === 'route'
|
||||
},
|
||||
fieldProps: {
|
||||
options: [
|
||||
{label: t("system.rule.link.0"), value: 0},
|
||||
{label: t("system.rule.link.1"), value: 1},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.routePath"),
|
||||
dataIndex: 'path',
|
||||
valueType: 'text',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
tooltip: t("system.rule.routePath.tooltip"),
|
||||
dependency: {
|
||||
dependencies: ['type'],
|
||||
visible: values => values.type === 'route'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.icon"),
|
||||
dataIndex: 'icon',
|
||||
valueType: 'icon',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
dependency: {
|
||||
dependencies: ['type'],
|
||||
visible: values => values.type === 'route' || values.type === 'menu'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.local"),
|
||||
dataIndex: 'local',
|
||||
valueType: 'text',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
dependency: {
|
||||
dependencies: ['type'],
|
||||
visible: values => values.type === 'route' || values.type === 'menu'
|
||||
},
|
||||
},
|
||||
/** ------------------ 表格使用的 Column ---------------- */
|
||||
{
|
||||
title: t("system.rule.name"),
|
||||
ellipsis: true,
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
ellipsis: true,
|
||||
align: 'center',
|
||||
title: t("system.rule.local.show"),
|
||||
dataIndex: 'local',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (data: string) => data ? t(data) : '-'
|
||||
},
|
||||
{
|
||||
title: t("system.rule.icon"),
|
||||
dataIndex: 'icon',
|
||||
align: 'center',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (data: string) => data ? <IconFont name={data} /> : '-'
|
||||
},
|
||||
{
|
||||
title: t("system.rule.type"),
|
||||
dataIndex: 'type',
|
||||
align: 'center',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (value: string) => <DictTag value={value} renderType={'tag'} code={'sys_rule_type'}/>
|
||||
},
|
||||
{
|
||||
title: t("system.rule.order"),
|
||||
align: 'center',
|
||||
dataIndex: 'order',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (value: number) => <Tag variant="filled" color={'purple'}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t("system.rule.key"),
|
||||
align: 'center',
|
||||
dataIndex: 'key',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
render: (value: string) => <Tag variant="filled" color={'geekblue'}>{value}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t("system.rule.hidden"),
|
||||
align: 'center',
|
||||
dataIndex: 'hidden',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
tooltip: t("system.rule.hidden.tooltip"),
|
||||
render: (_, data: ISysRule) => {
|
||||
if (data.type === 'rule') { return '-' }
|
||||
return (
|
||||
<Switch
|
||||
defaultValue={data.hidden === 1}
|
||||
disabled={!auth("system.rule.show")}
|
||||
checkedChildren={t("system.rule.hidden.1")}
|
||||
unCheckedChildren={t("system.rule.hidden.0")}
|
||||
onChange={ async (_, event) => {
|
||||
event.stopPropagation();
|
||||
await showRule(data.id!);
|
||||
message.success(t("system.rule.hidden.updateSuccess"));
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.status"),
|
||||
dataIndex: 'status',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
tooltip: t("system.rule.status.tooltip"),
|
||||
align: 'center',
|
||||
render: (_, data: ISysRule) => {
|
||||
return (
|
||||
<Switch
|
||||
defaultChecked={data.status === 1}
|
||||
disabled={!auth("system.rule.status")}
|
||||
checkedChildren={t("system.rule.status.1")}
|
||||
unCheckedChildren={t("system.rule.status.0")}
|
||||
onChange={async (_, event) => {
|
||||
event.stopPropagation();
|
||||
await statusRule(data.id!);
|
||||
message.success(t("system.rule.status.updateSuccess"));
|
||||
}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.rule.created_at"),
|
||||
dataIndex: 'created_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (value: string) => value ? dayjs(value).fromNow() : '-',
|
||||
},
|
||||
{
|
||||
title: t("system.rule.updated_at"),
|
||||
dataIndex: 'updated_at',
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (value: string) => value ? dayjs(value).fromNow() : '-',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t("system.rule.title")}</Title>
|
||||
<Text type="secondary">{t("system.rule.description")}</Text>
|
||||
</div>
|
||||
<XinTable<ISysRule>
|
||||
handleRequest={async () => {
|
||||
const { data } = await listRule();
|
||||
return {
|
||||
data: data.data || [],
|
||||
total: data.data?.length || 0
|
||||
};
|
||||
}}
|
||||
tableRef={tableRef}
|
||||
searchShow={false}
|
||||
searchProps={false}
|
||||
paginationShow={false}
|
||||
scroll={{x: 1200}}
|
||||
formProps={{
|
||||
grid: true,
|
||||
rowProps: {gutter: [20, 0]},
|
||||
colProps: {span: 12},
|
||||
layout: 'vertical'
|
||||
}}
|
||||
modalProps={{ width: 800 }}
|
||||
columns={columns}
|
||||
api={'/system/rule'}
|
||||
rowKey={"id"}
|
||||
accessName={"system.rule"}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Rule;
|
||||
@@ -0,0 +1,547 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Divider,
|
||||
Select
|
||||
} from 'antd';
|
||||
import { CloudUploadOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getStorageConfig, saveStorageConfig, testStorageConnection } from '@/api/system/sysStorage.ts';
|
||||
import type { FormColumn } from "@/components/XinFormField/FieldRender";
|
||||
import XinForm, { type XinFormRef } from "@/components/XinForm";
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface StorageResponse {
|
||||
default: string;
|
||||
local: {
|
||||
root: string;
|
||||
url: string;
|
||||
visibility: string;
|
||||
};
|
||||
s3: {
|
||||
key: string;
|
||||
secret: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
url: string;
|
||||
endpoint: string;
|
||||
use_path_style_endpoint: boolean;
|
||||
};
|
||||
ftp: {
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
port: number;
|
||||
root: string;
|
||||
passive: boolean;
|
||||
ssl: boolean;
|
||||
timeout: number;
|
||||
};
|
||||
sftp: {
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
port: number;
|
||||
root: string;
|
||||
timeout: number;
|
||||
private_key: string;
|
||||
passphrase: string;
|
||||
};
|
||||
}
|
||||
|
||||
const StorageConfig: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const formRef = useRef<XinFormRef>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [selectedDisk, setSelectedDisk] = useState<string>('local');
|
||||
|
||||
const driverOptions = [
|
||||
{ label: t('system.storage.driver.local'), value: 'local' },
|
||||
{ label: t('system.storage.driver.s3'), value: 's3' },
|
||||
{ label: t('system.storage.driver.ftp'), value: 'ftp' },
|
||||
{ label: t('system.storage.driver.sftp'), value: 'sftp' },
|
||||
];
|
||||
|
||||
const columns: FormColumn<StorageResponse>[] = [
|
||||
{
|
||||
dataIndex: 'default',
|
||||
valueType: 'radioButton',
|
||||
title: t('system.storage.driver'),
|
||||
fieldProps: {
|
||||
options: driverOptions
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 24 }
|
||||
},
|
||||
// 本地存储配置
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.storage.local.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'local'
|
||||
},
|
||||
colProps: { span: 24 },
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['local', 'url'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.local.url'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.local.url.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.local.url.tooltip'),
|
||||
colProps: { span: 24 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'local'
|
||||
}
|
||||
},
|
||||
// S3 配置
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.storage.s3.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
},
|
||||
colProps: { span: 24 },
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'key'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.s3.key'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.s3.key.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'secret'],
|
||||
valueType: 'password',
|
||||
title: t('system.storage.s3.secret'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.s3.secret.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'region'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.s3.region'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.s3.region.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'bucket'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.s3.bucket'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.s3.bucket.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'endpoint'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.s3.endpoint'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.s3.endpoint.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.s3.endpoint.tooltip'),
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'url'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.s3.url'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.s3.url.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.s3.url.tooltip'),
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['s3', 'use_path_style_endpoint'],
|
||||
valueType: 'switch',
|
||||
title: t('system.storage.s3.path_style'),
|
||||
tooltip: t('system.storage.s3.path_style.tooltip'),
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 's3'
|
||||
}
|
||||
},
|
||||
// FTP 配置
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.storage.ftp.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
},
|
||||
colProps: { span: 24 },
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'host'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.ftp.host'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.ftp.host.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 16 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'port'],
|
||||
valueType: 'digit',
|
||||
title: t('system.storage.ftp.port'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.ftp.port.placeholder'),
|
||||
},
|
||||
colProps: { span: 8 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'username'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.ftp.username'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.ftp.username.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'password'],
|
||||
valueType: 'password',
|
||||
title: t('system.storage.ftp.password'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.ftp.password.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'root'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.ftp.root'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.ftp.root.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.ftp.root.tooltip'),
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'timeout'],
|
||||
valueType: 'digit',
|
||||
title: t('system.storage.ftp.timeout'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.ftp.timeout.placeholder'),
|
||||
addonAfter: t('system.storage.ftp.timeout.suffix')
|
||||
},
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'passive'],
|
||||
valueType: 'switch',
|
||||
title: t('system.storage.ftp.passive'),
|
||||
tooltip: t('system.storage.ftp.passive.tooltip'),
|
||||
colProps: { span: 8 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['ftp', 'ssl'],
|
||||
valueType: 'switch',
|
||||
title: t('system.storage.ftp.ssl'),
|
||||
tooltip: t('system.storage.ftp.ssl.tooltip'),
|
||||
colProps: { span: 8 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'ftp'
|
||||
}
|
||||
},
|
||||
// SFTP 配置
|
||||
{
|
||||
fieldRender: () => <Divider>{t('system.storage.sftp.title')}</Divider>,
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
},
|
||||
colProps: { span: 24 },
|
||||
noStyle: true
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'host'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.sftp.host'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.host.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 16 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'port'],
|
||||
valueType: 'digit',
|
||||
title: t('system.storage.sftp.port'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.port.placeholder'),
|
||||
},
|
||||
colProps: { span: 8 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'username'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.sftp.username'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.username.placeholder'),
|
||||
},
|
||||
required: true,
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'password'],
|
||||
valueType: 'password',
|
||||
title: t('system.storage.sftp.password'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.password.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.sftp.password.tooltip'),
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'root'],
|
||||
valueType: 'text',
|
||||
title: t('system.storage.sftp.root'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.root.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.sftp.root.tooltip'),
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'timeout'],
|
||||
valueType: 'digit',
|
||||
title: t('system.storage.sftp.timeout'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.timeout.placeholder'),
|
||||
addonAfter: t('system.storage.sftp.timeout.suffix')
|
||||
},
|
||||
colProps: { span: 12 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'private_key'],
|
||||
valueType: 'textarea',
|
||||
title: t('system.storage.sftp.private_key'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.private_key.placeholder'),
|
||||
rows: 4
|
||||
},
|
||||
tooltip: t('system.storage.sftp.private_key.tooltip'),
|
||||
colProps: { span: 24 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: ['sftp', 'passphrase'],
|
||||
valueType: 'password',
|
||||
title: t('system.storage.sftp.passphrase'),
|
||||
fieldProps: {
|
||||
placeholder: t('system.storage.sftp.passphrase.placeholder'),
|
||||
},
|
||||
tooltip: t('system.storage.sftp.passphrase.tooltip'),
|
||||
colProps: { span: 24 },
|
||||
dependency: {
|
||||
dependencies: ['default'],
|
||||
visible: (values) => values.default === 'sftp'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const loadConfig = async () => {
|
||||
const { data } = await getStorageConfig();
|
||||
if (data.success) {
|
||||
const configData = data.data as StorageResponse;
|
||||
form.setFieldsValue(configData);
|
||||
setSelectedDisk(configData.default || 'local');
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
await saveStorageConfig(values);
|
||||
message.success(t('system.storage.save.success'));
|
||||
};
|
||||
|
||||
const onValuesChange = (changedValues: any) => {
|
||||
if (changedValues.default) {
|
||||
setSelectedDisk(changedValues.default);
|
||||
}
|
||||
};
|
||||
|
||||
const onTestConnection = async () => {
|
||||
try {
|
||||
setTesting(true);
|
||||
await testStorageConnection(selectedDisk);
|
||||
message.success(t('system.storage.test.success'));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t('system.storage.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.storage.page.description')}</Text>
|
||||
</div>
|
||||
<Row gutter={24}>
|
||||
<Col span={16}>
|
||||
<Card variant={'borderless'}>
|
||||
<XinForm
|
||||
columns={columns}
|
||||
layout="vertical"
|
||||
formRef={formRef}
|
||||
form={form}
|
||||
grid={true}
|
||||
onFinish={onFinish}
|
||||
onValuesChange={onValuesChange}
|
||||
rowProps={{
|
||||
gutter: [10, 0]
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card title={t('system.storage.test.title')}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t('system.storage.test.current_driver')}>
|
||||
<Select
|
||||
value={selectedDisk}
|
||||
onChange={setSelectedDisk}
|
||||
options={driverOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
icon={<CheckCircleOutlined />}
|
||||
loading={testing}
|
||||
onClick={onTestConnection}
|
||||
>
|
||||
{t('system.storage.test.button')}
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ fontSize: 12, color: '#666' }}>
|
||||
<Title level={5}><CloudUploadOutlined /> {t('system.storage.help.title')}</Title>
|
||||
<p><strong>{t('system.storage.help.local')}</strong>{t('system.storage.help.local.desc')}</p>
|
||||
<p><strong>{t('system.storage.help.s3')}</strong>{t('system.storage.help.s3.desc')}</p>
|
||||
<p><strong>{t('system.storage.help.ftp')}</strong>{t('system.storage.help.ftp.desc')}</p>
|
||||
<p><strong>{t('system.storage.help.sftp')}</strong>{t('system.storage.help.sftp.desc')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StorageConfig;
|
||||
@@ -0,0 +1,332 @@
|
||||
import {Avatar, Button, Form, Input, message, Modal, Tag, Tooltip, Typography} from 'antd';
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import XinTable from '@/components/XinTable';
|
||||
import type {XinTableColumn, XinTableProps} from "@/components/XinTable/typings.ts";
|
||||
import type ISysUser from "@/domain/iSysUser.ts";
|
||||
import AuthButton from "@/components/AuthButton";
|
||||
import type {DeptFieldType, ResetPasswordType, RoleFieldType} from "@/api/system/sys_user";
|
||||
import {deptField, resetPassword, roleField} from "@/api/system/sys_user";
|
||||
import {RedoOutlined} from "@ant-design/icons";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import useAuth from "@/hooks/useAuth.ts";
|
||||
const { Title, Text } = Typography;
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const Table: React.FC = () => {
|
||||
const {t} = useTranslation();
|
||||
const { auth } = useAuth();
|
||||
/** 角色选项数据 */
|
||||
const [roles, setRoles] = useState<RoleFieldType[]>([]);
|
||||
/** 部门选项数据 */
|
||||
const [departments, setDepartments] = useState<DeptFieldType[]>([]);
|
||||
/** 部门选项数据映射 */
|
||||
const departmentMap: Map<number, string> = useMemo(() => {
|
||||
const map: Map<number, string> = new Map();
|
||||
const buildMap = (dept: DeptFieldType[]) => {
|
||||
dept.forEach(item => {
|
||||
map.set(item.dept_id, item.name);
|
||||
if (item.children && item.children.length > 0) {
|
||||
buildMap(item.children);
|
||||
}
|
||||
})
|
||||
}
|
||||
buildMap(departments);
|
||||
return map;
|
||||
}, [departments]);
|
||||
|
||||
/** 高级表格列配置 */
|
||||
const columns: XinTableColumn<ISysUser>[] = [
|
||||
{
|
||||
title: t("system.user.id"),
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
sorter: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.user.username"),
|
||||
dataIndex: 'username',
|
||||
valueType: 'text',
|
||||
align: 'center',
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.username.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.user.nickname"),
|
||||
dataIndex: 'nickname',
|
||||
valueType: 'text',
|
||||
align: 'center',
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.nickname.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.user.sex"),
|
||||
dataIndex: 'sex',
|
||||
valueType: 'radio',
|
||||
filters: [
|
||||
{ text: t("system.user.sex.0"), value: 0 },
|
||||
{ text: t("system.user.sex.1"), value: 1 },
|
||||
],
|
||||
align: 'center',
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 0, label: t("system.user.sex.0") },
|
||||
{ value: 1, label: t("system.user.sex.1") },
|
||||
]
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 0 ? t("system.user.sex.0") : t("system.user.sex.1");
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("system.user.email"),
|
||||
dataIndex: 'email',
|
||||
valueType: 'text',
|
||||
align: 'center',
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.email.required")}],
|
||||
},
|
||||
{
|
||||
title: t("system.user.role"),
|
||||
dataIndex: 'role_id',
|
||||
valueType: 'select',
|
||||
align: 'center',
|
||||
hideInSearch: true,
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.role.required")}],
|
||||
render: (value: number[]) => (
|
||||
<>
|
||||
{value.map(item => (
|
||||
<Tag color={'magenta'}>
|
||||
{roles.find(r => r.role_id === item)?.name || '-'}
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
fieldProps: {
|
||||
mode: 'multiple',
|
||||
options: roles.map(r => ({ label: r.name, value: r.role_id })),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t("system.user.dept"),
|
||||
dataIndex: 'dept_id',
|
||||
valueType: 'treeSelect',
|
||||
align: 'center',
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.dept.required")}],
|
||||
render: (value) => (
|
||||
// 嵌套寻找部门名称
|
||||
<Tag color={'volcano'}>{departmentMap.get(value) || '-'}</Tag>
|
||||
),
|
||||
fieldProps: {
|
||||
treeData: departments,
|
||||
fieldNames: {label: 'name', value: 'dept_id'}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("system.user.status"),
|
||||
dataIndex: 'status',
|
||||
valueType: 'radioButton',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ value: 0, label: t("system.user.status.0") },
|
||||
{ value: 1, label: t("system.user.status.1") },
|
||||
]
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 1
|
||||
? <Tag color="success">{t("system.user.status.1")}</Tag>
|
||||
: <Tag color="error">{t("system.user.status.0")}</Tag>;
|
||||
},
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.status.required")}],
|
||||
filters: [
|
||||
{ text: t("system.user.status.0"), value: 0 },
|
||||
{ text: t("system.user.status.1"), value: 1 },
|
||||
],
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.user.mobile"),
|
||||
dataIndex: 'mobile',
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
rules: [{required: true, message: t("system.user.mobile.required")}],
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.user.avatar"),
|
||||
dataIndex: 'avatar_url',
|
||||
hideInSearch: true,
|
||||
hideInForm: true,
|
||||
render: (_, entity: ISysUser) => <Avatar size={'small'} src={entity.avatar_url}></Avatar>,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t("system.user.password"),
|
||||
dataIndex: 'password',
|
||||
valueType: 'password',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
hideInUpdate: true,
|
||||
rules: [{required: true, message: t("system.user.password.required")}],
|
||||
fieldProps: {autoComplete: 'new-password'},
|
||||
},
|
||||
{
|
||||
title: t("system.user.rePassword"),
|
||||
dataIndex: 'rePassword',
|
||||
valueType: 'password',
|
||||
hideInTable: true,
|
||||
hideInSearch: true,
|
||||
hideInUpdate: true,
|
||||
rules: [{required: true, message: t("system.user.rePassword.required")}],
|
||||
fieldProps: {autoComplete: 'new-password'},
|
||||
},
|
||||
{
|
||||
title: t("system.user.created_at"),
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'created_at',
|
||||
align: 'center',
|
||||
render: (value: string) => value ? dayjs(value).fromNow() : '-',
|
||||
},
|
||||
{
|
||||
title: t("system.user.updated_at"),
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
dataIndex: 'updated_at',
|
||||
align: 'center',
|
||||
render: (value: string) => value ? dayjs(value).fromNow() : '-',
|
||||
},
|
||||
];
|
||||
|
||||
/** 修改密码相关状态 */
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [resetUserId, setResetUserId] = useState<number>();
|
||||
const [buttonLoading, setButtonLoading] = useState<boolean>(false);
|
||||
|
||||
/** 打开重置密码框 */
|
||||
const showRedoModal = (id: number) => {
|
||||
setIsModalOpen(true);
|
||||
setResetUserId(id);
|
||||
};
|
||||
|
||||
/** 提交重置密码 */
|
||||
const handleRedoSubmit = async (values: ResetPasswordType) => {
|
||||
try {
|
||||
setButtonLoading(true);
|
||||
await resetPassword({...values, id: resetUserId!});
|
||||
setIsModalOpen(false);
|
||||
message.success(t("system.user.resetSuccess"));
|
||||
} finally {
|
||||
setButtonLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if(auth('system.user.dept')) {
|
||||
deptField().then(res => setDepartments(res.data.data!));
|
||||
}
|
||||
if(auth('system.user.role')) {
|
||||
roleField().then(res => setRoles(res.data.data!));
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 操作栏之后渲染 */
|
||||
const operateRender: XinTableProps['operateRender'] = (record, dom) => ([
|
||||
<>
|
||||
{record.id !== 1 &&
|
||||
<AuthButton auth={'system.user.resetPassword'}>
|
||||
<Tooltip title={t("system.user.resetPassword")}>
|
||||
<Button
|
||||
variant={'solid'}
|
||||
color={'pink'}
|
||||
icon={<RedoOutlined/>}
|
||||
size={'small'}
|
||||
onClick={() => showRedoModal(record.id!)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</AuthButton>
|
||||
}
|
||||
</>,
|
||||
dom.del,
|
||||
dom.edit
|
||||
]);
|
||||
|
||||
/** 表格配置 */
|
||||
const tableProps: XinTableProps<ISysUser> = {
|
||||
api: '/system/user',
|
||||
columns,
|
||||
rowKey: 'id',
|
||||
accessName: 'system.user',
|
||||
operateRender,
|
||||
scroll: { x: 1400 },
|
||||
editShow: (i) => i.id !== 1,
|
||||
deleteShow: (i) => i.id !== 1,
|
||||
formProps: {
|
||||
grid: true,
|
||||
colProps: {span: 12},
|
||||
rowProps: {gutter: [30, 0]},
|
||||
layout: 'vertical'
|
||||
},
|
||||
modalProps: {
|
||||
width: 800,
|
||||
},
|
||||
cardProps: {
|
||||
variant: 'borderless'
|
||||
},
|
||||
pagination: {
|
||||
size: 'small',
|
||||
style: {
|
||||
marginBottom: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={'mb-5'}>
|
||||
<Title level={3}>{t("system.user.page.title")}</Title>
|
||||
<Text type="secondary">{t("system.user.page.description")}</Text>
|
||||
</div>
|
||||
<XinTable<ISysUser> {...tableProps} />
|
||||
<Modal
|
||||
title={t("system.user.resetPassword")}
|
||||
closable={{'aria-label': 'Custom Close Button'}}
|
||||
open={isModalOpen}
|
||||
footer={null}
|
||||
styles={{body: {paddingTop: 20}}}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
>
|
||||
<Form<ResetPasswordType> autoComplete="off" layout={'vertical'} onFinish={handleRedoSubmit}>
|
||||
<Form.Item
|
||||
label={t("system.user.password")}
|
||||
name="password"
|
||||
rules={[{required: true, message: t("system.user.password.required")}]}
|
||||
>
|
||||
<Input.Password/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("system.user.rePassword")}
|
||||
name="rePassword"
|
||||
rules={[{required: true, message: t("system.user.rePassword.required")}]}
|
||||
>
|
||||
<Input.Password/>
|
||||
</Form.Item>
|
||||
<Form.Item label={null} style={{marginTop: 30}}>
|
||||
<Button type="primary" block size={'large'} htmlType="submit" loading={buttonLoading}>
|
||||
{t("system.user.resetButton")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Table;
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Tabs, Card, Form, Input, Button, message, Radio, Upload, Avatar, List, Tag, Space, Typography } from 'antd';
|
||||
import { UserOutlined, LockOutlined, SnippetsOutlined, UploadOutlined, LaptopOutlined, EnvironmentOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useMobile from '@/hooks/useMobile.ts';
|
||||
import useAuthStore from "@/stores/user";
|
||||
import { type InfoParams, updateInfo, type PasswordParams, updatePassword, loginRecord } from "@/api/system/sys_user";
|
||||
import type ISysLoginRecord from "@/domain/iSysLoginRecord.ts";
|
||||
import type { UploadProps, FormProps } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/** 基本信息 Tab */
|
||||
const InfoTab = () => {
|
||||
const userInfo = useAuthStore(state => state.userinfo);
|
||||
const getInfo = useAuthStore(state => state.info);
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const uploadChange: UploadProps['onChange'] = async (info) => {
|
||||
if (info.file.status === 'done') {
|
||||
message.success(t("user.profile.baseInfo.avatarSuccess"));
|
||||
await getInfo();
|
||||
}
|
||||
}
|
||||
|
||||
const onFinish: FormProps['onFinish'] = async (values: InfoParams) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await updateInfo(values);
|
||||
await getInfo();
|
||||
message.success(t("user.profile.baseInfo.success"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full px-6 py-4">
|
||||
<div className="flex flex-col items-center mb-3">
|
||||
<Avatar size={120} src={userInfo?.avatar_url} className="mb-4 border-2 border-gray-200" />
|
||||
<Upload
|
||||
name="file"
|
||||
action={`${import.meta.env.VITE_BASE_URL}/admin/uploadAvatar`}
|
||||
headers={{ Authorization: `Bearer ${localStorage.getItem('token')}` }}
|
||||
showUploadList={false}
|
||||
onChange={uploadChange}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>{t("user.profile.baseInfo.updateAvatar")}</Button>
|
||||
</Upload>
|
||||
<p className="text-gray-500 text-sm mt-2">{t("user.profile.baseInfo.updateAvatarDesc")}</p>
|
||||
</div>
|
||||
<Form<InfoParams>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
initialValues={{
|
||||
bio: userInfo?.bio,
|
||||
email: userInfo?.email,
|
||||
mobile: userInfo?.mobile,
|
||||
nickname: userInfo?.nickname,
|
||||
sex: userInfo?.sex,
|
||||
username: userInfo?.username,
|
||||
}}
|
||||
>
|
||||
<Form.Item label={t("user.profile.baseInfo.username")} name="username" rules={[{ required: true, message: t("user.profile.baseInfo.username.message") }]}>
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("user.profile.baseInfo.nickname")} name="nickname" rules={[{ required: true, message: t("user.profile.baseInfo.nickname.message") }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("user.profile.baseInfo.sex")} name="sex">
|
||||
<Radio.Group options={[{ value: 0, label: t("user.profile.baseInfo.sex.0") }, { value: 1, label: t("user.profile.baseInfo.sex.1") }]} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("user.profile.baseInfo.bio")} name="bio">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("user.profile.baseInfo.email")} name="email" rules={[{ type: 'email', message: t("user.profile.baseInfo.email.typeMessage") }, { required: true, message: t("user.profile.baseInfo.email.requiredMessage") }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("user.profile.baseInfo.mobile")} name="mobile" rules={[{ required: true, message: t("user.profile.baseInfo.mobile.message") }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" size="large" loading={loading} block>
|
||||
{t("user.profile.baseInfo.submit")}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** 修改密码 Tab */
|
||||
const SecurityTab = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onFinish: FormProps['onFinish'] = async (values: PasswordParams) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await updatePassword(values);
|
||||
form.resetFields();
|
||||
message.success(t("user.profile.changePassword.success"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form<PasswordParams> form={form} layout="vertical" onFinish={onFinish} className="w-full px-6 py-4">
|
||||
<Form.Item label={t("user.profile.changePassword.oldPassword")} name="oldPassword" rules={[{ required: true, message: t("user.profile.changePassword.oldPassword.message") }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item label={t("user.profile.changePassword.newPassword")} name="newPassword" rules={[{ required: true, message: t("user.profile.changePassword.requiredMessage") }, { min: 8, message: t("user.profile.changePassword.minMessage") }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t("user.profile.changePassword.rePassword")}
|
||||
name="rePassword"
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{ required: true, message: t("user.profile.changePassword.requiredMessage") },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t("user.profile.changePassword.rePassword.message")));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Button type="primary" block htmlType="submit" size="large" loading={loading}>
|
||||
{t("user.profile.changePassword.submit")}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
/** 登录日志 Tab */
|
||||
const LoginLogTab = () => {
|
||||
const [logs, setLogs] = useState<ISysLoginRecord[]>([]);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
loginRecord().then(res => setLogs(res.data.data || []));
|
||||
}, []);
|
||||
|
||||
const BrowserIcon = ({ browser }: { browser?: string }) => {
|
||||
const icons: Record<string, string> = { Chrome: '🚀', Firefox: '🦊', Safari: '🍎', Edge: '🌊' };
|
||||
const icon = Object.keys(icons).find(key => browser?.includes(key));
|
||||
return <span>{icon ? icons[icon] : '💻'}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<List
|
||||
dataSource={logs}
|
||||
className="w-full px-6 py-4 overflow-auto"
|
||||
locale={{ emptyText: t("user.profile.loginLog.empty") }}
|
||||
renderItem={(log) => (
|
||||
<List.Item style={{ minWidth: 600 }}>
|
||||
<Avatar
|
||||
size="large"
|
||||
icon={<UserOutlined />}
|
||||
style={{ backgroundColor: log.status === '0' ? '#87d068' : '#f56a00', marginRight: 16 }}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center mb-2">
|
||||
<Text strong className="mr-3 text-base">{log.username}</Text>
|
||||
{log.status === '0'
|
||||
? <Tag color="green" icon={<CheckCircleOutlined />}>{t("user.profile.loginLog.success")}</Tag>
|
||||
: <Tag color="red" icon={<CloseCircleOutlined />}>{t("user.profile.loginLog.error")}</Tag>
|
||||
}
|
||||
<Text type="secondary" className="ml-auto">
|
||||
{dayjs(log.login_time).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Text>
|
||||
</div>
|
||||
<Space size="large" wrap>
|
||||
<div className="flex items-center">
|
||||
<LaptopOutlined className="mr-1 text-blue-500" />
|
||||
<Text type="secondary">{log.ipaddr}</Text>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<EnvironmentOutlined className="mr-1 text-green-500" />
|
||||
<Text type="secondary">{log.login_location}</Text>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<BrowserIcon browser={log.browser} />
|
||||
<Text type="secondary" className="ml-1">{log.browser}</Text>
|
||||
</div>
|
||||
<Text type="secondary">OS: {log.os}</Text>
|
||||
</Space>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** 用户设置主页面 */
|
||||
const UserSettingPage = () => {
|
||||
const [activeTab, setActiveTab] = useState('info');
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
|
||||
const tabsList = [
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
<UserOutlined className="mr-2" />
|
||||
{isMobile ? null : t("user.profile.baseInfo")}
|
||||
</span>
|
||||
),
|
||||
key: 'info',
|
||||
children: <InfoTab />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
<LockOutlined className="mr-2" />
|
||||
{isMobile ? null : t("user.profile.changePassword")}
|
||||
</span>
|
||||
),
|
||||
key: 'security',
|
||||
children: <SecurityTab />,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center">
|
||||
<SnippetsOutlined className="mr-2" />
|
||||
{isMobile ? null : t("user.profile.loginLog")}
|
||||
</span>
|
||||
),
|
||||
key: 'loginlog',
|
||||
children: <LoginLogTab />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={t("user.profile.title")}
|
||||
variant="borderless"
|
||||
style={{ maxWidth: 800, width: '100%' }}
|
||||
styles={{ body: { paddingInline: 0, paddingRight: 25, display: "flex" } }}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
tabPlacement="start"
|
||||
className="min-h-[500px] w-full"
|
||||
items={tabsList}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSettingPage;
|
||||
Reference in New Issue
Block a user