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,139 @@
|
||||
import {Card, Row, Col, Space, Divider, List, Avatar, theme} from "antd";
|
||||
import {ArrowDownOutlined, ArrowUpOutlined} from "@ant-design/icons";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
const {useToken} = theme
|
||||
|
||||
const Monitor = () => {
|
||||
const {token} = useToken();
|
||||
const { t } = useTranslation();
|
||||
const imageUrl = import.meta.env.VITE_BASE_URL + '/static';
|
||||
const data = [
|
||||
{
|
||||
title: '3号楼2层走廊灯不亮,需检查线路并更换灯泡。',
|
||||
description: '3号楼2层走廊灯故障,经检查为灯泡损坏,需更换LED节能灯泡(型号:E27-9W),并排查线路是否老化。预计完成时间:6月20日前。',
|
||||
},
|
||||
{
|
||||
title: '安排维保单位对小区所有电梯进行月度维护,确保运行安全。',
|
||||
description: '联系XX维保公司(电话:123-456789)于6月25日对小区12部电梯进行月度维护,重点检查曳引机、门锁装置及应急报警功能,完成后提交保养报告。',
|
||||
},
|
||||
{
|
||||
title: '5号楼前绿化带杂草丛生,需安排园艺工人本周内清理。',
|
||||
description: '5号楼前绿化带因雨季杂草生长迅速,需安排园艺组(责任人:张师傅)本周内清理,并喷洒除草剂。同步检查灌溉系统是否漏水。',
|
||||
},
|
||||
{
|
||||
title: '8号楼302室反映厨房水管漏水,需上门检修并更换配件。',
|
||||
description: '8号楼302室业主反映厨房下水管接口渗水,已临时关闭水阀。需工程部带PVC管件(Φ50mm)及密封胶上门维修,完成后拍照反馈业主。',
|
||||
},
|
||||
{
|
||||
title: '检查各楼栋灭火器压力及有效期,记录并更换不合格器材。',
|
||||
description: "按消防规定,6月需全面检查56处灭火器压力表(绿色达标)、软管龟裂情况,并更换2号楼过期灭火器(编号:2023-015至018)。",
|
||||
},
|
||||
{
|
||||
title: '安排保洁人员彻底清扫B1层车库,清理杂物和积水。',
|
||||
description: "B1层车库地面油渍、垃圾较多,安排保洁组6月22日集中清扫,重点清理排水沟杂物,并检查照明设施是否正常。",
|
||||
},
|
||||
{
|
||||
title: '联系技术员升级小区门禁系统,测试各单元刷卡功能。',
|
||||
description: "新门禁系统(版本V2.3)需于6月28日前完成升级,联系供应商调试人脸识别功能,并同步更新业主卡数据。测试期3天。",
|
||||
},
|
||||
{
|
||||
title: '统计未缴费业主名单,发送短信及书面催缴通知。',
|
||||
description: "截至6月,尚有15户未缴纳Q2物业费(名单见附件),本周内发送短信提醒,对逾期户上门送达《催缴通知书》(模板编号:WY-2024-06)。",
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col xxl={18} lg={16} xs={24}>
|
||||
<Card variant={"borderless"} style={{marginBottom: 20}}>
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col xxl={6} lg={12} xs={24}>
|
||||
<div className={"flex items-center"}>
|
||||
<div className={"size-14 rounded-full p-3"} style={{background: token.colorPrimaryBg }}>
|
||||
<img src={imageUrl + '/帮办代办.png'} alt="1"/>
|
||||
</div>
|
||||
<div className={"ml-5"}>
|
||||
<div style={{color: token.colorTextDescription}}>{t('dashboard.monitor.helpService')}</div>
|
||||
<div className={"text-[30px]"}>30 / 255</div>
|
||||
<div>{t('dashboard.completion')} <span style={{color: token.colorError}}>11.60%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xxl={6} lg={12} xs={24}>
|
||||
<div className={"flex items-center"}>
|
||||
<div className={"size-14 rounded-full p-3"} style={{background: token.colorPrimaryBg }}>
|
||||
<img src={imageUrl + '/购房.png'} alt="1"/>
|
||||
</div>
|
||||
<div className={"ml-5"}>
|
||||
<div style={{color: token.colorTextDescription}}>{t('dashboard.monitor.propertyFee')}</div>
|
||||
<div className={"text-[30px]"}>19,308 ¥</div>
|
||||
<div>{t('dashboard.vs.lastWeek')} <span style={{color: token.colorError}}><ArrowUpOutlined />19.20%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xxl={6} lg={12} xs={24}>
|
||||
<div className={"flex items-center"}>
|
||||
<div className={"size-14 rounded-full p-3"} style={{background: token.colorPrimaryBg }}>
|
||||
<img src={imageUrl + '/购房.png'} alt="1"/>
|
||||
</div>
|
||||
<div className={"ml-5"}>
|
||||
<div style={{color: token.colorTextDescription}}>{t('dashboard.monitor.alarmEvents')}</div>
|
||||
<div className={"text-[30px]"}>30</div>
|
||||
<div>{t('dashboard.vs.lastWeek')} <span style={{color: token.colorSuccess}}><ArrowDownOutlined />32.60%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xxl={6} lg={12} xs={24}>
|
||||
<div className={"flex items-center"}>
|
||||
<div className={"size-14 rounded-full p-3"} style={{background: token.colorPrimaryBg }}>
|
||||
<img src={imageUrl + '/购房.png'} alt="1"/>
|
||||
</div>
|
||||
<div className={"ml-5"}>
|
||||
<div style={{color: token.colorTextDescription}}>{t('dashboard.monitor.otherEvents')}</div>
|
||||
<div className={"text-[30px]"}>255件</div>
|
||||
<div>{t('dashboard.vs.lastWeek')} <span style={{color: token.colorError}}><ArrowUpOutlined />16.50%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
<Card variant={"borderless"}>
|
||||
<div style={{ marginBottom: 20, fontSize: token.fontSizeLG, fontWeight: token.fontWeightStrong }}>
|
||||
{t('dashboard.monitor.todoList')} <Divider type="vertical" /> <span style={{color: token.colorTextDescription}}>{t('dashboard.monitor.myFocus')}</span>
|
||||
</div>
|
||||
<Space className={"mb-5"} wrap>
|
||||
<div className={"rounded-xl pl-3 pr-3"} style={{ color: token.colorPrimary, background: token.colorPrimaryBg }}>{t('dashboard.monitor.all')} 15</div>
|
||||
<div className={"rounded-xl pl-3 pr-3"} style={{ color: token.colorText, background: token.colorBorderSecondary }}>{t('dashboard.monitor.propertyOrder')} 8</div>
|
||||
<div className={"rounded-xl pl-3 pr-3"} style={{ color: token.colorText, background: token.colorBorderSecondary }}>{t('dashboard.monitor.repairOrder')} 2</div>
|
||||
<div className={"rounded-xl pl-3 pr-3"} style={{ color: token.colorText, background: token.colorBorderSecondary }}>{t('dashboard.monitor.fireSafety')} 3</div>
|
||||
<div className={"rounded-xl pl-3 pr-3"} style={{ color: token.colorText, background: token.colorBorderSecondary }}>{t('dashboard.monitor.paidService')} 5</div>
|
||||
<div className={"rounded-xl pl-3 pr-3"} style={{ color: token.colorText, background: token.colorBorderSecondary }}>{t('dashboard.monitor.publicService')} 7</div>
|
||||
</Space>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={data}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar src={`https://api.dicebear.com/7.x/avataaars/svg?seed=${index}`} />}
|
||||
title={<a>{item.title}</a>}
|
||||
description={item.description}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xxl={6} lg={8} xs={24}>
|
||||
<Card variant={"borderless"} style={{ marginBottom: 20 }}>
|
||||
<img src={imageUrl + '/group65.png'} alt="1"/>
|
||||
</Card>
|
||||
<Card variant={"borderless"} styles={{body: {padding: 0, borderRadius: token.borderRadius, overflow: 'hidden'}}}>
|
||||
<img src={imageUrl + '/group57.png'} alt="1"/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export default Monitor;
|
||||
@@ -0,0 +1,293 @@
|
||||
import {Card, Avatar, List, Badge, Tag, Divider, Typography, Space, Empty} from 'antd';
|
||||
import {
|
||||
ProjectOutlined,
|
||||
TeamOutlined,
|
||||
BellOutlined,
|
||||
ClockCircleOutlined,
|
||||
MailOutlined,
|
||||
PhoneOutlined,
|
||||
EnvironmentOutlined,
|
||||
LinkOutlined,
|
||||
SmileOutlined,
|
||||
AppstoreOutlined,
|
||||
ShopOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useAuthStore from "@/stores/user";
|
||||
import type {ReactNode} from "react";
|
||||
const { Meta } = Card;
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
interface AppType {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
badge?: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const PersonalCenter = () => {
|
||||
const { t } = useTranslation();
|
||||
// 用户信息数据
|
||||
const userInfo = useAuthStore(state => state.userinfo);
|
||||
if (! userInfo ) return <></>;
|
||||
|
||||
// 标签页列表数据
|
||||
const tabList = [
|
||||
{
|
||||
key: "projects",
|
||||
label: <><ProjectOutlined className={'mr-2'}/> {t('dashboard.workplace.myProjects')} </>,
|
||||
},
|
||||
{
|
||||
key: "teams",
|
||||
label: <><TeamOutlined className={'mr-2'}/> {t('dashboard.workplace.myTeams')} </>,
|
||||
},
|
||||
{
|
||||
key: "activities",
|
||||
label: <><ClockCircleOutlined className={'mr-2'}/> {t('dashboard.workplace.latestActivities')} </>,
|
||||
},
|
||||
{
|
||||
key: "notifications",
|
||||
label: (
|
||||
<Badge count={1} offset={[10, 0]}>
|
||||
<span>
|
||||
<BellOutlined className={'mr-2'} />
|
||||
{t('dashboard.workplace.notifications')}
|
||||
</span>
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
]
|
||||
// 我的项目数据
|
||||
const projects = [
|
||||
{
|
||||
id: 1,
|
||||
name: t('dashboard.workplace.project1.name'),
|
||||
description: t('dashboard.workplace.project1.desc'),
|
||||
status: t('dashboard.workplace.status.inProgress'),
|
||||
progress: 65,
|
||||
members: 5,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: t('dashboard.workplace.project2.name'),
|
||||
description: t('dashboard.workplace.project2.desc'),
|
||||
status: t('dashboard.workplace.status.completed'),
|
||||
progress: 100,
|
||||
members: 3,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: t('dashboard.workplace.project3.name'),
|
||||
description: t('dashboard.workplace.project3.desc'),
|
||||
status: t('dashboard.workplace.status.planning'),
|
||||
progress: 10,
|
||||
members: 2,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: t('dashboard.workplace.project4.name'),
|
||||
description: t('dashboard.workplace.project4.desc'),
|
||||
status: '2023-06-10',
|
||||
progress: 10,
|
||||
members: 2,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: t('dashboard.workplace.project5.name'),
|
||||
description: t('dashboard.workplace.project5.desc'),
|
||||
status: '2023-06-08',
|
||||
progress: 15,
|
||||
members: 5,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: t('dashboard.workplace.project6.name'),
|
||||
description: t('dashboard.workplace.project6.desc'),
|
||||
status: '2023-06-05',
|
||||
progress: 60,
|
||||
members: 3,
|
||||
},
|
||||
];
|
||||
// APP 应用列表
|
||||
const apps: AppType[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: t('dashboard.workplace.appStore'),
|
||||
icon: <AppstoreOutlined />,
|
||||
description: t('dashboard.workplace.appStore.desc'),
|
||||
badge: t('dashboard.workplace.badge.new'),
|
||||
color: '#1890ff',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: t('dashboard.workplace.mallSystem'),
|
||||
icon: <ShopOutlined />,
|
||||
description: t('dashboard.workplace.mallSystem.desc'),
|
||||
color: '#52c41a',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: t('dashboard.workplace.collaboration'),
|
||||
icon: <TeamOutlined />,
|
||||
description: t('dashboard.workplace.collaboration.desc'),
|
||||
color: '#faad14',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: t('dashboard.workplace.docCenter'),
|
||||
icon: <FileTextOutlined />,
|
||||
description: t('dashboard.workplace.docCenter.desc'),
|
||||
color: '#13c2c2',
|
||||
}
|
||||
];
|
||||
// 获取当前时间问候语
|
||||
const getGreeting = () => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return t('dashboard.workplace.greeting.morning');
|
||||
if (hour < 18) return t('dashboard.workplace.greeting.afternoon');
|
||||
return t('dashboard.workplace.greeting.evening');
|
||||
};
|
||||
// APP 应用卡片
|
||||
const AppCard = (props: {app: AppType}) => (
|
||||
<Card
|
||||
hoverable
|
||||
variant={'borderless'}
|
||||
cover={
|
||||
<div
|
||||
className="flex items-center justify-center h-32"
|
||||
style={{ backgroundColor: `${props.app.color}20` }} // 20表示透明度
|
||||
>
|
||||
<Avatar
|
||||
icon={props.app.icon}
|
||||
size={64}
|
||||
style={{
|
||||
backgroundColor: props.app.color,
|
||||
color: '#fff',
|
||||
fontSize: '28px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Meta
|
||||
title={<span className="font-semibold">{props.app.name}</span>}
|
||||
description={props.app.description}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* 左侧布局显示内容 */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
<Card variant={'borderless'} styles={{body: {padding: 0}}} className={'overflow-hidden'}>
|
||||
<div className="bg-gradient-to-r from-blue-500 to-purple-600 flex items-center p-8">
|
||||
<Space size="large" className="w-full">
|
||||
<Avatar
|
||||
size={64}
|
||||
src={userInfo.avatar_url}
|
||||
icon={<SmileOutlined />}
|
||||
className="border-2 border-white shadow"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Title level={3} className="!text-white !mb-1">
|
||||
{getGreeting()},{userInfo.nickname},{t('dashboard.workplace.welcome')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Text className="text-white/80 text-lg">
|
||||
{t('dashboard.workplace.today')} {new Date().toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long'
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{apps.map((app) => (
|
||||
<div key={app.id}>
|
||||
{ app.badge ?
|
||||
<Badge.Ribbon text={app.badge} color={app.color}>
|
||||
<AppCard app={app}></AppCard>
|
||||
</Badge.Ribbon>
|
||||
:
|
||||
<AppCard app={app} ></AppCard>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Card variant={'borderless'} defaultActiveTabKey="projects" tabList={tabList}>
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={projects}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={<Avatar icon={<ProjectOutlined />} />}
|
||||
title={<a href="#">{item.name}</a>}
|
||||
description={item.description}
|
||||
/>
|
||||
<div className="flex flex-col items-end">
|
||||
<Tag color={item.status === t('dashboard.workplace.status.completed') ? 'success' : item.status === t('dashboard.workplace.status.inProgress') ? 'processing' : 'default'}>
|
||||
{item.status}
|
||||
</Tag>
|
||||
<div className="mt-2">
|
||||
<span className="text-gray-500 mr-2">{t('dashboard.workplace.progress')}: {item.progress}%</span>
|
||||
<span className="text-gray-500">{t('dashboard.workplace.members')}: {item.members}{t('dashboard.workplace.people')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右侧布局显示内容 */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card variant={'borderless'} className="mb-5">
|
||||
<div className="flex flex-col items-center">
|
||||
<Avatar size={100} src={userInfo.avatar_url} className="mb-4" />
|
||||
<h2 className="text-xl font-bold mb-2">{userInfo.nickname}</h2>
|
||||
<Tag color="blue" className="mb-4">
|
||||
{userInfo.dept_name}
|
||||
</Tag>
|
||||
<p className="text-gray-700 mb-2 text-center">hello</p>
|
||||
<Divider className="my-3 mb-4" />
|
||||
<div className="w-full space-y-3">
|
||||
<div className="flex items-center text-gray-600">
|
||||
<MailOutlined className="mr-2" />
|
||||
<span>{userInfo.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<PhoneOutlined className="mr-2" />
|
||||
<span>{userInfo.mobile}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<EnvironmentOutlined className="mr-2" />
|
||||
<span>{t('dashboard.workplace.location')}</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-600">
|
||||
<LinkOutlined className="mr-2" />
|
||||
<a target="_blank" rel="noopener noreferrer" className="text-blue-500">
|
||||
123
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card variant={'borderless'} title={t('dashboard.workplace.recentVisits')}>
|
||||
<Empty/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonalCenter;
|
||||
@@ -0,0 +1,118 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Card, Space, Divider, Typography, message } from 'antd';
|
||||
import XinForm, { type XinFormRef } from '@/components/XinForm';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender/typings';
|
||||
import IconSelect from '@/components/XinFormField/IconSelector';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 图标选择器组件使用示例页面
|
||||
*/
|
||||
const IconSelectExample: React.FC = () => {
|
||||
const [icon, setIcon] = useState<string>('');
|
||||
const formRef = useRef<XinFormRef>(undefined);
|
||||
|
||||
// 表单列配置
|
||||
const columns: FormColumn<any>[] = [
|
||||
{
|
||||
dataIndex: 'systemName',
|
||||
title: '系统名称',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: '请输入系统名称' }],
|
||||
fieldProps: {
|
||||
placeholder: '请输入系统名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'systemIcon',
|
||||
title: '系统图标',
|
||||
rules: [{ required: true, message: '请选择系统图标' }],
|
||||
fieldRender: () => <IconSelect placeholder="请选择系统图标" />,
|
||||
},
|
||||
{
|
||||
dataIndex: 'description',
|
||||
title: '系统描述',
|
||||
valueType: 'textarea',
|
||||
fieldProps: {
|
||||
placeholder: '请输入系统描述',
|
||||
rows: 4,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography style={{ margin: '12px 0 24px 0' }}>
|
||||
<Title level={2}>图标选择器组件示例</Title>
|
||||
<Paragraph>
|
||||
基于 Ant Design Select + Modal + Tabs 封装的图标选择器组件,支持多分类图标选择。
|
||||
</Paragraph>
|
||||
</Typography>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card title="独立使用" bordered>
|
||||
|
||||
<Text>基础用法:</Text>
|
||||
<div className="mt-2">
|
||||
<IconSelect
|
||||
value={icon}
|
||||
onChange={(value) => {
|
||||
setIcon(value || '');
|
||||
if (value) {
|
||||
message.success(`选中图标: ${value}`);
|
||||
} else {
|
||||
message.info('已清空图标');
|
||||
}
|
||||
}}
|
||||
placeholder="请选择图标"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text>禁用状态:</Text>
|
||||
<div className="mt-2">
|
||||
<IconSelect
|
||||
value="HomeOutlined"
|
||||
disabled={true}
|
||||
placeholder="禁用状态"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text>只读状态:</Text>
|
||||
<div className="mt-2">
|
||||
<IconSelect
|
||||
value="SettingOutlined"
|
||||
readonly={true}
|
||||
placeholder="只读状态"
|
||||
/>
|
||||
</div>
|
||||
<Text type="secondary" className="mt-2 block text-sm">
|
||||
只读模式下不能打开选择弹窗,但可以清空
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card title="在 XinForm 中使用" bordered>
|
||||
<XinForm
|
||||
formRef={formRef}
|
||||
columns={columns}
|
||||
onFinish={async (values) => {
|
||||
console.log('XinForm 提交:', values);
|
||||
message.success('提交成功!');
|
||||
message.info(`系统图标: ${values.systemIcon}`);
|
||||
return true;
|
||||
}}
|
||||
submitter={{
|
||||
submitText: '提交表单',
|
||||
render: (dom) => dom.submit,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconSelectExample;
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Form, Button, Space, Typography } from 'antd';
|
||||
import ImageUploader from '@/components/XinFormField/ImageUploader';
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
const { Title, Paragraph } = Typography;
|
||||
/**
|
||||
* 图片上传器示例页面
|
||||
*/
|
||||
const ImageUploaderExample: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [singleValue, setSingleValue] = useState<ISysFileInfo | null>(null);
|
||||
const [multipleValue, setMultipleValue] = useState<ISysFileInfo[]>([]);
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
console.log('Form values:', values);
|
||||
console.log('Single image:', singleValue);
|
||||
console.log('Multiple images:', multipleValue);
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
form.resetFields();
|
||||
setSingleValue(null);
|
||||
setMultipleValue([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography style={{ margin: '12px 0 24px 0' }}>
|
||||
<Title level={2}>图片上传组件示例</Title>
|
||||
<Paragraph>
|
||||
基于 Ant Design Upload 封装的图片上传组件,支持图片剪裁、多张图片上传、尺寸限制、禁用状态等。
|
||||
</Paragraph>
|
||||
</Typography>
|
||||
<Card title="基础用法" style={{ marginBottom: 24 }}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
label="单张图片上传"
|
||||
name="avatar"
|
||||
extra="支持单张图片上传,最大 5MB,尺寸不超过 1920x1080"
|
||||
>
|
||||
<ImageUploader
|
||||
action="/sys/file/list/upload/image"
|
||||
mode="single"
|
||||
value={singleValue}
|
||||
onChange={(value) => setSingleValue(value as ISysFileInfo)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="多张图片上传"
|
||||
name="gallery"
|
||||
extra="支持最多 5 张图片上传"
|
||||
>
|
||||
<ImageUploader
|
||||
action="/sys/file/list/upload/image"
|
||||
mode="multiple"
|
||||
maxCount={5}
|
||||
value={multipleValue}
|
||||
onChange={(value) => setMultipleValue(value as ISysFileInfo[])}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSubmit}>
|
||||
提交
|
||||
</Button>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title="高级配置" style={{ marginBottom: 24 }}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
label="自定义尺寸限制"
|
||||
extra="限制图片尺寸为 800x600,大小不超过 2MB"
|
||||
>
|
||||
<ImageUploader
|
||||
action="/sys/file/list/upload/image"
|
||||
mode="single"
|
||||
maxWidth={800}
|
||||
maxHeight={600}
|
||||
maxSize={2}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="裁剪功能 - 自由裁剪"
|
||||
extra="启用裁剪功能,可自由调整裁剪区域"
|
||||
>
|
||||
<ImageUploader
|
||||
action="/sys/file/list/upload/image"
|
||||
mode="single"
|
||||
croppable
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="裁剪功能 - 1:1 正方形"
|
||||
extra="固定 1:1 比例裁剪,适合头像上传"
|
||||
>
|
||||
<ImageUploader
|
||||
action="/sys/file/list/upload/image"
|
||||
mode="single"
|
||||
croppable
|
||||
cropperOptions={{ aspect: 1 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="裁剪功能 - 圆形裁剪"
|
||||
extra="圆形裁剪模式,适合头像上传"
|
||||
>
|
||||
<ImageUploader
|
||||
action="/sys/file/list/upload/image"
|
||||
mode="single"
|
||||
croppable
|
||||
cropperOptions={{ cropShape: 'round', aspect: 1 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="裁剪功能 - 16:9 宽屏"
|
||||
extra="固定 16:9 比例裁剪,适合横幅图片"
|
||||
>
|
||||
<ImageUploader
|
||||
action="/sys/file/list/upload/image"
|
||||
mode="single"
|
||||
croppable
|
||||
cropperOptions={{ aspect: 16 / 9 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="禁用状态">
|
||||
<ImageUploader
|
||||
action="/sys/file/list/upload/image"
|
||||
mode="single"
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploaderExample;
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Card, Space, Divider, Typography, message } from 'antd';
|
||||
import XinForm, { type XinFormRef } from '@/components/XinForm';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender/typings';
|
||||
import UserSelector from '@/components/XinFormField/UserSelector';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 用户选择器组件使用示例页面
|
||||
*/
|
||||
const UserSelectorExample: React.FC = () => {
|
||||
const [singleUser, setSingleUser] = useState<number | null>(null);
|
||||
const [multipleUsers, setMultipleUsers] = useState<number[]>([]);
|
||||
const formRef = useRef<XinFormRef>(undefined);
|
||||
|
||||
// 表单列配置
|
||||
const columns: FormColumn<any>[] = [
|
||||
{
|
||||
dataIndex: 'taskName',
|
||||
title: '任务名称',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: '请输入任务名称' }],
|
||||
fieldProps: {
|
||||
placeholder: '请输入任务名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'owner_id',
|
||||
title: '任务负责人',
|
||||
rules: [{ required: true, message: '请选择负责人' }],
|
||||
fieldRender: () => <UserSelector placeholder="请选择负责人" />,
|
||||
},
|
||||
{
|
||||
dataIndex: 'participant_ids',
|
||||
title: '参与人员',
|
||||
rules: [
|
||||
{ required: true, message: '请至少选择一个参与人员' },
|
||||
{
|
||||
validator: (_: any, value: number[]) => {
|
||||
if (value && value.length > 10) {
|
||||
return Promise.reject('最多选择10个参与人员');
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldRender: () => <UserSelector mode="multiple" maxTagCount={3} placeholder="请选择参与人员" />,
|
||||
},
|
||||
{
|
||||
dataIndex: 'description',
|
||||
title: '任务描述',
|
||||
valueType: 'textarea',
|
||||
fieldProps: {
|
||||
placeholder: '请输入任务描述',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography style={{ margin: '12px 0 24px 0' }}>
|
||||
<Title level={2}>用户选择器组件示例</Title>
|
||||
<Paragraph>
|
||||
基于 AntDesign 封装的用户选择器表单组件,支持单选和多选模式。
|
||||
</Paragraph>
|
||||
</Typography>
|
||||
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card title="独立使用" bordered>
|
||||
|
||||
<Text>单选模式:</Text>
|
||||
<div className="mt-2">
|
||||
<UserSelector
|
||||
value={singleUser}
|
||||
onChange={(value) => {
|
||||
setSingleUser(value as number);
|
||||
message.success(`选中用户ID: ${value}`);
|
||||
}}
|
||||
placeholder="请选择用户"
|
||||
/>
|
||||
</div>
|
||||
<Text type="secondary" className="mt-2 block">
|
||||
当前选中: {singleUser || '未选择'}
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text>多选模式:</Text>
|
||||
<div className="mt-2">
|
||||
<UserSelector
|
||||
mode="multiple"
|
||||
value={multipleUsers}
|
||||
onChange={(value) => {
|
||||
setMultipleUsers(value as number[]);
|
||||
message.success(`选中${(value as number[]).length}个用户`);
|
||||
}}
|
||||
placeholder="请选择多个用户"
|
||||
maxTagCount={3}
|
||||
/>
|
||||
</div>
|
||||
<Text type="secondary" className="mt-2 block">
|
||||
当前选中: {multipleUsers.length > 0 ? multipleUsers.join(', ') : '未选择'}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card title="在 XinForm 中使用" bordered>
|
||||
<XinForm
|
||||
formRef={formRef}
|
||||
columns={columns}
|
||||
onFinish={async (values) => {
|
||||
console.log('表单提交:', values);
|
||||
message.success('提交成功!');
|
||||
message.info(`负责人ID: ${values.owner_id}, 参与人IDs: ${values.participant_ids?.join(', ')}`);
|
||||
return true;
|
||||
}}
|
||||
submitter={{
|
||||
submitText: '提交表单',
|
||||
render: (dom) => dom.submit,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSelectorExample;
|
||||
@@ -0,0 +1,710 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { Card, Space, Typography, message, Button, Divider, Input, Tag } from 'antd';
|
||||
import XinForm from '@/components/XinForm';
|
||||
import type { XinFormRef } from '@/components/XinForm';
|
||||
import type { FormColumn } from '@/components/XinFormField/FieldRender/typings';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
// 基础表单字段配置
|
||||
const basicColumns: FormColumn<any>[] = [
|
||||
{
|
||||
dataIndex: 'username',
|
||||
title: '用户名',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: '请输入用户名' }],
|
||||
fieldProps: {
|
||||
placeholder: '请输入用户名',
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'password',
|
||||
title: '密码',
|
||||
valueType: 'password',
|
||||
rules: [{ required: true, message: '请输入密码' }],
|
||||
fieldProps: {
|
||||
placeholder: '请输入密码',
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'email',
|
||||
title: '邮箱',
|
||||
valueType: 'text',
|
||||
rules: [
|
||||
{ required: true, message: '请输入邮箱' },
|
||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
||||
],
|
||||
fieldProps: {
|
||||
placeholder: '请输入邮箱地址',
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'description',
|
||||
title: '个人简介',
|
||||
valueType: 'textarea',
|
||||
fieldProps: {
|
||||
placeholder: '请输入个人简介',
|
||||
rows: 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Grid 布局表单字段配置
|
||||
const gridColumns: FormColumn<any>[] = [
|
||||
{
|
||||
dataIndex: 'name',
|
||||
title: '姓名',
|
||||
valueType: 'text',
|
||||
colProps: { span: 12 },
|
||||
rules: [{ required: true, message: '请输入姓名' }],
|
||||
fieldProps: { placeholder: '请输入姓名' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'age',
|
||||
title: '年龄',
|
||||
valueType: 'digit',
|
||||
colProps: { span: 12 },
|
||||
fieldProps: { placeholder: '请输入年龄', min: 0, max: 150, style: { width: '100%' } },
|
||||
},
|
||||
{
|
||||
dataIndex: 'gender',
|
||||
title: '性别',
|
||||
valueType: 'radio',
|
||||
colProps: { span: 12 },
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: '男', value: 'male' },
|
||||
{ label: '女', value: 'female' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '状态',
|
||||
valueType: 'switch',
|
||||
colProps: { span: 12 },
|
||||
valuePropName: 'checked',
|
||||
},
|
||||
{
|
||||
dataIndex: 'birthDate',
|
||||
title: '出生日期',
|
||||
valueType: 'date',
|
||||
colProps: { span: 12 },
|
||||
fieldProps: { style: { width: '100%' } },
|
||||
},
|
||||
{
|
||||
dataIndex: 'salary',
|
||||
title: '薪资',
|
||||
valueType: 'money',
|
||||
colProps: { span: 12 },
|
||||
fieldProps: { style: { width: '100%' }, placeholder: '请输入薪资' },
|
||||
},
|
||||
];
|
||||
|
||||
// 完整表单字段配置 - 展示所有字段类型
|
||||
const fullColumns: FormColumn<any>[] = [
|
||||
{
|
||||
dataIndex: 'text',
|
||||
title: '文本输入',
|
||||
valueType: 'text',
|
||||
tooltip: '这是一个帮助提示',
|
||||
fieldProps: { placeholder: '普通文本输入' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'password',
|
||||
title: '密码输入',
|
||||
valueType: 'password',
|
||||
fieldProps: { placeholder: '密码输入' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'digit',
|
||||
title: '数字输入',
|
||||
valueType: 'digit',
|
||||
extra: '请输入0-100之间的数字',
|
||||
fieldProps: {
|
||||
placeholder: '数字输入',
|
||||
min: 0,
|
||||
max: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'money',
|
||||
title: '金额输入',
|
||||
valueType: 'money',
|
||||
fieldProps: {
|
||||
placeholder: '金额输入',
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'select',
|
||||
title: '下拉选择',
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
placeholder: '请选择',
|
||||
options: [
|
||||
{ label: '选项一', value: 'option1' },
|
||||
{ label: '选项二', value: 'option2' },
|
||||
{ label: '选项三', value: 'option3' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'treeSelect',
|
||||
title: '树形选择',
|
||||
valueType: 'treeSelect',
|
||||
fieldProps: {
|
||||
placeholder: '请选择',
|
||||
treeData: [
|
||||
{ title: '父节点1', value: 'parent1', children: [
|
||||
{ title: '子节点1-1', value: 'child1-1' },
|
||||
{ title: '子节点1-2', value: 'child1-2' },
|
||||
]},
|
||||
{ title: '父节点2', value: 'parent2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'cascader',
|
||||
title: '级联选择',
|
||||
valueType: 'cascader',
|
||||
fieldProps: {
|
||||
placeholder: '请选择',
|
||||
options: [
|
||||
{ label: '浙江', value: 'zhejiang', children: [
|
||||
{ label: '杭州', value: 'hangzhou' },
|
||||
{ label: '宁波', value: 'ningbo' },
|
||||
]},
|
||||
{ label: '江苏', value: 'jiangsu', children: [
|
||||
{ label: '南京', value: 'nanjing' },
|
||||
{ label: '苏州', value: 'suzhou' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'radio',
|
||||
title: '单选框',
|
||||
valueType: 'radio',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: '选项A', value: 'a' },
|
||||
{ label: '选项B', value: 'b' },
|
||||
{ label: '选项C', value: 'c' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'radioButton',
|
||||
title: '单选按钮',
|
||||
valueType: 'radioButton',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: '按钮A', value: 'a' },
|
||||
{ label: '按钮B', value: 'b' },
|
||||
{ label: '按钮C', value: 'c' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'checkbox',
|
||||
title: '多选框',
|
||||
valueType: 'checkbox',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: '选项1', value: '1' },
|
||||
{ label: '选项2', value: '2' },
|
||||
{ label: '选项3', value: '3' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'switch',
|
||||
title: '开关',
|
||||
valueType: 'switch',
|
||||
valuePropName: 'checked',
|
||||
},
|
||||
{
|
||||
dataIndex: 'rate',
|
||||
title: '评分',
|
||||
valueType: 'rate',
|
||||
},
|
||||
{
|
||||
dataIndex: 'slider',
|
||||
title: '滑动条',
|
||||
valueType: 'slider',
|
||||
},
|
||||
{
|
||||
dataIndex: 'date',
|
||||
title: '日期选择',
|
||||
valueType: 'date',
|
||||
},
|
||||
{
|
||||
dataIndex: 'dateTime',
|
||||
title: '日期时间',
|
||||
valueType: 'dateTime',
|
||||
},
|
||||
{
|
||||
dataIndex: 'dateRange',
|
||||
title: '日期范围',
|
||||
valueType: 'dateRange',
|
||||
},
|
||||
{
|
||||
dataIndex: 'time',
|
||||
title: '时间选择',
|
||||
valueType: 'time',
|
||||
},
|
||||
{
|
||||
dataIndex: 'color',
|
||||
title: '颜色选择',
|
||||
valueType: 'color',
|
||||
},
|
||||
{
|
||||
dataIndex: 'icon',
|
||||
title: '图标选择',
|
||||
valueType: 'icon',
|
||||
},
|
||||
{
|
||||
dataIndex: 'image',
|
||||
title: '图片上传',
|
||||
valueType: 'image',
|
||||
fieldProps: {
|
||||
action: '/file/upload', // 假设的上传接口
|
||||
maxCount: 1,
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: 'user',
|
||||
title: '用户选择',
|
||||
valueType: 'user',
|
||||
fieldProps: {
|
||||
placeholder: '请选择用户',
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: 'textarea',
|
||||
title: '多行文本',
|
||||
valueType: 'textarea',
|
||||
fieldProps: { placeholder: '请输入多行文本', rows: 3 },
|
||||
},
|
||||
];
|
||||
|
||||
// 字段分组配置
|
||||
const groupColumns: FormColumn<any>[] = [
|
||||
{
|
||||
valueType: 'text',
|
||||
colProps: { span: 24 },
|
||||
fieldRender: () => <Divider titlePlacement={'left'}>基本信息</Divider>
|
||||
},
|
||||
{
|
||||
dataIndex: 'name',
|
||||
title: '姓名',
|
||||
valueType: 'text',
|
||||
colProps: { span: 12 },
|
||||
rules: [{ required: true, message: '请输入姓名' }],
|
||||
},
|
||||
{
|
||||
dataIndex: 'phone',
|
||||
title: '电话',
|
||||
valueType: 'text',
|
||||
colProps: { span: 12 },
|
||||
tooltip: '请输入11位手机号',
|
||||
},
|
||||
{
|
||||
valueType: 'text',
|
||||
colProps: { span: 24 },
|
||||
fieldRender: () => <Divider titlePlacement={'left'}>部门信息</Divider>
|
||||
},
|
||||
{
|
||||
dataIndex: 'department',
|
||||
title: '部门',
|
||||
valueType: 'select',
|
||||
colProps: { span: 12 },
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: '技术部', value: 'tech' },
|
||||
{ label: '产品部', value: 'product' },
|
||||
{ label: '运营部', value: 'operation' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'position',
|
||||
title: '职位',
|
||||
valueType: 'text',
|
||||
colProps: { span: 12 },
|
||||
},
|
||||
{
|
||||
dataIndex: 'remark',
|
||||
title: '备注',
|
||||
valueType: 'textarea',
|
||||
extra: '可选填写',
|
||||
},
|
||||
];
|
||||
|
||||
// 依赖联动配置
|
||||
interface DependencyFormData {
|
||||
hasDiscount: boolean;
|
||||
discount: number;
|
||||
productType: string;
|
||||
subType: string;
|
||||
}
|
||||
|
||||
const dependencyColumns: FormColumn<DependencyFormData>[] = [
|
||||
{
|
||||
dataIndex: 'hasDiscount',
|
||||
title: '是否有折扣',
|
||||
valueType: 'switch',
|
||||
valuePropName: 'checked',
|
||||
tooltip: '开启后可设置折扣比例',
|
||||
},
|
||||
{
|
||||
dataIndex: 'discount',
|
||||
title: '折扣比例',
|
||||
valueType: 'slider',
|
||||
dependency: {
|
||||
dependencies: ['hasDiscount'],
|
||||
visible: (values) => values.hasDiscount === true,
|
||||
},
|
||||
fieldProps: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
marks: { 0: '0%', 50: '50%', 100: '100%' },
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'productType',
|
||||
title: '产品类型',
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
placeholder: '请选择产品类型',
|
||||
options: [
|
||||
{ label: '电子产品', value: 'electronics' },
|
||||
{ label: '服装', value: 'clothing' },
|
||||
{ label: '食品', value: 'food' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'subType',
|
||||
title: '子类型',
|
||||
valueType: 'select',
|
||||
dependency: {
|
||||
dependencies: ['productType'],
|
||||
visible: (values) => !!values.productType,
|
||||
disabled: (values) => !values.productType,
|
||||
fieldProps: (values) => {
|
||||
const subTypeOptions: Record<string, { label: string; value: string }[]> = {
|
||||
electronics: [
|
||||
{ label: '手机', value: 'phone' },
|
||||
{ label: '电脑', value: 'computer' },
|
||||
{ label: '平板', value: 'tablet' },
|
||||
],
|
||||
clothing: [
|
||||
{ label: '上衣', value: 'top' },
|
||||
{ label: '裤子', value: 'pants' },
|
||||
{ label: '鞋子', value: 'shoes' },
|
||||
],
|
||||
food: [
|
||||
{ label: '零食', value: 'snack' },
|
||||
{ label: '饮料', value: 'drink' },
|
||||
{ label: '水果', value: 'fruit' },
|
||||
],
|
||||
};
|
||||
return {
|
||||
placeholder: '请先选择产品类型',
|
||||
options: subTypeOptions[values.productType as string] || [],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// 自定义渲染配置
|
||||
const customColumns: FormColumn<any>[] = [
|
||||
{
|
||||
dataIndex: 'username',
|
||||
title: '用户名',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: '请输入用户名' }],
|
||||
},
|
||||
{
|
||||
dataIndex: 'tags',
|
||||
title: '标签',
|
||||
valueType: 'text',
|
||||
fieldRender: (form) => (
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="输入标签后按回车"
|
||||
onPressEnter={(e) => {
|
||||
const value = (e.target as HTMLInputElement).value;
|
||||
const tags = form.getFieldValue('tags') || [];
|
||||
if (value && !tags.includes(value)) {
|
||||
form.setFieldValue('tags', [...tags, value]);
|
||||
}
|
||||
(e.target as HTMLInputElement).value = '';
|
||||
}}
|
||||
/>
|
||||
<Space>
|
||||
{(form.getFieldValue('tags') || []).map((tag: string, index: number) => (
|
||||
<Tag
|
||||
key={index}
|
||||
closable
|
||||
onClose={() => {
|
||||
const tags = form.getFieldValue('tags') || [];
|
||||
form.setFieldValue('tags', tags.filter((_: string, i: number) => i !== index));
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
dataIndex: 'hiddenField',
|
||||
title: '隐藏字段',
|
||||
valueType: 'text',
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Modal/Drawer 表单字段配置
|
||||
const modalColumns: FormColumn<any>[] = [
|
||||
{
|
||||
dataIndex: 'title',
|
||||
title: '标题',
|
||||
valueType: 'text',
|
||||
rules: [{ required: true, message: '请输入标题' }],
|
||||
fieldProps: { placeholder: '请输入标题' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'type',
|
||||
title: '类型',
|
||||
valueType: 'select',
|
||||
rules: [{ required: true, message: '请选择类型' }],
|
||||
fieldProps: {
|
||||
placeholder: '请选择类型',
|
||||
options: [
|
||||
{ label: '类型一', value: 'type1' },
|
||||
{ label: '类型二', value: 'type2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'priority',
|
||||
title: '优先级',
|
||||
valueType: 'radioButton',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: '低', value: 'low' },
|
||||
{ label: '中', value: 'medium' },
|
||||
{ label: '高', value: 'high' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'content',
|
||||
title: '内容',
|
||||
valueType: 'textarea',
|
||||
fieldProps: { placeholder: '请输入内容', rows: 4 },
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* XinForm 组件使用示例页面
|
||||
*/
|
||||
const XinFormExample: React.FC = () => {
|
||||
const modalFormRef = useRef<XinFormRef>(undefined);
|
||||
const drawerFormRef = useRef<XinFormRef>(undefined);
|
||||
|
||||
const handleFinish = async (values: any) => {
|
||||
console.log('表单提交:', values);
|
||||
message.success('表单提交成功!');
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography style={{ margin: '12px 0 24px 0' }}>
|
||||
<Title level={2}>XinForm 动态表单组件示例</Title>
|
||||
<Paragraph>
|
||||
基于 Ant Design Form 封装的 JSON 配置动态表单组件,支持多种布局和丰富的表单字段类型。
|
||||
</Paragraph>
|
||||
</Typography>
|
||||
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* 基础表单 */}
|
||||
<Card title="基础表单" variant={'borderless'}>
|
||||
<Paragraph type="secondary">
|
||||
最简单的表单用法,通过 columns 配置表单字段。
|
||||
</Paragraph>
|
||||
<XinForm
|
||||
columns={basicColumns}
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Grid 布局表单 */}
|
||||
<Card title="Grid 布局表单" variant={'borderless'}>
|
||||
<Paragraph type="secondary">
|
||||
使用 grid 属性开启栅格布局,通过 colProps 控制每列宽度。
|
||||
</Paragraph>
|
||||
<XinForm
|
||||
columns={gridColumns}
|
||||
layout="vertical"
|
||||
grid={true}
|
||||
rowProps={{ gutter: 16 }}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 所有字段类型 */}
|
||||
<Card title="所有字段类型" variant={'borderless'}>
|
||||
<Paragraph type="secondary">
|
||||
展示 XinForm 支持的所有字段类型:text、password、digit、money、select、treeSelect、cascader、radio、checkbox、switch、rate、slider、date、time、color、icon、textarea 等。
|
||||
<br />
|
||||
新增功能:<Text code>tooltip</Text> 提示信息、<Text code>extra</Text> 额外说明、<Text code>width</Text> 组件宽度。
|
||||
</Paragraph>
|
||||
<XinForm
|
||||
columns={fullColumns}
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 字段分组 */}
|
||||
<Card title="字段分组" variant={'borderless'}>
|
||||
<Paragraph type="secondary">
|
||||
使用 <Text code>group</Text> 属性对表单字段进行分组,自动显示分割线和标题。
|
||||
</Paragraph>
|
||||
<XinForm
|
||||
columns={groupColumns}
|
||||
layout="vertical"
|
||||
grid={true}
|
||||
rowProps={{ gutter: 16 }}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 依赖联动 */}
|
||||
<Card title="依赖联动" variant={'borderless'}>
|
||||
<Paragraph type="secondary">
|
||||
使用 <Text code>dependency</Text> 属性实现字段间的依赖联动:
|
||||
<br />
|
||||
- <Text code>visible</Text>:根据其他字段值控制显示/隐藏
|
||||
<br />
|
||||
- <Text code>disabled</Text>:根据其他字段值控制禁用状态
|
||||
<br />
|
||||
- <Text code>fieldProps</Text>:根据其他字段值动态设置组件属性
|
||||
</Paragraph>
|
||||
<XinForm<DependencyFormData>
|
||||
columns={dependencyColumns}
|
||||
layout="vertical"
|
||||
initialValues={{ hasDiscount: false }}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 自定义渲染 */}
|
||||
<Card title="自定义渲染" variant={'borderless'}>
|
||||
<Paragraph type="secondary">
|
||||
使用 <Text code>valueType="custom"</Text> + <Text code>renderField</Text> 实现完全自定义的表单字段渲染。
|
||||
<br />
|
||||
使用 <Text code>hidden</Text> 属性隐藏字段(支持布尔值或函数)。
|
||||
</Paragraph>
|
||||
<XinForm
|
||||
columns={customColumns}
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* ModalForm 弹窗表单 */}
|
||||
<Card title="ModalForm 弹窗表单" variant={'borderless'}>
|
||||
<Paragraph type="secondary">
|
||||
使用 layoutType="ModalForm" 实现弹窗表单,支持 trigger 触发器或 formRef 控制打开/关闭。
|
||||
</Paragraph>
|
||||
<Space>
|
||||
<XinForm
|
||||
columns={modalColumns}
|
||||
layoutType="ModalForm"
|
||||
layout="vertical"
|
||||
trigger={<Button type="primary">使用 Trigger 打开</Button>}
|
||||
modalProps={{ title: '新建任务', width: 520 }}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
<Button onClick={() => modalFormRef.current?.open()}>
|
||||
使用 Ref 打开
|
||||
</Button>
|
||||
<XinForm
|
||||
columns={modalColumns}
|
||||
layoutType="ModalForm"
|
||||
layout="vertical"
|
||||
formRef={modalFormRef}
|
||||
modalProps={{ title: '新建任务 (Ref)', width: 520 }}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* DrawerForm 抽屉表单 */}
|
||||
<Card title="DrawerForm 抽屉表单" variant={'borderless'}>
|
||||
<Paragraph type="secondary">
|
||||
使用 layoutType="DrawerForm" 实现抽屉表单,适用于复杂表单或需要更大空间的场景。
|
||||
</Paragraph>
|
||||
<Space>
|
||||
<XinForm
|
||||
columns={modalColumns}
|
||||
layoutType="DrawerForm"
|
||||
layout="vertical"
|
||||
trigger={<Button type="primary">使用 Trigger 打开</Button>}
|
||||
drawerProps={{ title: '新建任务', width: 520 }}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
<Button onClick={() => drawerFormRef.current?.open()}>
|
||||
使用 Ref 打开
|
||||
</Button>
|
||||
<XinForm
|
||||
columns={modalColumns}
|
||||
layoutType="DrawerForm"
|
||||
layout="vertical"
|
||||
formRef={drawerFormRef}
|
||||
drawerProps={{ title: '新建任务 (Ref)', width: 520 }}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 自定义提交按钮 */}
|
||||
<Card title="自定义提交按钮" variant={'borderless'}>
|
||||
<Paragraph type="secondary">
|
||||
通过 submitter 配置项自定义提交按钮文本、样式,或完全自定义渲染。
|
||||
</Paragraph>
|
||||
<Divider titlePlacement={'left'}>自定义按钮文本</Divider>
|
||||
<XinForm
|
||||
columns={basicColumns.slice(0, 2)}
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
submitter={{
|
||||
submitText: '确认提交',
|
||||
resetText: '清空表单',
|
||||
}}
|
||||
/>
|
||||
<Divider titlePlacement={'left'}>隐藏提交按钮</Divider>
|
||||
<Text type="secondary">设置 submitter.render = false 隐藏提交按钮</Text>
|
||||
<XinForm
|
||||
columns={basicColumns.slice(0, 2)}
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
submitter={{ render: false }}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default XinFormExample;
|
||||
@@ -0,0 +1,145 @@
|
||||
import { Button, Tag } from 'antd';
|
||||
import { ExportOutlined } from '@ant-design/icons';
|
||||
import type {XinTableColumn, XinTableProps} from '@/components/XinTable/typings';
|
||||
import XinTable from '@/components/XinTable';
|
||||
|
||||
// 模拟用户数据类型
|
||||
interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
age: number;
|
||||
status: number;
|
||||
role: string;
|
||||
department: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 模拟数据
|
||||
const mockData: User[] = Array.from({ length: 50 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `用户${i + 1}`,
|
||||
email: `user${i + 1}@example.com`,
|
||||
age: Math.floor(Math.random() * 40) + 20,
|
||||
status: Math.random() > 0.3 ? 1 : 0,
|
||||
role: ['管理员', '编辑', '访客'][Math.floor(Math.random() * 3)],
|
||||
department: ['技术部', '产品部', '运营部', '市场部'][Math.floor(Math.random() * 4)],
|
||||
createdAt: new Date(Date.now() - Math.random() * 10000000000).toLocaleDateString(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* XinTable 示例页面
|
||||
*/
|
||||
const XinTableExample = () => {
|
||||
|
||||
// 表格列配置
|
||||
const columns: XinTableColumn<User>[] = [
|
||||
{
|
||||
title: '序号',
|
||||
width: 60,
|
||||
valueType: 'text',
|
||||
dataIndex: 'id',
|
||||
},
|
||||
{
|
||||
dataIndex: 'name',
|
||||
title: '用户名',
|
||||
width: 120,
|
||||
valueType: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
dataIndex: 'email',
|
||||
title: '邮箱',
|
||||
valueType: 'text',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
dataIndex: 'age',
|
||||
title: '年龄',
|
||||
width: 80,
|
||||
valueType: 'digit',
|
||||
sorter: (a: User, b: User) => a.age - b.age,
|
||||
hideInSearch: true
|
||||
},
|
||||
{
|
||||
dataIndex: 'status',
|
||||
title: '状态',
|
||||
width: 100,
|
||||
valueType: 'select',
|
||||
// 自定义渲染 - 用户外理显示内容
|
||||
render: (value: number) => {
|
||||
const enumMap: Record<number, { text: string; color: string }> = {
|
||||
1: { text: '启用', color: 'green' },
|
||||
0: { text: '禁用', color: 'red' },
|
||||
};
|
||||
const item = enumMap[value];
|
||||
return item ? <Tag color={item.color}>{item.text}</Tag> : '-';
|
||||
},
|
||||
filters: [
|
||||
{ text: '启用', value: 1 },
|
||||
{ text: '禁用', value: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
dataIndex: 'role',
|
||||
title: '角色',
|
||||
width: 100,
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: '管理员', value: '管理员' },
|
||||
{ label: '编辑', value: '编辑' },
|
||||
{ label: '访客', value: '访客' },
|
||||
],
|
||||
}
|
||||
},
|
||||
{
|
||||
dataIndex: 'department',
|
||||
title: '部门',
|
||||
width: 120,
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: '技术部', value: '技术部' },
|
||||
{ label: '产品部', value: '产品部' },
|
||||
{ label: '运营部', value: '运营部' },
|
||||
{ label: '市场部', value: '市场部' },
|
||||
],
|
||||
},
|
||||
hideInSearch: true,
|
||||
},
|
||||
{
|
||||
dataIndex: 'createdAt',
|
||||
title: '创建时间',
|
||||
width: 120,
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
},
|
||||
];
|
||||
|
||||
// 自定义工具栏
|
||||
const customToolbar: XinTableProps['toolBarRender'] = (dom) => [
|
||||
<Button key="export" icon={<ExportOutlined />}>
|
||||
导出
|
||||
</Button>,
|
||||
dom.columnSetting,
|
||||
dom.hideBorder,
|
||||
dom.reload,
|
||||
dom.columnHeight
|
||||
];
|
||||
|
||||
return (
|
||||
<XinTable
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
dataSource={mockData}
|
||||
accessName='system.user.list'
|
||||
api={'/system-user/list'}
|
||||
toolBarRender={customToolbar}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default XinTableExample;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import {Button, Card, Result} from 'antd';
|
||||
|
||||
const App: React.FC = () => (
|
||||
<Card variant={"borderless"}>
|
||||
<Result
|
||||
status="403"
|
||||
title="403"
|
||||
subTitle="Sorry, you are not authorized to access this page."
|
||||
extra={<Button type="primary">Back Home</Button>}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import {Button, Card, Result} from 'antd';
|
||||
|
||||
const App: React.FC = () => (
|
||||
<Card variant={"borderless"}>
|
||||
<Result
|
||||
status="404"
|
||||
title="404"
|
||||
subTitle="Sorry, the page you visited does not exist."
|
||||
extra={<Button type="primary">Back Home</Button>}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import {Button, Card, Result} from 'antd';
|
||||
|
||||
const App: React.FC = () => (
|
||||
<Card variant={"borderless"}>
|
||||
<Result
|
||||
status="500"
|
||||
title="500"
|
||||
subTitle="Sorry, something went wrong."
|
||||
extra={<Button type="primary">Back Home</Button>}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -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,32 @@
|
||||
import {Button, Card, Row, Col, Breadcrumb, Space} from "antd";
|
||||
|
||||
const First = () => (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Breadcrumb items={[
|
||||
{ title: '多级菜单' },
|
||||
{ title: '二级页面' },
|
||||
]} />
|
||||
<h2 style={{ marginTop: 16, marginBottom: 0 }}>二级页面</h2>
|
||||
</Card>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Card style={{height: 200}} />
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Card style={{height: 200}} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={{height: 200}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Space>
|
||||
<Button>重置</Button>
|
||||
<Button type="primary">提交</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default First;
|
||||
@@ -0,0 +1,33 @@
|
||||
import {Button, Card, Row, Col, Breadcrumb, Space} from "antd";
|
||||
|
||||
const Second = () => (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Breadcrumb items={[
|
||||
{ title: '多级菜单' },
|
||||
{ title: '二级菜单' },
|
||||
{ title: '三级页面' },
|
||||
]} />
|
||||
<h2 style={{ marginTop: 16, marginBottom: 0 }}>三级页面</h2>
|
||||
</Card>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Card style={{height: 200}} />
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Card style={{height: 200}} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={{height: 200}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Space>
|
||||
<Button>重置</Button>
|
||||
<Button type="primary">提交</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Second;
|
||||
@@ -0,0 +1,34 @@
|
||||
import {Button, Card, Row, Col, Breadcrumb, Space} from "antd";
|
||||
|
||||
const Third = () => (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Breadcrumb items={[
|
||||
{ title: '多级菜单' },
|
||||
{ title: '二级菜单' },
|
||||
{ title: '三级菜单' },
|
||||
{ title: '四级页面' },
|
||||
]} />
|
||||
<h2 style={{ marginTop: 16, marginBottom: 0 }}>四级页面</h2>
|
||||
</Card>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Card style={{height: 200}} />
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Card style={{height: 200}} />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card style={{height: 200}} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Card style={{ marginTop: 16 }}>
|
||||
<Space>
|
||||
<Button>重置</Button>
|
||||
<Button type="primary">提交</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Third;
|
||||
@@ -0,0 +1,220 @@
|
||||
import {Button, Card, Row, Col, Dropdown, Space, Typography, Flex, Statistic, Progress, Descriptions, Tag, Avatar} from "antd";
|
||||
import {EllipsisOutlined, UserOutlined, TeamOutlined, ShoppingOutlined, RiseOutlined} from "@ant-design/icons";
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const BaseLayout = () => {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh'}}>
|
||||
{/* 页面头部 */}
|
||||
<Card variant={"borderless"} style={{ marginBottom: 20}}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>页面标题</Title>
|
||||
<Typography.Text type="secondary">这是页面的描述信息,可以简要说明页面用途</Typography.Text>
|
||||
</div>
|
||||
<Space style={{ height: "100%" }}>
|
||||
<Button icon={<UserOutlined />}>操作一</Button>
|
||||
<Button icon={<TeamOutlined />}>操作二</Button>
|
||||
<Button type="primary" icon={<ShoppingOutlined />}>主要操作</Button>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{ label: '导出数据', key: '1', icon: <RiseOutlined /> },
|
||||
{ label: '批量操作', key: '2' },
|
||||
{ label: '更多设置', key: '3' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button style={{ padding: '0 8px' }}>
|
||||
<EllipsisOutlined style={{ fontSize: 18 }} />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
{/* 数据统计卡片 */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card variant={"borderless"}>
|
||||
<Statistic
|
||||
title="总用户数"
|
||||
value={11893}
|
||||
prefix={<UserOutlined style={{ color: '#1677ff' }} />}
|
||||
valueStyle={{ color: '#1677ff' }}
|
||||
/>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Tag color="success">+12.5%</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>较上周</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card variant={"borderless"}>
|
||||
<Statistic
|
||||
title="活跃用户"
|
||||
value={8234}
|
||||
prefix={<TeamOutlined style={{ color: '#52c41a' }} />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Tag color="success">+8.2%</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>较上周</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card variant={"borderless"}>
|
||||
<Statistic
|
||||
title="总订单"
|
||||
value={32567}
|
||||
prefix={<ShoppingOutlined style={{ color: '#faad14' }} />}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Tag color="warning">+5.3%</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>较上周</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card variant={"borderless"}>
|
||||
<Statistic
|
||||
title="总收入"
|
||||
value={98234}
|
||||
prefix="¥"
|
||||
valueStyle={{ color: '#f5222d' }}
|
||||
/>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Tag color="error">-2.1%</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>较上周</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Card
|
||||
variant={"borderless"}
|
||||
tabList={[
|
||||
{
|
||||
label: '基本信息',
|
||||
key: 'base',
|
||||
},
|
||||
{
|
||||
label: '详细信息',
|
||||
key: 'info'
|
||||
},
|
||||
{
|
||||
label: '数据分析',
|
||||
key: 'analysis'
|
||||
}
|
||||
]}
|
||||
activeTabKey="base"
|
||||
>
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
{/* 用户信息卡片 */}
|
||||
<Col xs={24} lg={24}>
|
||||
<Card
|
||||
title={<span><UserOutlined style={{ marginRight: 8 }} />用户信息概览</span>}
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: '#fff',
|
||||
borderRadius: 8
|
||||
}}
|
||||
styles={{
|
||||
header: {
|
||||
color: '#fff',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.2)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={6}>
|
||||
<Avatar size={64} icon={<UserOutlined />} />
|
||||
</Col>
|
||||
<Col span={18}>
|
||||
<Typography.Title level={5} style={{ color: '#fff', marginTop: 0 }}>
|
||||
张三 / Zhang San
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||
高级管理员 · 北京市朝阳区 · 在职
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 详细信息卡片 */}
|
||||
<Col xs={24} lg={16}>
|
||||
<Card
|
||||
title="详细信息"
|
||||
style={{ height: '100%', borderRadius: 8 }}
|
||||
>
|
||||
<Descriptions column={{ xs: 1, sm: 2 }}>
|
||||
<Descriptions.Item label="用户名">zhangsan</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">138****8888</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">zhangsan@example.com</Descriptions.Item>
|
||||
<Descriptions.Item label="部门">技术部</Descriptions.Item>
|
||||
<Descriptions.Item label="职位">高级工程师</Descriptions.Item>
|
||||
<Descriptions.Item label="入职时间">2023-01-15</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color="success">正常</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="权限等级">
|
||||
<Tag color="blue">管理员</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 进度统计卡片 */}
|
||||
<Col xs={24} lg={8}>
|
||||
<Card
|
||||
title="任务完成度"
|
||||
style={{ height: '100%', borderRadius: 8 }}
|
||||
>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span>本周任务</span>
|
||||
<span style={{ float: 'right', fontWeight: 600 }}>75%</span>
|
||||
</div>
|
||||
<Progress percent={75} strokeColor="#52c41a" />
|
||||
</div>
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span>本月目标</span>
|
||||
<span style={{ float: 'right', fontWeight: 600 }}>60%</span>
|
||||
</div>
|
||||
<Progress percent={60} strokeColor="#1677ff" />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span>年度KPI</span>
|
||||
<span style={{ float: 'right', fontWeight: 600 }}>45%</span>
|
||||
</div>
|
||||
<Progress percent={45} strokeColor="#faad14" />
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 底部操作按钮 */}
|
||||
<Flex justify="space-between" align="center">
|
||||
<Space>
|
||||
<Button size="large">重置</Button>
|
||||
<Button type="primary" size="large">保存</Button>
|
||||
<Button type="primary" size="large" ghost>提交审核</Button>
|
||||
</Space>
|
||||
<Typography.Text type="secondary">
|
||||
最后更新时间: 2026-01-01 10:30:00
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseLayout;
|
||||
@@ -0,0 +1,339 @@
|
||||
import {
|
||||
Descriptions,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Flex,
|
||||
Tag,
|
||||
Divider,
|
||||
Steps,
|
||||
Timeline,
|
||||
Table,
|
||||
Avatar,
|
||||
} from 'antd';
|
||||
import {
|
||||
ShoppingOutlined,
|
||||
PrinterOutlined,
|
||||
DownloadOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const Layout = () => {
|
||||
// 订单商品数据
|
||||
const productColumns = [
|
||||
{
|
||||
title: '商品名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '商品编号',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity',
|
||||
},
|
||||
{
|
||||
title: '单价',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
render: (price: number) => `¥${price.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '小计',
|
||||
dataIndex: 'total',
|
||||
key: 'total',
|
||||
render: (total: number) => `¥${total.toFixed(2)}`,
|
||||
},
|
||||
];
|
||||
|
||||
const productData = [
|
||||
{
|
||||
key: '1',
|
||||
name: 'iPhone 15 Pro Max',
|
||||
code: 'IP15PM-256-BLK',
|
||||
quantity: 2,
|
||||
price: 9999,
|
||||
total: 19998,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
name: 'AirPods Pro 2',
|
||||
code: 'APP2-WHT',
|
||||
quantity: 1,
|
||||
price: 1899,
|
||||
total: 1899,
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
name: 'MacBook Pro 16"',
|
||||
code: 'MBP16-M3-SLV',
|
||||
quantity: 1,
|
||||
price: 25999,
|
||||
total: 25999,
|
||||
},
|
||||
];
|
||||
|
||||
// 物流信息
|
||||
const logisticsData = [
|
||||
{
|
||||
time: '2026-01-01 15:30:00',
|
||||
status: '已签收',
|
||||
description: '您的订单已由本人签收,感谢您的购买',
|
||||
},
|
||||
{
|
||||
time: '2026-01-01 09:20:00',
|
||||
status: '派送中',
|
||||
description: '快递员正在为您派送 [北京市朝阳区] 快递员:李师傅 13800138000',
|
||||
},
|
||||
{
|
||||
time: '2025-12-31 18:45:00',
|
||||
status: '运输中',
|
||||
description: '您的包裹已到达 [北京分拨中心]',
|
||||
},
|
||||
{
|
||||
time: '2025-12-30 14:20:00',
|
||||
status: '已发货',
|
||||
description: '您的订单已从 [上海仓库] 发出,物流单号: SF1234567890',
|
||||
},
|
||||
{
|
||||
time: '2025-12-30 10:00:00',
|
||||
status: '已支付',
|
||||
description: '订单支付成功,等待商家发货',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh' }}>
|
||||
{/* 页面头部 */}
|
||||
<Card variant={'borderless'} style={{ marginBottom: 20 }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<div>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>
|
||||
<ShoppingOutlined style={{ marginRight: 8 }} />
|
||||
订单详情
|
||||
</Title>
|
||||
<Text type="secondary">订单号: OD2026010100001 · 创建时间: 2025-12-30 09:45:32</Text>
|
||||
</div>
|
||||
<Space style={{ height: '100%' }}>
|
||||
<Button icon={<PrinterOutlined />}>打印订单</Button>
|
||||
<Button icon={<DownloadOutlined />}>导出订单</Button>
|
||||
<Button type="primary">联系客服</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
{/* 订单状态 */}
|
||||
<Card variant={'borderless'} style={{ marginBottom: 20 }}>
|
||||
<Steps
|
||||
current={4}
|
||||
items={[
|
||||
{
|
||||
title: '提交订单',
|
||||
description: '2025-12-30 09:45',
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
{
|
||||
title: '支付完成',
|
||||
description: '2025-12-30 10:00',
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
{
|
||||
title: '商家发货',
|
||||
description: '2025-12-30 14:20',
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
{
|
||||
title: '运输中',
|
||||
description: '2025-12-31 18:45',
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
{
|
||||
title: '已签收',
|
||||
description: '2026-01-01 15:30',
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* 订单基本信息 */}
|
||||
<Col xs={24} lg={16}>
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title="订单基本信息"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Descriptions column={{ xs: 1, sm: 2 }} bordered>
|
||||
<Descriptions.Item label="订单号">OD2026010100001</Descriptions.Item>
|
||||
<Descriptions.Item label="订单状态">
|
||||
<Tag color="success" icon={<CheckCircleOutlined />}>
|
||||
已完成
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="下单时间">2025-12-30 09:45:32</Descriptions.Item>
|
||||
<Descriptions.Item label="支付时间">2025-12-30 10:00:15</Descriptions.Item>
|
||||
<Descriptions.Item label="发货时间">2025-12-30 14:20:00</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间">2026-01-01 15:30:00</Descriptions.Item>
|
||||
<Descriptions.Item label="支付方式">
|
||||
<Tag color="blue">微信支付</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="配送方式">顺丰速运</Descriptions.Item>
|
||||
<Descriptions.Item label="发票类型">电子发票</Descriptions.Item>
|
||||
<Descriptions.Item label="发票抬头">北京科技有限公司</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 收货信息 */}
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title="收货信息"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Descriptions column={{ xs: 1, sm: 2 }} bordered>
|
||||
<Descriptions.Item label="收货人">张三</Descriptions.Item>
|
||||
<Descriptions.Item label="联系电话">138****8888</Descriptions.Item>
|
||||
<Descriptions.Item label="收货地址" span={2}>
|
||||
北京市朝阳区建国路88号SOHO现代城A座2106室
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 商品信息 */}
|
||||
<Card variant={'borderless'} title="商品信息">
|
||||
<Table
|
||||
columns={productColumns}
|
||||
dataSource={productData}
|
||||
pagination={false}
|
||||
summary={() => (
|
||||
<Table.Summary>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={4}>
|
||||
<Text strong>合计</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}>
|
||||
<Text strong style={{ color: '#f5222d', fontSize: 16 }}>
|
||||
¥47,896.00
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={4}>
|
||||
<Text>运费</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}>
|
||||
<Text>¥0.00</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={4}>
|
||||
<Text>优惠</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}>
|
||||
<Text style={{ color: '#52c41a' }}>-¥500.00</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
<Table.Summary.Row>
|
||||
<Table.Summary.Cell index={0} colSpan={4}>
|
||||
<Text strong style={{ fontSize: 16 }}>实付金额</Text>
|
||||
</Table.Summary.Cell>
|
||||
<Table.Summary.Cell index={1}>
|
||||
<Text strong style={{ color: '#f5222d', fontSize: 18 }}>
|
||||
¥47,396.00
|
||||
</Text>
|
||||
</Table.Summary.Cell>
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* 侧边栏信息 */}
|
||||
<Col xs={24} lg={8}>
|
||||
{/* 买家信息 */}
|
||||
<Card
|
||||
variant={'borderless'}
|
||||
title="买家信息"
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
<Flex align="center" style={{ marginBottom: 16 }}>
|
||||
<Avatar size={48} icon={<UserOutlined />} />
|
||||
<div style={{ marginLeft: 12 }}>
|
||||
<Text strong>张三</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
会员等级: VIP金卡
|
||||
</Text>
|
||||
</div>
|
||||
</Flex>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="用户ID">U202512300001</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">138****8888</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">zhangsan@example.com</Descriptions.Item>
|
||||
<Descriptions.Item label="历史订单">156 笔</Descriptions.Item>
|
||||
<Descriptions.Item label="累计消费">¥358,960</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 物流信息 */}
|
||||
<Card variant={'borderless'} title="物流追踪">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
物流公司: 顺丰速运
|
||||
</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
快递单号: SF1234567890
|
||||
</Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Timeline
|
||||
items={logisticsData.map((item) => ({
|
||||
color: item.status === '已签收' ? 'green' : 'blue',
|
||||
children: (
|
||||
<div>
|
||||
<Text strong>{item.status}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{item.description}
|
||||
</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 4 }} />
|
||||
{item.time}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 备注信息 */}
|
||||
<Card variant={'borderless'} title="订单备注" style={{ marginTop: 16 }}>
|
||||
<Text type="secondary">
|
||||
买家留言: 请在工作日配送,周末家里没人。如有问题请提前电话联系。
|
||||
</Text>
|
||||
<Divider />
|
||||
<Text type="secondary">
|
||||
商家备注: 已按照买家要求在工作日配送,客户体验良好。
|
||||
</Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -0,0 +1,418 @@
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Descriptions,
|
||||
Steps,
|
||||
Timeline,
|
||||
Tag,
|
||||
Avatar,
|
||||
Form,
|
||||
Input,
|
||||
Badge,
|
||||
Divider,
|
||||
Flex,
|
||||
} from "antd";
|
||||
import React from "react";
|
||||
import {
|
||||
AuditOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
FileDoneOutlined,
|
||||
EditOutlined,
|
||||
PrinterOutlined,
|
||||
ExportOutlined,
|
||||
} from "@ant-design/icons";
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const FixHeader = () => {
|
||||
const [activeTab, setActiveTab] = React.useState('1');
|
||||
|
||||
// 审批历史数据
|
||||
const approvalHistory = [
|
||||
{
|
||||
time: '2026-01-01 14:30:00',
|
||||
approver: '王总',
|
||||
role: '总经理',
|
||||
status: 'approved',
|
||||
statusText: '已通过',
|
||||
comment: '同意该采购申请,请采购部门尽快落实。',
|
||||
avatar: 'W',
|
||||
},
|
||||
{
|
||||
time: '2026-01-01 11:20:00',
|
||||
approver: '李经理',
|
||||
role: '财务经理',
|
||||
status: 'approved',
|
||||
statusText: '已通过',
|
||||
comment: '预算充足,财务审批通过。',
|
||||
avatar: 'L',
|
||||
},
|
||||
{
|
||||
time: '2026-01-01 10:15:00',
|
||||
approver: '赵主管',
|
||||
role: '部门主管',
|
||||
status: 'approved',
|
||||
statusText: '已通过',
|
||||
comment: '该采购申请合理,同意提交上级审批。',
|
||||
avatar: 'Z',
|
||||
},
|
||||
{
|
||||
time: '2025-12-31 16:45:00',
|
||||
approver: '张三',
|
||||
role: '申请人',
|
||||
status: 'submitted',
|
||||
statusText: '已提交',
|
||||
comment: '因公司业务需要,申请采购以下办公设备。',
|
||||
avatar: 'Z',
|
||||
},
|
||||
];
|
||||
|
||||
// 审批明细数据
|
||||
const approvalItems = [
|
||||
{ name: 'MacBook Pro 16"', quantity: 5, price: 25999, total: 129995 },
|
||||
{ name: 'Dell 显示器 27"', quantity: 10, price: 2999, total: 29990 },
|
||||
{ name: '人体工学椅', quantity: 10, price: 1899, total: 18990 },
|
||||
{ name: '会议摄像头', quantity: 2, price: 3999, total: 7998 },
|
||||
];
|
||||
|
||||
const totalAmount = approvalItems.reduce((sum, item) => sum + item.total, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16, backgroundColor: '#fff' }}>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 12 }}>
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
<AuditOutlined style={{ marginRight: 8 }} />
|
||||
采购审批申请
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
申请单号: PR2025123100001 · 创建时间: 2025-12-31 16:45:32
|
||||
</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<PrinterOutlined />}>打印</Button>
|
||||
<Button icon={<ExportOutlined />}>导出</Button>
|
||||
<Button type="primary" icon={<CheckCircleOutlined />}>
|
||||
审批通过
|
||||
</Button>
|
||||
<Button danger icon={<CloseCircleOutlined />}>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={{ marginBottom: 16, backgroundColor: '#fff' }}
|
||||
tabList={[
|
||||
{ label: <span><FileDoneOutlined /> 审批详情</span>, key: '1' },
|
||||
{ label: <span><ClockCircleOutlined /> 审批进度</span>, key: '2' },
|
||||
{ label: <span><EditOutlined /> 审批意见</span>, key: '3' },
|
||||
]}
|
||||
activeTabKey={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
>
|
||||
{/* 审批详情标签页 */}
|
||||
{activeTab === '1' && (
|
||||
<Row gutter={[20, 20]}>
|
||||
{/* 申请信息 */}
|
||||
<Col xs={24} lg={16}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Flex justify="space-between" align="center" style={{ marginBottom: 16 }}>
|
||||
<Title level={5} style={{ margin: 0 }}>申请信息</Title>
|
||||
<Tag color="success" icon={<CheckCircleOutlined />}>
|
||||
审批通过
|
||||
</Tag>
|
||||
</Flex>
|
||||
<Descriptions column={{ xs: 1, sm: 2 }} bordered>
|
||||
<Descriptions.Item label="申请单号">PR2025123100001</Descriptions.Item>
|
||||
<Descriptions.Item label="申请状态">
|
||||
<Badge status="success" text="已通过" />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">张三</Descriptions.Item>
|
||||
<Descriptions.Item label="申请部门">技术研发部</Descriptions.Item>
|
||||
<Descriptions.Item label="联系电话">138****8888</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">zhangsan@example.com</Descriptions.Item>
|
||||
<Descriptions.Item label="申请时间">2025-12-31 16:45:32</Descriptions.Item>
|
||||
<Descriptions.Item label="完成时间">2026-01-01 14:30:00</Descriptions.Item>
|
||||
<Descriptions.Item label="申请类型">
|
||||
<Tag color="blue">办公设备采购</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="紧急程度">
|
||||
<Tag color="orange">普通</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="预算编号">BUD-2026-Q1-001</Descriptions.Item>
|
||||
<Descriptions.Item label="成本中心">CC-RD-001</Descriptions.Item>
|
||||
<Descriptions.Item label="申请事由" span={2}>
|
||||
因公司业务扩展,技术研发部新增10名员工,需采购相应办公设备以满足日常工作需求。
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</div>
|
||||
|
||||
{/* 采购明细 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Title level={5} style={{ marginBottom: 16 }}>采购明细</Title>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: '#fafafa' }}>
|
||||
<th style={{ padding: '12px', textAlign: 'left', border: '1px solid #f0f0f0' }}>物品名称</th>
|
||||
<th style={{ padding: '12px', textAlign: 'center', border: '1px solid #f0f0f0' }}>数量</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', border: '1px solid #f0f0f0' }}>单价(¥)</th>
|
||||
<th style={{ padding: '12px', textAlign: 'right', border: '1px solid #f0f0f0' }}>小计(¥)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{approvalItems.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td style={{ padding: '12px', border: '1px solid #f0f0f0' }}>{item.name}</td>
|
||||
<td style={{ padding: '12px', textAlign: 'center', border: '1px solid #f0f0f0' }}>{item.quantity}</td>
|
||||
<td style={{ padding: '12px', textAlign: 'right', border: '1px solid #f0f0f0' }}>{item.price.toLocaleString()}</td>
|
||||
<td style={{ padding: '12px', textAlign: 'right', border: '1px solid #f0f0f0' }}>{item.total.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr style={{ backgroundColor: '#fafafa', fontWeight: 600 }}>
|
||||
<td colSpan={3} style={{ padding: '12px', textAlign: 'right', border: '1px solid #f0f0f0' }}>合计金额:</td>
|
||||
<td style={{ padding: '12px', textAlign: 'right', border: '1px solid #f0f0f0', color: '#f5222d', fontSize: 16 }}>
|
||||
¥{totalAmount.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 附件信息 */}
|
||||
<div>
|
||||
<Title level={5} style={{ marginBottom: 16 }}>附件材料</Title>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Space>
|
||||
<FileDoneOutlined style={{ fontSize: 16, color: '#1677ff' }} />
|
||||
<Text>采购申请表.pdf</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>(2.3 MB)</Text>
|
||||
</Space>
|
||||
<Button type="link" size="small">下载</Button>
|
||||
</Flex>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Flex justify="space-between" align="center">
|
||||
<Space>
|
||||
<FileDoneOutlined style={{ fontSize: 16, color: '#1677ff' }} />
|
||||
<Text>设备报价单.xlsx</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>(1.8 MB)</Text>
|
||||
</Space>
|
||||
<Button type="link" size="small">下载</Button>
|
||||
</Flex>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Flex justify="space-between" align="center">
|
||||
<Space>
|
||||
<FileDoneOutlined style={{ fontSize: 16, color: '#1677ff' }} />
|
||||
<Text>预算说明文档.docx</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>(856 KB)</Text>
|
||||
</Space>
|
||||
<Button type="link" size="small">下载</Button>
|
||||
</Flex>
|
||||
</Space>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
{/* 侧边栏 */}
|
||||
<Col xs={24} lg={8}>
|
||||
{/* 申请人信息 */}
|
||||
<Card title="申请人信息" style={{ marginBottom: 16 }}>
|
||||
<Flex align="center" style={{ marginBottom: 16 }}>
|
||||
<Avatar size={48} style={{ backgroundColor: '#1677ff' }}>
|
||||
张三
|
||||
</Avatar>
|
||||
<div style={{ marginLeft: 12 }}>
|
||||
<Text strong>张三</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>技术研发部 · 高级工程师</Text>
|
||||
</div>
|
||||
</Flex>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="员工编号">EMP202301001</Descriptions.Item>
|
||||
<Descriptions.Item label="联系电话">138****8888</Descriptions.Item>
|
||||
<Descriptions.Item label="电子邮箱">zhangsan@example.com</Descriptions.Item>
|
||||
<Descriptions.Item label="所属部门">技术研发部</Descriptions.Item>
|
||||
<Descriptions.Item label="直属领导">赵主管</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* 审批流程 */}
|
||||
<Card title="审批流程">
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={3}
|
||||
items={[
|
||||
{
|
||||
title: '申请人提交',
|
||||
description: (
|
||||
<div>
|
||||
<Text>张三 · 技术研发部</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>2025-12-31 16:45</Text>
|
||||
</div>
|
||||
),
|
||||
status: 'finish',
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
{
|
||||
title: '部门审批',
|
||||
description: (
|
||||
<div>
|
||||
<Text>赵主管 · 部门主管</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>2026-01-01 10:15</Text>
|
||||
<br />
|
||||
<Tag color="success" style={{ marginTop: 4 }}>已通过</Tag>
|
||||
</div>
|
||||
),
|
||||
status: 'finish',
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
{
|
||||
title: '财务审批',
|
||||
description: (
|
||||
<div>
|
||||
<Text>李经理 · 财务经理</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>2026-01-01 11:20</Text>
|
||||
<br />
|
||||
<Tag color="success" style={{ marginTop: 4 }}>已通过</Tag>
|
||||
</div>
|
||||
),
|
||||
status: 'finish',
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
{
|
||||
title: '总经理审批',
|
||||
description: (
|
||||
<div>
|
||||
<Text>王总 · 总经理</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>2026-01-01 14:30</Text>
|
||||
<br />
|
||||
<Tag color="success" style={{ marginTop: 4 }}>已通过</Tag>
|
||||
</div>
|
||||
),
|
||||
status: 'finish',
|
||||
icon: <CheckCircleOutlined />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* 审批进度标签页 */}
|
||||
{activeTab === '2' && (
|
||||
<Timeline
|
||||
style={{ maxWidth: 600, paddingTop: 20 }}
|
||||
mode="left"
|
||||
items={approvalHistory.map((item) => ({
|
||||
color: item.status === 'approved' ? 'green' : 'blue',
|
||||
label: <Text type="secondary">{item.time}</Text>,
|
||||
children: (
|
||||
<Card size="small" style={{ marginBottom: 8 }}>
|
||||
<Flex align="center" style={{ marginBottom: 8 }}>
|
||||
<Avatar style={{ backgroundColor: item.status === 'approved' ? '#52c41a' : '#1677ff' }}>
|
||||
{item.avatar}
|
||||
</Avatar>
|
||||
<div style={{ marginLeft: 12 }}>
|
||||
<Text strong>{item.approver}</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>({item.role})</Text>
|
||||
<br />
|
||||
<Tag
|
||||
color={item.status === 'approved' ? 'success' : 'processing'}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{item.statusText}
|
||||
</Tag>
|
||||
</div>
|
||||
</Flex>
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
style={{ marginBottom: 0, paddingLeft: 60, fontSize: 13 }}
|
||||
>
|
||||
{item.comment}
|
||||
</Paragraph>
|
||||
</Card>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 审批意见标签页 */}
|
||||
{activeTab === '3' && (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="填写审批意见">
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="审批结果" required>
|
||||
<Space>
|
||||
<Button type="primary" icon={<CheckCircleOutlined />} size="large">
|
||||
通过
|
||||
</Button>
|
||||
<Button danger icon={<CloseCircleOutlined />} size="large">
|
||||
驳回
|
||||
</Button>
|
||||
<Button icon={<EditOutlined />} size="large">
|
||||
转审
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item label="审批意见" required>
|
||||
<TextArea
|
||||
rows={6}
|
||||
placeholder="请输入您的审批意见..."
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" size="large">
|
||||
提交审批意见
|
||||
</Button>
|
||||
<Button size="large">保存草稿</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="审批提示">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text type="secondary">• 请仔细核对申请信息和采购明细</Text>
|
||||
<Text type="secondary">• 审批意见将发送给申请人和相关人员</Text>
|
||||
<Text type="secondary">• 审批通过后将自动流转至下一审批节点</Text>
|
||||
<Text type="secondary">• 驳回后申请人可修改后重新提交</Text>
|
||||
<Text type="secondary">• 转审可将审批任务转交给其他人员</Text>
|
||||
</Space>
|
||||
<Divider />
|
||||
<Text strong>审批时限</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>请在 2026-01-03 前完成审批</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FixHeader;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import {CloseCircleOutlined} from '@ant-design/icons';
|
||||
import {Button, Card, Result, Typography} from 'antd';
|
||||
|
||||
const {Paragraph, Text} = Typography;
|
||||
|
||||
const App: React.FC = () => (
|
||||
<Card variant={"borderless"}>
|
||||
<Result
|
||||
status="error"
|
||||
title="Submission Failed"
|
||||
subTitle="Please check and modify the following information before resubmitting."
|
||||
extra={[
|
||||
<Button type="primary" key="console">
|
||||
Go Console
|
||||
</Button>,
|
||||
<Button key="buy">Buy Again</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="desc">
|
||||
<Paragraph>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
The content you submitted has the following error:
|
||||
</Text>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<CloseCircleOutlined className="site-result-demo-error-icon"/> Your account has been
|
||||
frozen. <a>Thaw immediately ></a>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<CloseCircleOutlined className="site-result-demo-error-icon"/> Your account is not yet
|
||||
eligible to apply. <a>Apply Unlock ></a>
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Result>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import {Button, Card, Result} from 'antd';
|
||||
|
||||
const App: React.FC = () => (
|
||||
<Card variant={"borderless"}>
|
||||
<Result
|
||||
title="Your operation has been executed"
|
||||
extra={
|
||||
<Button type="primary" key="console">
|
||||
Go Console
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import {Button, Card, Result} from 'antd';
|
||||
|
||||
const App: React.FC = () => (
|
||||
<Card variant={"borderless"}>
|
||||
<Result
|
||||
status="success"
|
||||
title="Successfully Purchased Cloud Server ECS!"
|
||||
subTitle="Order number: 2017182818828182881 Cloud server configuration takes 1-5 minutes, please wait."
|
||||
extra={[
|
||||
<Button type="primary" key="console">
|
||||
Go Console
|
||||
</Button>,
|
||||
<Button key="buy">Buy Again</Button>,
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import {Button, Card, Result} from 'antd';
|
||||
|
||||
const App: React.FC = () => (
|
||||
<Card variant={"borderless"}>
|
||||
<Result
|
||||
status="warning"
|
||||
title="There are some problems with your operation."
|
||||
extra={
|
||||
<Button type="primary" key="console">
|
||||
Go Console
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default App;
|
||||
@@ -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,144 @@
|
||||
import XinTable from '@/components/XinTable';
|
||||
import { Badge, Image, Typography } from 'antd';
|
||||
import type { ICarousel } from '@/domain/iCarousel';
|
||||
import type { XinTableColumn } from '@/components/XinTable/typings';
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 从 image 字段中提取预览 URL
|
||||
* image 字段可能是 ISysFileInfo 对象或 URL 字符串
|
||||
*/
|
||||
function getImageUrl(image: ISysFileInfo | string | null | undefined): string {
|
||||
if (!image) return '';
|
||||
if (typeof image === 'string') return image;
|
||||
return image.preview_url || image.file_url || '';
|
||||
}
|
||||
|
||||
/** 首页轮播图管理 */
|
||||
export default function CarouselPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns: XinTableColumn<ICarousel>[] = [
|
||||
{
|
||||
title: t('system.carousel.id'),
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
width: 80,
|
||||
sorter: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.carousel.title'),
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
colProps: { span: 12 },
|
||||
rules: [{ required: true, message: t('system.carousel.title.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.carousel.image'),
|
||||
dataIndex: 'image_id',
|
||||
valueType: 'image',
|
||||
colProps: { span: 24 },
|
||||
rules: [{ required: true, message: t('system.carousel.image.required') }],
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
action: '/system/file/list/upload',
|
||||
mode: 'single',
|
||||
maxCount: 1,
|
||||
changeType: "id"
|
||||
},
|
||||
render: (_value: ISysFileInfo | string, record: ICarousel) => {
|
||||
const url = getImageUrl(record.image);
|
||||
if (!url) return '-';
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
width={120}
|
||||
height={60}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.carousel.link'),
|
||||
dataIndex: 'link',
|
||||
valueType: 'text',
|
||||
colProps: { span: 12 },
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
placeholder: t('system.carousel.link.placeholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.carousel.status'),
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
filters: [
|
||||
{ text: t('system.carousel.status.normal'), value: 0 },
|
||||
{ text: t('system.carousel.status.disabled'), value: 1 },
|
||||
],
|
||||
colProps: { span: 12 },
|
||||
rules: [{ required: true, message: t('system.carousel.status.required') }],
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: t('system.carousel.status.normal'), value: 0 },
|
||||
{ label: t('system.carousel.status.disabled'), value: 1 },
|
||||
],
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 0
|
||||
? <Badge status="success" text={t('system.carousel.status.normal')} />
|
||||
: <Badge status="error" text={t('system.carousel.status.disabled')} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.carousel.sort'),
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
colProps: { span: 12 },
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
fieldProps: {
|
||||
min: 0,
|
||||
style: { width: '100%' },
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.carousel.createdAt'),
|
||||
dataIndex: 'created_at',
|
||||
render: (value: string) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 160,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>{t('system.carousel.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.carousel.page.description')}</Text>
|
||||
</div>
|
||||
<XinTable<ICarousel>
|
||||
api="/system/carousel"
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
accessName="system.carousel"
|
||||
formProps={{
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
rowProps: { gutter: [30, 0] },
|
||||
layout: 'vertical',
|
||||
}}
|
||||
modalProps={{ width: 800 }}
|
||||
searchProps={false}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
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,
|
||||
ReloadOutlined,
|
||||
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, refreshCache,
|
||||
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 handleRefresh = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await refreshCache();
|
||||
message.success(t('system.config.item.refreshCacheSuccess'))
|
||||
} 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',
|
||||
initialValue: 0
|
||||
},
|
||||
{
|
||||
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={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
{t('system.config.refresh.button')}
|
||||
</Button>
|
||||
<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,144 @@
|
||||
import XinTable from '@/components/XinTable';
|
||||
import { Badge, Image, Typography } from 'antd';
|
||||
import type { IGridNav } from '@/domain/iGridNav';
|
||||
import type { XinTableColumn } from '@/components/XinTable/typings';
|
||||
import type { ISysFileInfo } from '@/domain/iSysFile';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/**
|
||||
* 从 image 字段中提取预览 URL
|
||||
* image 字段可能是 ISysFileInfo 对象或 URL 字符串
|
||||
*/
|
||||
function getImageUrl(image: ISysFileInfo | string | null | undefined): string {
|
||||
if (!image) return '';
|
||||
if (typeof image === 'string') return image;
|
||||
return image.preview_url || image.file_url || '';
|
||||
}
|
||||
|
||||
/** 首页宫格导航管理 */
|
||||
export default function GridNavPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns: XinTableColumn<IGridNav>[] = [
|
||||
{
|
||||
title: t('system.gridNav.id'),
|
||||
dataIndex: 'id',
|
||||
hideInForm: true,
|
||||
width: 80,
|
||||
sorter: true,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: t('system.gridNav.title'),
|
||||
dataIndex: 'title',
|
||||
valueType: 'text',
|
||||
colProps: { span: 12 },
|
||||
rules: [{ required: true, message: t('system.gridNav.title.required') }],
|
||||
},
|
||||
{
|
||||
title: t('system.gridNav.image'),
|
||||
dataIndex: 'image_id',
|
||||
valueType: 'image',
|
||||
colProps: { span: 24 },
|
||||
rules: [{ required: true, message: t('system.gridNav.image.required') }],
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
action: '/system/file/list/upload',
|
||||
mode: 'single',
|
||||
maxCount: 1,
|
||||
changeType: 'id'
|
||||
},
|
||||
render: (_value: ISysFileInfo | string, record: IGridNav) => {
|
||||
const url = getImageUrl(record.image);
|
||||
if (!url) return '-';
|
||||
return (
|
||||
<Image
|
||||
src={url}
|
||||
width={60}
|
||||
height={60}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.gridNav.link'),
|
||||
dataIndex: 'link',
|
||||
valueType: 'text',
|
||||
colProps: { span: 12 },
|
||||
hideInSearch: true,
|
||||
fieldProps: {
|
||||
placeholder: t('system.gridNav.link.placeholder'),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.gridNav.status'),
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
filters: [
|
||||
{ text: t('system.gridNav.status.normal'), value: 0 },
|
||||
{ text: t('system.gridNav.status.disabled'), value: 1 },
|
||||
],
|
||||
colProps: { span: 12 },
|
||||
rules: [{ required: true, message: t('system.gridNav.status.required') }],
|
||||
fieldProps: {
|
||||
options: [
|
||||
{ label: t('system.gridNav.status.normal'), value: 0 },
|
||||
{ label: t('system.gridNav.status.disabled'), value: 1 },
|
||||
],
|
||||
},
|
||||
render: (value: number) => {
|
||||
return value === 0
|
||||
? <Badge status="success" text={t('system.gridNav.status.normal')} />
|
||||
: <Badge status="error" text={t('system.gridNav.status.disabled')} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.gridNav.sort'),
|
||||
dataIndex: 'sort',
|
||||
valueType: 'digit',
|
||||
colProps: { span: 12 },
|
||||
hideInSearch: true,
|
||||
initialValue: 0,
|
||||
fieldProps: {
|
||||
min: 0,
|
||||
style: { width: '100%' },
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('system.gridNav.createdAt'),
|
||||
dataIndex: 'created_at',
|
||||
render: (value: string) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
hideInForm: true,
|
||||
hideInSearch: true,
|
||||
width: 160,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-5">
|
||||
<Title level={3}>{t('system.gridNav.page.title')}</Title>
|
||||
<Text type="secondary">{t('system.gridNav.page.description')}</Text>
|
||||
</div>
|
||||
<XinTable<IGridNav>
|
||||
api="/system/grid-nav"
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
accessName="system.grid-nav"
|
||||
formProps={{
|
||||
grid: true,
|
||||
colProps: { span: 12 },
|
||||
rowProps: { gutter: [30, 0] },
|
||||
layout: 'vertical',
|
||||
}}
|
||||
modalProps={{ width: 800 }}
|
||||
searchProps={false}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}/system/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