first commit

This commit is contained in:
liu
2026-07-13 15:23:29 +08:00
commit 50885a98c8
473 changed files with 33772 additions and 0 deletions
+118
View File
@@ -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>
</>
);
}
+372
View File
@@ -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;
+192
View File
@@ -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>
</>
);
}
+445
View File
@@ -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
+139
View File
@@ -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;
+293
View File
@@ -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;
+118
View File
@@ -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;
+156
View File
@@ -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;
+128
View File
@@ -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;
+710
View File
@@ -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 textpassworddigitmoneyselecttreeSelectcascaderradiocheckboxswitchratesliderdatetimecoloricontextarea
<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;
+145
View File
@@ -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;
+15
View File
@@ -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;
+16
View File
@@ -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;
+16
View File
@@ -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;
+221
View File
@@ -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;
+32
View File
@@ -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;
+33
View File
@@ -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;
+34
View File
@@ -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;
+220
View File
@@ -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;
+339
View File
@@ -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}>
88SOHO现代城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;
+418
View File
@@ -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;
+44
View File
@@ -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 &gt;</a>
</Paragraph>
<Paragraph>
<CloseCircleOutlined className="site-result-demo-error-icon"/> Your account is not yet
eligible to apply. <a>Apply Unlock &gt;</a>
</Paragraph>
</div>
</Result>
</Card>
);
export default App;
+17
View File
@@ -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;
+20
View File
@@ -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;
+18
View File
@@ -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;
+338
View File
@@ -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;
+144
View File
@@ -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 }}
/>
</>
);
}
+655
View File
@@ -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;
+452
View File
@@ -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;
+154
View File
@@ -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
]}
/>
</>
);
}
+204
View File
@@ -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
+144
View File
@@ -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 }}
/>
</>
);
}
+221
View File
@@ -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;
+514
View File
@@ -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;
+427
View File
@@ -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;
+300
View File
@@ -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;
+547
View File
@@ -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;
+332
View File
@@ -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;
+263
View File
@@ -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;